Merge pull request #3 from bnema/feat/capture-generic
feat(wayland): capture through ext-image-copy-capture and ext-workspace
51 files · +4914 -3462
M .hexcheck.yaml +1 -3
| @@ -18,6 +18,4 @@ mocking: | |||
| 18 | 18 | ruleSettings: | |
| 19 | 19 | suspicious-business-logic-in-adapter: | |
| 20 | 20 | # Run/resolveOutput and recordInteractive wire sessions, GUI and core. | |
| 21 | - | # resolveCapture maps compositor-granted session state to a screencopy | |
| 22 | - | # target; it makes no selection or fallback decision. | |
| 23 | - | excludePaths: [internal/app/app.go, internal/app/record_interactive.go, internal/adapters/wayland/capture_session.go] | |
| 21 | + | excludePaths: [internal/app/app.go, internal/app/record_interactive.go] | |
M Makefile +4 -4
| @@ -10,12 +10,12 @@ bin: | |||
| 10 | 10 | build: | |
| 11 | 11 | CGO_ENABLED=0 go build ./... | |
| 12 | 12 | test: | |
| 13 | - | CGO_ENABLED=0 go test ./... | |
| 13 | + | NEFERCAP_REQUIRE_WAYLAND=1 CGO_ENABLED=0 go test ./... | |
| 14 | 14 | vet: | |
| 15 | 15 | CGO_ENABLED=0 go vet ./... | |
| 16 | 16 | race: | |
| 17 | 17 | # cgo is enabled only for the race test binary. | |
| 18 | - | CGO_ENABLED=1 go test -race ./... | |
| 18 | + | NEFERCAP_REQUIRE_WAYLAND=1 CGO_ENABLED=1 go test -race ./... | |
| 19 | 19 | # go.mod and go.sum must be tidy (prints the diff and fails otherwise). | |
| 20 | 20 | mod-check: | |
| 21 | 21 | go mod tidy -diff | |
| @@ -45,7 +45,7 @@ arch: | |||
| 45 | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 46 | 46 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 47 | 47 | # pass vacuously when a frame-path test is removed or renamed. | |
| 48 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | |
| 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | |
| 49 | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | |
| 50 | 50 | perf_empty := | |
| 51 | 51 | perf_space := $(perf_empty) $(perf_empty) | |
| @@ -55,5 +55,5 @@ perf-check: | |||
| 55 | 55 | for t in $(PERF_TESTS); do \ | |
| 56 | 56 | printf '%s\n' "$$out" | grep -qx "$$t" || { echo "perf-check: test $$t not found in $(PERF_PKGS)" >&2; exit 1; }; \ | |
| 57 | 57 | done | |
| 58 | - | CGO_ENABLED=0 go test $(PERF_PKGS) -run '$(PERF_RE)' -count=1 | |
| 58 | + | NEFERCAP_REQUIRE_WAYLAND=1 CGO_ENABLED=0 go test $(PERF_PKGS) -run '$(PERF_RE)' -count=1 | |
| 59 | 59 | check: mod-check vet test arch fakes-check perf-check | |
M README.md +22 -8
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | <p align="center">Screenshots and silent screen recording for Wayland, from one shortcut.</p> | |
| 11 | 11 | ||
| 12 | 12 | > [!WARNING] | |
| 13 | - | > **Early alpha.** Built for [NeferWL](https://github.com/bnema/neferwl). Other compositors can take screenshots but cannot record. Expect bugs and breaking config changes. | |
| 13 | + | > **Early alpha.** Built for [NeferWL](https://github.com/bnema/neferwl), and works on other compositors with the standard capture protocols (see [Compatibility](#compatibility)). Expect bugs and breaking config changes. | |
| 14 | 14 | ||
| 15 | 15 | --- | |
| 16 | 16 | ||
| @@ -24,7 +24,7 @@ bind.cmd+p = spawn nefercap | |||
| 24 | 24 | ||
| 25 | 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. A badge at the top left shows the mode: `SHOT` or `REC`. | |
| 26 | 26 | ||
| 27 | - | While recording, a red border marks the target and a small **REC / Stop** HUD shows the time. Neither appears in the video. Press the key again, or click Stop, to finish the file. | |
| 27 | + | While recording on NeferWL, a border marks the target and a small **REC / Stop** HUD shows the time. Neither appears in the video. Press the key again, or click Stop, to finish the file. Other compositors show no indicator: the selector says so, and the same key (or `nefercap stop`) finishes the file. | |
| 28 | 28 | ||
| 29 | 29 | | Input | Action | | |
| 30 | 30 | | --- | --- | | |
| @@ -38,12 +38,12 @@ While recording, a red border marks the target and a small **REC / Stop** HUD sh | |||
| 38 | 38 | | `Tab` | Switch shot ⇄ rec | | |
| 39 | 39 | | `Esc` | Cancel | | |
| 40 | 40 | ||
| 41 | - | A workspace recording follows that workspace, even when you switch to another one. A monitor recording follows whatever the monitor shows. | |
| 41 | + | On NeferWL a workspace recording follows that workspace, even when you switch to another one. Elsewhere `W` offers only the workspace currently shown, and records the monitor. A monitor recording follows whatever the monitor shows. | |
| 42 | 42 | ||
| 43 | 43 | ## Features | |
| 44 | 44 | ||
| 45 | 45 | - **One shortcut.** Screenshot and recording share one selector; the same key stops a recording. | |
| 46 | - | - **Clean video.** The compositor keeps the border and HUD out of the recording. | |
| 46 | + | - **Clean video.** With NeferWL the compositor keeps the border and HUD out of the recording. | |
| 47 | 47 | - **Clipboard.** Screenshots can be saved, copied, or both. | |
| 48 | 48 | - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths. | |
| 49 | 49 | - **Scriptable.** `nefercap screenshot` and `nefercap record` run without the selector. | |
| @@ -92,13 +92,27 @@ nefercap record -output DP-1 -fps 30 -duration 10s -file clip.mp4 | |||
| 92 | 92 | ## Requirements | |
| 93 | 93 | ||
| 94 | 94 | - Go 1.27 to build. | |
| 95 | - | - A Wayland compositor with `zwlr_layer_shell_v1` v4 and `zwlr_screencopy_manager_v1`. | |
| 95 | + | - A Wayland compositor with `ext_image_copy_capture_manager_v1`, `ext_output_image_capture_source_manager_v1` and `zwlr_layer_shell_v1` v4. | |
| 96 | 96 | - Vulkan, libxkbcommon, linux-dmabuf and linux-drm-syncobj for the selector. | |
| 97 | - | - NeferWL's capture-session protocol to record. | |
| 98 | 97 | - FFmpeg with `libx264` to record. | |
| 99 | 98 | - `wl-copy` (wl-clipboard) for clipboard screenshots. | |
| 100 | 99 | ||
| 101 | - | Output is SDR, and video is silent H.264 in fragmented MP4. Audio, pause and cursor capture are not supported. | |
| 100 | + | NeferWL's optional capture extension adds workspace and region sources and keeps the HUD out of the video. Without it nefercap uses only the standard protocols. | |
| 101 | + | ||
| 102 | + | ## Compatibility | |
| 103 | + | ||
| 104 | + | | Compositor | Screenshots, recording (outputs, regions) | Workspaces | Hidden workspace | Recording indicator | | |
| 105 | + | | --- | --- | --- | --- | --- | | |
| 106 | + | | NeferWL | yes | yes | yes | yes, imposed by the compositor | | |
| 107 | + | | sway, river (wlroots ≥ 0.19), Hyprland, niri, COSMIC | assumed | assumed, where `ext-workspace-v1` exists: active only | no | no | | |
| 108 | + | | KDE Plasma | assumed | no | no | no | | |
| 109 | + | | GNOME | unsupported | no | no | no | | |
| 110 | + | ||
| 111 | + | Workspaces need `ext-workspace-v1`; with NeferWL's extension the selector outlines the workspace frame (a workspace smaller than its monitor), and the HUD sits in it. Without the extension `W` outlines the whole monitor and captures the workspace currently shown, as its monitor. Without the extension a region is cropped from the monitor frame. *Assumed* means it follows from the protocols the compositor advertises; only NeferWL is tested. | |
| 112 | + | ||
| 113 | + | A compositor may refuse a capture client; the capture then ends with an error. | |
| 114 | + | ||
| 115 | + | Output is SDR, and video is silent H.264 in fragmented MP4. Audio, pause and cursor capture are not supported. Rotated outputs (any non-normal output transform) are not supported: the capture fails with an error naming the transform. | |
| 102 | 116 | ||
| 103 | 117 | ## Install | |
| 104 | 118 | ||
| @@ -106,7 +120,7 @@ Output is SDR, and video is silent H.264 in fragmented MP4. Audio, pause and cur | |||
| 106 | 120 | make bin # builds bin/nefercap | |
| 107 | 121 | ``` | |
| 108 | 122 | ||
| 109 | - | Copy `bin/nefercap` to a directory on your `PATH`. | |
| 123 | + | Install it as `/usr/bin/nefercap`: NeferWL allows that path to capture by default. Elsewhere (for example `~/.local/bin`, or `go run`), NeferWL refuses capture until root lists the path in `/etc/neferwl/capture-allow`. | |
| 110 | 124 | ||
| 111 | 125 | ## Development | |
| 112 | 126 | ||
M go.mod +4 -3
| @@ -3,8 +3,9 @@ module github.com/bnema/nefercap | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/nefergui v0.2.0 | |
| 7 | - | github.com/bnema/wlturbo v0.3.0 | |
| 6 | + | github.com/bnema/nefergui v0.3.0 | |
| 7 | + | github.com/bnema/purego-libwayland v0.8.0 | |
| 8 | + | github.com/bnema/wlturbo v0.5.0 | |
| 8 | 9 | github.com/bnema/zerowrap v1.4.1 | |
| 9 | 10 | github.com/stretchr/testify v1.12.1 | |
| 10 | 11 | golang.org/x/sys v0.48.0 | |
| @@ -13,7 +14,7 @@ require ( | |||
| 13 | 14 | require ( | |
| 14 | 15 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 15 | 16 | github.com/bnema/purego-vulkan v0.6.0 // indirect | |
| 16 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 // indirect | |
| 17 | + | github.com/bnema/purego-xkbcommon v0.1.0 // indirect | |
| 17 | 18 | github.com/go-text/typesetting v0.3.5 // indirect | |
| 18 | 19 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 19 | 20 | github.com/mattn/go-isatty v0.0.22 // indirect | |
M go.sum +8 -6
| @@ -1,13 +1,15 @@ | |||
| 1 | - | github.com/bnema/nefergui v0.2.0 h1:+kuePc+HyZFmlm0C0JkFq7TfL/Et4ZQjNlGw7lgjeXc= | |
| 2 | - | github.com/bnema/nefergui v0.2.0/go.mod h1:JNjwv/YlesI0FflJvmHB5OqLd/bHfqFakpvJ2zJr+F4= | |
| 1 | + | github.com/bnema/nefergui v0.3.0 h1:JNzvFWt6SNOQ75WSPcTox+M7fHUF/WuyOR/FVWLc/Mg= | |
| 2 | + | github.com/bnema/nefergui v0.3.0/go.mod h1:M4jp5ZWXBSN94txcrnnsHo7uJESF68ioT7IR86Tz/ZU= | |
| 3 | 3 | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |
| 4 | 4 | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 5 | + | github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY= | |
| 6 | + | github.com/bnema/purego-libwayland v0.8.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg= | |
| 5 | 7 | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |
| 6 | 8 | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 7 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 h1:XePqfzZzlT1WEQ8G9atwlXjW5jFZAkEoUGnlYvsHAHg= | |
| 8 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg= | |
| 9 | - | github.com/bnema/wlturbo v0.3.0 h1:Xt5HoiTL/pTVnUjD29mzBsUAVJp+1bxB9DfzxZK6RVA= | |
| 10 | - | github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 9 | + | github.com/bnema/purego-xkbcommon v0.1.0 h1:VHNf9shPpafKxdTERcwCNJKHhSd4IG2tjmLsDACKnuw= | |
| 10 | + | github.com/bnema/purego-xkbcommon v0.1.0/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg= | |
| 11 | + | github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4= | |
| 12 | + | github.com/bnema/wlturbo v0.5.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 11 | 13 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| 12 | 14 | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | |
| 13 | 15 | github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= | |
M internal/adapters/ffmpeg/ffmpeg.go +2 -2
| @@ -351,10 +351,10 @@ func (w *Writer) diagnostics() string { | |||
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | 353 | // writeRows writes the frame in top-to-bottom row order without copying it. | |
| 354 | - | // A tightly packed, upright frame is a single write. | |
| 354 | + | // A tightly packed frame is a single write. | |
| 355 | 355 | func writeRows(dst io.Writer, f ports.Frame) error { | |
| 356 | 356 | rowLen := f.Width * ports.BytesPerPixel | |
| 357 | - | if !f.YInvert && f.Stride == rowLen { | |
| 357 | + | if f.Stride == rowLen { | |
| 358 | 358 | return writeFull(dst, f.Pixels[:rowLen*f.Height]) | |
| 359 | 359 | } | |
| 360 | 360 | for y := 0; y < f.Height; y++ { | |
M internal/adapters/ffmpeg/ffmpeg_test.go +14 -27
| @@ -234,11 +234,6 @@ func TestDrainLifecycle(t *testing.T) { | |||
| 234 | 234 | if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil { | |
| 235 | 235 | t.Fatalf("stride change rejected: %v", err) | |
| 236 | 236 | } | |
| 237 | - | inv := newFrame(4, 4, 16, 3) | |
| 238 | - | inv.YInvert = true | |
| 239 | - | if err := w.Write(ctx, inv); err != nil { | |
| 240 | - | t.Fatal(err) | |
| 241 | - | } | |
| 242 | 237 | for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} { | |
| 243 | 238 | if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) { | |
| 244 | 239 | t.Fatalf("got %v, want ErrGeometryChanged", err) | |
| @@ -272,8 +267,8 @@ func TestDrainLifecycle(t *testing.T) { | |||
| 272 | 267 | if !gone(pid) { | |
| 273 | 268 | t.Fatal("encoder not reaped") | |
| 274 | 269 | } | |
| 275 | - | // 4 frames of 4x4x4 bytes reached the encoder exactly (no padding). | |
| 276 | - | if got, _ := os.ReadFile(path); string(got) != "bytes=256" { | |
| 270 | + | // 3 frames of 4x4x4 bytes reached the encoder exactly (no padding). | |
| 271 | + | if got, _ := os.ReadFile(path); string(got) != "bytes=192" { | |
| 277 | 272 | t.Fatalf("encoder output %q", got) | |
| 278 | 273 | } | |
| 279 | 274 | } | |
| @@ -722,13 +717,12 @@ func TestRealFFmpeg(t *testing.T) { | |||
| 722 | 717 | w, h, stride int | |
| 723 | 718 | set ports.VideoSettings | |
| 724 | 719 | wantW, wantH int | |
| 725 | - | invert bool | |
| 726 | 720 | cancelInstead bool | |
| 727 | 721 | }{ | |
| 728 | - | {"retain padded inverted", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, true, false}, | |
| 729 | - | {"odd source scaled", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30, Width: 62, Height: 46}, 62, 46, false, false}, | |
| 730 | - | {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false, false}, | |
| 731 | - | {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, false, true}, | |
| 722 | + | {"retain padded", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, false}, | |
| 723 | + | {"odd source scaled", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30, Width: 62, Height: 46}, 62, 46, false}, | |
| 724 | + | {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false}, | |
| 725 | + | {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, true}, | |
| 732 | 726 | } { | |
| 733 | 727 | t.Run(tc.name, func(t *testing.T) { | |
| 734 | 728 | w := New() | |
| @@ -736,7 +730,6 @@ func TestRealFFmpeg(t *testing.T) { | |||
| 736 | 730 | ctx, cancel := context.WithCancel(context.Background()) | |
| 737 | 731 | defer cancel() | |
| 738 | 732 | f := newFrame(tc.w, tc.h, tc.stride, 0) | |
| 739 | - | f.YInvert = tc.invert | |
| 740 | 733 | if err := w.Start(ctx, f, path, tc.set); err != nil { | |
| 741 | 734 | t.Fatal(err) | |
| 742 | 735 | } | |
| @@ -793,13 +786,9 @@ func TestRealFFmpegRefusesExistingPath(t *testing.T) { | |||
| 793 | 786 | ||
| 794 | 787 | func TestVideoRowsAllocations(t *testing.T) { | |
| 795 | 788 | cases := map[string]ports.Frame{ | |
| 796 | - | "packed": newFrame(64, 32, 256, 1), | |
| 797 | - | "padded": newFrame(64, 32, 320, 1), | |
| 798 | - | "inverted": newFrame(64, 32, 320, 1), | |
| 789 | + | "packed": newFrame(64, 32, 256, 1), | |
| 790 | + | "padded": newFrame(64, 32, 320, 1), | |
| 799 | 791 | } | |
| 800 | - | inv := cases["inverted"] | |
| 801 | - | inv.YInvert = true | |
| 802 | - | cases["inverted"] = inv | |
| 803 | 792 | for name, f := range cases { | |
| 804 | 793 | t.Run(name, func(t *testing.T) { | |
| 805 | 794 | if err := f.Validate(); err != nil { | |
| @@ -827,12 +816,12 @@ func TestVideoRowsAllocations(t *testing.T) { | |||
| 827 | 816 | } | |
| 828 | 817 | ||
| 829 | 818 | func TestWriteRowsLayout(t *testing.T) { | |
| 830 | - | // 2x2, stride 12, stored bottom row first. | |
| 819 | + | // 2x2, stride 12: the padding is left out. | |
| 831 | 820 | pix := []byte{ | |
| 832 | - | 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9, | |
| 833 | 821 | 1, 1, 1, 1, 2, 2, 2, 2, 9, 9, 9, 9, | |
| 822 | + | 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9, | |
| 834 | 823 | } | |
| 835 | - | f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888, YInvert: true} | |
| 824 | + | f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888} | |
| 836 | 825 | var buf bytes.Buffer | |
| 837 | 826 | if err := writeRows(&buf, f); err != nil { | |
| 838 | 827 | t.Fatal(err) | |
| @@ -857,13 +846,11 @@ func TestStderrBounded(t *testing.T) { | |||
| 857 | 846 | ||
| 858 | 847 | func BenchmarkVideoRows(b *testing.B) { | |
| 859 | 848 | for _, size := range []struct { | |
| 860 | - | name string | |
| 861 | - | w, h int | |
| 862 | - | invert bool | |
| 863 | - | }{{"1080p", 1920, 1080, false}, {"1080p-inverted", 1920, 1080, true}, {"4K", 3840, 2160, false}, {"4K-inverted", 3840, 2160, true}} { | |
| 849 | + | name string | |
| 850 | + | w, h int | |
| 851 | + | }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} { | |
| 864 | 852 | b.Run(size.name, func(b *testing.B) { | |
| 865 | 853 | f := newFrame(size.w, size.h, size.w*4, 1) | |
| 866 | - | f.YInvert = size.invert | |
| 867 | 854 | b.SetBytes(int64(size.w * size.h * 4)) | |
| 868 | 855 | b.ReportAllocs() | |
| 869 | 856 | for b.Loop() { | |
M internal/adapters/indicator/indicator.go +20 -7
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | // Package indicator is the recording HUD: a small layer surface at the top | |
| 2 | - | // centre of the recorded output showing a REC timer and a Stop button. | |
| 2 | + | // centre of the recorded output (of the workspace frame, when it is smaller) | |
| 3 | + | // showing a REC timer and a Stop button. | |
| 3 | 4 | // | |
| 4 | 5 | // It is one thin surface, not a full-output overlay: it takes no keyboard | |
| 5 | 6 | // focus, and only the Stop button receives pointer input; the rest of the HUD | |
| 6 | - | // and everything around it pass clicks through. The red border around the | |
| 7 | - | // recorded area is not drawn here; the compositor owns it. | |
| 7 | + | // and everything around it pass clicks through. The border around the | |
| 8 | + | // recorded area is not drawn here: the compositor marks it. | |
| 8 | 9 | // | |
| 9 | 10 | // Capture exclusion is explicit authorization, not a property of how the HUD is | |
| 10 | 11 | // built. The caller supplies authorize, which NeferGUI calls with the surface | |
| @@ -58,6 +59,11 @@ type Indicator struct { | |||
| 58 | 59 | // name). It is shown after the timer, cut to 12 characters, and only as | |
| 59 | 60 | // text: the HUD never draws geometry for it. Set it before Run. | |
| 60 | 61 | TargetLabel string | |
| 62 | + | // Frame optionally is the part of the output that is recorded, in | |
| 63 | + | // output-local logical pixels (a workspace smaller than its output): the | |
| 64 | + | // HUD sits at the top centre of it. The zero value is the whole output. | |
| 65 | + | // Set it before Run. | |
| 66 | + | Frame ports.Region | |
| 61 | 67 | ||
| 62 | 68 | authorize func(context.Context, nefergui.WaylandSurface) error | |
| 63 | 69 | stop chan<- struct{} | |
| @@ -114,7 +120,7 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim | |||
| 114 | 120 | nefergui.Size(Width, Height), | |
| 115 | 121 | nefergui.Styles(sheet), | |
| 116 | 122 | nefergui.Transparent(), | |
| 117 | - | nefergui.Layer(layerConfig(output.Name)), | |
| 123 | + | nefergui.Layer(layerConfig(output.Name, i.Frame)), | |
| 118 | 124 | nefergui.OnSurface(i.authorize), | |
| 119 | 125 | nefergui.Wake(wake), | |
| 120 | 126 | ) | |
| @@ -127,9 +133,11 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim | |||
| 127 | 133 | } | |
| 128 | 134 | ||
| 129 | 135 | // layerConfig is a top-centre HUD that never takes keyboard focus and accepts | |
| 130 | - | // pointer input only inside the Stop button. | |
| 131 | - | func layerConfig(output string) nefergui.LayerConfig { | |
| 132 | - | return nefergui.LayerConfig{ | |
| 136 | + | // pointer input only inside the Stop button. Centre is that of the output, or | |
| 137 | + | // of frame when it is set: then the HUD is anchored to the top left of the | |
| 138 | + | // output and moved by margins. | |
| 139 | + | func layerConfig(output string, frame ports.Region) nefergui.LayerConfig { | |
| 140 | + | c := nefergui.LayerConfig{ | |
| 133 | 141 | Output: output, | |
| 134 | 142 | Namespace: namespace, | |
| 135 | 143 | Level: nefergui.LayerOverlay, | |
| @@ -139,6 +147,11 @@ func layerConfig(output string) nefergui.LayerConfig { | |||
| 139 | 147 | Margin: [4]int32{topMargin, 0, 0, 0}, | |
| 140 | 148 | InputRects: []nefergui.Rect{stopRect()}, | |
| 141 | 149 | } | |
| 150 | + | if frame.Width > 0 && frame.Height > 0 { | |
| 151 | + | c.Anchors = nefergui.AnchorTop | nefergui.AnchorLeft | |
| 152 | + | c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))} | |
| 153 | + | } | |
| 154 | + | return c | |
| 142 | 155 | } | |
| 143 | 156 | ||
| 144 | 157 | func stopRect() nefergui.Rect { | |
M internal/adapters/indicator/indicator_test.go +15 -1
| @@ -122,7 +122,7 @@ func TestRequestStopNeverBlocks(t *testing.T) { | |||
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | 124 | func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | |
| 125 | - | c := layerConfig("DP-1") | |
| 125 | + | c := layerConfig("DP-1", ports.Region{}) | |
| 126 | 126 | assert.Equal(t, nefergui.KeyboardNone, c.Keyboard) | |
| 127 | 127 | assert.Equal(t, nefergui.LayerOverlay, c.Level) | |
| 128 | 128 | assert.Equal(t, nefergui.AnchorTop, c.Anchors) | |
| @@ -133,6 +133,20 @@ func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | |||
| 133 | 133 | assert.LessOrEqual(t, r.X+r.Width, int32(Width)) | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | + | func TestLayerConfigCentresOnTheRecordedFrame(t *testing.T) { | |
| 137 | + | c := layerConfig("DP-1", ports.Region{X: 100, Y: 50, Width: 1000, Height: 600}) | |
| 138 | + | assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorLeft, c.Anchors) | |
| 139 | + | assert.Equal(t, [4]int32{50 + topMargin, 0, 0, 100 + (1000-Width)/2}, c.Margin) | |
| 140 | + | assert.Equal(t, nefergui.KeyboardNone, c.Keyboard) | |
| 141 | + | // A frame narrower than the HUD starts at its left edge. | |
| 142 | + | c = layerConfig("DP-1", ports.Region{X: 7, Width: 100, Height: 100}) | |
| 143 | + | assert.Equal(t, int32(7), c.Margin[3]) | |
| 144 | + | // No frame: the whole output, centred by the compositor. | |
| 145 | + | c = layerConfig("DP-1", ports.Region{}) | |
| 146 | + | assert.Equal(t, nefergui.AnchorTop, c.Anchors) | |
| 147 | + | assert.Equal(t, [4]int32{topMargin, 0, 0, 0}, c.Margin) | |
| 148 | + | } | |
| 149 | + | ||
| 136 | 150 | func TestHUDReadabilityAndStopGeometry(t *testing.T) { | |
| 137 | 151 | assert.Equal(t, 384, Width) | |
| 138 | 152 | assert.Equal(t, 40, Height) | |
M internal/adapters/png/png.go +1 -1
| @@ -86,7 +86,7 @@ func (*Writer) Encode(ctx context.Context, w io.Writer, f ports.Frame) error { | |||
| 86 | 86 | return nil | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | // toRGBA converts top-to-bottom, honoring stride and YInvert, and forces alpha | |
| 89 | + | // toRGBA converts top-to-bottom, honoring stride, and forces alpha | |
| 90 | 90 | // to 255 because output is opaque (XRGB padding and ARGB alpha are ignored). | |
| 91 | 91 | func toRGBA(ctx context.Context, f ports.Frame) (*image.RGBA, error) { | |
| 92 | 92 | rowLen := f.Width * ports.BytesPerPixel | |
M internal/adapters/png/png_test.go +6 -6
| @@ -29,17 +29,17 @@ func decode(t *testing.T, path string) image.Image { | |||
| 29 | 29 | return img | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | func TestSaveConvertsBGRAWithStrideAndInvert(t *testing.T) { | |
| 33 | - | // 2x2 pixels, stride 12 (4 padding bytes per row), stored bottom-first. | |
| 32 | + | func TestSaveConvertsBGRAWithStride(t *testing.T) { | |
| 33 | + | // 2x2 pixels, stride 12 (4 padding bytes per row). | |
| 34 | 34 | // Pixel bytes are B,G,R,A; alpha values must be ignored. | |
| 35 | 35 | pix := []byte{ | |
| 36 | - | // stored row 0 == bottom row | |
| 37 | - | 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE, | |
| 38 | - | // stored row 1 == top row | |
| 36 | + | // top row | |
| 39 | 37 | 3, 2, 1, 255, 6, 5, 4, 0, 0xEE, 0xEE, 0xEE, 0xEE, | |
| 38 | + | // bottom row | |
| 39 | + | 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE, | |
| 40 | 40 | } | |
| 41 | 41 | for _, format := range []ports.PixelFormat{ports.ARGB8888, ports.XRGB8888} { | |
| 42 | - | f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: format, YInvert: true} | |
| 42 | + | f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: format} | |
| 43 | 43 | path := filepath.Join(t.TempDir(), "shot.png") | |
| 44 | 44 | if err := New().Save(context.Background(), f, path); err != nil { | |
| 45 | 45 | t.Fatal(err) | |
M internal/adapters/selection/mode_test.go +1 -1
| @@ -15,7 +15,7 @@ func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model { | |||
| 15 | 15 | m := newTestModel(t) | |
| 16 | 16 | m.base, m.mode, m.toggle = mode, mode, toggle | |
| 17 | 17 | m.footerMode = mode | |
| 18 | - | m.footerText = footerText(mode, core.PickRegion, false, toggle) | |
| 18 | + | m.footerText = footerText(mode, core.PickRegion, false, toggle, false) | |
| 19 | 19 | return m | |
| 20 | 20 | } | |
| 21 | 21 | ||
M internal/adapters/selection/model.go +12 -9
| @@ -27,13 +27,16 @@ const ( | |||
| 27 | 27 | // here is locked. The decision leaves through the shared session, never through | |
| 28 | 28 | // this struct. | |
| 29 | 29 | type model struct { | |
| 30 | - | base ports.Mode // mode the selector started in | |
| 31 | - | mode ports.Mode // current mode: base flipped by the session's Tab toggles | |
| 32 | - | toggle bool // Tab switches shot <-> rec (all-in-one only) | |
| 33 | - | seen uint32 // session toggles already applied to mode | |
| 34 | - | wake chan struct{} | |
| 35 | - | outputs []ports.Output | |
| 36 | - | index int // the output this overlay covers; every output has its own model | |
| 30 | + | base ports.Mode // mode the selector started in | |
| 31 | + | mode ports.Mode // current mode: base flipped by the session's Tab toggles | |
| 32 | + | toggle bool // Tab switches shot <-> rec (all-in-one only) | |
| 33 | + | // noIndicator: the compositor cannot exclude a HUD from the video, so the | |
| 34 | + | // record footer says there is none. | |
| 35 | + | noIndicator bool | |
| 36 | + | seen uint32 // session toggles already applied to mode | |
| 37 | + | wake chan struct{} | |
| 38 | + | outputs []ports.Output | |
| 39 | + | index int // the output this overlay covers; every output has its own model | |
| 37 | 40 | ||
| 38 | 41 | picker core.Picker | |
| 39 | 42 | sized bool | |
| @@ -58,7 +61,7 @@ type model struct { | |||
| 58 | 61 | } | |
| 59 | 62 | ||
| 60 | 63 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | |
| 61 | - | return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false)} | |
| 64 | + | return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false, false)} | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | // otherMode is the mode a Tab switches to. | |
| @@ -276,7 +279,7 @@ func (m *model) refreshFooter() { | |||
| 276 | 279 | _, _, workspace := m.picker.Workspace() | |
| 277 | 280 | if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode { | |
| 278 | 281 | m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode | |
| 279 | - | m.footerText = footerText(m.mode, kind, workspace, m.toggle) | |
| 282 | + | m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator) | |
| 280 | 283 | } | |
| 281 | 284 | } | |
| 282 | 285 | ||
M internal/adapters/selection/scene.go +10 -2
| @@ -20,15 +20,19 @@ const ( | |||
| 20 | 20 | margin = baseFont | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | + | // noIndicatorHint is shown in record mode on a compositor that cannot keep a | |
| 24 | + | // recording indicator out of the video, so none is drawn. | |
| 25 | + | const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop" | |
| 26 | + | ||
| 23 | 27 | // footerText is rebuilt only when the capture mode, picker kind or workspace | |
| 24 | 28 | // availability changes; pointer motion never formats hints. | |
| 25 | - | func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) string { | |
| 29 | + | func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool) string { | |
| 26 | 30 | action, name, other := "capture", "shot", "rec" | |
| 27 | 31 | if mode == ports.Record { | |
| 28 | 32 | action, name, other = "record", "rec", "shot" | |
| 29 | 33 | } | |
| 30 | 34 | var b strings.Builder | |
| 31 | - | b.Grow(192) // one allocation for the whole legend | |
| 35 | + | b.Grow(192 + len(noIndicatorHint)) // one allocation for the whole legend | |
| 32 | 36 | b.WriteString(name) | |
| 33 | 37 | if toggle { | |
| 34 | 38 | b.WriteString(" · Tab ") | |
| @@ -58,6 +62,10 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) str | |||
| 58 | 62 | b.WriteString(action) | |
| 59 | 63 | b.WriteString(" monitor") | |
| 60 | 64 | } | |
| 65 | + | if noIndicator && mode == ports.Record { | |
| 66 | + | b.WriteByte('\n') | |
| 67 | + | b.WriteString(noIndicatorHint) | |
| 68 | + | } | |
| 61 | 69 | return b.String() | |
| 62 | 70 | } | |
| 63 | 71 | ||
M internal/adapters/selection/selection_test.go +15 -2
| @@ -660,8 +660,11 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) { | |||
| 660 | 660 | } | |
| 661 | 661 | ||
| 662 | 662 | func TestFooterAndHeaderText(t *testing.T) { | |
| 663 | - | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · R region") | |
| 664 | - | assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false), "rec · R region") | |
| 663 | + | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false), "shot · R region") | |
| 664 | + | assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false), "rec · R region") | |
| 665 | + | assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false), "No recording indicator") | |
| 666 | + | assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true), "No recording indicator on this compositor") | |
| 667 | + | assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true), "No recording indicator", "only record mode needs the hint") | |
| 665 | 668 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | |
| 666 | 669 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | |
| 667 | 670 | i, ok := outputKey("2", 2) | |
| @@ -779,3 +782,13 @@ func TestInputReportsClosing(t *testing.T) { | |||
| 779 | 782 | m.input(motion(200, 200)) | |
| 780 | 783 | assert.True(t, m.input(button(200, 200, false)), "a drag release accepts the region") | |
| 781 | 784 | } | |
| 785 | + | ||
| 786 | + | func TestCapabilitiesHideWorkspacesAndHintIndicator(t *testing.T) { | |
| 787 | + | ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Width: 640, Height: 480}}} | |
| 788 | + | s := New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{}) | |
| 789 | + | assert.Empty(t, s.workspaces, "no ext-workspace: W is not offered") | |
| 790 | + | assert.True(t, s.noIndicator) | |
| 791 | + | s = New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{Workspaces: true, Exclusion: true}) | |
| 792 | + | assert.Len(t, s.workspaces, 1) | |
| 793 | + | assert.False(t, s.noIndicator) | |
| 794 | + | } | |
M internal/adapters/selection/selector.go +23 -8
| @@ -45,20 +45,22 @@ var errNoOutputs = fmt.Errorf("selection: no outputs to select: %w", ports.ErrOu | |||
| 45 | 45 | ||
| 46 | 46 | // Selector shows the layer-shell selection overlay. | |
| 47 | 47 | type Selector struct { | |
| 48 | - | mode ports.Mode | |
| 49 | - | video ports.VideoSettings | |
| 50 | - | workspaces []ports.Workspace | |
| 51 | - | grid bool | |
| 52 | - | toggle bool | |
| 48 | + | mode ports.Mode | |
| 49 | + | video ports.VideoSettings | |
| 50 | + | workspaces []ports.Workspace | |
| 51 | + | grid bool | |
| 52 | + | toggle bool | |
| 53 | + | noIndicator bool | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | 56 | var _ ports.Selector = (*Selector)(nil) | |
| 56 | 57 | ||
| 57 | 58 | // New returns a selector for mode. Record selections carry video; a zero FPS | |
| 58 | - | // becomes DefaultFPS. workspaces is native compositor metadata: the W key is | |
| 59 | + | // becomes DefaultFPS. workspaces is ext-workspace metadata: the W key is | |
| 59 | 60 | // offered only for the Active workspace with a valid (nonzero) ID on the output | |
| 60 | - | // an overlay covers. Hidden workspaces are never offered and nothing is | |
| 61 | - | // invented when no workspace qualifies. grid is the initial state of the | |
| 61 | + | // an overlay covers. Other workspaces are never offered and nothing is | |
| 62 | + | // invented when no workspace qualifies; a NeferWL recording of that workspace | |
| 63 | + | // keeps following it when the user switches away. grid is the initial state of the | |
| 62 | 64 | // whole-monitor guides; the G key toggles them per overlay. The caller assigns | |
| 63 | 65 | // Selection.Path. The first output passed to Select opens first, so the caller | |
| 64 | 66 | // orders outputs to put the preferred one first. | |
| @@ -69,6 +71,17 @@ func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspac | |||
| 69 | 71 | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 74 | + | // WithCapabilities adapts the selector to the compositor: W is offered only | |
| 75 | + | // when it has workspaces, and record mode says so when it cannot show a | |
| 76 | + | // recording indicator. Without it the selector assumes nothing is missing. | |
| 77 | + | func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector { | |
| 78 | + | s.noIndicator = !c.Exclusion | |
| 79 | + | if !c.Workspaces { | |
| 80 | + | s.workspaces = nil | |
| 81 | + | } | |
| 82 | + | return s | |
| 83 | + | } | |
| 84 | + | ||
| 72 | 85 | // AllowToggle lets Tab switch between screenshot and record selection. The | |
| 73 | 86 | // starting mode is New's; the target selection is kept across a switch. | |
| 74 | 87 | func (s *Selector) AllowToggle() *Selector { | |
| @@ -113,10 +126,12 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 113 | 126 | for i := range outputs { | |
| 114 | 127 | m := newModel(s.mode, outputs, i, sess, s.grid) | |
| 115 | 128 | m.setWorkspaces(s.workspaces) | |
| 129 | + | m.noIndicator = s.noIndicator | |
| 116 | 130 | if s.toggle { | |
| 117 | 131 | m.toggle, m.wake = true, make(chan struct{}, 1) | |
| 118 | 132 | sess.wakes = append(sess.wakes, m.wake) | |
| 119 | 133 | } | |
| 134 | + | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | |
| 120 | 135 | wg.Add(1) | |
| 121 | 136 | go func() { | |
| 122 | 137 | defer wg.Done() | |
D internal/adapters/wayland/capture_session.go +0 -951
| @@ -1,951 +0,0 @@ | |||
| 1 | - | package wayland | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "cmp" | |
| 5 | - | "context" | |
| 6 | - | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "slices" | |
| 10 | - | "sync" | |
| 11 | - | "time" | |
| 12 | - | "unicode" | |
| 13 | - | "unicode/utf8" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/wlturbo" | |
| 16 | - | "github.com/bnema/wlturbo/protocol/core" | |
| 17 | - | ||
| 18 | - | "github.com/bnema/nefercap/internal/ports" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // Native private capture sessions. | |
| 22 | - | // | |
| 23 | - | // A session is a request to the compositor, on the capture connection itself, | |
| 24 | - | // to keep the capturing client's own surfaces out of the frames it copies. | |
| 25 | - | // The session belongs to the connection that began it: the compositor drops it | |
| 26 | - | // when that connection closes. A second connection (the selection indicator) | |
| 27 | - | // proves it belongs to the same peer by presenting the opaque session token | |
| 28 | - | // through AuthorizeLayer. | |
| 29 | - | // | |
| 30 | - | // The token is a credential. It is never logged and never printed by | |
| 31 | - | // SessionState's String method. | |
| 32 | - | ||
| 33 | - | const ( | |
| 34 | - | captureManagerIface = "neferwl_capture_manager_v1" | |
| 35 | - | captureManagerVersion = 1 | |
| 36 | - | tokenLen = 64 // lowercase hexadecimal characters of 32 random bytes | |
| 37 | - | sessionPingInterval = 2 * time.Second | |
| 38 | - | maxRegionRetries = 2 | |
| 39 | - | maxWorkspaces = 256 // bound of the workspace inventory | |
| 40 | - | maxNameLen = 256 // bytes, for output and workspace names | |
| 41 | - | ||
| 42 | - | // DefaultAttachTimeout bounds AuthorizeLayer. | |
| 43 | - | DefaultAttachTimeout = 5 * time.Second | |
| 44 | - | ) | |
| 45 | - | ||
| 46 | - | var ( | |
| 47 | - | // ErrSessionUnsupported means the compositor has no private capture | |
| 48 | - | // protocol, or the request cannot be served by the session (for example a | |
| 49 | - | // workspace capture without a session on that workspace). | |
| 50 | - | ErrSessionUnsupported = errors.New("wayland: private capture session unsupported") | |
| 51 | - | // ErrSessionUnavailable means the session is gone or was never started. | |
| 52 | - | // Callers that rely on the exclusion must stop recording. | |
| 53 | - | ErrSessionUnavailable = errors.New("wayland: private capture session unavailable") | |
| 54 | - | // ErrSessionActive reports BeginSession while a session is already active. | |
| 55 | - | ErrSessionActive = errors.New("wayland: private capture session already active") | |
| 56 | - | // ErrWorkspaceNotFound reports an unknown workspace ID. | |
| 57 | - | ErrWorkspaceNotFound = errors.New("wayland: workspace not found") | |
| 58 | - | // ErrSessionInactive means the compositor reported the session paused | |
| 59 | - | // (state active 0). The current compositor never does; the case is handled | |
| 60 | - | // defensively. It is transient: no frame is returned until it resumes. | |
| 61 | - | ErrSessionInactive = errors.New("wayland: private capture session paused") | |
| 62 | - | // ErrSessionGeometryChanging is transient: the session's region kept | |
| 63 | - | // changing during the bounded captures, so no frame is returned. | |
| 64 | - | ErrSessionGeometryChanging = errors.New("wayland: private capture session geometry is changing") | |
| 65 | - | // ErrServerMetadata means the compositor sent private-protocol metadata | |
| 66 | - | // that is out of bounds or hostile. The private capability is then | |
| 67 | - | // disabled and an active session fails closed; standard capture of | |
| 68 | - | // outputs is not affected. | |
| 69 | - | ErrServerMetadata = errors.New("wayland: invalid private capture metadata") | |
| 70 | - | // errLayerProtocol is a violation of the layer attachment state machine. | |
| 71 | - | errLayerProtocol = fmt.Errorf("%w: capture layer protocol violation", ErrSessionUnavailable) | |
| 72 | - | // ErrAttachFailed is matched by every AttachFailedError. | |
| 73 | - | ErrAttachFailed = errors.New("wayland: attach layer failed") | |
| 74 | - | // ErrUnauthorized means the compositor refused the peer. | |
| 75 | - | ErrUnauthorized = errors.New("wayland: capture session peer unauthorized") | |
| 76 | - | errCompositorFailedCapture = errors.New("wayland: compositor failed the capture") | |
| 77 | - | ) | |
| 78 | - | ||
| 79 | - | // StopReason is neferwl_capture_session_v1.stop_reason. | |
| 80 | - | type StopReason uint32 | |
| 81 | - | ||
| 82 | - | const ( | |
| 83 | - | StopRequested StopReason = 0 | |
| 84 | - | StopOutputGone StopReason = 1 | |
| 85 | - | StopOutputOff StopReason = 2 | |
| 86 | - | StopPingTimeout StopReason = 3 | |
| 87 | - | StopWorkspaceGone StopReason = 5 | |
| 88 | - | StopBusy StopReason = 6 | |
| 89 | - | StopInvalidRegion StopReason = 7 | |
| 90 | - | StopUnauthorized StopReason = 8 | |
| 91 | - | StopTooManyExcluded StopReason = 9 | |
| 92 | - | ) | |
| 93 | - | ||
| 94 | - | // SessionStoppedError reports that the compositor ended the session, or | |
| 95 | - | // refused it. It matches ErrSessionUnavailable. | |
| 96 | - | type SessionStoppedError struct{ Reason StopReason } | |
| 97 | - | ||
| 98 | - | func (e *SessionStoppedError) Error() string { | |
| 99 | - | return fmt.Sprintf("%v: stopped by the compositor (reason %d)", ErrSessionUnavailable, e.Reason) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func (e *SessionStoppedError) Is(target error) bool { return target == ErrSessionUnavailable } | |
| 103 | - | ||
| 104 | - | // AttachFailure is neferwl_capture_layer_v1.failure. | |
| 105 | - | type AttachFailure uint32 | |
| 106 | - | ||
| 107 | - | const ( | |
| 108 | - | AttachUnknownToken AttachFailure = 0 | |
| 109 | - | AttachUnauthorized AttachFailure = 1 | |
| 110 | - | AttachTooManyLayers AttachFailure = 2 | |
| 111 | - | AttachAlreadyAttached AttachFailure = 3 | |
| 112 | - | AttachSessionEnded AttachFailure = 4 | |
| 113 | - | AttachLayerDestroyed AttachFailure = 5 | |
| 114 | - | ) | |
| 115 | - | ||
| 116 | - | // AttachFailedError is the compositor's refusal of AuthorizeLayer. It matches | |
| 117 | - | // ErrAttachFailed; a missing or ended session also matches | |
| 118 | - | // ErrSessionUnavailable, and an unauthorized peer ErrUnauthorized. | |
| 119 | - | type AttachFailedError struct{ Reason AttachFailure } | |
| 120 | - | ||
| 121 | - | func (e *AttachFailedError) Error() string { | |
| 122 | - | return fmt.Sprintf("%v (reason %d)", ErrAttachFailed, e.Reason) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func (e *AttachFailedError) Is(target error) bool { | |
| 126 | - | switch target { | |
| 127 | - | case ErrAttachFailed: | |
| 128 | - | return true | |
| 129 | - | case ErrSessionUnavailable: | |
| 130 | - | return e.Reason == AttachUnknownToken || e.Reason == AttachSessionEnded | |
| 131 | - | case ErrUnauthorized: | |
| 132 | - | return e.Reason == AttachUnauthorized | |
| 133 | - | } | |
| 134 | - | return false | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // DetachReason is neferwl_capture_layer_v1.detach_reason. | |
| 138 | - | type DetachReason uint32 | |
| 139 | - | ||
| 140 | - | const ( | |
| 141 | - | // DetachSessionEnded: the session stopped and the compositor closed the | |
| 142 | - | // layer surface, which the client must destroy. | |
| 143 | - | DetachSessionEnded DetachReason = 0 | |
| 144 | - | // DetachLayerDestroyed: the layer role was destroyed. | |
| 145 | - | DetachLayerDestroyed DetachReason = 1 | |
| 146 | - | ) | |
| 147 | - | ||
| 148 | - | // LayerDetachedError is what onDetached receives. A session end also matches | |
| 149 | - | // ErrSessionUnavailable. | |
| 150 | - | type LayerDetachedError struct{ Reason DetachReason } | |
| 151 | - | ||
| 152 | - | func (e *LayerDetachedError) Error() string { | |
| 153 | - | return fmt.Sprintf("wayland: layer detached from the capture session (reason %d)", e.Reason) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | func (e *LayerDetachedError) Is(target error) bool { | |
| 157 | - | return target == ErrSessionUnavailable && e.Reason == DetachSessionEnded | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // SessionState is a snapshot of a session. Token is opaque. | |
| 161 | - | type SessionState struct { | |
| 162 | - | // Token is a secret: it is omitted from JSON, String and GoString. | |
| 163 | - | Token string `json:"-"` | |
| 164 | - | // Revision grows whenever the compositor changes the session's | |
| 165 | - | // configuration (layers, popups, target, pause). It starts at 1. | |
| 166 | - | Revision uint64 | |
| 167 | - | // Target is directly usable with Capture. For an output session it holds | |
| 168 | - | // the output and the granted Region. For a workspace session it holds the | |
| 169 | - | // output and WorkspaceID with a zero Region: the workspace frame is | |
| 170 | - | // resolved live by the Source, so Region is never a stale crop. | |
| 171 | - | Target ports.Target | |
| 172 | - | // Region is the geometry the compositor granted, output-local logical | |
| 173 | - | // pixels, at the time of this snapshot (for display, e.g. the indicator). | |
| 174 | - | Region ports.Region | |
| 175 | - | Active bool | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // String omits the token. | |
| 179 | - | func (s SessionState) String() string { | |
| 180 | - | return fmt.Sprintf("session{target:%+v active:%t}", s.Target, s.Active) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // GoString omits the token from %#v as well. | |
| 184 | - | func (s SessionState) GoString() string { return s.String() } | |
| 185 | - | ||
| 186 | - | // MarshalJSON omits the token, also when SessionState is embedded or logged | |
| 187 | - | // through a structured logger. | |
| 188 | - | func (s SessionState) MarshalJSON() ([]byte, error) { | |
| 189 | - | return json.Marshal(struct { | |
| 190 | - | Revision uint64 | |
| 191 | - | Target ports.Target | |
| 192 | - | Region ports.Region | |
| 193 | - | Active bool | |
| 194 | - | }{s.Revision, s.Target, s.Region, s.Active}) | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | // --------------------------------------------------------------------------- | |
| 198 | - | // Wire layer: neferwl-capture-v1.xml of the compositor, interface versions 1. | |
| 199 | - | // | |
| 200 | - | // neferwl_capture_manager_v1 | |
| 201 | - | // requests: 0 destroy, 1 begin_session(new_id, output, x, y, w, h, | |
| 202 | - | // workspace_hi, workspace_lo, record), | |
| 203 | - | // 2 attach_surface(new_id layer, token, surface) | |
| 204 | - | // events: 0 workspace(hi, lo, output_name, name, x, y, w, h, active) | |
| 205 | - | // 1 workspace_removed(hi, lo) | |
| 206 | - | // neferwl_capture_layer_v1 | |
| 207 | - | // requests: 0 destroy | |
| 208 | - | // events: 0 attached 1 failed(reason) 2 detached(reason) | |
| 209 | - | // neferwl_capture_session_v1 | |
| 210 | - | // requests: 0 destroy, 1 ping | |
| 211 | - | // events: 0 state(token, active, x, y, w, h, workspace_hi, workspace_lo, | |
| 212 | - | // revision_hi, revision_lo) 1 stopped(reason) | |
| 213 | - | // --------------------------------------------------------------------------- | |
| 214 | - | ||
| 215 | - | const ( | |
| 216 | - | reqBeginSession = 1 | |
| 217 | - | reqAttachSurface = 2 | |
| 218 | - | reqManagerDestroy = 0 | |
| 219 | - | reqLayerDestroy = 0 | |
| 220 | - | reqSessionDestroy = 0 | |
| 221 | - | reqSessionPing = 1 | |
| 222 | - | ) | |
| 223 | - | ||
| 224 | - | // captureManager is the bound manager. s is nil on a connection that only | |
| 225 | - | // authorizes a layer surface: workspace events are then ignored. | |
| 226 | - | type captureManager struct { | |
| 227 | - | wlturbo.BaseProxy | |
| 228 | - | s *Source | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | func (*captureManager) EventSignature(op uint16) (string, bool) { | |
| 232 | - | switch op { | |
| 233 | - | case 0: | |
| 234 | - | return "uint,uint,string,string,int,int,int,int,uint,", true | |
| 235 | - | case 1: | |
| 236 | - | return "uint,uint,", true | |
| 237 | - | } | |
| 238 | - | return "", false | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func (m *captureManager) Dispatch(e *wlturbo.Event) { | |
| 242 | - | if m.s == nil { | |
| 243 | - | return | |
| 244 | - | } | |
| 245 | - | switch e.Opcode { | |
| 246 | - | case 0: | |
| 247 | - | hi, lo := e.Uint32(), e.Uint32() | |
| 248 | - | outName, name := e.String(), e.String() | |
| 249 | - | x, y, w, h := e.Int32(), e.Int32(), e.Int32(), e.Int32() | |
| 250 | - | m.s.onWorkspace(join64(hi, lo), outName, name, ports.Region{X: int(x), Y: int(y), Width: int(w), Height: int(h)}, e.Uint32() != 0) | |
| 251 | - | case 1: | |
| 252 | - | if m.s.metaErr == nil { | |
| 253 | - | delete(m.s.workspaces, join64(e.Uint32(), e.Uint32())) | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | // join64 joins the hi and lo halves of a 64-bit wire value. | |
| 259 | - | func join64(hi, lo uint32) uint64 { return uint64(hi)<<32 | uint64(lo) } | |
| 260 | - | ||
| 261 | - | // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are | |
| 262 | - | // dispatched by whichever goroutine reads the GUI connection: during | |
| 263 | - | // authorizeLayer that is the caller, afterwards the connection's reader. | |
| 264 | - | // | |
| 265 | - | // State machine: pending -> attached | failed; attached -> detached. Any other | |
| 266 | - | // event is a protocol violation: the exclusion cannot be trusted any more. | |
| 267 | - | type layerProxy struct { | |
| 268 | - | wlturbo.BaseProxy | |
| 269 | - | display *wlturbo.Display | |
| 270 | - | onDetached func(error) | |
| 271 | - | ||
| 272 | - | mu sync.Mutex | |
| 273 | - | state layerState | |
| 274 | - | failReason uint32 | |
| 275 | - | violation bool | |
| 276 | - | returned bool // authorizeLayer handed the surface to its caller | |
| 277 | - | notified bool | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | type layerState int | |
| 281 | - | ||
| 282 | - | const ( | |
| 283 | - | layerPending layerState = iota | |
| 284 | - | layerAttached | |
| 285 | - | layerFailed | |
| 286 | - | layerDetached | |
| 287 | - | ) | |
| 288 | - | ||
| 289 | - | func (*layerProxy) EventSignature(op uint16) (string, bool) { | |
| 290 | - | switch op { | |
| 291 | - | case 0: | |
| 292 | - | return "", true | |
| 293 | - | case 1, 2: | |
| 294 | - | return "uint,", true | |
| 295 | - | } | |
| 296 | - | return "", false | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | func (l *layerProxy) Dispatch(e *wlturbo.Event) { | |
| 300 | - | var arg uint32 | |
| 301 | - | if e.Opcode != 0 { | |
| 302 | - | arg = e.Uint32() | |
| 303 | - | } | |
| 304 | - | var notify error | |
| 305 | - | closeDisplay := false | |
| 306 | - | l.mu.Lock() | |
| 307 | - | returned := l.returned | |
| 308 | - | switch { | |
| 309 | - | case l.violation: | |
| 310 | - | // Terminal: the display is closed already. | |
| 311 | - | case e.Opcode == 0 && l.state == layerPending: | |
| 312 | - | l.state = layerAttached | |
| 313 | - | case e.Opcode == 1 && l.state == layerPending: | |
| 314 | - | l.state, l.failReason = layerFailed, arg | |
| 315 | - | case e.Opcode == 2 && l.state == layerAttached: | |
| 316 | - | l.state = layerDetached | |
| 317 | - | if returned { | |
| 318 | - | l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)} | |
| 319 | - | } | |
| 320 | - | default: | |
| 321 | - | l.violation, closeDisplay = true, true | |
| 322 | - | if returned && !l.notified { | |
| 323 | - | l.notified, notify = true, errLayerProtocol | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | l.mu.Unlock() | |
| 327 | - | if closeDisplay && returned { | |
| 328 | - | _ = l.display.Close() // the surface could be visible in captures | |
| 329 | - | } | |
| 330 | - | if notify != nil && l.onDetached != nil { | |
| 331 | - | l.onDetached(notify) | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | // settled reports that the answer arrived, or the layer misbehaved. | |
| 336 | - | func (l *layerProxy) settled() bool { | |
| 337 | - | l.mu.Lock() | |
| 338 | - | defer l.mu.Unlock() | |
| 339 | - | return l.state != layerPending || l.violation | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | // hand marks the layer as owned by the caller of authorizeLayer, and reports | |
| 343 | - | // its outcome so far. | |
| 344 | - | func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) { | |
| 345 | - | l.mu.Lock() | |
| 346 | - | defer l.mu.Unlock() | |
| 347 | - | l.returned = true | |
| 348 | - | return l.state, l.failReason, l.violation | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | // captureSession is one private capture session proxy. | |
| 352 | - | type captureSession struct { | |
| 353 | - | wlturbo.BaseProxy | |
| 354 | - | ||
| 355 | - | // Pinned at begin: what the owner asked for and where. | |
| 356 | - | outputID uint32 | |
| 357 | - | requestedWorkspace uint64 | |
| 358 | - | requestedRegion ports.Region // output targets only; zero is the full output | |
| 359 | - | ||
| 360 | - | haveState bool | |
| 361 | - | token string | |
| 362 | - | active bool | |
| 363 | - | region ports.Region // last granted geometry | |
| 364 | - | revision uint64 | |
| 365 | - | ||
| 366 | - | stopped bool | |
| 367 | - | stopReason uint32 | |
| 368 | - | violation bool // the compositor broke the protocol: fail closed | |
| 369 | - | ||
| 370 | - | lastPing time.Time // monotonic | |
| 371 | - | destroyed bool | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | func (*captureSession) EventSignature(op uint16) (string, bool) { | |
| 375 | - | switch op { | |
| 376 | - | case 0: | |
| 377 | - | return "string,uint,int,int,int,int,uint,uint,uint,uint,", true | |
| 378 | - | case 1: | |
| 379 | - | return "uint,", true | |
| 380 | - | } | |
| 381 | - | return "", false | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func (c *captureSession) Dispatch(e *wlturbo.Event) { | |
| 385 | - | switch e.Opcode { | |
| 386 | - | case 0: | |
| 387 | - | token, active := e.String(), e.Uint32() != 0 | |
| 388 | - | x, y, w, h := e.Int32(), e.Int32(), e.Int32(), e.Int32() | |
| 389 | - | ws := join64(e.Uint32(), e.Uint32()) | |
| 390 | - | rev := join64(e.Uint32(), e.Uint32()) | |
| 391 | - | if c.lost() { | |
| 392 | - | return // final: later events change nothing | |
| 393 | - | } | |
| 394 | - | grant := ports.Region{X: int(x), Y: int(y), Width: int(w), Height: int(h)} | |
| 395 | - | switch { | |
| 396 | - | case !c.haveState && !validToken(token), | |
| 397 | - | c.haveState && token != c.token, | |
| 398 | - | rev == 0 || rev < c.revision, // revisions start at 1 and never go back | |
| 399 | - | !c.validGrant(grant, ws): | |
| 400 | - | c.violation = true | |
| 401 | - | return | |
| 402 | - | } | |
| 403 | - | c.haveState, c.token = true, token | |
| 404 | - | c.active, c.revision, c.region = active, rev, grant | |
| 405 | - | case 1: | |
| 406 | - | c.stopped, c.stopReason = true, e.Uint32() | |
| 407 | - | } | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // validGrant checks a granted geometry against what was requested. A zero, | |
| 411 | - | // negative or oversized rectangle is never read as "the full output". | |
| 412 | - | func (c *captureSession) validGrant(g ports.Region, workspace uint64) bool { | |
| 413 | - | if workspace != c.requestedWorkspace || !boundedRegion(g) { | |
| 414 | - | return false | |
| 415 | - | } | |
| 416 | - | if c.requestedWorkspace != 0 || c.requestedRegion == (ports.Region{}) { | |
| 417 | - | // The workspace frame may move and resize; the full output has no | |
| 418 | - | // exactly known logical size (scales can be fractional), so only the | |
| 419 | - | // hard bounds apply. | |
| 420 | - | return true | |
| 421 | - | } | |
| 422 | - | r := c.requestedRegion | |
| 423 | - | return g.X >= r.X && g.Y >= r.Y && | |
| 424 | - | int64(g.X)+int64(g.Width) <= int64(r.X)+int64(r.Width) && | |
| 425 | - | int64(g.Y)+int64(g.Height) <= int64(r.Y)+int64(r.Height) | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // boundedRegion reports a positive rectangle inside the maximum dimensions. | |
| 429 | - | func boundedRegion(r ports.Region) bool { | |
| 430 | - | return r.X >= 0 && r.Y >= 0 && r.Width >= 1 && r.Height >= 1 && | |
| 431 | - | r.Width <= ports.MaxDimension && r.Height <= ports.MaxDimension && | |
| 432 | - | int64(r.X)+int64(r.Width) <= ports.MaxDimension && | |
| 433 | - | int64(r.Y)+int64(r.Height) <= ports.MaxDimension | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | // lost reports that the exclusion can no longer be relied upon. | |
| 437 | - | func (c *captureSession) lost() bool { return c.stopped || c.violation } | |
| 438 | - | ||
| 439 | - | // begun reports that the compositor answered begin_session with a state. | |
| 440 | - | func (c *captureSession) begun() bool { return c.haveState } | |
| 441 | - | ||
| 442 | - | // workspace is the client-side state of one advertised workspace. | |
| 443 | - | type workspace struct { | |
| 444 | - | id uint64 | |
| 445 | - | output string | |
| 446 | - | name string | |
| 447 | - | region ports.Region | |
| 448 | - | active bool | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | func (s *Source) onWorkspace(id uint64, outName, name string, r ports.Region, active bool) { | |
| 452 | - | if s.metaErr != nil { | |
| 453 | - | return | |
| 454 | - | } | |
| 455 | - | switch { | |
| 456 | - | case !validName(outName, false), !validName(name, true): | |
| 457 | - | s.failMetadata() | |
| 458 | - | case id == 0 || !boundedRegion(r): | |
| 459 | - | delete(s.workspaces, id) // unusable announcement | |
| 460 | - | case s.workspaces[id] == nil && len(s.workspaces) >= maxWorkspaces: | |
| 461 | - | s.failMetadata() | |
| 462 | - | default: | |
| 463 | - | s.workspaces[id] = &workspace{id: id, output: outName, name: name, region: r, active: active} | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | // failMetadata disables the private capability: the inventory is emptied, and | |
| 468 | - | // an active session fails closed. Standard output capture is unaffected. | |
| 469 | - | func (s *Source) failMetadata() { | |
| 470 | - | s.metaErr = fmt.Errorf("%w: %w", ErrSessionUnavailable, ErrServerMetadata) | |
| 471 | - | clear(s.workspaces) | |
| 472 | - | if s.session != nil { | |
| 473 | - | s.session.violation = true | |
| 474 | - | } | |
| 475 | - | s.log.Warn().Msg("private capture metadata rejected") | |
| 476 | - | } | |
| 477 | - | ||
| 478 | - | // validName accepts a bounded, valid UTF-8, single-line name without control | |
| 479 | - | // or bidirectional formatting characters. | |
| 480 | - | func validName(n string, emptyOK bool) bool { | |
| 481 | - | if len(n) > maxNameLen || (n == "" && !emptyOK) || !utf8.ValidString(n) { | |
| 482 | - | return false | |
| 483 | - | } | |
| 484 | - | for _, r := range n { | |
| 485 | - | switch { | |
| 486 | - | case unicode.IsControl(r), r == 0x2028, r == 0x2029, r == 0x061C, r == 0x200E, r == 0x200F, | |
| 487 | - | r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069: | |
| 488 | - | return false | |
| 489 | - | } | |
| 490 | - | } | |
| 491 | - | return true | |
| 492 | - | } | |
| 493 | - | ||
| 494 | - | func outputName(o *output) string { | |
| 495 | - | if o.name == "" { // wl_output older than version 4 has no name | |
| 496 | - | return fmt.Sprintf("output-%d", o.id) | |
| 497 | - | } | |
| 498 | - | return o.name | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | // bindCaptureManager binds the private manager when the compositor offers it. | |
| 502 | - | // Its absence is not an error: the session methods then report | |
| 503 | - | // ErrSessionUnsupported. | |
| 504 | - | func (s *Source) bindCaptureManager() error { | |
| 505 | - | s.workspaces = make(map[uint64]*workspace) | |
| 506 | - | m := &captureManager{s: s} | |
| 507 | - | m.SetContext(s.wl) | |
| 508 | - | _, err := s.display.Registry().BindNegotiated(captureManagerIface, captureManagerVersion, m) | |
| 509 | - | switch { | |
| 510 | - | case err == nil: | |
| 511 | - | s.private = m | |
| 512 | - | return nil | |
| 513 | - | case errors.Is(err, wlturbo.ErrGlobalNotFound): | |
| 514 | - | return nil | |
| 515 | - | } | |
| 516 | - | return fmt.Errorf("wayland: bind %s: %w", captureManagerIface, err) | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | func (s *Source) newSessionProxy() *captureSession { | |
| 520 | - | c := &captureSession{} | |
| 521 | - | c.SetContext(s.wl) | |
| 522 | - | return c | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func (s *Source) destroySession(c *captureSession) { | |
| 526 | - | if c.destroyed { | |
| 527 | - | return | |
| 528 | - | } | |
| 529 | - | c.destroyed = true | |
| 530 | - | _ = s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionDestroy, Name: "neferwl_capture_session_v1.destroy", Destructor: true}) | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | // Workspaces lists the workspaces the compositor advertises through the | |
| 534 | - | // private capture protocol, by ID. It returns ErrSessionUnsupported when the | |
| 535 | - | // compositor has no such protocol: workspace identities are never guessed. | |
| 536 | - | func (s *Source) Workspaces(ctx context.Context) ([]ports.Workspace, error) { | |
| 537 | - | s.opMu.Lock() | |
| 538 | - | defer s.opMu.Unlock() | |
| 539 | - | if err := s.begin(ctx); err != nil { | |
| 540 | - | return nil, err | |
| 541 | - | } | |
| 542 | - | err := s.settle() | |
| 543 | - | if ferr := s.finish(ctx, err); ferr != nil { | |
| 544 | - | return nil, ferr | |
| 545 | - | } | |
| 546 | - | if s.private == nil { | |
| 547 | - | return nil, ErrSessionUnsupported | |
| 548 | - | } | |
| 549 | - | if s.metaErr != nil { | |
| 550 | - | return nil, s.metaErr | |
| 551 | - | } | |
| 552 | - | return s.workspaceList(), nil | |
| 553 | - | } | |
| 554 | - | ||
| 555 | - | func (s *Source) workspaceList() []ports.Workspace { | |
| 556 | - | byName := make(map[string]uint32, len(s.outputs)) | |
| 557 | - | for _, o := range s.outputs { | |
| 558 | - | byName[outputName(o)] = o.id | |
| 559 | - | } | |
| 560 | - | list := make([]ports.Workspace, 0, len(s.workspaces)) | |
| 561 | - | for _, w := range s.workspaces { | |
| 562 | - | id, ok := byName[w.output] | |
| 563 | - | if !ok { | |
| 564 | - | continue // its output is not (or no longer) announced | |
| 565 | - | } | |
| 566 | - | list = append(list, ports.Workspace{ID: w.id, OutputID: id, Name: w.name, Region: w.region, Active: w.active}) | |
| 567 | - | } | |
| 568 | - | slices.SortFunc(list, func(a, b ports.Workspace) int { return cmp.Compare(a.ID, b.ID) }) | |
| 569 | - | return list | |
| 570 | - | } | |
| 571 | - | ||
| 572 | - | // BeginSession starts the private capture session of this connection for | |
| 573 | - | // target and waits until the compositor confirms it active. record tells the | |
| 574 | - | // compositor whether the session backs a recording. The Source owns at most | |
| 575 | - | // one session; it ends with EndSession, a compositor stop, or Close. | |
| 576 | - | // | |
| 577 | - | // Output targets (with an optional output-local region) and workspace targets | |
| 578 | - | // are accepted. A workspace target needs a workspace advertised by Workspaces, | |
| 579 | - | // on screen or not, and no region: the request carries a zero region and the | |
| 580 | - | // compositor grants the workspace frame. The output, the workspace and the | |
| 581 | - | // requested region are pinned for the session; the compositor's state events | |
| 582 | - | // are validated against them. | |
| 583 | - | func (s *Source) BeginSession(ctx context.Context, target ports.Target, record bool) (SessionState, error) { | |
| 584 | - | s.opMu.Lock() | |
| 585 | - | defer s.opMu.Unlock() | |
| 586 | - | if err := s.begin(ctx); err != nil { | |
| 587 | - | return SessionState{}, err | |
| 588 | - | } | |
| 589 | - | state, err := s.beginSession(target, record) | |
| 590 | - | if err = s.finish(ctx, err); err != nil { | |
| 591 | - | return SessionState{}, err | |
| 592 | - | } | |
| 593 | - | return state, nil | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | func (s *Source) beginSession(target ports.Target, record bool) (SessionState, error) { | |
| 597 | - | if s.private == nil { | |
| 598 | - | return SessionState{}, ErrSessionUnsupported | |
| 599 | - | } | |
| 600 | - | if s.session != nil { | |
| 601 | - | return SessionState{}, ErrSessionActive | |
| 602 | - | } | |
| 603 | - | if err := s.settle(); err != nil { // current outputs and workspaces | |
| 604 | - | return SessionState{}, err | |
| 605 | - | } | |
| 606 | - | if s.metaErr != nil { | |
| 607 | - | return SessionState{}, s.metaErr | |
| 608 | - | } | |
| 609 | - | out, region, err := s.sessionTarget(target) | |
| 610 | - | if err != nil { | |
| 611 | - | return SessionState{}, err | |
| 612 | - | } | |
| 613 | - | c := s.newSessionProxy() | |
| 614 | - | c.outputID, c.requestedWorkspace, c.requestedRegion = out.id, target.WorkspaceID, region | |
| 615 | - | hi, lo := uint32(target.WorkspaceID>>32), uint32(target.WorkspaceID) | |
| 616 | - | rec := uint32(0) | |
| 617 | - | if record { | |
| 618 | - | rec = 1 | |
| 619 | - | } | |
| 620 | - | err = s.wl.Request(wlturbo.Request{Proxy: s.private, Opcode: reqBeginSession, Name: "neferwl_capture_manager_v1.begin_session", Child: c}, | |
| 621 | - | c, out.proxy, int32(region.X), int32(region.Y), int32(region.Width), int32(region.Height), hi, lo, rec) | |
| 622 | - | if err != nil { | |
| 623 | - | s.terminate() | |
| 624 | - | return SessionState{}, fmt.Errorf("wayland: begin session: %w", err) | |
| 625 | - | } | |
| 626 | - | // The answer is a state (possibly paused) or a stop without a state. | |
| 627 | - | for !c.begun() && !c.lost() { | |
| 628 | - | if err := s.dispatch(); err != nil { | |
| 629 | - | return SessionState{}, err | |
| 630 | - | } | |
| 631 | - | } | |
| 632 | - | if c.lost() { | |
| 633 | - | s.destroySession(c) | |
| 634 | - | if err := s.roundtrip(); err != nil { | |
| 635 | - | return SessionState{}, err | |
| 636 | - | } | |
| 637 | - | return SessionState{}, sessionLostError(c) | |
| 638 | - | } | |
| 639 | - | c.lastPing = time.Now() | |
| 640 | - | s.session = c | |
| 641 | - | return snapshot(c), nil | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | // sessionTarget resolves target to an output and the region to request: zero | |
| 645 | - | // for a workspace, the whole frame. | |
| 646 | - | func (s *Source) sessionTarget(t ports.Target) (*output, ports.Region, error) { | |
| 647 | - | if t.WorkspaceID != 0 { | |
| 648 | - | ws := s.workspaces[t.WorkspaceID] | |
| 649 | - | if ws == nil { | |
| 650 | - | return nil, ports.Region{}, ErrWorkspaceNotFound | |
| 651 | - | } | |
| 652 | - | if t.Region != (ports.Region{}) { | |
| 653 | - | return nil, ports.Region{}, fmt.Errorf("%w: a workspace target has no region", ports.ErrInvalidRegion) | |
| 654 | - | } | |
| 655 | - | out := s.outputByName(ws.output) | |
| 656 | - | if out == nil || (t.OutputID != 0 && t.OutputID != out.id) { | |
| 657 | - | return nil, ports.Region{}, ports.ErrOutputNotFound | |
| 658 | - | } | |
| 659 | - | return out, ports.Region{}, nil | |
| 660 | - | } | |
| 661 | - | if err := validRegion(t.Region); err != nil { | |
| 662 | - | return nil, ports.Region{}, err | |
| 663 | - | } | |
| 664 | - | out := s.outputs[t.OutputID] | |
| 665 | - | if out == nil { | |
| 666 | - | return nil, ports.Region{}, ports.ErrOutputNotFound | |
| 667 | - | } | |
| 668 | - | return out, t.Region, nil | |
| 669 | - | } | |
| 670 | - | ||
| 671 | - | func (s *Source) outputByName(name string) *output { | |
| 672 | - | for _, o := range s.outputs { | |
| 673 | - | if outputName(o) == name { | |
| 674 | - | return o | |
| 675 | - | } | |
| 676 | - | } | |
| 677 | - | return nil | |
| 678 | - | } | |
| 679 | - | ||
| 680 | - | func snapshot(c *captureSession) SessionState { | |
| 681 | - | target := ports.Target{OutputID: c.outputID, Region: c.region} | |
| 682 | - | if c.requestedWorkspace != 0 { | |
| 683 | - | target = ports.Target{OutputID: c.outputID, WorkspaceID: c.requestedWorkspace} | |
| 684 | - | } | |
| 685 | - | return SessionState{ | |
| 686 | - | Token: c.token, | |
| 687 | - | Revision: c.revision, | |
| 688 | - | Target: target, | |
| 689 | - | Region: c.region, | |
| 690 | - | Active: c.active && !c.lost(), | |
| 691 | - | } | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | // EndSession ends the session. It is a no-op without one and is safe after the | |
| 695 | - | // compositor stopped the session. | |
| 696 | - | func (s *Source) EndSession(ctx context.Context) error { | |
| 697 | - | s.opMu.Lock() | |
| 698 | - | defer s.opMu.Unlock() | |
| 699 | - | if err := s.begin(ctx); err != nil { | |
| 700 | - | return err | |
| 701 | - | } | |
| 702 | - | var err error | |
| 703 | - | if c := s.session; c != nil { | |
| 704 | - | s.session = nil | |
| 705 | - | s.destroySession(c) | |
| 706 | - | err = s.roundtrip() | |
| 707 | - | } | |
| 708 | - | return s.finish(ctx, err) | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | // PingSession sends a keep-alive and reports ErrSessionUnavailable when the | |
| 712 | - | // compositor has stopped the session, or when none is active. | |
| 713 | - | func (s *Source) PingSession(ctx context.Context) error { | |
| 714 | - | s.opMu.Lock() | |
| 715 | - | defer s.opMu.Unlock() | |
| 716 | - | if err := s.begin(ctx); err != nil { | |
| 717 | - | return err | |
| 718 | - | } | |
| 719 | - | err := s.pingSession() | |
| 720 | - | return s.finish(ctx, err) | |
| 721 | - | } | |
| 722 | - | ||
| 723 | - | func (s *Source) pingSession() error { | |
| 724 | - | c := s.session | |
| 725 | - | if c == nil { | |
| 726 | - | return fmt.Errorf("%w: no active session", ErrSessionUnavailable) | |
| 727 | - | } | |
| 728 | - | if c.lost() { | |
| 729 | - | return sessionLostError(c) | |
| 730 | - | } | |
| 731 | - | if err := s.sendPing(c); err != nil { | |
| 732 | - | return err | |
| 733 | - | } | |
| 734 | - | if err := s.roundtrip(); err != nil { // observe a stop that answers the ping | |
| 735 | - | return err | |
| 736 | - | } | |
| 737 | - | if c.lost() { | |
| 738 | - | return sessionLostError(c) | |
| 739 | - | } | |
| 740 | - | return nil | |
| 741 | - | } | |
| 742 | - | ||
| 743 | - | func (s *Source) sendPing(c *captureSession) error { | |
| 744 | - | if err := s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionPing, Name: "neferwl_capture_session_v1.ping"}); err != nil { | |
| 745 | - | s.terminate() | |
| 746 | - | return fmt.Errorf("wayland: session ping: %w", err) | |
| 747 | - | } | |
| 748 | - | c.lastPing = time.Now() | |
| 749 | - | return nil | |
| 750 | - | } | |
| 751 | - | ||
| 752 | - | func sessionLostError(c *captureSession) error { | |
| 753 | - | if c.violation { | |
| 754 | - | return fmt.Errorf("%w: invalid session state from the compositor", ErrSessionUnavailable) | |
| 755 | - | } | |
| 756 | - | return &SessionStoppedError{Reason: StopReason(c.stopReason)} | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | // captureTarget is Capture under an active session: it resolves workspace | |
| 760 | - | // targets, sends a keep-alive when one is due (a single request, no wait), and | |
| 761 | - | // never returns a frame captured while the exclusion was lost. The caller | |
| 762 | - | // holds opMu. | |
| 763 | - | func (s *Source) captureTarget(t ports.Target) (ports.Frame, error) { | |
| 764 | - | c := s.session | |
| 765 | - | if c != nil && c.lost() { | |
| 766 | - | return ports.Frame{}, sessionLostError(c) | |
| 767 | - | } | |
| 768 | - | if c != nil && !c.active { | |
| 769 | - | // Events are only read inside operations: look once for a resume. | |
| 770 | - | if err := s.roundtrip(); err != nil { | |
| 771 | - | return ports.Frame{}, err | |
| 772 | - | } | |
| 773 | - | if c.lost() { | |
| 774 | - | return ports.Frame{}, sessionLostError(c) | |
| 775 | - | } | |
| 776 | - | if !c.active { | |
| 777 | - | return ports.Frame{}, ErrSessionInactive | |
| 778 | - | } | |
| 779 | - | } | |
| 780 | - | if c != nil && time.Since(c.lastPing) >= s.pingEvery { | |
| 781 | - | if err := s.sendPing(c); err != nil { | |
| 782 | - | return ports.Frame{}, err | |
| 783 | - | } | |
| 784 | - | } | |
| 785 | - | // Events are only read while a request is in flight, so a live geometry | |
| 786 | - | // change is seen during the capture that used the previous region. Such a | |
| 787 | - | // frame is stale for a workspace target: it is never returned. Capture | |
| 788 | - | // again, a bounded number of times and without any extra wait, then give up | |
| 789 | - | // with a transient error. | |
| 790 | - | retriedFailure := false | |
| 791 | - | for attempt := 0; ; attempt++ { | |
| 792 | - | rt, err := s.resolveCapture(t) | |
| 793 | - | if err != nil { | |
| 794 | - | return ports.Frame{}, err | |
| 795 | - | } | |
| 796 | - | used := rt.Region | |
| 797 | - | frame, err := s.capture(rt) | |
| 798 | - | if c != nil && errors.Is(err, ports.ErrOutputNotFound) && !c.lost() { | |
| 799 | - | // An output that went away ends the session: read that stop. | |
| 800 | - | if rerr := s.roundtrip(); rerr != nil { | |
| 801 | - | return ports.Frame{}, rerr | |
| 802 | - | } | |
| 803 | - | } | |
| 804 | - | if c != nil && c.lost() { // a stop was dispatched during the capture | |
| 805 | - | return ports.Frame{}, sessionLostError(c) | |
| 806 | - | } | |
| 807 | - | if errors.Is(err, errCompositorFailedCapture) && c != nil && c.active && !retriedFailure { | |
| 808 | - | // The compositor's scene may lag a session change by one frame and | |
| 809 | - | // fail the copy: retry once, never blindly beyond that. | |
| 810 | - | retriedFailure = true | |
| 811 | - | attempt-- | |
| 812 | - | continue | |
| 813 | - | } | |
| 814 | - | if err != nil { | |
| 815 | - | return ports.Frame{}, err | |
| 816 | - | } | |
| 817 | - | if c != nil && !c.active { // paused meanwhile: the frame is not clean | |
| 818 | - | return ports.Frame{}, ErrSessionInactive | |
| 819 | - | } | |
| 820 | - | if t.WorkspaceID == 0 || c == nil || c.region == used { | |
| 821 | - | return frame, nil | |
| 822 | - | } | |
| 823 | - | if attempt >= maxRegionRetries { | |
| 824 | - | return ports.Frame{}, ErrSessionGeometryChanging | |
| 825 | - | } | |
| 826 | - | } | |
| 827 | - | } | |
| 828 | - | ||
| 829 | - | // resolveCapture maps a workspace target to the session's pinned output and | |
| 830 | - | // the live region the compositor last granted. The workspace inventory is not | |
| 831 | - | // consulted: it can change or lose the workspace at any time, and the session | |
| 832 | - | // alone decides what is captured. | |
| 833 | - | func (s *Source) resolveCapture(t ports.Target) (ports.Target, error) { | |
| 834 | - | if t.WorkspaceID == 0 { | |
| 835 | - | return t, nil | |
| 836 | - | } | |
| 837 | - | c := s.session | |
| 838 | - | if c == nil || c.requestedWorkspace != t.WorkspaceID { | |
| 839 | - | return t, fmt.Errorf("%w: workspace capture needs a session on that workspace", ErrSessionUnsupported) | |
| 840 | - | } | |
| 841 | - | if t.Region != (ports.Region{}) { | |
| 842 | - | return t, fmt.Errorf("%w: a workspace target has no region", ports.ErrInvalidRegion) | |
| 843 | - | } | |
| 844 | - | if t.OutputID != 0 && t.OutputID != c.outputID { | |
| 845 | - | return t, ports.ErrOutputNotFound | |
| 846 | - | } | |
| 847 | - | return ports.Target{OutputID: c.outputID, Region: c.region}, nil | |
| 848 | - | } | |
| 849 | - | ||
| 850 | - | // AuthorizeLayer tells the compositor, from a connection other than the | |
| 851 | - | // capture connection, that surface is a layer surface of the capture peer and | |
| 852 | - | // must be excluded from its session. token comes from SessionState. Call it | |
| 853 | - | // after the layer-shell role is assigned and before the surface is mapped | |
| 854 | - | // (before the first buffer); when it returns nil the compositor core has | |
| 855 | - | // confirmed the exclusion for this very attachment, so the surface is safe to | |
| 856 | - | // show. | |
| 857 | - | // | |
| 858 | - | // AuthorizeLayer dispatches display's events itself until the attachment | |
| 859 | - | // answers, so it must run before the connection's event loop starts. A | |
| 860 | - | // refusal returns an *AttachFailedError. A protocol error is returned as the | |
| 861 | - | // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout | |
| 862 | - | // (an error wrapping context.DeadlineExceeded), closes display before | |
| 863 | - | // returning: the surface must then stay unmapped. So does a violation of the | |
| 864 | - | // attachment protocol (an answer twice, a detach before the answer, ...). | |
| 865 | - | // | |
| 866 | - | // After it returns nil the attachment stays until the session stops or the | |
| 867 | - | // layer is destroyed; then onDetached (may be nil) receives a | |
| 868 | - | // *LayerDetachedError, once. A violation of the attachment protocol at that | |
| 869 | - | // point closes display and calls onDetached with an error matching | |
| 870 | - | // ErrSessionUnavailable; the surface may still draw until the caller stops, | |
| 871 | - | // so onDetached must make the caller stop. It runs on the goroutine that reads | |
| 872 | - | // display, so it must only do thread-safe, non-blocking work such as | |
| 873 | - | // cancelling a context. It never sees the token. The attachment object is | |
| 874 | - | // never destroyed after a success: that would not release the exclusion, and | |
| 875 | - | // closing the connection ends it. After DetachSessionEnded the compositor has | |
| 876 | - | // closed the layer surface, which the caller destroys. | |
| 877 | - | func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error)) error { | |
| 878 | - | return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout) | |
| 879 | - | } | |
| 880 | - | ||
| 881 | - | func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error), timeout time.Duration) error { | |
| 882 | - | if display == nil || surface == nil || surface.Context() != display.Context() { | |
| 883 | - | return errors.New("wayland: authorize layer: surface does not belong to display") | |
| 884 | - | } | |
| 885 | - | if !validToken(token) { | |
| 886 | - | return errors.New("wayland: authorize layer: invalid token") | |
| 887 | - | } | |
| 888 | - | if err := ctx.Err(); err != nil { | |
| 889 | - | return err | |
| 890 | - | } | |
| 891 | - | m := &captureManager{} | |
| 892 | - | m.SetContext(display.Context()) | |
| 893 | - | if _, err := display.Registry().BindNegotiated(captureManagerIface, captureManagerVersion, m); err != nil { | |
| 894 | - | if errors.Is(err, wlturbo.ErrGlobalNotFound) { | |
| 895 | - | return ErrSessionUnsupported | |
| 896 | - | } | |
| 897 | - | return fmt.Errorf("wayland: bind %s: %w", captureManagerIface, err) | |
| 898 | - | } | |
| 899 | - | wctx, cancel := context.WithTimeout(ctx, timeout) | |
| 900 | - | defer cancel() | |
| 901 | - | stop := context.AfterFunc(wctx, func() { _ = display.Close() }) | |
| 902 | - | defer stop() | |
| 903 | - | ||
| 904 | - | l := &layerProxy{display: display, onDetached: onDetached} | |
| 905 | - | l.SetContext(display.Context()) | |
| 906 | - | err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_manager_v1.attach_surface", Child: l}, l, token, surface) | |
| 907 | - | for err == nil && !l.settled() { | |
| 908 | - | err = display.Dispatch() | |
| 909 | - | } | |
| 910 | - | // Detach the watcher before anything else is decided or sent. When stop | |
| 911 | - | // reports false the watcher has fired or is firing: cancellation and the | |
| 912 | - | // deadline win over any outcome, and the display is closed here too so it | |
| 913 | - | // is closed on return whatever the watcher goroutine is doing. When it | |
| 914 | - | // reports true the watcher can never fire, and the caller owns the | |
| 915 | - | // connection; a context that is already done is still caught below. | |
| 916 | - | if !stop() || wctx.Err() != nil { | |
| 917 | - | _ = display.Close() | |
| 918 | - | if ctx.Err() != nil { | |
| 919 | - | return ctx.Err() | |
| 920 | - | } | |
| 921 | - | return fmt.Errorf("wayland: attach surface: %w", wctx.Err()) | |
| 922 | - | } | |
| 923 | - | if err != nil { | |
| 924 | - | return fmt.Errorf("wayland: attach surface: %w", err) | |
| 925 | - | } | |
| 926 | - | state, reason, violation := l.hand() | |
| 927 | - | if violation { | |
| 928 | - | _ = display.Close() | |
| 929 | - | return errLayerProtocol | |
| 930 | - | } | |
| 931 | - | // The manager binding is not needed any more. | |
| 932 | - | _ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_manager_v1.destroy", Destructor: true}) | |
| 933 | - | if state == layerFailed { | |
| 934 | - | _ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true}) | |
| 935 | - | return &AttachFailedError{Reason: AttachFailure(reason)} | |
| 936 | - | } | |
| 937 | - | return nil | |
| 938 | - | } | |
| 939 | - | ||
| 940 | - | // validToken accepts exactly the compositor's token format. | |
| 941 | - | func validToken(token string) bool { | |
| 942 | - | if len(token) != tokenLen { | |
| 943 | - | return false | |
| 944 | - | } | |
| 945 | - | for i := range len(token) { | |
| 946 | - | if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 947 | - | return false | |
| 948 | - | } | |
| 949 | - | } | |
| 950 | - | return true | |
| 951 | - | } | |
D internal/adapters/wayland/capture_session_test.go +0 -783
| @@ -1,783 +0,0 @@ | |||
| 1 | - | package wayland | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "net" | |
| 9 | - | "os" | |
| 10 | - | "strings" | |
| 11 | - | "sync/atomic" | |
| 12 | - | "testing" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/wlturbo" | |
| 16 | - | "github.com/bnema/wlturbo/protocol/core" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | ||
| 19 | - | "github.com/bnema/nefercap/internal/ports" | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | - | const ( | |
| 23 | - | testToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 24 | - | testToken2 = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" | |
| 25 | - | ) | |
| 26 | - | ||
| 27 | - | func privateCfg(p privateConfig) compositorConfig { | |
| 28 | - | p.enabled = true | |
| 29 | - | return compositorConfig{ | |
| 30 | - | outputs: []outputSpec{{name: "DP-1", width: 16, height: 8, scale: 1, version: 4}}, | |
| 31 | - | private: p, | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func TestSessionUnsupportedWithoutGlobal(t *testing.T) { | |
| 36 | - | s, _ := newSource(t, compositorConfig{}) | |
| 37 | - | ctx := context.Background() | |
| 38 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 39 | - | require.ErrorIs(t, err, ErrSessionUnsupported) | |
| 40 | - | _, err = s.Workspaces(ctx) | |
| 41 | - | require.ErrorIs(t, err, ErrSessionUnsupported) | |
| 42 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 43 | - | require.NoError(t, s.EndSession(ctx)) | |
| 44 | - | // Standard capture is unaffected and the Source stays usable. | |
| 45 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 46 | - | require.NoError(t, err) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | func TestBeginSessionOutputAndRegion(t *testing.T) { | |
| 50 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 51 | - | ctx := context.Background() | |
| 52 | - | out := firstOutput(t, s) | |
| 53 | - | ||
| 54 | - | st, err := s.BeginSession(ctx, ports.Target{OutputID: out.ID, Region: ports.Region{X: 2, Y: 1, Width: 4, Height: 3}}, true) | |
| 55 | - | require.NoError(t, err) | |
| 56 | - | require.True(t, st.Active) | |
| 57 | - | require.Equal(t, testToken, st.Token) | |
| 58 | - | require.EqualValues(t, 1, st.Revision) | |
| 59 | - | require.Equal(t, ports.Target{OutputID: out.ID, Region: ports.Region{X: 2, Y: 1, Width: 4, Height: 3}}, st.Target) | |
| 60 | - | require.Equal(t, ports.Region{X: 2, Y: 1, Width: 4, Height: 3}, st.Region) | |
| 61 | - | require.Equal(t, [4]int32{2, 1, 4, 3}, comp.snap().requested) | |
| 62 | - | require.EqualValues(t, 1, comp.snap().record) | |
| 63 | - | ||
| 64 | - | _, err = s.BeginSession(ctx, ports.Target{OutputID: out.ID}, false) | |
| 65 | - | require.ErrorIs(t, err, ErrSessionActive) | |
| 66 | - | ||
| 67 | - | require.NoError(t, s.EndSession(ctx)) | |
| 68 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privDestroyed })) | |
| 69 | - | require.NoError(t, s.EndSession(ctx)) // idempotent | |
| 70 | - | ||
| 71 | - | st, err = s.BeginSession(ctx, ports.Target{OutputID: out.ID}, false) // full output | |
| 72 | - | require.NoError(t, err) | |
| 73 | - | require.Equal(t, ports.Region{X: 0, Y: 0, Width: 16, Height: 8}, st.Target.Region) | |
| 74 | - | require.EqualValues(t, 0, comp.snap().record) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func TestBeginSessionReturnsFirstStateThenResumes(t *testing.T) { | |
| 78 | - | s, _ := newSource(t, privateCfg(privateConfig{inactive: true})) | |
| 79 | - | ctx := context.Background() | |
| 80 | - | st, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 81 | - | require.NoError(t, err) | |
| 82 | - | require.False(t, st.Active, "the first state was paused") | |
| 83 | - | require.EqualValues(t, 1, st.Revision) | |
| 84 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // the resume is read here | |
| 85 | - | require.NoError(t, err) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func TestBeginSessionRefused(t *testing.T) { | |
| 89 | - | s, _ := newSource(t, privateCfg(privateConfig{refuse: true})) | |
| 90 | - | ctx := context.Background() | |
| 91 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: firstOutput(t, s).ID}, true) | |
| 92 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 93 | - | var stopped *SessionStoppedError | |
| 94 | - | require.ErrorAs(t, err, &stopped) | |
| 95 | - | require.Equal(t, StopBusy, stopped.Reason) | |
| 96 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 97 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // the connection survived | |
| 98 | - | require.NoError(t, err) | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func TestBeginSessionInvalidTargets(t *testing.T) { | |
| 102 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 103 | - | ctx := context.Background() | |
| 104 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 99}, true) | |
| 105 | - | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 106 | - | _, err = s.BeginSession(ctx, ports.Target{OutputID: 1, Region: ports.Region{Width: -1, Height: 1}}, true) | |
| 107 | - | require.ErrorIs(t, err, ports.ErrInvalidRegion) | |
| 108 | - | _, err = s.BeginSession(ctx, ports.Target{WorkspaceID: 42}, true) | |
| 109 | - | require.ErrorIs(t, err, ErrWorkspaceNotFound) | |
| 110 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.privSessions })) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func TestWorkspacesFromAdvertisements(t *testing.T) { | |
| 114 | - | cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 115 | - | {id: 1<<32 | 7, output: "DP-1", name: "main", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 116 | - | {id: 5, output: "DP-1", name: "other", region: [4]int32{0, 0, 16, 8}}, | |
| 117 | - | {id: 6, output: "GONE", name: "orphan", region: [4]int32{0, 0, 16, 8}}, | |
| 118 | - | {id: 8, output: "DP-1", name: "bad", region: [4]int32{0, 0, 0, 8}}, | |
| 119 | - | }}) | |
| 120 | - | s, _ := newSource(t, cfg) | |
| 121 | - | list, err := s.Workspaces(context.Background()) | |
| 122 | - | require.NoError(t, err) | |
| 123 | - | require.Equal(t, []ports.Workspace{ | |
| 124 | - | {ID: 5, OutputID: 1, Name: "other", Region: ports.Region{Width: 16, Height: 8}}, | |
| 125 | - | {ID: 1<<32 | 7, OutputID: 1, Name: "main", Region: ports.Region{Width: 16, Height: 8}, Active: true}, | |
| 126 | - | }, list) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func TestWorkspaceSessionAndLiveRegion(t *testing.T) { | |
| 130 | - | const id = 1<<32 | 7 | |
| 131 | - | cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 132 | - | {id: id, output: "DP-1", name: "main", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 133 | - | {id: 9, output: "DP-1", name: "off", region: [4]int32{0, 0, 16, 8}}, | |
| 134 | - | }}) | |
| 135 | - | s, comp := newSource(t, cfg) | |
| 136 | - | ctx := context.Background() | |
| 137 | - | ||
| 138 | - | st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: id}, true) | |
| 139 | - | require.NoError(t, err) | |
| 140 | - | require.Equal(t, ports.Target{OutputID: 1, WorkspaceID: id}, st.Target, "a workspace target has a zero region") | |
| 141 | - | require.Equal(t, ports.Region{Width: 16, Height: 8}, st.Region) | |
| 142 | - | ||
| 143 | - | // The chain the application runs: Begin, then Capture(state.Target). | |
| 144 | - | f, err := s.Capture(ctx, st.Target) | |
| 145 | - | require.NoError(t, err) | |
| 146 | - | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) | |
| 147 | - | require.EqualValues(t, id, comp.snap().workspace) | |
| 148 | - | ||
| 149 | - | f, err = s.Capture(ctx, ports.Target{WorkspaceID: id}) // OutputID may be omitted | |
| 150 | - | require.NoError(t, err) | |
| 151 | - | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) | |
| 152 | - | require.Equal(t, [4]int32{0, 0, 16, 8}, comp.snap().lastRegion) | |
| 153 | - | require.Equal(t, [4]int32{}, comp.snap().requested, "a workspace session requests no region") | |
| 154 | - | ||
| 155 | - | // The compositor moves the workspace: the next frame follows the live region. | |
| 156 | - | comp.pushState([4]int32{4, 2, 8, 4}, id) | |
| 157 | - | f, err = s.Capture(ctx, st.Target) // the same target, still valid | |
| 158 | - | require.NoError(t, err) | |
| 159 | - | require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height}) | |
| 160 | - | require.Equal(t, [4]int32{4, 2, 8, 4}, comp.snap().lastRegion) | |
| 161 | - | ||
| 162 | - | // A workspace other than the session's needs a private render path. | |
| 163 | - | _, err = s.Capture(ctx, ports.Target{WorkspaceID: 9}) | |
| 164 | - | require.ErrorIs(t, err, ErrSessionUnsupported) | |
| 165 | - | _, err = s.Capture(ctx, ports.Target{WorkspaceID: id, Region: ports.Region{X: 1, Y: 1, Width: 1, Height: 1}}) | |
| 166 | - | require.ErrorIs(t, err, ports.ErrInvalidRegion) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | func TestWorkspaceCaptureNeedsSession(t *testing.T) { | |
| 170 | - | s, _ := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 171 | - | {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 172 | - | }})) | |
| 173 | - | _, err := s.Capture(context.Background(), ports.Target{WorkspaceID: 3}) | |
| 174 | - | require.ErrorIs(t, err, ErrSessionUnsupported) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // The compositor serves a workspace that is not on screen: the session is | |
| 178 | - | // requested by identity with a zero region, whatever the inventory says. | |
| 179 | - | func TestBeginHiddenWorkspace(t *testing.T) { | |
| 180 | - | s, comp := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 181 | - | {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}}, | |
| 182 | - | }})) | |
| 183 | - | ctx := context.Background() | |
| 184 | - | st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true) | |
| 185 | - | require.NoError(t, err) | |
| 186 | - | require.Equal(t, ports.Target{OutputID: 1, WorkspaceID: 3}, st.Target) | |
| 187 | - | require.EqualValues(t, 3, comp.snap().workspace) | |
| 188 | - | require.Equal(t, [4]int32{}, comp.snap().requested) | |
| 189 | - | _, err = s.Capture(ctx, st.Target) | |
| 190 | - | require.NoError(t, err) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | // The session stays pinned to its output and workspace whatever the inventory | |
| 194 | - | // announces or removes afterwards. | |
| 195 | - | func TestSessionPinnedAgainstInventoryChanges(t *testing.T) { | |
| 196 | - | cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 197 | - | {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 198 | - | }}) | |
| 199 | - | cfg.outputs = append(cfg.outputs, outputSpec{name: "DP-2", width: 32, height: 16, scale: 1, version: 4}) | |
| 200 | - | s, comp := newSource(t, cfg) | |
| 201 | - | ctx := context.Background() | |
| 202 | - | st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true) | |
| 203 | - | require.NoError(t, err) | |
| 204 | - | require.EqualValues(t, 1, st.Target.OutputID) | |
| 205 | - | ||
| 206 | - | comp.announceWorkspace(workspaceSpec{id: 3, output: "DP-2", region: [4]int32{0, 0, 32, 16}, active: true}) | |
| 207 | - | comp.removeWorkspace(3) | |
| 208 | - | f, err := s.Capture(ctx, st.Target) | |
| 209 | - | require.NoError(t, err, "a removed workspace is not consulted") | |
| 210 | - | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}, "still the pinned output") | |
| 211 | - | f, err = s.Capture(ctx, ports.Target{OutputID: 1, WorkspaceID: 3}) | |
| 212 | - | require.NoError(t, err) | |
| 213 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 2, WorkspaceID: 3}) | |
| 214 | - | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 215 | - | ||
| 216 | - | // The removal is followed by the stop: the last grant is never used after. | |
| 217 | - | comp.stopSession(uint32(StopWorkspaceGone)) | |
| 218 | - | _, err = s.Capture(ctx, st.Target) | |
| 219 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 220 | - | var stopped *SessionStoppedError | |
| 221 | - | require.ErrorAs(t, err, &stopped) | |
| 222 | - | require.Equal(t, StopWorkspaceGone, stopped.Reason) | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | func TestPausedSessionIsInactive(t *testing.T) { | |
| 226 | - | s, comp := newSource(t, privateCfg(privateConfig{pausedStart: true})) | |
| 227 | - | ctx := context.Background() | |
| 228 | - | st, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 229 | - | require.NoError(t, err) | |
| 230 | - | require.False(t, st.Active) | |
| 231 | - | captures := comp.stat(func(c *compositor) int { return c.captures }) | |
| 232 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 233 | - | require.ErrorIs(t, err, ErrSessionInactive) | |
| 234 | - | require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures }), "no capture while paused") | |
| 235 | - | ||
| 236 | - | comp.pushState([4]int32{0, 0, 16, 8}, 0) // resumes | |
| 237 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 238 | - | require.NoError(t, err) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func TestCaptureRetriesCompositorFailureOnce(t *testing.T) { | |
| 242 | - | cfg := privateCfg(privateConfig{}) | |
| 243 | - | cfg.failCopies = 1 | |
| 244 | - | s, comp := newSource(t, cfg) | |
| 245 | - | ctx := context.Background() | |
| 246 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 247 | - | require.NoError(t, err) | |
| 248 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 249 | - | require.NoError(t, err) | |
| 250 | - | require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.copies })) | |
| 251 | - | ||
| 252 | - | comp.mu.Lock() | |
| 253 | - | comp.cfg.failCopies = 5 | |
| 254 | - | comp.mu.Unlock() | |
| 255 | - | before := comp.stat(func(c *compositor) int { return c.copies }) | |
| 256 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 257 | - | require.Error(t, err) | |
| 258 | - | require.NotErrorIs(t, err, ErrSessionUnavailable) | |
| 259 | - | require.Equal(t, before+2, comp.stat(func(c *compositor) int { return c.copies }), "one retry, no more") | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | func TestOutputCaptureUnderSession(t *testing.T) { | |
| 263 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 264 | - | ctx := context.Background() | |
| 265 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 266 | - | require.NoError(t, err) | |
| 267 | - | f, err := s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 268 | - | require.NoError(t, err) | |
| 269 | - | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) | |
| 270 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.captures })) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func TestPingSession(t *testing.T) { | |
| 274 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 275 | - | ctx := context.Background() | |
| 276 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) // none active | |
| 277 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 278 | - | require.NoError(t, err) | |
| 279 | - | require.NoError(t, s.PingSession(ctx)) | |
| 280 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privPings })) | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | func TestCapturePingsOnlyWhenDue(t *testing.T) { | |
| 284 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 285 | - | ctx := context.Background() | |
| 286 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 287 | - | require.NoError(t, err) | |
| 288 | - | ||
| 289 | - | for range 3 { | |
| 290 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 291 | - | require.NoError(t, err) | |
| 292 | - | } | |
| 293 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.privPings }), "no ping inside the interval") | |
| 294 | - | ||
| 295 | - | s.pingEvery = 0 | |
| 296 | - | for range 3 { | |
| 297 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 298 | - | require.NoError(t, err) | |
| 299 | - | } | |
| 300 | - | require.NoError(t, s.PingSession(ctx)) // round trip: every earlier ping was received | |
| 301 | - | require.Equal(t, 4, comp.stat(func(c *compositor) int { return c.privPings })) | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | func TestStopDuringSessionFailsCaptureAndPing(t *testing.T) { | |
| 305 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 306 | - | ctx := context.Background() | |
| 307 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 308 | - | require.NoError(t, err) | |
| 309 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 310 | - | require.NoError(t, err) | |
| 311 | - | ||
| 312 | - | comp.stopSession(3) | |
| 313 | - | // Dispatching the stop happens inside the operation that reads it. | |
| 314 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 315 | - | captures := comp.stat(func(c *compositor) int { return c.captures }) | |
| 316 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 317 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 318 | - | require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures }), "no frame is requested after the stop") | |
| 319 | - | require.NoError(t, s.EndSession(ctx)) | |
| 320 | - | // A new session can follow. | |
| 321 | - | _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 322 | - | require.NoError(t, err) | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | func TestStoppedSessionStaysFailClosedUntilEnded(t *testing.T) { | |
| 326 | - | s, comp := newSource(t, privateCfg(privateConfig{})) | |
| 327 | - | ctx := context.Background() | |
| 328 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 329 | - | require.NoError(t, err) | |
| 330 | - | comp.stopSession(uint32(StopWorkspaceGone)) | |
| 331 | - | // The stop is read inside the first operation; a frame copied meanwhile is | |
| 332 | - | // discarded. Nothing is requested afterwards, and never a plain capture. | |
| 333 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 334 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 335 | - | captures := comp.stat(func(c *compositor) int { return c.captures }) | |
| 336 | - | for range 3 { | |
| 337 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 338 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 339 | - | } | |
| 340 | - | var stopped *SessionStoppedError | |
| 341 | - | require.ErrorAs(t, err, &stopped) | |
| 342 | - | require.Equal(t, StopWorkspaceGone, stopped.Reason) | |
| 343 | - | require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures })) | |
| 344 | - | _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 345 | - | require.ErrorIs(t, err, ErrSessionActive, "the owner must end the stopped session first") | |
| 346 | - | require.NoError(t, s.EndSession(ctx)) | |
| 347 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privDestroyed })) | |
| 348 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // no session: plain capture again | |
| 349 | - | require.NoError(t, err) | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | func TestStopArrivingWithFrameDiscardsFrame(t *testing.T) { | |
| 353 | - | s, _ := newSource(t, privateCfg(privateConfig{stopOnPing: true})) | |
| 354 | - | ctx := context.Background() | |
| 355 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 356 | - | require.NoError(t, err) | |
| 357 | - | s.pingEvery = 0 | |
| 358 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 359 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 360 | - | require.NoError(t, s.EndSession(ctx)) | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | func TestRevisionGoingBackIsProtocolViolation(t *testing.T) { | |
| 364 | - | s, _ := newSource(t, privateCfg(privateConfig{badRevision: true, inactive: true})) | |
| 365 | - | ctx := context.Background() | |
| 366 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 367 | - | require.NoError(t, err) | |
| 368 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 369 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 370 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | func TestZeroRevisionIsProtocolViolation(t *testing.T) { | |
| 374 | - | s, _ := newSource(t, privateCfg(privateConfig{zeroRevision: true})) | |
| 375 | - | ctx := context.Background() | |
| 376 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 377 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | func TestMalformedTokenRefusesSession(t *testing.T) { | |
| 381 | - | for _, tok := range []string{"short", testToken[:63] + "G", testToken + "0"} { | |
| 382 | - | s, _ := newSource(t, privateCfg(privateConfig{token: tok})) | |
| 383 | - | _, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1}, true) | |
| 384 | - | require.ErrorIs(t, err, ErrSessionUnavailable, tok) | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | func TestTokenChangeIsProtocolViolation(t *testing.T) { | |
| 389 | - | s, _ := newSource(t, privateCfg(privateConfig{swapToken: true})) | |
| 390 | - | ctx := context.Background() | |
| 391 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 392 | - | require.NoError(t, err) | |
| 393 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 394 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) | |
| 395 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | func TestSessionStateHidesToken(t *testing.T) { | |
| 399 | - | st := SessionState{Token: testToken, Active: true} | |
| 400 | - | for _, s := range []string{fmt.Sprint(st), fmt.Sprintf("%v", st), fmt.Sprintf("%+v", st), fmt.Sprintf("%#v", st)} { | |
| 401 | - | require.NotContains(t, s, testToken) | |
| 402 | - | } | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | func TestSessionOperationsAfterClose(t *testing.T) { | |
| 406 | - | s, _ := newSource(t, privateCfg(privateConfig{})) | |
| 407 | - | require.NoError(t, s.Close()) | |
| 408 | - | ctx := context.Background() | |
| 409 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 410 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 411 | - | require.ErrorIs(t, s.PingSession(ctx), ports.ErrClosed) | |
| 412 | - | require.ErrorIs(t, s.EndSession(ctx), ports.ErrClosed) | |
| 413 | - | _, err = s.Workspaces(ctx) | |
| 414 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | func TestBeginSessionCancelled(t *testing.T) { | |
| 418 | - | s, _ := newSource(t, privateCfg(privateConfig{})) | |
| 419 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 420 | - | cancel() | |
| 421 | - | _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 422 | - | require.ErrorIs(t, err, context.Canceled) | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | func secondConnection(t *testing.T, comp *compositor) (*wlturbo.Display, *core.Surface) { | |
| 426 | - | t.Helper() | |
| 427 | - | conn, err := net.Dial("unix", comp.path) | |
| 428 | - | require.NoError(t, err) | |
| 429 | - | display, err := wlturbo.ConnectFromConn(conn) | |
| 430 | - | require.NoError(t, err) | |
| 431 | - | t.Cleanup(func() { _ = display.Close() }) | |
| 432 | - | require.NoError(t, display.Roundtrip()) | |
| 433 | - | wlComp := core.NewCompositor(display.Context()) | |
| 434 | - | if _, err = display.Registry().BindNegotiated("wl_compositor", 4, wlComp); err != nil { | |
| 435 | - | return display, core.NewSurface(display.Context()) // peer without wl_compositor | |
| 436 | - | } | |
| 437 | - | surface, err := wlComp.CreateSurface() | |
| 438 | - | require.NoError(t, err) | |
| 439 | - | return display, surface | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | func TestAuthorizeLayerWaitsForAttached(t *testing.T) { | |
| 443 | - | comp := startCompositor(t, privateCfg(privateConfig{})) | |
| 444 | - | display, surface := secondConnection(t, comp) | |
| 445 | - | require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testToken, nil)) | |
| 446 | - | require.Equal(t, []string{testToken}, comp.privAttachedCopy()) | |
| 447 | - | } | |
| 448 | - | ||
| 449 | - | func TestAuthorizeLayerAttachFailed(t *testing.T) { | |
| 450 | - | for reason, want := range map[int]AttachFailure{ | |
| 451 | - | 1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers, | |
| 452 | - | 4: AttachAlreadyAttached, 5: AttachSessionEnded, 6: AttachLayerDestroyed, | |
| 453 | - | } { | |
| 454 | - | comp := startCompositor(t, privateCfg(privateConfig{attachFail: reason})) | |
| 455 | - | display, surface := secondConnection(t, comp) | |
| 456 | - | err := AuthorizeLayer(context.Background(), display, surface, testToken, nil) | |
| 457 | - | require.ErrorIs(t, err, ErrAttachFailed) | |
| 458 | - | var failed *AttachFailedError | |
| 459 | - | require.ErrorAs(t, err, &failed) | |
| 460 | - | require.Equal(t, want, failed.Reason) | |
| 461 | - | require.Equal(t, want == AttachUnknownToken || want == AttachSessionEnded, errors.Is(err, ErrSessionUnavailable)) | |
| 462 | - | require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized)) | |
| 463 | - | require.NotContains(t, err.Error(), testToken) | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | // Two attachments on one connection are told apart by their own objects: a | |
| 468 | - | // detach of the first, arriving while the second authorizes, reaches only the | |
| 469 | - | // first callback. | |
| 470 | - | func TestAuthorizeLayerDetachedCallback(t *testing.T) { | |
| 471 | - | comp := startCompositor(t, privateCfg(privateConfig{})) | |
| 472 | - | display, first := secondConnection(t, comp) | |
| 473 | - | var got atomic.Pointer[LayerDetachedError] | |
| 474 | - | calls := make(chan struct{}, 4) | |
| 475 | - | require.NoError(t, AuthorizeLayer(context.Background(), display, first, testToken, func(err error) { | |
| 476 | - | var d *LayerDetachedError | |
| 477 | - | if errors.As(err, &d) { | |
| 478 | - | got.Store(d) | |
| 479 | - | } | |
| 480 | - | calls <- struct{}{} | |
| 481 | - | })) | |
| 482 | - | require.Nil(t, got.Load(), "no detach before it happens") | |
| 483 | - | ||
| 484 | - | comp.detachLayer(0, uint32(DetachSessionEnded)) | |
| 485 | - | second := core.NewSurface(display.Context()) | |
| 486 | - | otherCalls := 0 | |
| 487 | - | require.NoError(t, AuthorizeLayer(context.Background(), display, second, testToken, func(error) { otherCalls++ })) | |
| 488 | - | // The reader goroutine of the GUI delivers the callback; here the test | |
| 489 | - | // drives the connection until it arrives. | |
| 490 | - | for len(calls) == 0 { | |
| 491 | - | require.NoError(t, display.Roundtrip()) | |
| 492 | - | } | |
| 493 | - | d := got.Load() | |
| 494 | - | require.NotNil(t, d) | |
| 495 | - | require.Equal(t, DetachSessionEnded, d.Reason) | |
| 496 | - | require.ErrorIs(t, d, ErrSessionUnavailable) | |
| 497 | - | require.Zero(t, otherCalls) | |
| 498 | - | require.Len(t, calls, 1) | |
| 499 | - | require.NotContains(t, d.Error(), testToken) | |
| 500 | - | ||
| 501 | - | // The second layer's own detach reaches only its own callback. | |
| 502 | - | comp.detachLayer(1, uint32(DetachLayerDestroyed)) | |
| 503 | - | require.NoError(t, display.Roundtrip()) | |
| 504 | - | require.Equal(t, 1, otherCalls) | |
| 505 | - | require.Len(t, calls, 1) | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | func TestAuthorizeLayerContextCancelled(t *testing.T) { | |
| 509 | - | comp := startCompositor(t, privateCfg(privateConfig{attachSilent: true})) | |
| 510 | - | display, surface := secondConnection(t, comp) | |
| 511 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 512 | - | done := make(chan error, 1) | |
| 513 | - | go func() { done <- AuthorizeLayer(ctx, display, surface, testToken, nil) }() | |
| 514 | - | require.Eventually(t, func() bool { return len(comp.privAttachedCopy()) == 1 }, 5*time.Second, time.Millisecond) | |
| 515 | - | cancel() | |
| 516 | - | require.ErrorIs(t, <-done, context.Canceled) | |
| 517 | - | require.True(t, display.Closed()) | |
| 518 | - | ||
| 519 | - | // A context that is already cancelled sends nothing. | |
| 520 | - | comp2 := startCompositor(t, privateCfg(privateConfig{})) | |
| 521 | - | display2, surface2 := secondConnection(t, comp2) | |
| 522 | - | require.ErrorIs(t, AuthorizeLayer(ctx, display2, surface2, testToken, nil), context.Canceled) | |
| 523 | - | require.Empty(t, comp2.privAttachedCopy()) | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | func TestAuthorizeLayerCancelAfterSuccessKeepsConnection(t *testing.T) { | |
| 527 | - | comp := startCompositor(t, privateCfg(privateConfig{})) | |
| 528 | - | display, surface := secondConnection(t, comp) | |
| 529 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 530 | - | require.NoError(t, AuthorizeLayer(ctx, display, surface, testToken, nil)) | |
| 531 | - | cancel() // the watcher is gone: the GUI owns the connection now | |
| 532 | - | require.NoError(t, display.Roundtrip(), "the server still answers") | |
| 533 | - | require.False(t, display.Closed()) | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | // The deadline lands around the delayed answer. Whatever wins, the result and | |
| 537 | - | // the connection agree: success leaves it open, a failure closed it. | |
| 538 | - | func TestAuthorizeLayerDeadlineRacingTheAnswer(t *testing.T) { | |
| 539 | - | const delay = 2 * time.Millisecond | |
| 540 | - | var ok, failed int | |
| 541 | - | for range 100 { | |
| 542 | - | comp := startCompositor(t, privateCfg(privateConfig{attachDelay: delay})) | |
| 543 | - | display, surface := secondConnection(t, comp) | |
| 544 | - | err := authorizeLayer(context.Background(), display, surface, testToken, nil, delay) | |
| 545 | - | if err == nil { | |
| 546 | - | ok++ | |
| 547 | - | require.False(t, display.Closed()) | |
| 548 | - | continue | |
| 549 | - | } | |
| 550 | - | failed++ | |
| 551 | - | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 552 | - | require.True(t, display.Closed()) | |
| 553 | - | } | |
| 554 | - | t.Logf("success %d, deadline %d", ok, failed) | |
| 555 | - | } | |
| 556 | - | ||
| 557 | - | func TestAuthorizeLayerTimesOutSilently(t *testing.T) { | |
| 558 | - | comp := startCompositor(t, privateCfg(privateConfig{attachSilent: true})) | |
| 559 | - | display, surface := secondConnection(t, comp) | |
| 560 | - | err := authorizeLayer(context.Background(), display, surface, testToken, nil, 100*time.Millisecond) | |
| 561 | - | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 562 | - | require.True(t, display.Closed()) | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | func TestAuthorizeLayerReportsProtocolError(t *testing.T) { | |
| 566 | - | comp := startCompositor(t, privateCfg(privateConfig{attachError: true})) | |
| 567 | - | display, surface := secondConnection(t, comp) | |
| 568 | - | err := AuthorizeLayer(context.Background(), display, surface, testToken, nil) | |
| 569 | - | require.ErrorIs(t, err, wlturbo.ErrDisplayError) | |
| 570 | - | var derr *wlturbo.DisplayError | |
| 571 | - | require.ErrorAs(t, err, &derr) | |
| 572 | - | require.EqualValues(t, 2, derr.Code) | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | func TestAuthorizeLayerRejectsBadInput(t *testing.T) { | |
| 576 | - | require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testToken, nil)) | |
| 577 | - | comp := startCompositor(t, privateCfg(privateConfig{})) | |
| 578 | - | display, surface := secondConnection(t, comp) | |
| 579 | - | for _, tok := range []string{"", "has space", "new\nline", testToken[:63], testToken + "0", testToken[:63] + "G", testToken[:63] + "A"} { | |
| 580 | - | require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok) | |
| 581 | - | } | |
| 582 | - | require.Empty(t, comp.privAttachedCopy()) | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | func TestAuthorizeLayerWithoutGlobal(t *testing.T) { | |
| 586 | - | comp := startCompositor(t, compositorConfig{}) | |
| 587 | - | display, surface := secondConnection(t, comp) | |
| 588 | - | require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testToken, nil), ErrSessionUnsupported) | |
| 589 | - | } | |
| 590 | - | ||
| 591 | - | // TestSessionLiveHeadless runs against a real compositor when | |
| 592 | - | // NEFERCAP_TEST_WAYLAND_PRIVATE names its socket. It skips otherwise, and when | |
| 593 | - | // that compositor lacks the private protocol. | |
| 594 | - | func TestSessionLiveHeadless(t *testing.T) { | |
| 595 | - | socket := os.Getenv("NEFERCAP_TEST_WAYLAND_PRIVATE") | |
| 596 | - | if socket == "" { | |
| 597 | - | t.Skip("NEFERCAP_TEST_WAYLAND_PRIVATE is not set") | |
| 598 | - | } | |
| 599 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 600 | - | defer cancel() | |
| 601 | - | s, err := New(ctx, socket) | |
| 602 | - | require.NoError(t, err) | |
| 603 | - | defer s.Close() | |
| 604 | - | outs, err := s.Outputs(ctx) | |
| 605 | - | require.NoError(t, err) | |
| 606 | - | require.NotEmpty(t, outs) | |
| 607 | - | st, err := s.BeginSession(ctx, ports.Target{OutputID: outs[0].ID}, false) | |
| 608 | - | if err != nil { | |
| 609 | - | require.ErrorIs(t, err, ErrSessionUnsupported) | |
| 610 | - | t.Skip("compositor has no private capture protocol") | |
| 611 | - | } | |
| 612 | - | require.True(t, st.Active) | |
| 613 | - | require.NotEmpty(t, st.Token) | |
| 614 | - | require.NoError(t, s.PingSession(ctx)) | |
| 615 | - | _, err = s.Capture(ctx, ports.Target{OutputID: outs[0].ID}) | |
| 616 | - | require.NoError(t, err) | |
| 617 | - | require.NoError(t, s.EndSession(ctx)) | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | func TestGrantValidation(t *testing.T) { | |
| 621 | - | region := ports.Region{X: 2, Y: 1, Width: 4, Height: 3} | |
| 622 | - | for name, grant := range map[string][4]int32{ | |
| 623 | - | "zero width": {2, 1, 0, 3}, | |
| 624 | - | "negative width": {2, 1, -4, 3}, | |
| 625 | - | "oversized": {0, 0, 1 << 20, 3}, | |
| 626 | - | "sum overflow": {2147483647, 1, 2147483647, 3}, | |
| 627 | - | "outside region": {0, 0, 4, 3}, | |
| 628 | - | "larger": {2, 1, 5, 3}, | |
| 629 | - | } { | |
| 630 | - | s, _ := newSource(t, privateCfg(privateConfig{stateRegion: &grant})) | |
| 631 | - | _, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1, Region: region}, true) | |
| 632 | - | require.ErrorIs(t, err, ErrSessionUnavailable, name) | |
| 633 | - | } | |
| 634 | - | // A grant inside the requested region is fine, and so is any bounded grant | |
| 635 | - | // of a full-output request (its logical size is not known exactly). | |
| 636 | - | inside := [4]int32{3, 1, 2, 2} | |
| 637 | - | s, _ := newSource(t, privateCfg(privateConfig{stateRegion: &inside})) | |
| 638 | - | st, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1, Region: region}, true) | |
| 639 | - | require.NoError(t, err) | |
| 640 | - | require.Equal(t, ports.Region{X: 3, Y: 1, Width: 2, Height: 2}, st.Region) | |
| 641 | - | zero := [4]int32{0, 0, 0, 0} | |
| 642 | - | s, _ = newSource(t, privateCfg(privateConfig{stateRegion: &zero})) | |
| 643 | - | _, err = s.BeginSession(context.Background(), ports.Target{OutputID: 1}, true) | |
| 644 | - | require.ErrorIs(t, err, ErrSessionUnavailable, "a zero grant is not the full output") | |
| 645 | - | } | |
| 646 | - | ||
| 647 | - | func TestLaterStateForOtherWorkspaceFailsClosed(t *testing.T) { | |
| 648 | - | s, comp := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 649 | - | {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 650 | - | }})) | |
| 651 | - | ctx := context.Background() | |
| 652 | - | _, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true) | |
| 653 | - | require.NoError(t, err) | |
| 654 | - | comp.pushState([4]int32{0, 0, 16, 8}, 4) | |
| 655 | - | require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) | |
| 656 | - | _, err = s.Capture(ctx, ports.Target{WorkspaceID: 3}) | |
| 657 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | func TestGeometryKeepsChanging(t *testing.T) { | |
| 661 | - | s, comp := newSource(t, privateCfg(privateConfig{moveEachCapture: true, workspaces: []workspaceSpec{ | |
| 662 | - | {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true}, | |
| 663 | - | }})) | |
| 664 | - | ctx := context.Background() | |
| 665 | - | st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true) | |
| 666 | - | require.NoError(t, err) | |
| 667 | - | f, err := s.Capture(ctx, st.Target) | |
| 668 | - | require.ErrorIs(t, err, ErrSessionGeometryChanging) | |
| 669 | - | require.Zero(t, f.Width, "no stale frame") | |
| 670 | - | require.Equal(t, maxRegionRetries+1, comp.stat(func(c *compositor) int { return c.captures })) | |
| 671 | - | require.NoError(t, s.EndSession(ctx)) | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | func TestHostileMetadataDisablesPrivateCapability(t *testing.T) { | |
| 675 | - | long := strings.Repeat("a", maxNameLen+1) | |
| 676 | - | var many []workspaceSpec | |
| 677 | - | for i := range maxWorkspaces + 1 { | |
| 678 | - | many = append(many, workspaceSpec{id: uint64(i + 1), output: "DP-1", region: [4]int32{0, 0, 16, 8}}) | |
| 679 | - | } | |
| 680 | - | for name, ws := range map[string][]workspaceSpec{ | |
| 681 | - | "control": {{id: 1, output: "DP-1", name: "a\nb", region: [4]int32{0, 0, 16, 8}}}, | |
| 682 | - | "bidi": {{id: 1, output: "DP-1", name: "a\u202eb", region: [4]int32{0, 0, 16, 8}}}, | |
| 683 | - | "utf8": {{id: 1, output: "DP-1", name: "a\xffb", region: [4]int32{0, 0, 16, 8}}}, | |
| 684 | - | "long": {{id: 1, output: "DP-1", name: long, region: [4]int32{0, 0, 16, 8}}}, | |
| 685 | - | "empty out": {{id: 1, output: "", name: "x", region: [4]int32{0, 0, 16, 8}}}, | |
| 686 | - | "too many": many, | |
| 687 | - | } { | |
| 688 | - | s, _ := newSource(t, privateCfg(privateConfig{workspaces: ws})) | |
| 689 | - | ctx := context.Background() | |
| 690 | - | _, err := s.Workspaces(ctx) | |
| 691 | - | require.ErrorIs(t, err, ErrServerMetadata, name) | |
| 692 | - | require.ErrorIs(t, err, ErrSessionUnavailable, name) | |
| 693 | - | _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true) | |
| 694 | - | require.ErrorIs(t, err, ErrServerMetadata, name) | |
| 695 | - | _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // standard capture is untouched | |
| 696 | - | require.NoError(t, err, name) | |
| 697 | - | } | |
| 698 | - | // Legitimate unicode names are fine. | |
| 699 | - | s, _ := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{ | |
| 700 | - | {id: 1, output: "DP-1", name: "espace de travail é ✓", region: [4]int32{0, 0, 16, 8}}, | |
| 701 | - | }})) | |
| 702 | - | list, err := s.Workspaces(context.Background()) | |
| 703 | - | require.NoError(t, err) | |
| 704 | - | require.Len(t, list, 1) | |
| 705 | - | } | |
| 706 | - | ||
| 707 | - | func TestSessionStateJSONOmitsToken(t *testing.T) { | |
| 708 | - | st := SessionState{Token: testToken, Revision: 3, Active: true} | |
| 709 | - | for _, v := range []any{st, &st, struct{ S SessionState }{st}, map[string]any{"session": st}} { | |
| 710 | - | b, err := json.Marshal(v) | |
| 711 | - | require.NoError(t, err) | |
| 712 | - | require.NotContains(t, string(b), testToken) | |
| 713 | - | require.NotContains(t, strings.ToLower(string(b)), "token") | |
| 714 | - | } | |
| 715 | - | } | |
| 716 | - | ||
| 717 | - | // layerScript runs AuthorizeLayer against a peer that answers with events. | |
| 718 | - | func layerScript(t *testing.T, script [][2]uint32, onDetached func(error)) (*wlturbo.Display, error) { | |
| 719 | - | t.Helper() | |
| 720 | - | comp := startCompositor(t, privateCfg(privateConfig{attachScript: script})) | |
| 721 | - | display, surface := secondConnection(t, comp) | |
| 722 | - | return display, AuthorizeLayer(context.Background(), display, surface, testToken, onDetached) | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | func TestLayerDetachedBeforeAckIsViolation(t *testing.T) { | |
| 726 | - | display, err := layerScript(t, [][2]uint32{{2, 0}}, nil) | |
| 727 | - | require.ErrorIs(t, err, ErrSessionUnavailable) | |
| 728 | - | require.True(t, display.Closed()) | |
| 729 | - | } | |
| 730 | - | ||
| 731 | - | // After a refusal the attachment object is destroyed: a late event for it is | |
| 732 | - | // dropped, it is neither a violation nor a reason to close the connection. | |
| 733 | - | func TestLateEventOnRetiredLayerIsDropped(t *testing.T) { | |
| 734 | - | display, err := layerScript(t, [][2]uint32{{1, 0}, {1, 0}}, nil) | |
| 735 | - | var failed *AttachFailedError | |
| 736 | - | require.ErrorAs(t, err, &failed) | |
| 737 | - | require.NoError(t, display.Roundtrip()) | |
| 738 | - | require.False(t, display.Closed()) | |
| 739 | - | } | |
| 740 | - | ||
| 741 | - | func TestLayerStateMachineViolationsAfterAck(t *testing.T) { | |
| 742 | - | for name, script := range map[string][][2]uint32{ | |
| 743 | - | "attached twice": {{0, 0}, {0, 0}}, | |
| 744 | - | "failed after attach": {{0, 0}, {1, 0}}, | |
| 745 | - | "detached then attach": {{0, 0}, {2, 0}, {0, 0}}, | |
| 746 | - | } { | |
| 747 | - | got := make(chan error, 4) | |
| 748 | - | display, err := layerScript(t, script, func(e error) { got <- e }) | |
| 749 | - | require.NoError(t, err, name) | |
| 750 | - | for range 3 { | |
| 751 | - | _ = display.Roundtrip() // the reader of the GUI connection | |
| 752 | - | } | |
| 753 | - | if name == "detached then attach" { | |
| 754 | - | var d *LayerDetachedError | |
| 755 | - | require.ErrorAs(t, recv(t, got), &d, name) | |
| 756 | - | require.True(t, display.Closed(), "the late event closes the connection") | |
| 757 | - | require.Empty(t, got, "the callback ran once") | |
| 758 | - | continue | |
| 759 | - | } | |
| 760 | - | require.ErrorIs(t, recv(t, got), ErrSessionUnavailable, name) | |
| 761 | - | require.True(t, display.Closed(), name) | |
| 762 | - | require.Empty(t, got, "reported once") | |
| 763 | - | } | |
| 764 | - | } | |
| 765 | - | ||
| 766 | - | func TestAuthorizeLayerRejectsForeignSurface(t *testing.T) { | |
| 767 | - | comp := startCompositor(t, privateCfg(privateConfig{})) | |
| 768 | - | display, _ := secondConnection(t, comp) | |
| 769 | - | _, foreign := secondConnection(t, startCompositor(t, privateCfg(privateConfig{}))) | |
| 770 | - | require.Error(t, AuthorizeLayer(context.Background(), display, foreign, testToken, nil)) | |
| 771 | - | require.Empty(t, comp.privAttachedCopy()) | |
| 772 | - | } | |
| 773 | - | ||
| 774 | - | func recv(t *testing.T, ch <-chan error) error { | |
| 775 | - | t.Helper() | |
| 776 | - | select { | |
| 777 | - | case err := <-ch: | |
| 778 | - | return err | |
| 779 | - | case <-time.After(5 * time.Second): | |
| 780 | - | t.Fatal("no callback") | |
| 781 | - | return nil | |
| 782 | - | } | |
| 783 | - | } | |
D internal/adapters/wayland/compositor_test.go +0 -835
| @@ -1,835 +0,0 @@ | |||
| 1 | - | package wayland | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/binary" | |
| 6 | - | "net" | |
| 7 | - | "path/filepath" | |
| 8 | - | "sync" | |
| 9 | - | "syscall" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "golang.org/x/sys/unix" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // This file implements the compositor side of the Wayland wire protocol over a | |
| 17 | - | // real Unix socket: it decodes the requests the Source sends (including | |
| 18 | - | // SCM_RIGHTS descriptors), maps the client's shm pools and answers with | |
| 19 | - | // encoded events. It is a protocol peer, not a substitute for a project type. | |
| 20 | - | ||
| 21 | - | const ( | |
| 22 | - | kindDisplay = iota + 1 | |
| 23 | - | kindRegistry | |
| 24 | - | kindOutput | |
| 25 | - | kindShm | |
| 26 | - | kindPool | |
| 27 | - | kindBuffer | |
| 28 | - | kindScreencopy | |
| 29 | - | kindFrame | |
| 30 | - | kindPrivManager | |
| 31 | - | kindPrivSession | |
| 32 | - | kindPrivLayer | |
| 33 | - | kindCompositor | |
| 34 | - | kindSurface | |
| 35 | - | ) | |
| 36 | - | ||
| 37 | - | const ( | |
| 38 | - | globalShm = 1000 | |
| 39 | - | globalScreencopy = 1001 | |
| 40 | - | globalPrivate = 1002 | |
| 41 | - | globalWlComp = 1003 | |
| 42 | - | ) | |
| 43 | - | ||
| 44 | - | type outputSpec struct { | |
| 45 | - | name string | |
| 46 | - | width, height uint32 | |
| 47 | - | scale int32 | |
| 48 | - | version uint32 | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // announce is one zwlr_screencopy_frame_v1.buffer event. | |
| 52 | - | type announce struct{ format, width, height, stride uint32 } | |
| 53 | - | ||
| 54 | - | type compositorConfig struct { | |
| 55 | - | outputs []outputSpec | |
| 56 | - | screencopyVersion uint32 // 0 selects 3 | |
| 57 | - | noScreencopy bool | |
| 58 | - | announce []announce // empty: derived from the output or region | |
| 59 | - | flags uint32 | |
| 60 | - | hold bool // do not answer copy until completePending | |
| 61 | - | failCapture bool | |
| 62 | - | removeOnCapture bool // global_remove of the output, then failed | |
| 63 | - | silent bool // accept the connection and never answer | |
| 64 | - | failCopies int // answer this many copy requests with failed | |
| 65 | - | readyEarly bool // send ready right after the buffer events, before any copy | |
| 66 | - | noShmOffer bool // send only buffer_done: no wl_shm buffer is offered | |
| 67 | - | duplicateGlobal bool // announce the first output global twice | |
| 68 | - | ||
| 69 | - | private privateConfig | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // workspaceSpec is one private-protocol workspace advertisement. | |
| 73 | - | type workspaceSpec struct { | |
| 74 | - | id uint64 | |
| 75 | - | output string | |
| 76 | - | name string | |
| 77 | - | region [4]int32 | |
| 78 | - | active bool | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // privateConfig configures the private capture protocol of the peer. The wire | |
| 82 | - | // layout is the client's provisional one, see capture_session.go. | |
| 83 | - | type privateConfig struct { | |
| 84 | - | enabled bool | |
| 85 | - | token string // empty selects a default | |
| 86 | - | workspaces []workspaceSpec | |
| 87 | - | refuse bool // answer begin_session with stopped | |
| 88 | - | inactive bool // send an inactive state before the active one | |
| 89 | - | stopOnPing bool // answer ping with stopped | |
| 90 | - | swapToken bool // send a second state carrying a different token | |
| 91 | - | badRevision bool // send a state whose revision goes back | |
| 92 | - | zeroRevision bool // send a state with revision 0 | |
| 93 | - | pausedStart bool // the first state is paused (active 0) and no active follows | |
| 94 | - | attachFail int // non-zero: answer attach_surface with attach_failed(value-1) | |
| 95 | - | attachSilent bool // never answer attach_surface | |
| 96 | - | attachDelay time.Duration // delay before attached | |
| 97 | - | attachScript [][2]uint32 // layer events (opcode, argument) sent instead of attached | |
| 98 | - | moveEachCapture bool // push a new region with every capture request | |
| 99 | - | attachError bool // answer attach_surface with wl_display.error(code 2) | |
| 100 | - | stateRegion *[4]int32 | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func (p privateConfig) tok() string { | |
| 104 | - | if p.token == "" { | |
| 105 | - | return testToken | |
| 106 | - | } | |
| 107 | - | return p.token | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | type poolMap struct{ data []byte } | |
| 111 | - | ||
| 112 | - | type bufferInfo struct { | |
| 113 | - | pool *poolMap | |
| 114 | - | offset int | |
| 115 | - | width, height, stride, format uint32 | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | type compositor struct { | |
| 119 | - | t testing.TB | |
| 120 | - | path string | |
| 121 | - | ln net.Listener | |
| 122 | - | cfg compositorConfig | |
| 123 | - | ||
| 124 | - | mu sync.Mutex | |
| 125 | - | conn *net.UnixConn | |
| 126 | - | pools int | |
| 127 | - | buffers int | |
| 128 | - | destroyedBuffers int | |
| 129 | - | captures int | |
| 130 | - | copies int | |
| 131 | - | fdsReceived int | |
| 132 | - | lastRegion [4]int32 | |
| 133 | - | lastRegionSet bool | |
| 134 | - | seq uint32 | |
| 135 | - | pending struct { | |
| 136 | - | frame uint32 | |
| 137 | - | buffer *bufferInfo | |
| 138 | - | } | |
| 139 | - | hasPending bool | |
| 140 | - | ||
| 141 | - | // private protocol observations | |
| 142 | - | privSessions int | |
| 143 | - | privPings int | |
| 144 | - | privDestroyed int | |
| 145 | - | privRecord uint32 | |
| 146 | - | privRequested [4]int32 | |
| 147 | - | privWorkspace uint64 | |
| 148 | - | privAttached []string | |
| 149 | - | privSessionID uint32 | |
| 150 | - | privRev uint64 | |
| 151 | - | privLayers []uint32 | |
| 152 | - | privManager uint32 | |
| 153 | - | privLayerGone int | |
| 154 | - | copyEvent chan struct{} | |
| 155 | - | done chan struct{} | |
| 156 | - | ||
| 157 | - | wmu sync.Mutex | |
| 158 | - | out [4096]byte | |
| 159 | - | reg uint32 | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func startCompositor(t testing.TB, cfg compositorConfig) *compositor { | |
| 163 | - | t.Helper() | |
| 164 | - | if len(cfg.outputs) == 0 { | |
| 165 | - | cfg.outputs = []outputSpec{{name: "TEST-1", width: 8, height: 4, scale: 1, version: 4}} | |
| 166 | - | } | |
| 167 | - | if cfg.screencopyVersion == 0 { | |
| 168 | - | cfg.screencopyVersion = 3 | |
| 169 | - | } | |
| 170 | - | dir, err := filepath.EvalSymlinks(t.TempDir()) | |
| 171 | - | if err != nil { | |
| 172 | - | t.Fatal(err) | |
| 173 | - | } | |
| 174 | - | ln, err := net.Listen("unix", filepath.Join(dir, "wl-test")) | |
| 175 | - | if err != nil { | |
| 176 | - | t.Fatal(err) | |
| 177 | - | } | |
| 178 | - | c := &compositor{t: t, path: ln.Addr().String(), ln: ln, cfg: cfg, copyEvent: make(chan struct{}, 64), done: make(chan struct{})} | |
| 179 | - | go c.serve() | |
| 180 | - | t.Cleanup(func() { | |
| 181 | - | ln.Close() | |
| 182 | - | c.mu.Lock() | |
| 183 | - | if c.conn != nil { | |
| 184 | - | c.conn.Close() | |
| 185 | - | } | |
| 186 | - | c.mu.Unlock() | |
| 187 | - | <-c.done | |
| 188 | - | }) | |
| 189 | - | return c | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | func (c *compositor) stat(f func(*compositor) int) int { | |
| 193 | - | c.mu.Lock() | |
| 194 | - | defer c.mu.Unlock() | |
| 195 | - | return f(c) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | func (c *compositor) serve() { | |
| 199 | - | defer close(c.done) | |
| 200 | - | nc, err := c.ln.Accept() | |
| 201 | - | if err != nil { | |
| 202 | - | return | |
| 203 | - | } | |
| 204 | - | conn := nc.(*net.UnixConn) | |
| 205 | - | c.mu.Lock() | |
| 206 | - | c.conn = conn | |
| 207 | - | c.mu.Unlock() | |
| 208 | - | defer conn.Close() | |
| 209 | - | if c.cfg.silent { | |
| 210 | - | buf := make([]byte, 64) | |
| 211 | - | for { | |
| 212 | - | if _, err := conn.Read(buf); err != nil { | |
| 213 | - | return | |
| 214 | - | } | |
| 215 | - | } | |
| 216 | - | } | |
| 217 | - | c.loop(conn) | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | type objects struct { | |
| 221 | - | kinds map[uint32]int | |
| 222 | - | outputs map[uint32]int | |
| 223 | - | pools map[uint32]*poolMap | |
| 224 | - | buffers map[uint32]*bufferInfo | |
| 225 | - | frames map[uint32]*frameState | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | type frameState struct { | |
| 229 | - | output int | |
| 230 | - | w, h uint32 | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | func (c *compositor) loop(conn *net.UnixConn) { | |
| 234 | - | objs := &objects{ | |
| 235 | - | kinds: map[uint32]int{1: kindDisplay}, outputs: map[uint32]int{}, pools: map[uint32]*poolMap{}, | |
| 236 | - | buffers: map[uint32]*bufferInfo{}, frames: map[uint32]*frameState{}, | |
| 237 | - | } | |
| 238 | - | buf := make([]byte, 0, 1<<16) | |
| 239 | - | chunk := make([]byte, 1<<15) | |
| 240 | - | oob := make([]byte, unix.CmsgSpace(4*16)) | |
| 241 | - | var fds []int | |
| 242 | - | defer func() { | |
| 243 | - | for _, fd := range fds { | |
| 244 | - | unix.Close(fd) | |
| 245 | - | } | |
| 246 | - | }() | |
| 247 | - | for { | |
| 248 | - | n, oobn, _, _, err := conn.ReadMsgUnix(chunk, oob) | |
| 249 | - | if oobn > 0 { | |
| 250 | - | msgs, perr := syscall.ParseSocketControlMessage(oob[:oobn]) | |
| 251 | - | if perr != nil { | |
| 252 | - | c.t.Errorf("control message: %v", perr) | |
| 253 | - | return | |
| 254 | - | } | |
| 255 | - | for i := range msgs { | |
| 256 | - | got, perr := syscall.ParseUnixRights(&msgs[i]) | |
| 257 | - | if perr != nil { | |
| 258 | - | c.t.Errorf("rights: %v", perr) | |
| 259 | - | return | |
| 260 | - | } | |
| 261 | - | fds = append(fds, got...) | |
| 262 | - | c.mu.Lock() | |
| 263 | - | c.fdsReceived += len(got) | |
| 264 | - | c.mu.Unlock() | |
| 265 | - | } | |
| 266 | - | } | |
| 267 | - | buf = append(buf, chunk[:max(n, 0)]...) | |
| 268 | - | for len(buf) >= 8 { | |
| 269 | - | size := int(binary.LittleEndian.Uint32(buf[4:]) >> 16) | |
| 270 | - | if size < 8 || len(buf) < size { | |
| 271 | - | break | |
| 272 | - | } | |
| 273 | - | id := binary.LittleEndian.Uint32(buf) | |
| 274 | - | op := uint16(binary.LittleEndian.Uint32(buf[4:])) | |
| 275 | - | if !c.handle(objs, id, op, buf[8:size], &fds) { | |
| 276 | - | return | |
| 277 | - | } | |
| 278 | - | buf = buf[:copy(buf, buf[size:])] | |
| 279 | - | } | |
| 280 | - | if err != nil { | |
| 281 | - | return | |
| 282 | - | } | |
| 283 | - | } | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[i*4:]) } | |
| 287 | - | ||
| 288 | - | func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *[]int) bool { | |
| 289 | - | switch o.kinds[id] { | |
| 290 | - | case kindDisplay: | |
| 291 | - | switch op { | |
| 292 | - | case 0: // sync | |
| 293 | - | cb := word(body, 0) | |
| 294 | - | c.send(cb, 0, 0) | |
| 295 | - | c.send(1, 1, cb) | |
| 296 | - | case 1: // get_registry | |
| 297 | - | c.wmu.Lock() | |
| 298 | - | c.reg = word(body, 0) | |
| 299 | - | c.wmu.Unlock() | |
| 300 | - | o.kinds[word(body, 0)] = kindRegistry | |
| 301 | - | c.advertise() | |
| 302 | - | } | |
| 303 | - | case kindRegistry: | |
| 304 | - | name := word(body, 0) | |
| 305 | - | n := int(word(body, 1)) | |
| 306 | - | iface := string(body[8 : 8+n-1]) | |
| 307 | - | i := 8 + (n+3)&^3 | |
| 308 | - | version, newID := word(body, i/4), word(body, i/4+1) | |
| 309 | - | c.bind(o, name, iface, version, newID) | |
| 310 | - | case kindOutput: | |
| 311 | - | if op == 0 { | |
| 312 | - | c.send(1, 1, id) | |
| 313 | - | delete(o.kinds, id) | |
| 314 | - | } | |
| 315 | - | case kindShm: | |
| 316 | - | if op == 0 { | |
| 317 | - | if len(*fds) == 0 { | |
| 318 | - | c.t.Error("create_pool without descriptor") | |
| 319 | - | return false | |
| 320 | - | } | |
| 321 | - | fd := (*fds)[0] | |
| 322 | - | *fds = (*fds)[1:] | |
| 323 | - | size := int(word(body, 1)) | |
| 324 | - | data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 325 | - | unix.Close(fd) | |
| 326 | - | if err != nil { | |
| 327 | - | c.t.Errorf("server mmap: %v", err) | |
| 328 | - | return false | |
| 329 | - | } | |
| 330 | - | pid := word(body, 0) | |
| 331 | - | o.kinds[pid] = kindPool | |
| 332 | - | o.pools[pid] = &poolMap{data: data} | |
| 333 | - | c.mu.Lock() | |
| 334 | - | c.pools++ | |
| 335 | - | c.mu.Unlock() | |
| 336 | - | } | |
| 337 | - | case kindPool: | |
| 338 | - | switch op { | |
| 339 | - | case 0: | |
| 340 | - | bid := word(body, 0) | |
| 341 | - | o.kinds[bid] = kindBuffer | |
| 342 | - | o.buffers[bid] = &bufferInfo{pool: o.pools[id], offset: int(word(body, 1)), width: word(body, 2), height: word(body, 3), stride: word(body, 4), format: word(body, 5)} | |
| 343 | - | c.mu.Lock() | |
| 344 | - | c.buffers++ | |
| 345 | - | c.mu.Unlock() | |
| 346 | - | case 1: | |
| 347 | - | c.send(1, 1, id) | |
| 348 | - | delete(o.kinds, id) | |
| 349 | - | } | |
| 350 | - | case kindBuffer: | |
| 351 | - | if op == 0 { | |
| 352 | - | c.mu.Lock() | |
| 353 | - | c.destroyedBuffers++ | |
| 354 | - | c.mu.Unlock() | |
| 355 | - | c.send(1, 1, id) | |
| 356 | - | delete(o.kinds, id) | |
| 357 | - | delete(o.buffers, id) | |
| 358 | - | } | |
| 359 | - | case kindScreencopy: | |
| 360 | - | if op <= 1 { | |
| 361 | - | c.capture(o, op, body) | |
| 362 | - | } | |
| 363 | - | case kindPrivManager: | |
| 364 | - | c.handlePrivManager(o, id, op, body) | |
| 365 | - | case kindPrivSession: | |
| 366 | - | c.handlePrivSession(o, id, op) | |
| 367 | - | case kindPrivLayer: | |
| 368 | - | if op == 0 { // destroy | |
| 369 | - | c.mu.Lock() | |
| 370 | - | c.privLayerGone++ | |
| 371 | - | c.mu.Unlock() | |
| 372 | - | c.send(1, 1, id) | |
| 373 | - | delete(o.kinds, id) | |
| 374 | - | } | |
| 375 | - | case kindCompositor: | |
| 376 | - | if op == 0 { | |
| 377 | - | o.kinds[word(body, 0)] = kindSurface | |
| 378 | - | } | |
| 379 | - | case kindFrame: | |
| 380 | - | switch op { | |
| 381 | - | case 0: | |
| 382 | - | c.copy(o, id, o.buffers[word(body, 0)]) | |
| 383 | - | case 1: | |
| 384 | - | c.send(1, 1, id) | |
| 385 | - | delete(o.kinds, id) | |
| 386 | - | delete(o.frames, id) | |
| 387 | - | } | |
| 388 | - | } | |
| 389 | - | return true | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func (c *compositor) advertise() { | |
| 393 | - | for i, spec := range c.cfg.outputs { | |
| 394 | - | c.sendGlobal(uint32(i+1), "wl_output", spec.version) | |
| 395 | - | } | |
| 396 | - | if c.cfg.duplicateGlobal { | |
| 397 | - | c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version) | |
| 398 | - | } | |
| 399 | - | c.sendGlobal(globalShm, "wl_shm", 1) | |
| 400 | - | if c.cfg.private.enabled { | |
| 401 | - | c.sendGlobal(globalPrivate, captureManagerIface, 1) | |
| 402 | - | c.sendGlobal(globalWlComp, "wl_compositor", 4) | |
| 403 | - | } | |
| 404 | - | if !c.cfg.noScreencopy { | |
| 405 | - | c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion) | |
| 406 | - | } | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | func (c *compositor) bind(o *objects, name uint32, iface string, version, id uint32) { | |
| 410 | - | switch iface { | |
| 411 | - | case "wl_output": | |
| 412 | - | o.kinds[id] = kindOutput | |
| 413 | - | idx := int(name) - 1 | |
| 414 | - | o.outputs[id] = idx | |
| 415 | - | spec := c.cfg.outputs[idx] | |
| 416 | - | c.wmu.Lock() | |
| 417 | - | m := c.begin(id, 0) | |
| 418 | - | m = c.words(m, 0, 0, uint32(spec.width), uint32(spec.height), 0) | |
| 419 | - | m = c.str(m, "Test") | |
| 420 | - | m = c.str(m, "Model") | |
| 421 | - | m = c.words(m, 0) | |
| 422 | - | c.flush(m) | |
| 423 | - | c.wmu.Unlock() | |
| 424 | - | c.send(id, 1, 1, spec.width, spec.height, 60000) | |
| 425 | - | if version >= 2 { | |
| 426 | - | c.send(id, 3, uint32(spec.scale)) | |
| 427 | - | } | |
| 428 | - | if version >= 4 { | |
| 429 | - | c.wmu.Lock() | |
| 430 | - | m := c.begin(id, 4) | |
| 431 | - | m = c.str(m, spec.name) | |
| 432 | - | c.flush(m) | |
| 433 | - | c.wmu.Unlock() | |
| 434 | - | } | |
| 435 | - | if version >= 2 { | |
| 436 | - | c.send(id, 2) | |
| 437 | - | } | |
| 438 | - | case "wl_shm": | |
| 439 | - | o.kinds[id] = kindShm | |
| 440 | - | c.send(id, 0, 0) | |
| 441 | - | c.send(id, 0, 1) | |
| 442 | - | case "zwlr_screencopy_manager_v1": | |
| 443 | - | o.kinds[id] = kindScreencopy | |
| 444 | - | case "wl_compositor": | |
| 445 | - | o.kinds[id] = kindCompositor | |
| 446 | - | case captureManagerIface: | |
| 447 | - | o.kinds[id] = kindPrivManager | |
| 448 | - | c.mu.Lock() | |
| 449 | - | c.privManager = id | |
| 450 | - | c.mu.Unlock() | |
| 451 | - | for _, w := range c.cfg.private.workspaces { | |
| 452 | - | c.sendWorkspace(id, w) | |
| 453 | - | } | |
| 454 | - | default: | |
| 455 | - | c.t.Errorf("bind of unexpected interface %q", iface) | |
| 456 | - | } | |
| 457 | - | } | |
| 458 | - | ||
| 459 | - | func (c *compositor) capture(o *objects, op uint16, body []byte) { | |
| 460 | - | frame := word(body, 0) | |
| 461 | - | out := o.outputs[word(body, 2)] | |
| 462 | - | spec := c.cfg.outputs[out] | |
| 463 | - | w, h := spec.width, spec.height | |
| 464 | - | c.mu.Lock() | |
| 465 | - | // cfg fields may be changed by tests between captures: read them under mu. | |
| 466 | - | removeOnCapture, failCapture := c.cfg.removeOnCapture, c.cfg.failCapture | |
| 467 | - | readyEarly, noShmOffer := c.cfg.readyEarly, c.cfg.noShmOffer | |
| 468 | - | list, version := c.cfg.announce, c.cfg.screencopyVersion | |
| 469 | - | c.captures++ | |
| 470 | - | move, moveWS := c.cfg.private.moveEachCapture, c.privWorkspace | |
| 471 | - | moveN := int32(c.captures) | |
| 472 | - | if op == 1 { | |
| 473 | - | c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))} | |
| 474 | - | c.lastRegionSet = true | |
| 475 | - | w, h = word(body, 5), word(body, 6) | |
| 476 | - | } | |
| 477 | - | c.mu.Unlock() | |
| 478 | - | if move { | |
| 479 | - | c.pushState([4]int32{moveN, 0, 8, 4}, moveWS) | |
| 480 | - | } | |
| 481 | - | o.kinds[frame] = kindFrame | |
| 482 | - | o.frames[frame] = &frameState{output: out, w: w, h: h} | |
| 483 | - | if removeOnCapture { | |
| 484 | - | c.removeOutput(uint32(out + 1)) | |
| 485 | - | c.send(frame, 3) | |
| 486 | - | return | |
| 487 | - | } | |
| 488 | - | if failCapture { | |
| 489 | - | c.send(frame, 3) | |
| 490 | - | return | |
| 491 | - | } | |
| 492 | - | if readyEarly { | |
| 493 | - | c.send(frame, 2, 0, 1, 500) | |
| 494 | - | return | |
| 495 | - | } | |
| 496 | - | if noShmOffer { | |
| 497 | - | c.send(frame, 6) | |
| 498 | - | return | |
| 499 | - | } | |
| 500 | - | if len(list) == 0 { | |
| 501 | - | list = []announce{{format: 1, width: w, height: h, stride: w * 4}} | |
| 502 | - | } | |
| 503 | - | for _, a := range list { | |
| 504 | - | c.send(frame, 0, a.format, a.width, a.height, a.stride) | |
| 505 | - | } | |
| 506 | - | if version >= 3 { | |
| 507 | - | c.send(frame, 6) | |
| 508 | - | } | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | func (c *compositor) copy(o *objects, frame uint32, b *bufferInfo) { | |
| 512 | - | if b == nil { | |
| 513 | - | c.t.Error("copy with unknown buffer") | |
| 514 | - | return | |
| 515 | - | } | |
| 516 | - | c.mu.Lock() | |
| 517 | - | c.copies++ | |
| 518 | - | if c.cfg.failCopies > 0 { | |
| 519 | - | c.cfg.failCopies-- | |
| 520 | - | c.mu.Unlock() | |
| 521 | - | c.send(frame, 3) | |
| 522 | - | return | |
| 523 | - | } | |
| 524 | - | hold := c.cfg.hold | |
| 525 | - | if hold { | |
| 526 | - | c.pending.frame, c.pending.buffer, c.hasPending = frame, b, true | |
| 527 | - | } | |
| 528 | - | c.mu.Unlock() | |
| 529 | - | select { | |
| 530 | - | case c.copyEvent <- struct{}{}: | |
| 531 | - | default: | |
| 532 | - | } | |
| 533 | - | if !hold { | |
| 534 | - | c.finish(frame, b) | |
| 535 | - | } | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | // completePending answers a copy that was held. | |
| 539 | - | func (c *compositor) completePending() { | |
| 540 | - | c.mu.Lock() | |
| 541 | - | frame, b, ok := c.pending.frame, c.pending.buffer, c.hasPending | |
| 542 | - | c.hasPending = false | |
| 543 | - | c.mu.Unlock() | |
| 544 | - | if !ok { | |
| 545 | - | c.t.Error("no pending copy") | |
| 546 | - | return | |
| 547 | - | } | |
| 548 | - | c.finish(frame, b) | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | // pixel is the deterministic content written for (x, y) of frame number seq. | |
| 552 | - | func pixel(x, y int, seq uint32) [4]byte { | |
| 553 | - | return [4]byte{byte(x), byte(y), byte(seq), 0xff} | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | func (c *compositor) finish(frame uint32, b *bufferInfo) { | |
| 557 | - | c.mu.Lock() | |
| 558 | - | c.seq++ | |
| 559 | - | seq := c.seq | |
| 560 | - | c.mu.Unlock() | |
| 561 | - | data := b.pool.data[b.offset:] | |
| 562 | - | for y := 0; y < int(b.height); y++ { | |
| 563 | - | row := data[y*int(b.stride) : (y+1)*int(b.stride)] | |
| 564 | - | for x := 0; x < int(b.width); x++ { | |
| 565 | - | p := pixel(x, y, seq) | |
| 566 | - | copy(row[x*4:], p[:]) | |
| 567 | - | } | |
| 568 | - | for i := int(b.width) * 4; i < len(row); i++ { | |
| 569 | - | row[i] = 0xEE // stride padding | |
| 570 | - | } | |
| 571 | - | } | |
| 572 | - | c.send(frame, 1, c.cfg.flags) | |
| 573 | - | c.send(frame, 2, 0, 1, 500) | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | // removeOutput announces the removal of output global name (1-based). | |
| 577 | - | func (c *compositor) removeOutput(name uint32) { | |
| 578 | - | c.wmu.Lock() | |
| 579 | - | m := c.begin(c.reg, 1) | |
| 580 | - | m = c.words(m, name) | |
| 581 | - | c.flush(m) | |
| 582 | - | c.wmu.Unlock() | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | // Encoding helpers. Every message is built in c.out under wmu. | |
| 586 | - | ||
| 587 | - | func (c *compositor) send(obj uint32, op uint16, args ...uint32) { | |
| 588 | - | c.wmu.Lock() | |
| 589 | - | m := c.begin(obj, op) | |
| 590 | - | m = c.words(m, args...) | |
| 591 | - | c.flush(m) | |
| 592 | - | c.wmu.Unlock() | |
| 593 | - | } | |
| 594 | - | ||
| 595 | - | func (c *compositor) sendGlobal(name uint32, iface string, version uint32) { | |
| 596 | - | c.wmu.Lock() | |
| 597 | - | m := c.begin(c.reg, 0) | |
| 598 | - | m = c.words(m, name) | |
| 599 | - | m = c.str(m, iface) | |
| 600 | - | m = c.words(m, version) | |
| 601 | - | c.flush(m) | |
| 602 | - | c.wmu.Unlock() | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | func (c *compositor) begin(obj uint32, op uint16) []byte { | |
| 606 | - | m := c.out[:8] | |
| 607 | - | binary.LittleEndian.PutUint32(m, obj) | |
| 608 | - | binary.LittleEndian.PutUint32(m[4:], uint32(op)) | |
| 609 | - | return m | |
| 610 | - | } | |
| 611 | - | ||
| 612 | - | func (c *compositor) words(m []byte, args ...uint32) []byte { | |
| 613 | - | for _, a := range args { | |
| 614 | - | m = binary.LittleEndian.AppendUint32(m, a) | |
| 615 | - | } | |
| 616 | - | return m | |
| 617 | - | } | |
| 618 | - | ||
| 619 | - | func (c *compositor) str(m []byte, s string) []byte { | |
| 620 | - | m = binary.LittleEndian.AppendUint32(m, uint32(len(s)+1)) | |
| 621 | - | m = append(m, s...) | |
| 622 | - | m = append(m, 0) | |
| 623 | - | return append(m, bytes.Repeat([]byte{0}, (4-(len(s)+1)%4)%4)...) | |
| 624 | - | } | |
| 625 | - | ||
| 626 | - | func (c *compositor) flush(m []byte) { | |
| 627 | - | binary.LittleEndian.PutUint32(m[4:], uint32(len(m))<<16|binary.LittleEndian.Uint32(m[4:])&0xffff) | |
| 628 | - | c.mu.Lock() | |
| 629 | - | conn := c.conn | |
| 630 | - | c.mu.Unlock() | |
| 631 | - | if _, err := conn.Write(m); err != nil { | |
| 632 | - | // The client may have gone away during teardown. | |
| 633 | - | return | |
| 634 | - | } | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | // Private capture protocol peer (provisional layout, see capture_session.go). | |
| 638 | - | ||
| 639 | - | func (c *compositor) sendWorkspace(mgr uint32, w workspaceSpec) { | |
| 640 | - | c.wmu.Lock() | |
| 641 | - | m := c.begin(mgr, 0) | |
| 642 | - | m = c.words(m, uint32(w.id>>32), uint32(w.id)) | |
| 643 | - | m = c.str(m, w.output) | |
| 644 | - | m = c.str(m, w.name) | |
| 645 | - | m = c.words(m, uint32(w.region[0]), uint32(w.region[1]), uint32(w.region[2]), uint32(w.region[3])) | |
| 646 | - | act := uint32(0) | |
| 647 | - | if w.active { | |
| 648 | - | act = 1 | |
| 649 | - | } | |
| 650 | - | m = c.words(m, act) | |
| 651 | - | c.flush(m) | |
| 652 | - | c.wmu.Unlock() | |
| 653 | - | } | |
| 654 | - | ||
| 655 | - | func (c *compositor) nextRevision() uint64 { | |
| 656 | - | c.mu.Lock() | |
| 657 | - | defer c.mu.Unlock() | |
| 658 | - | c.privRev++ | |
| 659 | - | return c.privRev | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | func (c *compositor) sendState(session uint32, token string, active bool, r [4]int32, ws uint64) { | |
| 663 | - | rev := c.nextRevision() | |
| 664 | - | if c.cfg.private.zeroRevision { | |
| 665 | - | rev = 0 | |
| 666 | - | } | |
| 667 | - | if c.cfg.private.badRevision { | |
| 668 | - | rev = 10 - rev // 9, 8: the revision goes back | |
| 669 | - | } | |
| 670 | - | c.wmu.Lock() | |
| 671 | - | m := c.begin(session, 0) | |
| 672 | - | m = c.str(m, token) | |
| 673 | - | act := uint32(0) | |
| 674 | - | if active { | |
| 675 | - | act = 1 | |
| 676 | - | } | |
| 677 | - | m = c.words(m, act, uint32(r[0]), uint32(r[1]), uint32(r[2]), uint32(r[3]), uint32(ws>>32), uint32(ws), uint32(rev>>32), uint32(rev)) | |
| 678 | - | c.flush(m) | |
| 679 | - | c.wmu.Unlock() | |
| 680 | - | } | |
| 681 | - | ||
| 682 | - | // pushState sends a state event on the current session, as a live geometry | |
| 683 | - | // change would. | |
| 684 | - | func (c *compositor) pushState(r [4]int32, ws uint64) { | |
| 685 | - | c.mu.Lock() | |
| 686 | - | id := c.privSessionID | |
| 687 | - | c.mu.Unlock() | |
| 688 | - | c.sendState(id, c.cfg.private.tok(), true, r, ws) | |
| 689 | - | } | |
| 690 | - | ||
| 691 | - | func (c *compositor) stopSession(reason uint32) { | |
| 692 | - | c.mu.Lock() | |
| 693 | - | id := c.privSessionID | |
| 694 | - | c.mu.Unlock() | |
| 695 | - | c.send(id, 1, reason) | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | func (c *compositor) handlePrivManager(o *objects, mgrID uint32, op uint16, body []byte) { | |
| 699 | - | switch op { | |
| 700 | - | case 1: // begin_session | |
| 701 | - | session := word(body, 0) | |
| 702 | - | out := c.cfg.outputs[o.outputs[word(body, 1)]] | |
| 703 | - | r := [4]int32{int32(word(body, 2)), int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5))} | |
| 704 | - | ws := uint64(word(body, 6))<<32 | uint64(word(body, 7)) | |
| 705 | - | o.kinds[session] = kindPrivSession | |
| 706 | - | c.mu.Lock() | |
| 707 | - | c.privSessions++ | |
| 708 | - | c.privSessionID = session | |
| 709 | - | c.privRecord = word(body, 8) | |
| 710 | - | c.privRequested = r | |
| 711 | - | c.privWorkspace = ws | |
| 712 | - | p := c.cfg.private | |
| 713 | - | c.mu.Unlock() | |
| 714 | - | if p.refuse { | |
| 715 | - | c.send(session, 1, 6) // busy | |
| 716 | - | return | |
| 717 | - | } | |
| 718 | - | if r == [4]int32{} { | |
| 719 | - | r = [4]int32{0, 0, int32(out.width), int32(out.height)} | |
| 720 | - | } | |
| 721 | - | for _, w := range c.cfg.private.workspaces { | |
| 722 | - | if w.id == ws && ws != 0 { | |
| 723 | - | r = w.region // a workspace target is its whole frame | |
| 724 | - | } | |
| 725 | - | } | |
| 726 | - | if p.stateRegion != nil { | |
| 727 | - | r = *p.stateRegion | |
| 728 | - | } | |
| 729 | - | if p.inactive || p.pausedStart { | |
| 730 | - | c.sendState(session, p.tok(), false, r, ws) | |
| 731 | - | } | |
| 732 | - | if !p.pausedStart { | |
| 733 | - | c.sendState(session, p.tok(), true, r, ws) | |
| 734 | - | } | |
| 735 | - | if p.swapToken { | |
| 736 | - | c.sendState(session, p.tok()+"x", true, r, ws) | |
| 737 | - | } | |
| 738 | - | case 2: // attach_surface(new_id layer, token, surface) | |
| 739 | - | layer := word(body, 0) | |
| 740 | - | n := int(word(body, 1)) | |
| 741 | - | tok := string(body[8 : 8+n-1]) | |
| 742 | - | o.kinds[layer] = kindPrivLayer | |
| 743 | - | c.mu.Lock() | |
| 744 | - | c.privAttached = append(c.privAttached, tok) | |
| 745 | - | c.privLayers = append(c.privLayers, layer) | |
| 746 | - | p := c.cfg.private | |
| 747 | - | c.mu.Unlock() | |
| 748 | - | switch { | |
| 749 | - | case p.attachSilent: | |
| 750 | - | case p.attachError: | |
| 751 | - | c.wmu.Lock() | |
| 752 | - | m := c.begin(1, 0) | |
| 753 | - | m = c.words(m, mgrID, 2) | |
| 754 | - | m = c.str(m, "surface_role") | |
| 755 | - | c.flush(m) | |
| 756 | - | c.wmu.Unlock() | |
| 757 | - | case p.attachScript != nil: | |
| 758 | - | for _, ev := range p.attachScript { | |
| 759 | - | if ev[0] == 0 { | |
| 760 | - | c.send(layer, 0) | |
| 761 | - | } else { | |
| 762 | - | c.send(layer, uint16(ev[0]), ev[1]) | |
| 763 | - | } | |
| 764 | - | } | |
| 765 | - | case p.attachFail != 0: | |
| 766 | - | c.send(layer, 1, uint32(p.attachFail-1)) | |
| 767 | - | default: | |
| 768 | - | time.Sleep(p.attachDelay) | |
| 769 | - | c.send(layer, 0) | |
| 770 | - | } | |
| 771 | - | case 0: // destroy | |
| 772 | - | c.send(1, 1, mgrID) | |
| 773 | - | delete(o.kinds, mgrID) | |
| 774 | - | } | |
| 775 | - | } | |
| 776 | - | ||
| 777 | - | func (c *compositor) handlePrivSession(o *objects, id uint32, op uint16) { | |
| 778 | - | switch op { | |
| 779 | - | case 0: | |
| 780 | - | c.mu.Lock() | |
| 781 | - | c.privDestroyed++ | |
| 782 | - | c.mu.Unlock() | |
| 783 | - | c.send(1, 1, id) | |
| 784 | - | delete(o.kinds, id) | |
| 785 | - | case 1: | |
| 786 | - | c.mu.Lock() | |
| 787 | - | c.privPings++ | |
| 788 | - | stop := c.cfg.private.stopOnPing | |
| 789 | - | c.mu.Unlock() | |
| 790 | - | if stop { | |
| 791 | - | c.send(id, 1, 2) | |
| 792 | - | } | |
| 793 | - | } | |
| 794 | - | } | |
| 795 | - | ||
| 796 | - | func (c *compositor) privAttachedCopy() []string { | |
| 797 | - | c.mu.Lock() | |
| 798 | - | defer c.mu.Unlock() | |
| 799 | - | return append([]string(nil), c.privAttached...) | |
| 800 | - | } | |
| 801 | - | ||
| 802 | - | type privSnap struct { | |
| 803 | - | requested [4]int32 | |
| 804 | - | record uint32 | |
| 805 | - | workspace uint64 | |
| 806 | - | lastRegion [4]int32 | |
| 807 | - | } | |
| 808 | - | ||
| 809 | - | func (c *compositor) snap() privSnap { | |
| 810 | - | c.mu.Lock() | |
| 811 | - | defer c.mu.Unlock() | |
| 812 | - | return privSnap{c.privRequested, c.privRecord, c.privWorkspace, c.lastRegion} | |
| 813 | - | } | |
| 814 | - | ||
| 815 | - | // detachLayer sends neferwl_capture_layer_v1.detached on the n-th attachment. | |
| 816 | - | func (c *compositor) detachLayer(n int, reason uint32) { | |
| 817 | - | c.mu.Lock() | |
| 818 | - | id := c.privLayers[n] | |
| 819 | - | c.mu.Unlock() | |
| 820 | - | c.send(id, 2, reason) | |
| 821 | - | } | |
| 822 | - | ||
| 823 | - | func (c *compositor) announceWorkspace(w workspaceSpec) { | |
| 824 | - | c.mu.Lock() | |
| 825 | - | id := c.privManager | |
| 826 | - | c.mu.Unlock() | |
| 827 | - | c.sendWorkspace(id, w) | |
| 828 | - | } | |
| 829 | - | ||
| 830 | - | func (c *compositor) removeWorkspace(id uint64) { | |
| 831 | - | c.mu.Lock() | |
| 832 | - | m := c.privManager | |
| 833 | - | c.mu.Unlock() | |
| 834 | - | c.send(m, 1, uint32(id>>32), uint32(id)) | |
| 835 | - | } | |
A internal/adapters/wayland/copycapture.go +489 -0
| @@ -0,0 +1,489 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/wlturbo/protocol/imagecapturesource" | |
| 9 | + | "github.com/bnema/wlturbo/protocol/imagecopycapture" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // wl_shm format values for the two supported native little-endian layouts. | |
| 15 | + | const ( | |
| 16 | + | shmARGB8888 = uint32(ports.ARGB8888) | |
| 17 | + | shmXRGB8888 = uint32(ports.XRGB8888) | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // maxFrameAttempts bounds the retries of one frame after failures that the | |
| 21 | + | // protocol calls recoverable. | |
| 22 | + | const maxFrameAttempts = 4 | |
| 23 | + | ||
| 24 | + | var errCompositorFailedCapture = errors.New("wayland: compositor failed the capture") | |
| 25 | + | ||
| 26 | + | // errCaptureRefused is ErrCaptureStopped for a session that stopped before | |
| 27 | + | // serving any frame. | |
| 28 | + | var errCaptureRefused = fmt.Errorf("%w: compositor refused capture (on NeferWL, list this executable in /etc/neferwl/capture-allow)", ports.ErrCaptureStopped) | |
| 29 | + | ||
| 30 | + | // constraints is one batch of buffer constraints, valid once done arrived. | |
| 31 | + | type constraints struct { | |
| 32 | + | width, height uint32 | |
| 33 | + | haveSize bool | |
| 34 | + | format uint32 // first supported shm format of the batch | |
| 35 | + | haveFormat bool | |
| 36 | + | sawShm bool | |
| 37 | + | unsupported uint32 // last rejected shm format | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // session is one ext_image_copy_capture_session_v1. Its events are dispatched | |
| 41 | + | // on the goroutine that owns the operation. | |
| 42 | + | type session struct { | |
| 43 | + | proxy *imagecopycapture.ExtImageCopyCaptureSession | |
| 44 | + | key sessionKey | |
| 45 | + | ||
| 46 | + | pending constraints | |
| 47 | + | current constraints | |
| 48 | + | gen uint64 // number of constraint batches completed | |
| 49 | + | stopped bool | |
| 50 | + | dead bool // destroy sent | |
| 51 | + | ||
| 52 | + | exclusion *exclusion // nil until requested | |
| 53 | + | ||
| 54 | + | // Frame pacing. At most one frame is in flight. The last completed frame | |
| 55 | + | // lives in buffer cur and stays untouched while the next one is written to | |
| 56 | + | // the other buffer, so a Capture that finds the compositor idle (it waits | |
| 57 | + | // for damage after the first frame) can repeat the last frame at once. | |
| 58 | + | frame captureFrame // outcome of the in-flight frame, reset per frame | |
| 59 | + | inflight *captureFrame // &frame while a frame is in flight, else nil | |
| 60 | + | inflightBuf int | |
| 61 | + | inflightGeom bufGeom // buffer layout the in-flight frame was made for | |
| 62 | + | haveLast bool | |
| 63 | + | cur int | |
| 64 | + | lastGeom bufGeom | |
| 65 | + | served int // frames handed out by this session | |
| 66 | + | fails int // consecutive failed frames | |
| 67 | + | ||
| 68 | + | // Frame event handlers, built once and shared by every frame proxy. | |
| 69 | + | onTransform func(uint32) | |
| 70 | + | onReady func() | |
| 71 | + | onFailed func(uint32) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // newSession wraps a created session proxy and subscribes to its events. | |
| 75 | + | func newSession(proxy *imagecopycapture.ExtImageCopyCaptureSession, key sessionKey) *session { | |
| 76 | + | c := &session{proxy: proxy, key: key} | |
| 77 | + | proxy.OnBufferSize(func(width, height uint32) { | |
| 78 | + | c.pending.width, c.pending.height, c.pending.haveSize = width, height, true | |
| 79 | + | }) | |
| 80 | + | proxy.OnShmFormat(func(format uint32) { | |
| 81 | + | c.pending.sawShm = true | |
| 82 | + | switch { | |
| 83 | + | case format != shmARGB8888 && format != shmXRGB8888: | |
| 84 | + | c.pending.unsupported = format | |
| 85 | + | case !c.pending.haveFormat: | |
| 86 | + | c.pending.format, c.pending.haveFormat = format, true | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | proxy.OnDone(func() { | |
| 90 | + | c.current, c.pending = c.pending, constraints{} | |
| 91 | + | c.gen++ | |
| 92 | + | }) | |
| 93 | + | proxy.OnStopped(func() { c.stopped = true }) | |
| 94 | + | c.onTransform = func(transform uint32) { c.frame.transform = transform } | |
| 95 | + | c.onReady = func() { c.frame.ready = true } | |
| 96 | + | c.onFailed = func(reason uint32) { c.frame.failed, c.frame.reason = true, reason } | |
| 97 | + | return c | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // captureFrame is one ext_image_copy_capture_frame_v1 and its outcome. The | |
| 101 | + | // session owns a single one, reset for each frame. | |
| 102 | + | type captureFrame struct { | |
| 103 | + | proxy *imagecopycapture.ExtImageCopyCaptureFrame | |
| 104 | + | transform uint32 | |
| 105 | + | ready bool | |
| 106 | + | failed bool | |
| 107 | + | reason uint32 | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // newCaptureFrame creates the next frame of c, resetting its outcome, and | |
| 111 | + | // subscribes the session's shared handlers to its events. | |
| 112 | + | func newCaptureFrame(c *session) (*captureFrame, error) { | |
| 113 | + | proxy, err := c.proxy.CreateFrame() | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | c.frame = captureFrame{proxy: proxy} | |
| 118 | + | proxy.OnTransform(c.onTransform) | |
| 119 | + | proxy.OnReady(c.onReady) | |
| 120 | + | proxy.OnFailed(c.onFailed) | |
| 121 | + | return &c.frame, nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // sessionKey identifies what a session captures. Crop sessions have a zero | |
| 125 | + | // region: the region is cut from each frame. | |
| 126 | + | type sessionKey struct { | |
| 127 | + | output uint32 | |
| 128 | + | workspace uint64 | |
| 129 | + | region ports.Region | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func validRegion(r ports.Region) error { | |
| 133 | + | if r == (ports.Region{}) { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 || | |
| 137 | + | r.X > ports.MaxDimension || r.Y > ports.MaxDimension || | |
| 138 | + | r.Width > ports.MaxDimension || r.Height > ports.MaxDimension { | |
| 139 | + | return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r) | |
| 140 | + | } | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // captureTarget resolves t to a session, creating or replacing it, and copies | |
| 145 | + | // one frame. The caller holds opMu. | |
| 146 | + | func (s *Source) captureTarget(t ports.Target) (ports.Frame, error) { | |
| 147 | + | out := s.outputs[t.OutputID] | |
| 148 | + | if out == nil { | |
| 149 | + | return ports.Frame{}, ports.ErrOutputNotFound | |
| 150 | + | } | |
| 151 | + | key, crop, err := s.resolve(t, out) | |
| 152 | + | if err != nil { | |
| 153 | + | return ports.Frame{}, err | |
| 154 | + | } | |
| 155 | + | sess, err := s.sessionFor(key, out) | |
| 156 | + | if err != nil { | |
| 157 | + | return ports.Frame{}, err | |
| 158 | + | } | |
| 159 | + | frame, err := s.nextFrame(sess, out) | |
| 160 | + | if err != nil { | |
| 161 | + | return ports.Frame{}, err | |
| 162 | + | } | |
| 163 | + | if crop == (ports.Region{}) { | |
| 164 | + | return frame, nil | |
| 165 | + | } | |
| 166 | + | return cropFrame(frame, crop, out) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // resolve maps a target to its session key and the crop to apply per frame. | |
| 170 | + | func (s *Source) resolve(t ports.Target, out *output) (sessionKey, ports.Region, error) { | |
| 171 | + | if t.WorkspaceID != 0 { | |
| 172 | + | return s.workspaceKey(t, out) | |
| 173 | + | } | |
| 174 | + | if t.Region != (ports.Region{}) && s.ext.source != nil { | |
| 175 | + | return sessionKey{output: out.id, region: t.Region}, ports.Region{}, nil | |
| 176 | + | } | |
| 177 | + | return sessionKey{output: out.id}, t.Region, nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // sessionFor returns the live session for key. A different key replaces the | |
| 181 | + | // current session, which ends its exclusion. | |
| 182 | + | func (s *Source) sessionFor(key sessionKey, out *output) (*session, error) { | |
| 183 | + | if c := s.sess; c != nil { | |
| 184 | + | if c.key == key && (!c.stopped || c.exclusion != nil) { | |
| 185 | + | // A stopped session with an exclusion stays stopped: replacing it | |
| 186 | + | // would quietly lose the exclusion in the middle of a recording. | |
| 187 | + | return c, nil | |
| 188 | + | } | |
| 189 | + | s.dropSession() | |
| 190 | + | } | |
| 191 | + | var src *imagecapturesource.ExtImageCaptureSource | |
| 192 | + | var err error | |
| 193 | + | switch { | |
| 194 | + | case key.workspace != 0: | |
| 195 | + | src, err = s.createWorkspaceSource(key.workspace) | |
| 196 | + | case key.region != (ports.Region{}): | |
| 197 | + | src, err = s.createRegionSource(out, key.region) | |
| 198 | + | default: | |
| 199 | + | src, err = s.outSource.CreateSource(out.proxy) | |
| 200 | + | } | |
| 201 | + | if err != nil { | |
| 202 | + | s.terminate() | |
| 203 | + | return nil, fmt.Errorf("wayland: create capture source: %w", err) | |
| 204 | + | } | |
| 205 | + | proxy, err := s.copyManager.CreateSession(src, 0) | |
| 206 | + | _ = src.Destroy() | |
| 207 | + | if err != nil { | |
| 208 | + | s.terminate() | |
| 209 | + | return nil, fmt.Errorf("wayland: create capture session: %w", err) | |
| 210 | + | } | |
| 211 | + | c := newSession(proxy, key) | |
| 212 | + | s.sess = c | |
| 213 | + | return c, nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // dropSession destroys the current session, if any. | |
| 217 | + | func (s *Source) dropSession() { | |
| 218 | + | c := s.sess | |
| 219 | + | s.sess = nil | |
| 220 | + | if c == nil || c.dead { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | c.dead = true | |
| 224 | + | s.destroyFrame(c) | |
| 225 | + | if c.exclusion != nil { | |
| 226 | + | c.exclusion.destroy(s) | |
| 227 | + | } | |
| 228 | + | _ = c.proxy.Destroy() | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // nextFrame returns the session's current frame. | |
| 232 | + | // | |
| 233 | + | // The first frame of a session is waited for: the protocol guarantees it does | |
| 234 | + | // not wait for damage. After that the compositor may hold a capture back until | |
| 235 | + | // the screen changes, so a capture stays pending across calls (still one frame | |
| 236 | + | // in flight) and a call that finds it unfinished after pollWait returns the | |
| 237 | + | // last completed frame again. The caller's schedule is then never stretched by | |
| 238 | + | // a static screen, and a recording repeats that frame. | |
| 239 | + | // | |
| 240 | + | // A failed frame is retried after a roundtrip, at most maxFrameAttempts | |
| 241 | + | // failures in a row (the count survives calls and restarts at each completed | |
| 242 | + | // frame): stopped ends the capture, buffer_constraints is retried with the | |
| 243 | + | // latest constraints, which are the current ones when the compositor sent no | |
| 244 | + | // new batch. An unknown failure is retried the same way, unless a last frame | |
| 245 | + | // exists: that one is repeated, like when a capture is still pending. | |
| 246 | + | func (s *Source) nextFrame(c *session, out *output) (ports.Frame, error) { | |
| 247 | + | for { | |
| 248 | + | for c.gen == 0 && !c.stopped { | |
| 249 | + | if err := s.dispatch(); err != nil { | |
| 250 | + | return ports.Frame{}, err | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | if c.stopped { | |
| 254 | + | return ports.Frame{}, s.stoppedError(c, out) | |
| 255 | + | } | |
| 256 | + | g, err := s.geometry(c) | |
| 257 | + | if err != nil { | |
| 258 | + | return ports.Frame{}, err | |
| 259 | + | } | |
| 260 | + | // A new constraint batch with the same layout changes nothing: the last | |
| 261 | + | // frame stays valid and the pending capture stays in flight. | |
| 262 | + | if c.haveLast && c.lastGeom != g { | |
| 263 | + | c.haveLast = false | |
| 264 | + | } | |
| 265 | + | if c.inflight != nil && c.inflightGeom != g { | |
| 266 | + | s.destroyFrame(c) | |
| 267 | + | } | |
| 268 | + | if c.inflight == nil { | |
| 269 | + | if err := s.startFrame(c, g); err != nil { | |
| 270 | + | return ports.Frame{}, err | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | if err := s.awaitFrame(c, c.haveLast); err != nil { | |
| 274 | + | return ports.Frame{}, err | |
| 275 | + | } | |
| 276 | + | f := c.inflight | |
| 277 | + | switch { | |
| 278 | + | case c.stopped: | |
| 279 | + | return ports.Frame{}, s.stoppedError(c, out) | |
| 280 | + | case f.ready: | |
| 281 | + | return s.complete(c, g, f) | |
| 282 | + | case f.failed: | |
| 283 | + | if err := s.retryAfterFailure(c, f); err != nil { | |
| 284 | + | if c.stopped { | |
| 285 | + | return ports.Frame{}, s.stoppedError(c, out) | |
| 286 | + | } | |
| 287 | + | c.fails = 0 // the next call starts afresh | |
| 288 | + | return ports.Frame{}, err | |
| 289 | + | } | |
| 290 | + | if c.stopped { | |
| 291 | + | return ports.Frame{}, s.stoppedError(c, out) | |
| 292 | + | } | |
| 293 | + | if c.haveLast && f.reason == imagecopycapture.FAILURE_REASON_UNKNOWN { | |
| 294 | + | return s.lastFrame(c) | |
| 295 | + | } | |
| 296 | + | default: // still pending after the poll: repeat the last frame | |
| 297 | + | return s.lastFrame(c) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // awaitFrame dispatches until the in-flight frame ended or the session | |
| 303 | + | // stopped. With poll set it gives up after pollWait. | |
| 304 | + | func (s *Source) awaitFrame(c *session, poll bool) error { | |
| 305 | + | f := c.inflight | |
| 306 | + | var deadline time.Time | |
| 307 | + | if poll { | |
| 308 | + | deadline = time.Now().Add(s.pollWait) | |
| 309 | + | } | |
| 310 | + | for !f.ready && !f.failed && !c.stopped { | |
| 311 | + | timedOut, err := s.dispatchUntil(deadline) | |
| 312 | + | if err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | if timedOut { | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | return nil | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // retryAfterFailure handles a failed frame: one roundtrip, so that a stop or a | |
| 323 | + | // new constraint batch that follows the failure is seen, then a retry. | |
| 324 | + | func (s *Source) retryAfterFailure(c *session, f *captureFrame) error { | |
| 325 | + | s.destroyFrame(c) | |
| 326 | + | if f.reason == imagecopycapture.FAILURE_REASON_STOPPED { | |
| 327 | + | c.stopped = true | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | if c.fails++; c.fails >= maxFrameAttempts { | |
| 331 | + | return errCompositorFailedCapture | |
| 332 | + | } | |
| 333 | + | switch f.reason { | |
| 334 | + | case imagecopycapture.FAILURE_REASON_BUFFER_CONSTRAINTS, imagecopycapture.FAILURE_REASON_UNKNOWN: | |
| 335 | + | return s.roundtrip() | |
| 336 | + | } | |
| 337 | + | return fmt.Errorf("%w: failure reason %d", errCompositorFailedCapture, f.reason) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // complete hands out a frame the compositor finished. | |
| 341 | + | func (s *Source) complete(c *session, g bufGeom, f *captureFrame) (ports.Frame, error) { | |
| 342 | + | transform := f.transform | |
| 343 | + | idx := c.inflightBuf | |
| 344 | + | s.destroyFrame(c) | |
| 345 | + | if transform != 0 { | |
| 346 | + | return ports.Frame{}, fmt.Errorf("%w: output transform %d is not supported", ports.ErrUnsupportedTransform, transform) | |
| 347 | + | } | |
| 348 | + | c.haveLast, c.cur, c.lastGeom = true, idx, g | |
| 349 | + | c.served++ | |
| 350 | + | c.fails = 0 | |
| 351 | + | frame, err := s.frameOf(idx, g) | |
| 352 | + | // From the second frame on the next capture starts right away, so the | |
| 353 | + | // compositor can answer during the wait for the next slot. A one-shot | |
| 354 | + | // screenshot never reaches it and allocates a single buffer. | |
| 355 | + | if err == nil && c.served > 1 { | |
| 356 | + | err = s.startFrame(c, g) | |
| 357 | + | } | |
| 358 | + | return frame, err | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // lastFrame repeats the last completed frame, still held in its own buffer. | |
| 362 | + | func (s *Source) lastFrame(c *session) (ports.Frame, error) { | |
| 363 | + | return s.frameOf(c.cur, c.lastGeom) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // startFrame sends one capture into the buffer the last frame is not in. | |
| 367 | + | func (s *Source) startFrame(c *session, g bufGeom) error { | |
| 368 | + | idx := 0 | |
| 369 | + | if c.haveLast { | |
| 370 | + | idx = 1 - c.cur | |
| 371 | + | } | |
| 372 | + | if err := s.ensureBuffer(idx, g); err != nil { | |
| 373 | + | return err | |
| 374 | + | } | |
| 375 | + | f, err := newCaptureFrame(c) | |
| 376 | + | if err != nil { | |
| 377 | + | s.terminate() | |
| 378 | + | return fmt.Errorf("wayland: create frame: %w", err) | |
| 379 | + | } | |
| 380 | + | c.inflight, c.inflightBuf, c.inflightGeom = f, idx, g | |
| 381 | + | if err := f.proxy.AttachBuffer(s.bufs[idx].buffer); err != nil { | |
| 382 | + | s.terminate() | |
| 383 | + | return fmt.Errorf("wayland: attach buffer: %w", err) | |
| 384 | + | } | |
| 385 | + | if err := f.proxy.DamageBuffer(0, 0, int32(g.width), int32(g.height)); err != nil { | |
| 386 | + | s.terminate() | |
| 387 | + | return fmt.Errorf("wayland: damage buffer: %w", err) | |
| 388 | + | } | |
| 389 | + | if err := f.proxy.Capture(); err != nil { | |
| 390 | + | s.terminate() | |
| 391 | + | return fmt.Errorf("wayland: capture request: %w", err) | |
| 392 | + | } | |
| 393 | + | return nil | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // destroyFrame destroys the in-flight frame object once it ended. | |
| 397 | + | func (s *Source) destroyFrame(c *session) { | |
| 398 | + | if f := c.inflight; f != nil { | |
| 399 | + | c.inflight = nil | |
| 400 | + | _ = f.proxy.Destroy() | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // stoppedError classifies a stopped session. An output that went away is the | |
| 405 | + | // usual cause: read its removal first. A workspace session stops when its | |
| 406 | + | // workspace is removed: that is a plain stop, whatever the session served. Any | |
| 407 | + | // other session that stopped before serving a frame was refused by the | |
| 408 | + | // compositor, which the error says. | |
| 409 | + | func (s *Source) stoppedError(c *session, out *output) error { | |
| 410 | + | if err := s.roundtrip(); err != nil { | |
| 411 | + | return err | |
| 412 | + | } | |
| 413 | + | switch { | |
| 414 | + | case out.removed: | |
| 415 | + | return ports.ErrOutputNotFound | |
| 416 | + | case c.key.workspace != 0 && s.ws.byID[c.key.workspace] == nil: | |
| 417 | + | return fmt.Errorf("%w: %w", ports.ErrWorkspaceUnavailable, ports.ErrCaptureStopped) | |
| 418 | + | case c.served == 0: | |
| 419 | + | return errCaptureRefused | |
| 420 | + | } | |
| 421 | + | return ports.ErrCaptureStopped | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | // geometry validates the session's current constraints and returns the buffer | |
| 425 | + | // layout to allocate. | |
| 426 | + | func (s *Source) geometry(c *session) (bufGeom, error) { | |
| 427 | + | k := c.current | |
| 428 | + | switch { | |
| 429 | + | case !k.sawShm: | |
| 430 | + | return bufGeom{}, fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat) | |
| 431 | + | case !k.haveFormat: | |
| 432 | + | return bufGeom{}, fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, k.unsupported) | |
| 433 | + | case !k.haveSize: | |
| 434 | + | return bufGeom{}, errors.New("wayland: compositor sent no buffer size") | |
| 435 | + | } | |
| 436 | + | g := bufGeom{format: k.format, width: k.width, height: k.height, stride: k.width * ports.BytesPerPixel} | |
| 437 | + | if err := g.validate(); err != nil { | |
| 438 | + | return bufGeom{}, err | |
| 439 | + | } | |
| 440 | + | return g, nil | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func (s *Source) frameOf(idx int, g bufGeom) (ports.Frame, error) { | |
| 444 | + | frame := ports.Frame{ | |
| 445 | + | Pixels: s.bufs[idx].data[:g.size:g.size], | |
| 446 | + | Width: int(g.width), Height: int(g.height), Stride: int(g.stride), | |
| 447 | + | Format: ports.PixelFormat(g.format), | |
| 448 | + | } | |
| 449 | + | if err := frame.Validate(); err != nil { | |
| 450 | + | return ports.Frame{}, err | |
| 451 | + | } | |
| 452 | + | return frame, nil | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // logicalSize is the output's size in logical pixels: xdg-output when the | |
| 456 | + | // compositor offers it, else the current mode divided by the integer scale. | |
| 457 | + | func (o *output) logicalSize() (w, h int) { | |
| 458 | + | if o.lw > 0 && o.lh > 0 { | |
| 459 | + | return int(o.lw), int(o.lh) | |
| 460 | + | } | |
| 461 | + | return int(o.width) / int(max(o.scale, 1)), int(o.height) / int(max(o.scale, 1)) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // cropFrame returns the window of f that covers region r, given in logical | |
| 465 | + | // pixels of out. The result borrows f's storage: no pixel is copied. | |
| 466 | + | func cropFrame(f ports.Frame, r ports.Region, out *output) (ports.Frame, error) { | |
| 467 | + | lw, lh := out.logicalSize() | |
| 468 | + | if lw < 1 || lh < 1 { | |
| 469 | + | return ports.Frame{}, fmt.Errorf("%w: output %s has no known logical size", ports.ErrInvalidRegion, outputName(out)) | |
| 470 | + | } | |
| 471 | + | edge := func(v, buf, logical int) int { // round to the nearest buffer pixel | |
| 472 | + | return int((int64(v)*int64(buf) + int64(logical)/2) / int64(logical)) | |
| 473 | + | } | |
| 474 | + | x0, x1 := edge(r.X, f.Width, lw), min(edge(r.X+r.Width, f.Width, lw), f.Width) | |
| 475 | + | y0, y1 := edge(r.Y, f.Height, lh), min(edge(r.Y+r.Height, f.Height, lh), f.Height) | |
| 476 | + | if x0 >= x1 || y0 >= y1 { | |
| 477 | + | return ports.Frame{}, fmt.Errorf("%w: %+v is outside output %s", ports.ErrInvalidRegion, r, outputName(out)) | |
| 478 | + | } | |
| 479 | + | w, h := x1-x0, y1-y0 | |
| 480 | + | start := y0*f.Stride + x0*ports.BytesPerPixel | |
| 481 | + | end := start + (h-1)*f.Stride + w*ports.BytesPerPixel | |
| 482 | + | cropped := f | |
| 483 | + | cropped.Pixels = f.Pixels[start:end:end] | |
| 484 | + | cropped.Width, cropped.Height = w, h | |
| 485 | + | if err := cropped.Validate(); err != nil { | |
| 486 | + | return ports.Frame{}, err | |
| 487 | + | } | |
| 488 | + | return cropped, nil | |
| 489 | + | } | |
A internal/adapters/wayland/crop_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "runtime" | |
| 6 | + | "testing" | |
| 7 | + | "unsafe" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func bigOutput() []testserver.OutputSpec { | |
| 16 | + | return []testserver.OutputSpec{{Name: "BIG", Width: 64, Height: 48, Scale: 1}} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Without the extension a region is cropped from the output frame, borrowing | |
| 20 | + | // the shared storage: no pixel is copied and the full stride is kept. | |
| 21 | + | func TestCaptureRegionCropsOutputFrame(t *testing.T) { | |
| 22 | + | s, srv := newSource(t, testserver.Config{Outputs: bigOutput()}) | |
| 23 | + | out := firstOutput(t, s) | |
| 24 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}}) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.NoError(t, f.Validate()) | |
| 27 | + | require.Equal(t, [3]int{20, 8, 64 * 4}, [3]int{f.Width, f.Height, f.Stride}) | |
| 28 | + | want := testserver.Pixel(10, 5, 1) | |
| 29 | + | require.Equal(t, want[:], f.Row(0)[:4]) | |
| 30 | + | want = testserver.Pixel(29, 12, 1) | |
| 31 | + | require.Equal(t, want[:], f.Row(7)[19*4:]) | |
| 32 | + | require.Equal(t, "output", srv.Stats().LastSource) | |
| 33 | + | require.Equal(t, 1, srv.Stats().Buffers, "a single screenshot allocates a single buffer") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestCropIsAWindowOfTheFrame(t *testing.T) { | |
| 37 | + | out := &output{id: 1, width: 64, height: 48, scale: 1} | |
| 38 | + | f := ports.Frame{Pixels: make([]byte, 64*48*4), Width: 64, Height: 48, Stride: 64 * 4, Format: ports.XRGB8888} | |
| 39 | + | c, err := cropFrame(f, ports.Region{X: 10, Y: 5, Width: 20, Height: 8}, out) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Equal(t, unsafe.Add(unsafe.Pointer(&f.Pixels[0]), 5*64*4+10*4), unsafe.Pointer(&c.Pixels[0])) | |
| 42 | + | require.Equal(t, 7*64*4+20*4, len(c.Pixels), "the window ends with the last row's pixels") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestCropRoundsToBufferPixels(t *testing.T) { | |
| 46 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "HI", Width: 64, Height: 48, Scale: 2}}}) | |
| 47 | + | out := firstOutput(t, s) | |
| 48 | + | // logical size is 32x24 (mode over scale): one logical pixel is two buffer pixels | |
| 49 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 3, Y: 2, Width: 5, Height: 4}}) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.Equal(t, [2]int{10, 8}, [2]int{f.Width, f.Height}) | |
| 52 | + | want := testserver.Pixel(6, 4, 1) | |
| 53 | + | require.Equal(t, want[:], f.Row(0)[:4]) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestCropClipsToOutputAndRejectsOutside(t *testing.T) { | |
| 57 | + | s, _ := newSource(t, testserver.Config{Outputs: bigOutput()}) | |
| 58 | + | out := firstOutput(t, s) | |
| 59 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 60, Y: 40, Width: 100, Height: 100}}) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, [2]int{4, 8}, [2]int{f.Width, f.Height}) | |
| 62 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 64, Y: 0, Width: 4, Height: 4}}) | |
| 63 | + | require.ErrorIs(t, err, ports.ErrInvalidRegion) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestCropUsesXdgOutputLogicalSize(t *testing.T) { | |
| 67 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "FR", Width: 64, Height: 48, Scale: 1, LogicalWidth: 32, LogicalHeight: 24}}}) | |
| 68 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, Region: ports.Region{Width: 16, Height: 12}}) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | require.Equal(t, [2]int{32, 24}, [2]int{f.Width, f.Height}, "fractional scale: logical 16x12 is 32x24 buffer pixels") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // The crop must not allocate: it is a window, not a copy. | |
| 74 | + | func TestCropAllocations(t *testing.T) { | |
| 75 | + | if raceEnabled { | |
| 76 | + | t.Skip("race instrumentation changes allocation counts") | |
| 77 | + | } | |
| 78 | + | out := &output{id: 1, width: 1920, height: 1080, scale: 1} | |
| 79 | + | f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888} | |
| 80 | + | r := ports.Region{X: 100, Y: 100, Width: 800, Height: 600} | |
| 81 | + | runtime.GC() | |
| 82 | + | avg := testing.AllocsPerRun(1000, func() { | |
| 83 | + | if _, err := cropFrame(f, r, out); err != nil { | |
| 84 | + | panic(err) | |
| 85 | + | } | |
| 86 | + | }) | |
| 87 | + | require.Zero(t, avg) | |
| 88 | + | } | |
A internal/adapters/wayland/extensions.go +566 -0
| @@ -0,0 +1,566 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/wlturbo" | |
| 11 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 12 | + | "github.com/bnema/wlturbo/protocol/imagecapturesource" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/xdgoutput" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/nefercap/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // NeferWL's optional extension to ext-image-copy-capture-v1, see | |
| 19 | + | // neferwl-image-capture-v1.xml in the NeferWL repository. Every part is | |
| 20 | + | // optional: without it, capture still works through the standard protocols. | |
| 21 | + | // | |
| 22 | + | // neferwl_image_capture_source_manager_v1 | |
| 23 | + | // requests: 0 destroy, 1 create_workspace_source(new_id, workspace), | |
| 24 | + | // 2 create_output_region_source(new_id, output, x, y, w, h), | |
| 25 | + | // 3 get_workspace_frame(new_id, workspace) | |
| 26 | + | // neferwl_workspace_frame_v1 | |
| 27 | + | // requests: 0 destroy events: 0 frame(x, y, width, height) | |
| 28 | + | // neferwl_capture_exclusion_manager_v1 | |
| 29 | + | // requests: 0 destroy, 1 get_exclusion(new_id, session), | |
| 30 | + | // 2 attach_surface(new_id, token, surface) | |
| 31 | + | // neferwl_capture_exclusion_v1 | |
| 32 | + | // requests: 0 destroy events: 0 token(string), 1 failed(reason) | |
| 33 | + | // neferwl_capture_layer_v1 | |
| 34 | + | // requests: 0 destroy events: 0 attached, 1 failed(reason), 2 detached(reason) | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | sourceManagerIface = "neferwl_image_capture_source_manager_v1" | |
| 38 | + | exclusionManagerIface = "neferwl_capture_exclusion_manager_v1" | |
| 39 | + | extensionVersion = 1 | |
| 40 | + | tokenLen = 64 // lowercase hexadecimal characters of 32 random bytes | |
| 41 | + | ||
| 42 | + | reqNeferwlWorkspaceSource = 1 | |
| 43 | + | reqNeferwlRegionSource = 2 | |
| 44 | + | reqGetWorkspaceFrame = 3 | |
| 45 | + | reqWorkspaceFrameDestroy = 0 | |
| 46 | + | reqGetExclusion = 1 | |
| 47 | + | reqAttachSurface = 2 | |
| 48 | + | reqManagerDestroy = 0 | |
| 49 | + | reqExclusionDestroy = 0 | |
| 50 | + | reqLayerDestroy = 0 | |
| 51 | + | ||
| 52 | + | // DefaultAttachTimeout bounds AuthorizeLayer. | |
| 53 | + | DefaultAttachTimeout = 5 * time.Second | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | var ( | |
| 57 | + | // ErrExclusionUnsupported means the compositor cannot leave the client's | |
| 58 | + | // own surfaces out of its captures. | |
| 59 | + | ErrExclusionUnsupported = errors.New("wayland: capture exclusion unsupported") | |
| 60 | + | // ErrExclusionUnavailable means the exclusion is gone, refused or never | |
| 61 | + | // existed. Callers that rely on it must stop recording. | |
| 62 | + | ErrExclusionUnavailable = errors.New("wayland: capture exclusion unavailable") | |
| 63 | + | // ErrExclusionBusy is an ErrExclusionUnavailable: another exclusion is live | |
| 64 | + | // in the compositor. | |
| 65 | + | ErrExclusionBusy = fmt.Errorf("%w: another exclusion is live", ErrExclusionUnavailable) | |
| 66 | + | // ErrAttachFailed is matched by every AttachFailedError. | |
| 67 | + | ErrAttachFailed = errors.New("wayland: attach layer failed") | |
| 68 | + | // ErrUnauthorized means the compositor refused the peer. | |
| 69 | + | ErrUnauthorized = errors.New("wayland: capture exclusion peer unauthorized") | |
| 70 | + | // errLayerProtocol is a violation of the layer attachment state machine. | |
| 71 | + | errLayerProtocol = fmt.Errorf("%w: capture layer protocol violation", ErrExclusionUnavailable) | |
| 72 | + | ) | |
| 73 | + | ||
| 74 | + | // neferwl_capture_exclusion_v1.failure | |
| 75 | + | const ( | |
| 76 | + | exclusionBusy = 0 | |
| 77 | + | exclusionSessionStopped = 1 | |
| 78 | + | ) | |
| 79 | + | ||
| 80 | + | // requestOnly is a bound NeferWL global or created object that never sends | |
| 81 | + | // events: wlturbo has no generated binding for the extension. | |
| 82 | + | type requestOnly struct{ wlturbo.BaseProxy } | |
| 83 | + | ||
| 84 | + | func (*requestOnly) EventSignature(uint16) (string, bool) { return "", false } | |
| 85 | + | ||
| 86 | + | func newRequestOnly(ctx *wlturbo.Context) *requestOnly { | |
| 87 | + | p := &requestOnly{} | |
| 88 | + | p.SetContext(ctx) | |
| 89 | + | return p | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // extensions holds the bound optional NeferWL globals. | |
| 93 | + | type extensions struct { | |
| 94 | + | source *requestOnly | |
| 95 | + | exclusion *requestOnly | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // bindExtras binds every optional global. None is an error when absent. | |
| 99 | + | func (s *Source) bindExtras() error { | |
| 100 | + | x := xdgoutput.NewZxdgOutputManager(s.wl) | |
| 101 | + | switch ok, err := s.bindOptional(xdgoutput.ZxdgOutputManagerInterface, xdgOutputVersion, x); { | |
| 102 | + | case err != nil: | |
| 103 | + | return err | |
| 104 | + | case ok: | |
| 105 | + | s.xdgOutputs = x | |
| 106 | + | } | |
| 107 | + | for _, o := range s.outputs { | |
| 108 | + | s.watchLogicalSize(o) | |
| 109 | + | } | |
| 110 | + | var err error | |
| 111 | + | if s.ext.source, err = s.bindOptionalExtension(sourceManagerIface); err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | if s.ext.exclusion, err = s.bindOptionalExtension(exclusionManagerIface); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | return s.bindWorkspaces() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // bindOptionalExtension binds a NeferWL global, nil when the compositor has | |
| 121 | + | // none. | |
| 122 | + | func (s *Source) bindOptionalExtension(iface string) (*requestOnly, error) { | |
| 123 | + | p := newRequestOnly(s.wl) | |
| 124 | + | if ok, err := s.bindOptional(iface, extensionVersion, p); err != nil || !ok { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | return p, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // createWorkspaceSource makes a source that follows the workspace id. | |
| 131 | + | func (s *Source) createWorkspaceSource(id uint64) (*imagecapturesource.ExtImageCaptureSource, error) { | |
| 132 | + | h := s.ws.byID[id] | |
| 133 | + | if h == nil || h.removed { | |
| 134 | + | return nil, ports.ErrWorkspaceUnavailable | |
| 135 | + | } | |
| 136 | + | src := imagecapturesource.NewExtImageCaptureSource(s.wl) | |
| 137 | + | err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqNeferwlWorkspaceSource, Name: "neferwl_image_capture_source_manager_v1.create_workspace_source", Child: src}, src, h.proxy) | |
| 138 | + | return src, err | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // createRegionSource makes a source of a region of out, in logical pixels. | |
| 142 | + | func (s *Source) createRegionSource(out *output, r ports.Region) (*imagecapturesource.ExtImageCaptureSource, error) { | |
| 143 | + | src := imagecapturesource.NewExtImageCaptureSource(s.wl) | |
| 144 | + | err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqNeferwlRegionSource, Name: "neferwl_image_capture_source_manager_v1.create_output_region_source", Child: src}, | |
| 145 | + | src, out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height)) | |
| 146 | + | return src, err | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // workspaceFrame is one neferwl_workspace_frame_v1: the frame of a workspace | |
| 150 | + | // in output-local logical pixels. It is read under opMu, with every other | |
| 151 | + | // state of the connection, so it needs no lock of its own. | |
| 152 | + | type workspaceFrame struct { | |
| 153 | + | wlturbo.BaseProxy | |
| 154 | + | x, y, w, h int32 | |
| 155 | + | known bool | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (*workspaceFrame) EventSignature(op uint16) (string, bool) { | |
| 159 | + | if op == 0 { | |
| 160 | + | return "int,int,int,int,", true | |
| 161 | + | } | |
| 162 | + | return "", false | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (f *workspaceFrame) Dispatch(ev *wlturbo.Event) { | |
| 166 | + | if ev.Opcode == 0 { | |
| 167 | + | f.x, f.y, f.w, f.h = ev.Int32(), ev.Int32(), ev.Int32(), ev.Int32() | |
| 168 | + | f.known = true | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // region is the frame when it is a sane rectangle. | |
| 173 | + | func (f *workspaceFrame) region() (ports.Region, bool) { | |
| 174 | + | if f == nil || !f.known || f.w < 1 || f.h < 1 || f.w > ports.MaxDimension || f.h > ports.MaxDimension || | |
| 175 | + | f.x < -ports.MaxDimension || f.x > ports.MaxDimension || f.y < -ports.MaxDimension || f.y > ports.MaxDimension { | |
| 176 | + | return ports.Region{}, false | |
| 177 | + | } | |
| 178 | + | return ports.Region{X: int(f.x), Y: int(f.y), Width: int(f.w), Height: int(f.h)}, true | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // requestFrames asks for the frame of every workspace that has none yet and | |
| 182 | + | // reports whether it asked for any. It does nothing without the extension. | |
| 183 | + | func (s *Source) requestFrames() (bool, error) { | |
| 184 | + | if s.ext.source == nil { | |
| 185 | + | return false, nil | |
| 186 | + | } | |
| 187 | + | asked := false | |
| 188 | + | for _, w := range s.ws.byID { | |
| 189 | + | if w.frame != nil || w.removed { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | f := &workspaceFrame{} | |
| 193 | + | f.SetContext(s.wl) | |
| 194 | + | if err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqGetWorkspaceFrame, Name: "neferwl_image_capture_source_manager_v1.get_workspace_frame", Child: f}, f, w.proxy); err != nil { | |
| 195 | + | s.terminate() | |
| 196 | + | return false, fmt.Errorf("wayland: get workspace frame: %w", err) | |
| 197 | + | } | |
| 198 | + | w.frame, asked = f, true | |
| 199 | + | } | |
| 200 | + | return asked, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // releaseWorkspaceFrame releases the frame object of a workspace that is gone. | |
| 204 | + | func (s *Source) releaseWorkspaceFrame(w *workspace) { | |
| 205 | + | if w.frame == nil { | |
| 206 | + | return | |
| 207 | + | } | |
| 208 | + | _ = s.wl.Request(wlturbo.Request{Proxy: w.frame, Opcode: reqWorkspaceFrameDestroy, Name: "neferwl_workspace_frame_v1.destroy", Destructor: true}) | |
| 209 | + | w.frame = nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // exclusion is one neferwl_capture_exclusion_v1: it answers with a token or a | |
| 213 | + | // failure, and ends with its capture session. | |
| 214 | + | type exclusion struct { | |
| 215 | + | wlturbo.BaseProxy | |
| 216 | + | token string | |
| 217 | + | failed bool | |
| 218 | + | reason uint32 | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (*exclusion) EventSignature(op uint16) (string, bool) { | |
| 222 | + | switch op { | |
| 223 | + | case 0: | |
| 224 | + | return "string,", true | |
| 225 | + | case 1: | |
| 226 | + | return "uint,", true | |
| 227 | + | } | |
| 228 | + | return "", false | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (e *exclusion) Dispatch(ev *wlturbo.Event) { | |
| 232 | + | switch ev.Opcode { | |
| 233 | + | case 0: | |
| 234 | + | if tok := ev.String(); validToken(tok) && !e.failed && e.token == "" { | |
| 235 | + | e.token = tok | |
| 236 | + | } else { | |
| 237 | + | e.failed, e.reason = true, ^uint32(0) | |
| 238 | + | } | |
| 239 | + | case 1: | |
| 240 | + | if e.token == "" && !e.failed { | |
| 241 | + | e.failed, e.reason = true, ev.Uint32() | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (e *exclusion) destroy(s *Source) { | |
| 247 | + | _ = s.wl.Request(wlturbo.Request{Proxy: e, Opcode: reqExclusionDestroy, Name: "neferwl_capture_exclusion_v1.destroy", Destructor: true}) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // BeginExclusion asks the compositor to leave the client's own layer surfaces | |
| 251 | + | // out of the frames of the session that captures t, and returns the token a | |
| 252 | + | // HUD on another connection passes to AuthorizeLayer. The token is a secret: | |
| 253 | + | // never log it. The exclusion ends with the session, which later Capture | |
| 254 | + | // calls on the same target keep using; capturing another target replaces the | |
| 255 | + | // session and ends the exclusion. | |
| 256 | + | func (s *Source) BeginExclusion(ctx context.Context, t ports.Target) (string, error) { | |
| 257 | + | if err := validRegion(t.Region); err != nil { | |
| 258 | + | return "", err | |
| 259 | + | } | |
| 260 | + | s.opMu.Lock() | |
| 261 | + | defer s.opMu.Unlock() | |
| 262 | + | if err := s.begin(ctx); err != nil { | |
| 263 | + | return "", err | |
| 264 | + | } | |
| 265 | + | token, err := s.beginExclusion(t) | |
| 266 | + | if err = s.finish(ctx, err); err != nil { | |
| 267 | + | return "", err | |
| 268 | + | } | |
| 269 | + | return token, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (s *Source) beginExclusion(t ports.Target) (string, error) { | |
| 273 | + | if s.ext.exclusion == nil { | |
| 274 | + | return "", ErrExclusionUnsupported | |
| 275 | + | } | |
| 276 | + | if err := s.settle(); err != nil { | |
| 277 | + | return "", err | |
| 278 | + | } | |
| 279 | + | out := s.outputs[t.OutputID] | |
| 280 | + | if out == nil { | |
| 281 | + | return "", ports.ErrOutputNotFound | |
| 282 | + | } | |
| 283 | + | key, _, err := s.resolve(t, out) | |
| 284 | + | if err != nil { | |
| 285 | + | return "", err | |
| 286 | + | } | |
| 287 | + | c, err := s.sessionFor(key, out) | |
| 288 | + | if err != nil { | |
| 289 | + | return "", err | |
| 290 | + | } | |
| 291 | + | if c.exclusion != nil { | |
| 292 | + | return "", errors.New("wayland: the capture session already has an exclusion") | |
| 293 | + | } | |
| 294 | + | e := &exclusion{} | |
| 295 | + | e.SetContext(s.wl) | |
| 296 | + | if err := s.wl.Request(wlturbo.Request{Proxy: s.ext.exclusion, Opcode: reqGetExclusion, Name: "neferwl_capture_exclusion_manager_v1.get_exclusion", Child: e}, e, c.proxy); err != nil { | |
| 297 | + | s.terminate() | |
| 298 | + | return "", fmt.Errorf("wayland: get exclusion: %w", err) | |
| 299 | + | } | |
| 300 | + | for e.token == "" && !e.failed && !c.stopped { | |
| 301 | + | if err := s.dispatch(); err != nil { | |
| 302 | + | return "", err | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | if e.token != "" { | |
| 306 | + | c.exclusion = e // only a live exclusion makes a stopped session sticky | |
| 307 | + | return e.token, nil | |
| 308 | + | } | |
| 309 | + | // Every other outcome (refused, invalid token, session stopped first) | |
| 310 | + | // leaves no exclusion behind. | |
| 311 | + | e.destroy(s) | |
| 312 | + | switch { | |
| 313 | + | case c.stopped || (e.failed && e.reason == exclusionSessionStopped): | |
| 314 | + | return "", s.stoppedError(c, out) | |
| 315 | + | case e.failed && e.reason == exclusionBusy: | |
| 316 | + | return "", ErrExclusionBusy | |
| 317 | + | default: | |
| 318 | + | return "", fmt.Errorf("%w: refused (reason %d)", ErrExclusionUnavailable, e.reason) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // AttachFailure is neferwl_capture_layer_v1.failure. | |
| 323 | + | type AttachFailure uint32 | |
| 324 | + | ||
| 325 | + | const ( | |
| 326 | + | AttachUnknownToken AttachFailure = 0 | |
| 327 | + | AttachUnauthorized AttachFailure = 1 | |
| 328 | + | AttachTooManyLayers AttachFailure = 2 | |
| 329 | + | AttachAlreadyAttached AttachFailure = 3 | |
| 330 | + | AttachExclusionEnded AttachFailure = 4 | |
| 331 | + | AttachLayerDestroyed AttachFailure = 5 | |
| 332 | + | ) | |
| 333 | + | ||
| 334 | + | // AttachFailedError is the compositor's refusal of AuthorizeLayer. It matches | |
| 335 | + | // ErrAttachFailed; a missing or ended exclusion also matches | |
| 336 | + | // ErrExclusionUnavailable, and an unauthorized peer ErrUnauthorized. | |
| 337 | + | type AttachFailedError struct{ Reason AttachFailure } | |
| 338 | + | ||
| 339 | + | func (e *AttachFailedError) Error() string { | |
| 340 | + | return fmt.Sprintf("%v (reason %d)", ErrAttachFailed, e.Reason) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func (e *AttachFailedError) Is(target error) bool { | |
| 344 | + | switch target { | |
| 345 | + | case ErrAttachFailed: | |
| 346 | + | return true | |
| 347 | + | case ErrExclusionUnavailable: | |
| 348 | + | return e.Reason == AttachUnknownToken || e.Reason == AttachExclusionEnded | |
| 349 | + | case ErrUnauthorized: | |
| 350 | + | return e.Reason == AttachUnauthorized | |
| 351 | + | } | |
| 352 | + | return false | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // DetachReason is neferwl_capture_layer_v1.detach_reason. | |
| 356 | + | type DetachReason uint32 | |
| 357 | + | ||
| 358 | + | const ( | |
| 359 | + | // DetachExclusionEnded: the exclusion ended and the compositor closed the | |
| 360 | + | // layer surface, which the client must destroy. | |
| 361 | + | DetachExclusionEnded DetachReason = 0 | |
| 362 | + | // DetachLayerDestroyed: the layer role was destroyed. | |
| 363 | + | DetachLayerDestroyed DetachReason = 1 | |
| 364 | + | ) | |
| 365 | + | ||
| 366 | + | // LayerDetachedError is what onDetached receives. An exclusion end also | |
| 367 | + | // matches ErrExclusionUnavailable. | |
| 368 | + | type LayerDetachedError struct{ Reason DetachReason } | |
| 369 | + | ||
| 370 | + | func (e *LayerDetachedError) Error() string { | |
| 371 | + | return fmt.Sprintf("wayland: layer detached from the capture exclusion (reason %d)", e.Reason) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func (e *LayerDetachedError) Is(target error) bool { | |
| 375 | + | return target == ErrExclusionUnavailable && e.Reason == DetachExclusionEnded | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are | |
| 379 | + | // dispatched by whichever goroutine reads the GUI connection: during | |
| 380 | + | // authorizeLayer that is the caller, afterwards the connection's reader. | |
| 381 | + | // | |
| 382 | + | // State machine: pending -> attached | failed; attached -> detached. Any other | |
| 383 | + | // event is a protocol violation: the exclusion cannot be trusted any more. | |
| 384 | + | type layerProxy struct { | |
| 385 | + | wlturbo.BaseProxy | |
| 386 | + | display *wlturbo.Display | |
| 387 | + | onDetached func(error) | |
| 388 | + | ||
| 389 | + | mu sync.Mutex | |
| 390 | + | state layerState | |
| 391 | + | failReason uint32 | |
| 392 | + | violation bool | |
| 393 | + | returned bool // authorizeLayer handed the surface to its caller | |
| 394 | + | notified bool | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | type layerState int | |
| 398 | + | ||
| 399 | + | const ( | |
| 400 | + | layerPending layerState = iota | |
| 401 | + | layerAttached | |
| 402 | + | layerFailed | |
| 403 | + | layerDetached | |
| 404 | + | ) | |
| 405 | + | ||
| 406 | + | func (*layerProxy) EventSignature(op uint16) (string, bool) { | |
| 407 | + | switch op { | |
| 408 | + | case 0: | |
| 409 | + | return "", true | |
| 410 | + | case 1, 2: | |
| 411 | + | return "uint,", true | |
| 412 | + | } | |
| 413 | + | return "", false | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (l *layerProxy) Dispatch(e *wlturbo.Event) { | |
| 417 | + | var arg uint32 | |
| 418 | + | if e.Opcode != 0 { | |
| 419 | + | arg = e.Uint32() | |
| 420 | + | } | |
| 421 | + | var notify error | |
| 422 | + | closeDisplay := false | |
| 423 | + | l.mu.Lock() | |
| 424 | + | returned := l.returned | |
| 425 | + | switch { | |
| 426 | + | case l.violation: | |
| 427 | + | // Terminal: the display is closed already. | |
| 428 | + | case e.Opcode == 0 && l.state == layerPending: | |
| 429 | + | l.state = layerAttached | |
| 430 | + | case e.Opcode == 1 && l.state == layerPending: | |
| 431 | + | l.state, l.failReason = layerFailed, arg | |
| 432 | + | case e.Opcode == 2 && l.state == layerAttached: | |
| 433 | + | l.state = layerDetached | |
| 434 | + | if returned { | |
| 435 | + | l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)} | |
| 436 | + | } | |
| 437 | + | default: | |
| 438 | + | l.violation, closeDisplay = true, true | |
| 439 | + | if returned && !l.notified { | |
| 440 | + | l.notified, notify = true, errLayerProtocol | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | l.mu.Unlock() | |
| 444 | + | if closeDisplay && returned { | |
| 445 | + | _ = l.display.Close() // the surface could be visible in captures | |
| 446 | + | } | |
| 447 | + | if notify != nil && l.onDetached != nil { | |
| 448 | + | l.onDetached(notify) | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // settled reports that the answer arrived, or the layer misbehaved. | |
| 453 | + | func (l *layerProxy) settled() bool { | |
| 454 | + | l.mu.Lock() | |
| 455 | + | defer l.mu.Unlock() | |
| 456 | + | return l.state != layerPending || l.violation | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // hand marks the layer as owned by the caller of authorizeLayer, and reports | |
| 460 | + | // its outcome so far. | |
| 461 | + | func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) { | |
| 462 | + | l.mu.Lock() | |
| 463 | + | defer l.mu.Unlock() | |
| 464 | + | l.returned = true | |
| 465 | + | return l.state, l.failReason, l.violation | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // AuthorizeLayer tells the compositor, from a connection other than the | |
| 469 | + | // capture connection, that surface is a layer surface of the capturing peer | |
| 470 | + | // and must be excluded from the frames of the session that owns token (from | |
| 471 | + | // Source.BeginExclusion). Call it after the layer-shell role is assigned and | |
| 472 | + | // before the surface is mapped; when it returns nil the compositor has | |
| 473 | + | // confirmed the exclusion for this very attachment, so the surface is safe to | |
| 474 | + | // show. | |
| 475 | + | // | |
| 476 | + | // AuthorizeLayer dispatches display's events itself until the attachment | |
| 477 | + | // answers, so it must run before the connection's event loop starts. A | |
| 478 | + | // refusal returns an *AttachFailedError. A protocol error is returned as the | |
| 479 | + | // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout | |
| 480 | + | // (an error wrapping context.DeadlineExceeded), closes display before | |
| 481 | + | // returning: the surface must then stay unmapped. So does a violation of the | |
| 482 | + | // attachment protocol (an answer twice, a detach before the answer, ...). | |
| 483 | + | // | |
| 484 | + | // After it returns nil the attachment stays until the exclusion ends or the | |
| 485 | + | // layer is destroyed; then onDetached (may be nil) receives a | |
| 486 | + | // *LayerDetachedError, once. A violation at that point closes display and | |
| 487 | + | // calls onDetached with an error matching ErrExclusionUnavailable; the surface | |
| 488 | + | // may still draw until the caller stops, so onDetached must make the caller | |
| 489 | + | // stop. It runs on the goroutine that reads display, so it must only do | |
| 490 | + | // thread-safe, non-blocking work such as cancelling a context. It never sees | |
| 491 | + | // the token. The attachment object is never destroyed after a success: that | |
| 492 | + | // would not release the exclusion, and closing the connection ends it. | |
| 493 | + | func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error)) error { | |
| 494 | + | return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error), timeout time.Duration) error { | |
| 498 | + | if display == nil || surface == nil || surface.Context() != display.Context() { | |
| 499 | + | return errors.New("wayland: authorize layer: surface does not belong to display") | |
| 500 | + | } | |
| 501 | + | if !validToken(token) { | |
| 502 | + | return errors.New("wayland: authorize layer: invalid token") | |
| 503 | + | } | |
| 504 | + | if err := ctx.Err(); err != nil { | |
| 505 | + | return err | |
| 506 | + | } | |
| 507 | + | m := newRequestOnly(display.Context()) | |
| 508 | + | if _, err := display.Registry().BindNegotiated(exclusionManagerIface, extensionVersion, m); err != nil { | |
| 509 | + | if errors.Is(err, wlturbo.ErrGlobalNotFound) { | |
| 510 | + | return ErrExclusionUnsupported | |
| 511 | + | } | |
| 512 | + | return fmt.Errorf("wayland: bind %s: %w", exclusionManagerIface, err) | |
| 513 | + | } | |
| 514 | + | wctx, cancel := context.WithTimeout(ctx, timeout) | |
| 515 | + | defer cancel() | |
| 516 | + | stop := context.AfterFunc(wctx, func() { _ = display.Close() }) | |
| 517 | + | defer stop() | |
| 518 | + | ||
| 519 | + | l := &layerProxy{display: display, onDetached: onDetached} | |
| 520 | + | l.SetContext(display.Context()) | |
| 521 | + | err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface) | |
| 522 | + | for err == nil && !l.settled() { | |
| 523 | + | err = display.Dispatch() | |
| 524 | + | } | |
| 525 | + | // Detach the watcher before anything else is decided or sent. When stop | |
| 526 | + | // reports false the watcher has fired or is firing: cancellation and the | |
| 527 | + | // deadline win over any outcome, and the display is closed here too so it | |
| 528 | + | // is closed on return whatever the watcher goroutine is doing. When it | |
| 529 | + | // reports true the watcher can never fire, and the caller owns the | |
| 530 | + | // connection; a context that is already done is still caught below. | |
| 531 | + | if !stop() || wctx.Err() != nil { | |
| 532 | + | _ = display.Close() | |
| 533 | + | if ctx.Err() != nil { | |
| 534 | + | return ctx.Err() | |
| 535 | + | } | |
| 536 | + | return fmt.Errorf("wayland: attach surface: %w", wctx.Err()) | |
| 537 | + | } | |
| 538 | + | if err != nil { | |
| 539 | + | return fmt.Errorf("wayland: attach surface: %w", err) | |
| 540 | + | } | |
| 541 | + | state, reason, violation := l.hand() | |
| 542 | + | if violation { | |
| 543 | + | _ = display.Close() | |
| 544 | + | return errLayerProtocol | |
| 545 | + | } | |
| 546 | + | // The manager binding is not needed any more. | |
| 547 | + | _ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_exclusion_manager_v1.destroy", Destructor: true}) | |
| 548 | + | if state == layerFailed { | |
| 549 | + | _ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true}) | |
| 550 | + | return &AttachFailedError{Reason: AttachFailure(reason)} | |
| 551 | + | } | |
| 552 | + | return nil | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // validToken accepts exactly the compositor's token format. | |
| 556 | + | func validToken(token string) bool { | |
| 557 | + | if len(token) != tokenLen { | |
| 558 | + | return false | |
| 559 | + | } | |
| 560 | + | for i := range len(token) { | |
| 561 | + | if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 562 | + | return false | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | return true | |
| 566 | + | } | |
A internal/adapters/wayland/extensions_test.go +276 -0
| @@ -0,0 +1,276 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "net" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/wlturbo" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 17 | + | "github.com/bnema/nefercap/internal/logging" | |
| 18 | + | "github.com/bnema/nefercap/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func extConfig() testserver.Config { | |
| 22 | + | return testserver.Config{ | |
| 23 | + | Outputs: []testserver.OutputSpec{{Name: "BIG", Width: 64, Height: 48, Scale: 1}}, | |
| 24 | + | NeferwlSource: true, NeferwlExclusion: true, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestCapabilitiesWithExtension(t *testing.T) { | |
| 29 | + | s, _ := newSource(t, extConfig()) | |
| 30 | + | require.Equal(t, ports.Capabilities{Exclusion: true}, s.Capabilities()) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestRegionUsesExtensionSource(t *testing.T) { | |
| 34 | + | s, srv := newSource(t, extConfig()) | |
| 35 | + | out := firstOutput(t, s) | |
| 36 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.NoError(t, f.Validate()) | |
| 39 | + | require.Equal(t, [3]int{20, 8, 80}, [3]int{f.Width, f.Height, f.Stride}, "the compositor sends only the region: no crop") | |
| 40 | + | st := srv.Stats() | |
| 41 | + | require.Equal(t, "region", st.LastSource) | |
| 42 | + | require.Equal(t, [4]int32{10, 5, 20, 8}, st.LastRegion) | |
| 43 | + | want := testserver.Pixel(10, 5, 1) | |
| 44 | + | require.Equal(t, want[:], f.Row(0)[:4]) | |
| 45 | + | // the same region reuses the session; another one replaces it | |
| 46 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}}) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, 1, srv.Stats().Sessions) | |
| 49 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 0, Y: 0, Width: 4, Height: 4}}) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | st = srv.Stats() | |
| 52 | + | require.Equal(t, 2, st.Sessions) | |
| 53 | + | require.Equal(t, 1, st.DestroyedSessions) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestBeginExclusionNeedsExtension(t *testing.T) { | |
| 57 | + | s, _ := newSource(t, testserver.Config{}) | |
| 58 | + | _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 59 | + | require.ErrorIs(t, err, ErrExclusionUnsupported) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestBeginExclusionAndCaptureKeepSession(t *testing.T) { | |
| 63 | + | s, srv := newSource(t, extConfig()) | |
| 64 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 65 | + | token, err := s.BeginExclusion(context.Background(), target) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.Equal(t, testserver.Token, token) | |
| 68 | + | for range 3 { | |
| 69 | + | _, err = s.Capture(context.Background(), target) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | } | |
| 72 | + | st := srv.Stats() | |
| 73 | + | require.Equal(t, 1, st.Sessions, "capture reuses the session that owns the exclusion") | |
| 74 | + | require.Equal(t, 1, st.Exclusions) | |
| 75 | + | _, err = s.BeginExclusion(context.Background(), target) | |
| 76 | + | require.Error(t, err, "one exclusion per session") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestExclusionRefused(t *testing.T) { | |
| 80 | + | cfg := extConfig() | |
| 81 | + | cfg.ExclusionFail = 1 // busy | |
| 82 | + | s, _ := newSource(t, cfg) | |
| 83 | + | _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 84 | + | require.ErrorIs(t, err, ErrExclusionUnavailable) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Any refusal that is neither busy nor session_stopped is a plain | |
| 88 | + | // ErrExclusionUnavailable carrying the reason. | |
| 89 | + | func TestExclusionOtherReasonIsUnavailable(t *testing.T) { | |
| 90 | + | cfg := extConfig() | |
| 91 | + | cfg.ExclusionFail = 4 // reason 3 | |
| 92 | + | s, _ := newSource(t, cfg) | |
| 93 | + | _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 94 | + | require.ErrorIs(t, err, ErrExclusionUnavailable) | |
| 95 | + | require.NotErrorIs(t, err, ErrExclusionBusy) | |
| 96 | + | require.NotErrorIs(t, err, ports.ErrCaptureStopped) | |
| 97 | + | require.ErrorContains(t, err, "reason 3") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestExclusionSessionStoppedStaysStopped(t *testing.T) { | |
| 101 | + | s, srv := newSource(t, extConfig()) | |
| 102 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 103 | + | _, err := s.BeginExclusion(context.Background(), target) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | srv.StopSessions() | |
| 106 | + | for range 2 { | |
| 107 | + | _, err = s.Capture(context.Background(), target) | |
| 108 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped, "a recording never continues on a session without its exclusion") | |
| 109 | + | } | |
| 110 | + | require.Equal(t, 1, srv.Stats().Sessions) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestExclusionEndsWithTargetChange(t *testing.T) { | |
| 114 | + | s, srv := newSource(t, extConfig()) | |
| 115 | + | out := firstOutput(t, s) | |
| 116 | + | _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: out.ID}) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 4, Height: 4}}) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | require.Equal(t, 1, srv.Stats().DestroyedExclusions) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // hudConnection is a second connection, like the HUD's, with a surface. | |
| 124 | + | func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) { | |
| 125 | + | t.Helper() | |
| 126 | + | conn, err := net.Dial("unix", srv.Path) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | display, err := wlturbo.ConnectFromConn(conn) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | t.Cleanup(func() { _ = display.Close() }) | |
| 131 | + | require.NoError(t, display.Roundtrip()) | |
| 132 | + | comp := core.NewCompositor(display.Context()) | |
| 133 | + | _, err = display.Registry().BindNegotiated("wl_compositor", 4, comp) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | surface, err := comp.CreateSurface() | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | return display, surface | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestAuthorizeLayerWaitsForAttached(t *testing.T) { | |
| 141 | + | _, srv := newSource(t, extConfig()) | |
| 142 | + | display, surface := hudConnection(t, srv) | |
| 143 | + | require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil)) | |
| 144 | + | require.Equal(t, []string{testserver.Token}, srv.Stats().Attached) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestAuthorizeLayerAttachFailed(t *testing.T) { | |
| 148 | + | for reason, want := range map[uint32]AttachFailure{ | |
| 149 | + | 1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers, | |
| 150 | + | 4: AttachAlreadyAttached, 5: AttachExclusionEnded, 6: AttachLayerDestroyed, | |
| 151 | + | } { | |
| 152 | + | cfg := extConfig() | |
| 153 | + | cfg.AttachFail = reason | |
| 154 | + | srv := testserver.Start(t, cfg) | |
| 155 | + | display, surface := hudConnection(t, srv) | |
| 156 | + | err := AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil) | |
| 157 | + | require.ErrorIs(t, err, ErrAttachFailed) | |
| 158 | + | var failed *AttachFailedError | |
| 159 | + | require.ErrorAs(t, err, &failed) | |
| 160 | + | require.Equal(t, want, failed.Reason) | |
| 161 | + | require.Equal(t, want == AttachUnknownToken || want == AttachExclusionEnded, errors.Is(err, ErrExclusionUnavailable)) | |
| 162 | + | require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized)) | |
| 163 | + | require.NotContains(t, err.Error(), testserver.Token) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestAuthorizeLayerDetachedCallback(t *testing.T) { | |
| 168 | + | _, srv := newSource(t, extConfig()) | |
| 169 | + | display, surface := hudConnection(t, srv) | |
| 170 | + | var got atomic.Pointer[LayerDetachedError] | |
| 171 | + | calls := make(chan struct{}, 4) | |
| 172 | + | require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { | |
| 173 | + | var d *LayerDetachedError | |
| 174 | + | if errors.As(err, &d) { | |
| 175 | + | got.Store(d) | |
| 176 | + | } | |
| 177 | + | calls <- struct{}{} | |
| 178 | + | })) | |
| 179 | + | require.Nil(t, got.Load()) | |
| 180 | + | srv.Detach(0, uint32(DetachExclusionEnded)) | |
| 181 | + | for len(calls) == 0 { | |
| 182 | + | require.NoError(t, display.Roundtrip()) | |
| 183 | + | } | |
| 184 | + | d := got.Load() | |
| 185 | + | require.NotNil(t, d) | |
| 186 | + | require.ErrorIs(t, d, ErrExclusionUnavailable) | |
| 187 | + | require.NotContains(t, d.Error(), testserver.Token) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) { | |
| 191 | + | _, srv := newSource(t, extConfig()) | |
| 192 | + | display, surface := hudConnection(t, srv) | |
| 193 | + | got := make(chan error, 1) | |
| 194 | + | require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err })) | |
| 195 | + | srv.SendRaw(0, 0, 0) // a second attached | |
| 196 | + | for !display.Closed() { | |
| 197 | + | if err := display.Dispatch(); err != nil { | |
| 198 | + | break | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | require.ErrorIs(t, <-got, ErrExclusionUnavailable) | |
| 202 | + | require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestAuthorizeLayerContextAndTimeout(t *testing.T) { | |
| 206 | + | cfg := extConfig() | |
| 207 | + | cfg.AttachSilent = true | |
| 208 | + | srv := testserver.Start(t, cfg) | |
| 209 | + | display, surface := hudConnection(t, srv) | |
| 210 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 211 | + | done := make(chan error, 1) | |
| 212 | + | go func() { done <- AuthorizeLayer(ctx, display, surface, testserver.Token, nil) }() | |
| 213 | + | require.Eventually(t, func() bool { return len(srv.Stats().Attached) == 1 }, 5*time.Second, time.Millisecond) | |
| 214 | + | cancel() | |
| 215 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 216 | + | require.True(t, display.Closed()) | |
| 217 | + | ||
| 218 | + | display2, surface2 := hudConnection(t, srv) | |
| 219 | + | err := authorizeLayer(context.Background(), display2, surface2, testserver.Token, nil, 50*time.Millisecond) | |
| 220 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 221 | + | require.True(t, display2.Closed()) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestAuthorizeLayerRejectsBadInput(t *testing.T) { | |
| 225 | + | require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testserver.Token, nil)) | |
| 226 | + | _, srv := newSource(t, extConfig()) | |
| 227 | + | display, surface := hudConnection(t, srv) | |
| 228 | + | for _, tok := range []string{"", "has space", testserver.Token[:63], testserver.Token + "0", testserver.Token[:63] + "G", testserver.Token[:63] + "A"} { | |
| 229 | + | require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok) | |
| 230 | + | } | |
| 231 | + | require.Empty(t, srv.Stats().Attached) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestAuthorizeLayerWithoutGlobal(t *testing.T) { | |
| 235 | + | srv := testserver.Start(t, testserver.Config{}) | |
| 236 | + | display, surface := hudConnection(t, srv) | |
| 237 | + | require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil), ErrExclusionUnsupported) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // A refused exclusion leaves none behind: the session is an ordinary one and | |
| 241 | + | // is not kept alive by the sticky-stopped rule. | |
| 242 | + | func TestRefusedExclusionDoesNotPinStoppedSession(t *testing.T) { | |
| 243 | + | cfg := extConfig() | |
| 244 | + | cfg.ExclusionFail = 1 | |
| 245 | + | s, srv := newSource(t, cfg) | |
| 246 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 247 | + | _, err := s.BeginExclusion(context.Background(), target) | |
| 248 | + | require.ErrorIs(t, err, ErrExclusionUnavailable) | |
| 249 | + | require.Equal(t, 1, srv.Stats().DestroyedExclusions, "the failed exclusion object is destroyed") | |
| 250 | + | srv.StopSessions() | |
| 251 | + | _, err = s.Capture(context.Background(), target) | |
| 252 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 253 | + | _, err = s.Capture(context.Background(), target) | |
| 254 | + | require.NoError(t, err, "a stopped session without an exclusion is replaced") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // The token is a secret: nothing of the exclusion flow logs it. | |
| 258 | + | func TestTokenNeverLogged(t *testing.T) { | |
| 259 | + | var logs bytes.Buffer | |
| 260 | + | ctx := logging.With(context.Background(), &logs, true) | |
| 261 | + | srv := testserver.Start(t, extConfig()) | |
| 262 | + | s, err := New(ctx, srv.Path) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | defer s.Close() | |
| 265 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 266 | + | token, err := s.BeginExclusion(ctx, target) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | _, err = s.Capture(ctx, target) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | display, surface := hudConnection(t, srv) | |
| 271 | + | require.NoError(t, AuthorizeLayer(ctx, display, surface, token, nil)) | |
| 272 | + | srv.StopSessions() | |
| 273 | + | _, _ = s.Capture(ctx, target) | |
| 274 | + | require.NotEmpty(t, logs.String(), "the source logs something") | |
| 275 | + | require.NotContains(t, logs.String(), token) | |
| 276 | + | } | |
D internal/adapters/wayland/screencopy.go +0 -199
| @@ -1,199 +0,0 @@ | |||
| 1 | - | package wayland | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/wlturbo" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/nefercap/internal/ports" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // wl_shm format values for the two supported native little-endian layouts. | |
| 13 | - | const ( | |
| 14 | - | shmARGB8888 = uint32(ports.ARGB8888) | |
| 15 | - | shmXRGB8888 = uint32(ports.XRGB8888) | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | const flagYInvert = 1 // zwlr_screencopy_frame_v1.flags.y_invert | |
| 19 | - | ||
| 20 | - | // The generated bindings of wlturbo v0.3 do not cover wlr-screencopy, so the | |
| 21 | - | // two objects used are declared here with their wire signatures. | |
| 22 | - | ||
| 23 | - | type screencopyManager struct{ wlturbo.BaseProxy } | |
| 24 | - | ||
| 25 | - | func (*screencopyManager) EventSignature(uint16) (string, bool) { return "", false } | |
| 26 | - | ||
| 27 | - | const ( | |
| 28 | - | reqCaptureOutput = 0 | |
| 29 | - | reqCaptureOutputRegion = 1 | |
| 30 | - | reqFrameCopy = 0 | |
| 31 | - | reqFrameDestroy = 1 | |
| 32 | - | ) | |
| 33 | - | ||
| 34 | - | // frameProxy is one zwlr_screencopy_frame_v1. Its events are dispatched on the | |
| 35 | - | // goroutine that owns the operation. | |
| 36 | - | type frameProxy struct { | |
| 37 | - | wlturbo.BaseProxy | |
| 38 | - | out *output | |
| 39 | - | ||
| 40 | - | haveBuffer bool // a buffer event announcing a supported shm format arrived | |
| 41 | - | sawBuffer bool | |
| 42 | - | unsupported uint32 // last rejected format | |
| 43 | - | bufDone bool | |
| 44 | - | geom bufGeom | |
| 45 | - | ||
| 46 | - | copied bool | |
| 47 | - | yInvert bool | |
| 48 | - | ready bool | |
| 49 | - | failed bool | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func (*frameProxy) EventSignature(op uint16) (string, bool) { | |
| 53 | - | switch op { | |
| 54 | - | case 0, 4: | |
| 55 | - | return "uint,uint,uint,uint,", true // buffer, damage | |
| 56 | - | case 1: | |
| 57 | - | return "uint,", true // flags | |
| 58 | - | case 2, 5: | |
| 59 | - | return "uint,uint,uint,", true // ready, linux_dmabuf | |
| 60 | - | case 3, 6: | |
| 61 | - | return "", true // failed, buffer_done | |
| 62 | - | } | |
| 63 | - | return "", false | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func (f *frameProxy) Dispatch(e *wlturbo.Event) { | |
| 67 | - | switch e.Opcode { | |
| 68 | - | case 0: | |
| 69 | - | format, w, h, stride := e.Uint32(), e.Uint32(), e.Uint32(), e.Uint32() | |
| 70 | - | f.sawBuffer = true | |
| 71 | - | switch { | |
| 72 | - | case format != shmARGB8888 && format != shmXRGB8888: | |
| 73 | - | f.unsupported = format | |
| 74 | - | case !f.haveBuffer: | |
| 75 | - | f.haveBuffer = true | |
| 76 | - | f.geom = bufGeom{format: format, width: w, height: h, stride: stride} | |
| 77 | - | } | |
| 78 | - | case 1: | |
| 79 | - | f.yInvert = e.Uint32()&flagYInvert != 0 | |
| 80 | - | case 2: | |
| 81 | - | f.ready = true | |
| 82 | - | case 3: | |
| 83 | - | f.failed = true | |
| 84 | - | case 6: | |
| 85 | - | f.bufDone = true | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func validRegion(r ports.Region) error { | |
| 90 | - | if r == (ports.Region{}) { | |
| 91 | - | return nil | |
| 92 | - | } | |
| 93 | - | if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 || | |
| 94 | - | r.X > ports.MaxDimension || r.Y > ports.MaxDimension || | |
| 95 | - | r.Width > ports.MaxDimension || r.Height > ports.MaxDimension { | |
| 96 | - | return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r) | |
| 97 | - | } | |
| 98 | - | return nil | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (s *Source) capture(t ports.Target) (ports.Frame, error) { | |
| 102 | - | out := s.outputs[t.OutputID] | |
| 103 | - | if out == nil { | |
| 104 | - | return ports.Frame{}, ports.ErrOutputNotFound | |
| 105 | - | } | |
| 106 | - | f := &frameProxy{out: out} | |
| 107 | - | f.SetContext(s.wl) | |
| 108 | - | var err error | |
| 109 | - | if t.Region == (ports.Region{}) { | |
| 110 | - | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutput, Name: "zwlr_screencopy_manager_v1.capture_output", Child: f}, | |
| 111 | - | f, int32(0), out.proxy) | |
| 112 | - | } else { | |
| 113 | - | r := t.Region | |
| 114 | - | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutputRegion, Name: "zwlr_screencopy_manager_v1.capture_output_region", Child: f}, | |
| 115 | - | f, int32(0), out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height)) | |
| 116 | - | } | |
| 117 | - | if err != nil { | |
| 118 | - | s.terminate() | |
| 119 | - | return ports.Frame{}, fmt.Errorf("wayland: capture request: %w", err) | |
| 120 | - | } | |
| 121 | - | // Until copy is sent the compositor does not touch our buffer, so a | |
| 122 | - | // negotiation failure leaves the Source usable. After copy, any failure | |
| 123 | - | // other than the compositor's own "failed" event ends in terminal state. | |
| 124 | - | for { | |
| 125 | - | if err := s.dispatch(); err != nil { | |
| 126 | - | return ports.Frame{}, err | |
| 127 | - | } | |
| 128 | - | switch { | |
| 129 | - | case f.failed: | |
| 130 | - | s.destroyFrame(f) | |
| 131 | - | // The removal may trail the failure on the wire: read it. | |
| 132 | - | if err := s.roundtrip(); err != nil { | |
| 133 | - | return ports.Frame{}, err | |
| 134 | - | } | |
| 135 | - | if f.out.removed { | |
| 136 | - | return ports.Frame{}, ports.ErrOutputNotFound | |
| 137 | - | } | |
| 138 | - | return ports.Frame{}, errCompositorFailedCapture | |
| 139 | - | case f.ready && !f.copied: | |
| 140 | - | s.terminate() | |
| 141 | - | return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied") | |
| 142 | - | case f.ready: | |
| 143 | - | s.destroyFrame(f) | |
| 144 | - | return s.frameOf(f) | |
| 145 | - | case !f.copied && s.negotiated(f): | |
| 146 | - | if err := s.startCopy(f); err != nil { | |
| 147 | - | s.destroyFrame(f) | |
| 148 | - | return ports.Frame{}, err | |
| 149 | - | } | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // negotiated reports whether the compositor announced every buffer type. | |
| 155 | - | // Before version 3 there is no buffer_done: the single buffer event is final. | |
| 156 | - | func (s *Source) negotiated(f *frameProxy) bool { | |
| 157 | - | if s.screencopy.Version() >= 3 { | |
| 158 | - | return f.bufDone | |
| 159 | - | } | |
| 160 | - | return f.sawBuffer | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | func (s *Source) startCopy(f *frameProxy) error { | |
| 164 | - | if !f.sawBuffer { | |
| 165 | - | return fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat) | |
| 166 | - | } | |
| 167 | - | if !f.haveBuffer { | |
| 168 | - | return fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, f.unsupported) | |
| 169 | - | } | |
| 170 | - | if err := f.geom.validate(); err != nil { | |
| 171 | - | return err | |
| 172 | - | } | |
| 173 | - | if err := s.ensureBuffer(f.geom); err != nil { | |
| 174 | - | return err | |
| 175 | - | } | |
| 176 | - | f.copied = true | |
| 177 | - | if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCopy, Name: "zwlr_screencopy_frame_v1.copy"}, s.buf.buffer); err != nil { | |
| 178 | - | s.terminate() | |
| 179 | - | return fmt.Errorf("wayland: copy request: %w", err) | |
| 180 | - | } | |
| 181 | - | return nil | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | func (s *Source) destroyFrame(f *frameProxy) { | |
| 185 | - | _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "zwlr_screencopy_frame_v1.destroy", Destructor: true}) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | func (s *Source) frameOf(f *frameProxy) (ports.Frame, error) { | |
| 189 | - | g := f.geom | |
| 190 | - | frame := ports.Frame{ | |
| 191 | - | Pixels: s.buf.data[:g.size:g.size], | |
| 192 | - | Width: int(g.width), Height: int(g.height), Stride: int(g.stride), | |
| 193 | - | Format: ports.PixelFormat(g.format), YInvert: f.yInvert, | |
| 194 | - | } | |
| 195 | - | if err := frame.Validate(); err != nil { | |
| 196 | - | return ports.Frame{}, err | |
| 197 | - | } | |
| 198 | - | return frame, nil | |
| 199 | - | } | |
M internal/adapters/wayland/shm.go +20 -13
| @@ -30,21 +30,22 @@ func (g *bufGeom) validate() error { | |||
| 30 | 30 | return nil | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | - | // shmBuffer is the single wl_shm mapping and wl_buffer reused between | |
| 34 | - | // captures. The pool proxy is destroyed right after buffer creation, which the | |
| 35 | - | // protocol allows: the buffer keeps the compositor-side mapping alive. | |
| 33 | + | // shmBuffer is one wl_shm mapping and wl_buffer reused between captures. A | |
| 34 | + | // Source has two: one holds the last frame while the next is written. The pool | |
| 35 | + | // proxy is destroyed right after buffer creation, which the protocol allows: | |
| 36 | + | // the buffer keeps the compositor-side mapping alive. | |
| 36 | 37 | type shmBuffer struct { | |
| 37 | 38 | geom bufGeom | |
| 38 | 39 | data []byte | |
| 39 | 40 | buffer *core.Buffer | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | - | // ensureBuffer makes s.buf match g, reusing it when unchanged. | |
| 43 | - | func (s *Source) ensureBuffer(g bufGeom) error { | |
| 44 | - | if s.buf.buffer != nil && s.buf.geom == g { | |
| 43 | + | // ensureBuffer makes s.bufs[i] match g, reusing it when unchanged. | |
| 44 | + | func (s *Source) ensureBuffer(i int, g bufGeom) error { | |
| 45 | + | if s.bufs[i].buffer != nil && s.bufs[i].geom == g { | |
| 45 | 46 | return nil | |
| 46 | 47 | } | |
| 47 | - | if err := s.releaseBuffer(true); err != nil { | |
| 48 | + | if err := s.releaseBuffer(i, true); err != nil { | |
| 48 | 49 | s.terminate() | |
| 49 | 50 | return err | |
| 50 | 51 | } | |
| @@ -74,15 +75,15 @@ func (s *Source) ensureBuffer(g bufGeom) error { | |||
| 74 | 75 | s.terminate() | |
| 75 | 76 | return fmt.Errorf("wayland: create buffer: %w", err) | |
| 76 | 77 | } | |
| 77 | - | s.buf = shmBuffer{geom: g, data: data, buffer: buffer} | |
| 78 | + | s.bufs[i] = shmBuffer{geom: g, data: data, buffer: buffer} | |
| 78 | 79 | return nil | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 81 | - | // releaseBuffer unmaps the storage. With destroy set, and while connected, it | |
| 82 | - | // also destroys the wl_buffer. The caller holds opMu. | |
| 83 | - | func (s *Source) releaseBuffer(destroy bool) error { | |
| 84 | - | b := s.buf | |
| 85 | - | s.buf = shmBuffer{} | |
| 82 | + | // releaseBuffer unmaps the storage of s.bufs[i]. With destroy set, and while | |
| 83 | + | // connected, it also destroys the wl_buffer. The caller holds opMu. | |
| 84 | + | func (s *Source) releaseBuffer(i int, destroy bool) error { | |
| 85 | + | b := s.bufs[i] | |
| 86 | + | s.bufs[i] = shmBuffer{} | |
| 86 | 87 | var err error | |
| 87 | 88 | if b.buffer != nil && destroy && !s.dead.Load() { | |
| 88 | 89 | err = b.buffer.Destroy() | |
| @@ -92,3 +93,9 @@ func (s *Source) releaseBuffer(destroy bool) error { | |||
| 92 | 93 | } | |
| 93 | 94 | return err | |
| 94 | 95 | } | |
| 96 | + | ||
| 97 | + | // releaseBuffers releases both buffers without destroying the wl_buffers: the | |
| 98 | + | // connection is ending. | |
| 99 | + | func (s *Source) releaseBuffers() error { | |
| 100 | + | return errors.Join(s.releaseBuffer(0, false), s.releaseBuffer(1, false)) | |
| 101 | + | } | |
M internal/adapters/wayland/source.go +117 -38
| @@ -1,5 +1,8 @@ | |||
| 1 | - | // Package wayland captures output frames over the wlr-screencopy protocol | |
| 2 | - | // using the wlturbo transport and wl_shm buffers. | |
| 1 | + | // Package wayland captures frames over ext-image-copy-capture-v1 with the | |
| 2 | + | // wlturbo transport and wl_shm buffers. It lists workspaces through | |
| 3 | + | // ext-workspace-v1 and uses NeferWL's optional extension, when the compositor | |
| 4 | + | // has it, for workspace and region sources and for excluding the recording | |
| 5 | + | // HUD from frames. | |
| 3 | 6 | package wayland | |
| 4 | 7 | ||
| 5 | 8 | import ( | |
| @@ -17,6 +20,9 @@ import ( | |||
| 17 | 20 | ||
| 18 | 21 | "github.com/bnema/wlturbo" | |
| 19 | 22 | "github.com/bnema/wlturbo/protocol/core" | |
| 23 | + | "github.com/bnema/wlturbo/protocol/imagecapturesource" | |
| 24 | + | "github.com/bnema/wlturbo/protocol/imagecopycapture" | |
| 25 | + | "github.com/bnema/wlturbo/protocol/xdgoutput" | |
| 20 | 26 | "github.com/bnema/zerowrap" | |
| 21 | 27 | ||
| 22 | 28 | "github.com/bnema/nefercap/internal/logging" | |
| @@ -24,12 +30,15 @@ import ( | |||
| 24 | 30 | ) | |
| 25 | 31 | ||
| 26 | 32 | const ( | |
| 27 | - | outputVersion = 4 // wl_output.name arrived in version 4 | |
| 28 | - | shmVersion = 1 | |
| 29 | - | screencopyVersion = 3 | |
| 30 | - | screencopyIface = "zwlr_screencopy_manager_v1" | |
| 31 | - | maxSettleRounds = 4 | |
| 32 | - | maxOutputScale = 16 // informational; larger announcements are clamped | |
| 33 | + | outputVersion = 4 // wl_output.name arrived in version 4 | |
| 34 | + | shmVersion = 1 | |
| 35 | + | copyVersion = 1 | |
| 36 | + | xdgOutputVersion = 3 | |
| 37 | + | maxSettleRounds = 4 | |
| 38 | + | maxOutputScale = 16 // informational; larger announcements are clamped | |
| 39 | + | // defaultPollWait is how long Capture waits for a frame the compositor may | |
| 40 | + | // hold back until the screen changes; short against a frame interval. | |
| 41 | + | defaultPollWait = 2 * time.Millisecond | |
| 33 | 42 | ) | |
| 34 | 43 | ||
| 35 | 44 | // output is the client-side state of one bound wl_output global. | |
| @@ -40,6 +49,8 @@ type output struct { | |||
| 40 | 49 | name string | |
| 41 | 50 | width, height int32 | |
| 42 | 51 | scale int32 | |
| 52 | + | lw, lh int32 // logical size from xdg-output, 0 when unknown | |
| 53 | + | xdg *xdgoutput.ZxdgOutput | |
| 43 | 54 | removed bool | |
| 44 | 55 | } | |
| 45 | 56 | ||
| @@ -69,18 +80,20 @@ type Source struct { | |||
| 69 | 80 | watchGen atomic.Uint64 | |
| 70 | 81 | watchActive atomic.Bool | |
| 71 | 82 | ||
| 72 | - | shm *core.Shm | |
| 73 | - | screencopy *screencopyManager | |
| 74 | - | outputs map[uint32]*output | |
| 75 | - | bound int // wl_output globals bound so far | |
| 76 | - | buf shmBuffer | |
| 77 | - | ||
| 78 | - | // Private capture session state, see capture_session.go. | |
| 79 | - | private *captureManager // nil when the compositor lacks the protocol | |
| 80 | - | workspaces map[uint64]*workspace | |
| 81 | - | metaErr error // set once the private inventory was rejected | |
| 82 | - | session *captureSession | |
| 83 | - | pingEvery time.Duration | |
| 83 | + | shm *core.Shm | |
| 84 | + | copyManager *imagecopycapture.ExtImageCopyCaptureManager | |
| 85 | + | outSource *imagecapturesource.ExtOutputImageCaptureSourceManager | |
| 86 | + | xdgOutputs *xdgoutput.ZxdgOutputManager // nil when absent | |
| 87 | + | outputs map[uint32]*output | |
| 88 | + | byProxy map[uint32]*output // wl_output proxy ID to output | |
| 89 | + | bound int // wl_output globals bound so far | |
| 90 | + | bufs [2]shmBuffer | |
| 91 | + | conn net.Conn | |
| 92 | + | pollWait time.Duration // how long Capture waits for a pending frame | |
| 93 | + | ||
| 94 | + | sess *session // the one live capture session, nil before the first capture | |
| 95 | + | ext extensions | |
| 96 | + | ws workspaceState | |
| 84 | 97 | } | |
| 85 | 98 | ||
| 86 | 99 | var _ ports.Source = (*Source)(nil) | |
| @@ -105,11 +118,13 @@ func New(ctx context.Context, socket string) (*Source, error) { | |||
| 105 | 118 | return nil, fmt.Errorf("wayland: %w", err) | |
| 106 | 119 | } | |
| 107 | 120 | s := &Source{ | |
| 108 | - | log: logging.For(ctx, "wayland"), | |
| 109 | - | display: display, | |
| 110 | - | wl: display.Context(), | |
| 111 | - | outputs: make(map[uint32]*output), | |
| 112 | - | pingEvery: sessionPingInterval, | |
| 121 | + | log: logging.For(ctx, "wayland"), | |
| 122 | + | display: display, | |
| 123 | + | wl: display.Context(), | |
| 124 | + | outputs: make(map[uint32]*output), | |
| 125 | + | byProxy: make(map[uint32]*output), | |
| 126 | + | conn: conn, | |
| 127 | + | pollWait: defaultPollWait, | |
| 113 | 128 | } | |
| 114 | 129 | stop := context.AfterFunc(ctx, s.terminate) | |
| 115 | 130 | err = s.discover() | |
| @@ -122,7 +137,10 @@ func New(ctx context.Context, socket string) (*Source, error) { | |||
| 122 | 137 | s.log.Error().Err(err).Msg("wayland source failed to start") | |
| 123 | 138 | return nil, err | |
| 124 | 139 | } | |
| 125 | - | s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started") | |
| 140 | + | s.log.Info().Int("outputs", len(s.outputs)).Interface("capabilities", s.Capabilities()). | |
| 141 | + | Bool("region_source", s.ext.source != nil). | |
| 142 | + | Bool("hidden_workspaces", s.ws.mgr != nil && s.ext.source != nil). | |
| 143 | + | Msg("wayland source started") | |
| 126 | 144 | return s, nil | |
| 127 | 145 | } | |
| 128 | 146 | ||
| @@ -143,26 +161,53 @@ func socketPath(socket string) (string, error) { | |||
| 143 | 161 | return filepath.Join(dir, socket), nil | |
| 144 | 162 | } | |
| 145 | 163 | ||
| 146 | - | func (s *Source) discover() error { | |
| 164 | + | func (s *Source) discover() (err error) { | |
| 147 | 165 | reg := s.display.Registry() | |
| 148 | 166 | reg.AddHandler(core.OutputInterface, s.onOutputGlobal) | |
| 149 | 167 | reg.AddGlobalRemoveHandler(globalRemoved{s}) | |
| 150 | - | if err := s.roundtrip(); err != nil { | |
| 168 | + | if err = s.roundtrip(); err != nil { | |
| 151 | 169 | return err | |
| 152 | 170 | } | |
| 153 | 171 | s.shm = core.NewShm(s.wl) | |
| 154 | - | if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil { | |
| 172 | + | if _, err = reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil { | |
| 155 | 173 | return fmt.Errorf("wayland: bind wl_shm: %w", err) | |
| 156 | 174 | } | |
| 157 | - | s.screencopy = &screencopyManager{} | |
| 158 | - | s.screencopy.SetContext(s.wl) | |
| 159 | - | if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil { | |
| 160 | - | return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err) | |
| 175 | + | s.copyManager = imagecopycapture.NewExtImageCopyCaptureManager(s.wl) | |
| 176 | + | if err = s.bindRequired(imagecopycapture.ExtImageCopyCaptureManagerInterface, s.copyManager); err != nil { | |
| 177 | + | return err | |
| 178 | + | } | |
| 179 | + | s.outSource = imagecapturesource.NewExtOutputImageCaptureSourceManager(s.wl) | |
| 180 | + | if err = s.bindRequired(imagecapturesource.ExtOutputImageCaptureSourceManagerInterface, s.outSource); err != nil { | |
| 181 | + | return err | |
| 161 | 182 | } | |
| 162 | - | if err := s.bindCaptureManager(); err != nil { | |
| 183 | + | if err = s.bindExtras(); err != nil { | |
| 163 | 184 | return err | |
| 164 | 185 | } | |
| 165 | - | return s.roundtrip() | |
| 186 | + | return s.settle() | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // bindRequired binds a global that capture cannot work without. | |
| 190 | + | func (s *Source) bindRequired(iface string, p wlturbo.Proxy) error { | |
| 191 | + | if _, err := s.display.Registry().BindNegotiated(iface, copyVersion, p); err != nil { | |
| 192 | + | if errors.Is(err, wlturbo.ErrGlobalNotFound) { | |
| 193 | + | return fmt.Errorf("wayland: the compositor does not support ext-image-copy-capture-v1 (%s missing)", iface) | |
| 194 | + | } | |
| 195 | + | return fmt.Errorf("wayland: bind %s: %w", iface, err) | |
| 196 | + | } | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // bindOptional binds a global when the compositor offers it, and reports | |
| 201 | + | // whether it did. | |
| 202 | + | func (s *Source) bindOptional(iface string, version uint32, p wlturbo.Proxy) (bool, error) { | |
| 203 | + | _, err := s.display.Registry().BindNegotiated(iface, version, p) | |
| 204 | + | switch { | |
| 205 | + | case err == nil: | |
| 206 | + | return true, nil | |
| 207 | + | case errors.Is(err, wlturbo.ErrGlobalNotFound): | |
| 208 | + | return false, nil | |
| 209 | + | } | |
| 210 | + | return false, fmt.Errorf("wayland: bind %s: %w", iface, err) | |
| 166 | 211 | } | |
| 167 | 212 | ||
| 168 | 213 | func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) { | |
| @@ -183,7 +228,11 @@ func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) { | |||
| 183 | 228 | return | |
| 184 | 229 | } | |
| 185 | 230 | s.outputs[name] = o | |
| 231 | + | s.byProxy[o.proxy.ID()] = o | |
| 186 | 232 | s.bound++ | |
| 233 | + | if s.xdgOutputs != nil { | |
| 234 | + | s.watchLogicalSize(o) | |
| 235 | + | } | |
| 187 | 236 | } | |
| 188 | 237 | ||
| 189 | 238 | type globalRemoved struct{ s *Source } | |
| @@ -194,7 +243,10 @@ func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemove | |||
| 194 | 243 | return | |
| 195 | 244 | } | |
| 196 | 245 | delete(g.s.outputs, e.Name) | |
| 246 | + | delete(g.s.byProxy, o.proxy.ID()) | |
| 197 | 247 | o.removed = true | |
| 248 | + | g.s.releaseXdg(o) | |
| 249 | + | g.s.ws.forgetOutput(o.proxy.ID()) | |
| 198 | 250 | if o.version >= 3 { | |
| 199 | 251 | _ = o.proxy.Release() | |
| 200 | 252 | } // older wl_output has no destructor: the proxy must stay to absorb events | |
| @@ -216,6 +268,25 @@ func (s *Source) dispatch() error { | |||
| 216 | 268 | return nil | |
| 217 | 269 | } | |
| 218 | 270 | ||
| 271 | + | // dispatchUntil is dispatch with a read deadline; a zero deadline waits | |
| 272 | + | // forever. It reports a missed deadline, which is not an error. | |
| 273 | + | func (s *Source) dispatchUntil(deadline time.Time) (timedOut bool, err error) { | |
| 274 | + | if deadline.IsZero() { | |
| 275 | + | return false, s.dispatch() | |
| 276 | + | } | |
| 277 | + | _ = s.conn.SetReadDeadline(deadline) | |
| 278 | + | err = s.display.Dispatch() | |
| 279 | + | _ = s.conn.SetReadDeadline(time.Time{}) | |
| 280 | + | if errors.Is(err, os.ErrDeadlineExceeded) { | |
| 281 | + | return true, nil | |
| 282 | + | } | |
| 283 | + | if err != nil { | |
| 284 | + | s.terminate() | |
| 285 | + | return false, fmt.Errorf("wayland: dispatch: %w", err) | |
| 286 | + | } | |
| 287 | + | return false, nil | |
| 288 | + | } | |
| 289 | + | ||
| 219 | 290 | // settle round-trips until no output was bound meanwhile, so that the events | |
| 220 | 291 | // of an output announced during the last round trip have been received too. | |
| 221 | 292 | func (s *Source) settle() error { | |
| @@ -260,7 +331,7 @@ func (s *Source) begin(ctx context.Context) error { | |||
| 260 | 331 | s.watchActive.Store(false) | |
| 261 | 332 | s.terminate() | |
| 262 | 333 | s.detachWatcher() | |
| 263 | - | s.releaseBuffer(false) | |
| 334 | + | s.releaseBuffers() | |
| 264 | 335 | return err | |
| 265 | 336 | } | |
| 266 | 337 | return nil | |
| @@ -301,7 +372,7 @@ func (s *Source) finish(ctx context.Context, err error) error { | |||
| 301 | 372 | return err | |
| 302 | 373 | } | |
| 303 | 374 | s.detachWatcher() | |
| 304 | - | s.releaseBuffer(false) | |
| 375 | + | s.releaseBuffers() | |
| 305 | 376 | if cerr := ctx.Err(); cerr != nil { | |
| 306 | 377 | return cerr | |
| 307 | 378 | } | |
| @@ -352,6 +423,14 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro | |||
| 352 | 423 | return frame, nil | |
| 353 | 424 | } | |
| 354 | 425 | ||
| 426 | + | // Capabilities reports what the compositor offers beyond output capture. | |
| 427 | + | func (s *Source) Capabilities() ports.Capabilities { | |
| 428 | + | return ports.Capabilities{ | |
| 429 | + | Workspaces: s.ws.mgr != nil, | |
| 430 | + | Exclusion: s.ext.exclusion != nil, | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 355 | 434 | // Close terminates the connection, waits for a blocked operation to observe | |
| 356 | 435 | // it, and releases the mapped storage. It is idempotent. | |
| 357 | 436 | func (s *Source) Close() error { | |
| @@ -359,5 +438,5 @@ func (s *Source) Close() error { | |||
| 359 | 438 | s.opMu.Lock() | |
| 360 | 439 | defer s.opMu.Unlock() | |
| 361 | 440 | s.detachWatcher() | |
| 362 | - | return s.releaseBuffer(false) | |
| 441 | + | return s.releaseBuffers() | |
| 363 | 442 | } | |
M internal/adapters/wayland/source_test.go +431 -452
| @@ -9,21 +9,22 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 12 | 13 | "github.com/bnema/nefercap/internal/ports" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | - | func newSource(t *testing.T, cfg compositorConfig) (*Source, *compositor) { | |
| 16 | + | func newSource(t testing.TB, cfg testserver.Config) (*Source, *testserver.Server) { | |
| 16 | 17 | t.Helper() | |
| 17 | - | comp := startCompositor(t, cfg) | |
| 18 | + | srv := testserver.Start(t, cfg) | |
| 18 | 19 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 19 | 20 | defer cancel() | |
| 20 | - | s, err := New(ctx, comp.path) | |
| 21 | + | s, err := New(ctx, srv.Path) | |
| 21 | 22 | require.NoError(t, err) | |
| 22 | 23 | t.Cleanup(func() { _ = s.Close() }) | |
| 23 | - | return s, comp | |
| 24 | + | return s, srv | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | - | func firstOutput(t *testing.T, s *Source) ports.Output { | |
| 27 | + | func firstOutput(t testing.TB, s *Source) ports.Output { | |
| 27 | 28 | t.Helper() | |
| 28 | 29 | outs, err := s.Outputs(context.Background()) | |
| 29 | 30 | require.NoError(t, err) | |
| @@ -39,374 +40,390 @@ func fdCount(t *testing.T) int { | |||
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | func TestOutputs(t *testing.T) { | |
| 42 | - | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 43 | - | {name: "DP-1", width: 8, height: 4, scale: 2, version: 4}, | |
| 44 | - | {name: "ignored", width: 6, height: 2, scale: 1, version: 3}, | |
| 43 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{ | |
| 44 | + | {Name: "A-1", Width: 8, Height: 4, Scale: 1}, {Name: "B-2", Width: 16, Height: 8, Scale: 2}, | |
| 45 | 45 | }}) | |
| 46 | 46 | outs, err := s.Outputs(context.Background()) | |
| 47 | 47 | require.NoError(t, err) | |
| 48 | - | require.Equal(t, []ports.Output{ | |
| 49 | - | {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2}, | |
| 50 | - | {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4 | |
| 51 | - | }, outs) | |
| 48 | + | require.Len(t, outs, 2) | |
| 49 | + | require.Equal(t, "A-1", outs[0].Name) | |
| 50 | + | require.Equal(t, 16, outs[1].Width) | |
| 51 | + | require.Equal(t, 2, outs[1].Scale) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func TestOutputRemovedBetweenCalls(t *testing.T) { | |
| 55 | - | s, comp := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 56 | - | {name: "A", width: 4, height: 4, scale: 1, version: 4}, | |
| 57 | - | {name: "B", width: 4, height: 4, scale: 1, version: 4}, | |
| 58 | - | }}) | |
| 59 | - | comp.removeOutput(1) | |
| 60 | - | outs, err := s.Outputs(context.Background()) | |
| 61 | - | require.NoError(t, err) | |
| 62 | - | require.Len(t, outs, 1) | |
| 63 | - | require.Equal(t, "B", outs[0].Name) | |
| 64 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |
| 65 | - | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 66 | - | // Not finding an output is not a connection failure. | |
| 67 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: 2}) | |
| 68 | - | require.NoError(t, err) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | func TestOutputRemovedDuringCapture(t *testing.T) { | |
| 72 | - | s, _ := newSource(t, compositorConfig{removeOnCapture: true}) | |
| 73 | - | out := firstOutput(t, s) | |
| 74 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 75 | - | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 76 | - | outs, err := s.Outputs(context.Background()) | |
| 77 | - | require.NoError(t, err) | |
| 78 | - | require.Empty(t, outs) | |
| 54 | + | func TestCapabilitiesBaseline(t *testing.T) { | |
| 55 | + | s, _ := newSource(t, testserver.Config{}) | |
| 56 | + | require.Equal(t, ports.Capabilities{}, s.Capabilities()) | |
| 79 | 57 | } | |
| 80 | 58 | ||
| 81 | 59 | func TestCaptureFullOutput(t *testing.T) { | |
| 82 | - | s, comp := newSource(t, compositorConfig{}) | |
| 60 | + | s, srv := newSource(t, testserver.Config{}) | |
| 83 | 61 | out := firstOutput(t, s) | |
| 84 | 62 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 85 | 63 | require.NoError(t, err) | |
| 86 | 64 | require.NoError(t, f.Validate()) | |
| 87 | - | require.Equal(t, 8, f.Width) | |
| 88 | - | require.Equal(t, 4, f.Height) | |
| 89 | - | require.Equal(t, 32, f.Stride) | |
| 65 | + | require.Equal(t, [3]int{8, 4, 32}, [3]int{f.Width, f.Height, f.Stride}) | |
| 90 | 66 | require.Equal(t, ports.XRGB8888, f.Format) | |
| 91 | - | require.False(t, f.YInvert) | |
| 92 | - | for y := 0; y < f.Height; y++ { | |
| 93 | - | row := f.Row(y) | |
| 94 | - | for x := 0; x < f.Width; x++ { | |
| 95 | - | want := pixel(x, y, 1) | |
| 96 | - | require.Equal(t, want[:], row[x*4:x*4+4], "pixel %d,%d", x, y) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | require.Zero(t, comp.stat(func(c *compositor) int { return int(c.lastRegion[2]) }), "full output must not send a region") | |
| 67 | + | want := testserver.Pixel(3, 2, 1) | |
| 68 | + | require.Equal(t, want[:], f.Row(2)[12:16]) | |
| 69 | + | require.Equal(t, "output", srv.Stats().LastSource) | |
| 100 | 70 | } | |
| 101 | 71 | ||
| 102 | - | func TestCaptureStrideFormatAndYInvert(t *testing.T) { | |
| 103 | - | s, _ := newSource(t, compositorConfig{ | |
| 104 | - | announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}}, | |
| 105 | - | flags: 1, | |
| 106 | - | }) | |
| 107 | - | out := firstOutput(t, s) | |
| 108 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 72 | + | func TestCapturePicksSupportedFormat(t *testing.T) { | |
| 73 | + | s, _ := newSource(t, testserver.Config{Formats: []uint32{0x34325258 /* XR24 big */, testserver.ARGB8888}}) | |
| 74 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 109 | 75 | require.NoError(t, err) | |
| 110 | - | require.NoError(t, f.Validate()) | |
| 111 | 76 | require.Equal(t, ports.ARGB8888, f.Format) | |
| 112 | - | require.Equal(t, 32, f.Stride) | |
| 113 | - | require.Equal(t, 5, f.Width) | |
| 114 | - | require.Equal(t, 3, f.Height) | |
| 115 | - | require.True(t, f.YInvert) | |
| 116 | - | require.Len(t, f.Pixels, 32*3) | |
| 117 | - | // Row(0) is the top row, stored last when y-inverted. | |
| 118 | - | want := pixel(0, 2, 1) | |
| 119 | - | require.Equal(t, want[:], f.Row(0)[:4]) | |
| 120 | - | require.Len(t, f.Row(0), 20) | |
| 121 | - | // Padding is not part of a row but is preserved in storage. | |
| 122 | - | require.Equal(t, byte(0xEE), f.Pixels[20]) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func TestCaptureRegion(t *testing.T) { | |
| 126 | - | s, comp := newSource(t, compositorConfig{}) | |
| 127 | - | out := firstOutput(t, s) | |
| 128 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 1}}) | |
| 129 | - | require.NoError(t, err) | |
| 130 | - | require.Equal(t, 3, f.Width) | |
| 131 | - | require.Equal(t, 1, f.Height) | |
| 132 | - | comp.mu.Lock() | |
| 133 | - | defer comp.mu.Unlock() | |
| 134 | - | require.True(t, comp.lastRegionSet) | |
| 135 | - | require.Equal(t, [4]int32{1, 2, 3, 1}, comp.lastRegion) | |
| 136 | 77 | } | |
| 137 | 78 | ||
| 138 | - | func TestCaptureInvalidRegion(t *testing.T) { | |
| 139 | - | s, comp := newSource(t, compositorConfig{}) | |
| 79 | + | func TestCaptureUnsupportedFormat(t *testing.T) { | |
| 80 | + | s, _ := newSource(t, testserver.Config{Formats: []uint32{0x34325258}}) | |
| 81 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 82 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | |
| 83 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 84 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "a negotiation failure leaves the Source usable") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestCaptureNoShmFormat(t *testing.T) { | |
| 88 | + | s, _ := newSource(t, testserver.Config{NoShmFormat: true}) | |
| 89 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 90 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestCaptureUnknownOutputAndInvalidRegion(t *testing.T) { | |
| 94 | + | s, _ := newSource(t, testserver.Config{}) | |
| 95 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: 999}) | |
| 96 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 140 | 97 | out := firstOutput(t, s) | |
| 141 | - | for _, r := range []ports.Region{ | |
| 142 | - | {X: -1, Y: 0, Width: 1, Height: 1}, | |
| 143 | - | {X: 0, Y: -1, Width: 1, Height: 1}, | |
| 144 | - | {X: 0, Y: 0, Width: 0, Height: 1}, | |
| 145 | - | {X: 0, Y: 0, Width: 1, Height: -3}, | |
| 146 | - | {X: 0, Y: 0, Width: ports.MaxDimension + 1, Height: 1}, | |
| 147 | - | {X: 1 << 40, Y: 0, Width: 1, Height: 1}, | |
| 148 | - | } { | |
| 149 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r}) | |
| 98 | + | for _, r := range []ports.Region{{X: -1, Width: 1, Height: 1}, {Width: 0, Height: 1, X: 1}, {Width: 1, Height: -1}, {Width: ports.MaxDimension + 1, Height: 1}} { | |
| 99 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r}) | |
| 150 | 100 | require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r) | |
| 151 | 101 | } | |
| 152 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.captures })) | |
| 153 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 154 | - | require.NoError(t, err, "invalid regions must not end the session") | |
| 155 | 102 | } | |
| 156 | 103 | ||
| 157 | - | func TestCaptureUnknownOutput(t *testing.T) { | |
| 158 | - | s, _ := newSource(t, compositorConfig{}) | |
| 159 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: 99}) | |
| 104 | + | func TestMissingCopyCapture(t *testing.T) { | |
| 105 | + | srv := testserver.Start(t, testserver.Config{NoCopyCapture: true}) | |
| 106 | + | _, err := New(context.Background(), srv.Path) | |
| 107 | + | require.ErrorContains(t, err, "ext-image-copy-capture") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestOutputRemovedDuringCapture(t *testing.T) { | |
| 111 | + | s, srv := newSource(t, testserver.Config{Hold: true}) | |
| 112 | + | out := firstOutput(t, s) | |
| 113 | + | errc := make(chan error, 1) | |
| 114 | + | go func() { _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}); errc <- err }() | |
| 115 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 116 | + | srv.RemoveOutput("TEST-1") | |
| 117 | + | srv.Release() | |
| 118 | + | require.ErrorIs(t, <-errc, ports.ErrOutputNotFound) | |
| 119 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 160 | 120 | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 161 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestSessionStoppedEndsCapture(t *testing.T) { | |
| 124 | + | s, srv := newSource(t, testserver.Config{}) | |
| 125 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 126 | + | _, err := s.Capture(context.Background(), target) | |
| 162 | 127 | require.NoError(t, err) | |
| 128 | + | srv.StopSessions() | |
| 129 | + | _, err = s.Capture(context.Background(), target) | |
| 130 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 131 | + | _, err = s.Capture(context.Background(), target) | |
| 132 | + | require.NoError(t, err, "a stopped session is replaced by the next capture") | |
| 163 | 133 | } | |
| 164 | 134 | ||
| 165 | - | func TestCaptureRejectsBadBuffers(t *testing.T) { | |
| 166 | - | const xrgb = 1 | |
| 167 | - | cases := map[string]announce{ | |
| 168 | - | "zero width": {xrgb, 0, 4, 16}, | |
| 169 | - | "zero height": {xrgb, 4, 0, 16}, | |
| 170 | - | "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4}, | |
| 171 | - | "tall": {xrgb, 1, ports.MaxDimension + 1, 4}, | |
| 172 | - | "short stride": {xrgb, 4, 4, 12}, | |
| 173 | - | "unaligned stride": {xrgb, 4, 4, 18}, | |
| 174 | - | "over 256 MiB": {xrgb, 16384, 16384, 16384 * 4}, | |
| 175 | - | "huge stride": {xrgb, 4, 4, 1 << 31}, | |
| 176 | - | "stride overflow": {xrgb, 4, 2, 0xffffffff - 3}, | |
| 177 | - | } | |
| 178 | - | for name, a := range cases { | |
| 179 | - | t.Run(name, func(t *testing.T) { | |
| 180 | - | s, comp := newSource(t, compositorConfig{announce: []announce{a}}) | |
| 181 | - | out := firstOutput(t, s) | |
| 182 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 183 | - | require.Error(t, err) | |
| 184 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }), "nothing may be allocated before validation") | |
| 185 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.copies })) | |
| 186 | - | _, err = s.Outputs(context.Background()) | |
| 187 | - | require.NoError(t, err, "a rejected announcement leaves the connection usable") | |
| 188 | - | }) | |
| 189 | - | } | |
| 135 | + | func TestFailedStoppedReasonEndsCapture(t *testing.T) { | |
| 136 | + | s, srv := newSource(t, testserver.Config{}) | |
| 137 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 138 | + | srv.Fail(1, 2) | |
| 139 | + | _, err := s.Capture(context.Background(), target) | |
| 140 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 190 | 141 | } | |
| 191 | 142 | ||
| 192 | - | func TestCaptureUnsupportedFormat(t *testing.T) { | |
| 193 | - | for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage | |
| 194 | - | s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}}) | |
| 195 | - | out := firstOutput(t, s) | |
| 196 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 197 | - | require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format) | |
| 198 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |
| 199 | - | } | |
| 143 | + | func TestFailedUnknownRetries(t *testing.T) { | |
| 144 | + | s, srv := newSource(t, testserver.Config{}) | |
| 145 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 146 | + | srv.Fail(2, 0) | |
| 147 | + | _, err := s.Capture(context.Background(), target) | |
| 148 | + | require.NoError(t, err, "recoverable failures are retried") | |
| 149 | + | s, srv = newSource(t, testserver.Config{}) | |
| 150 | + | srv.Fail(maxFrameAttempts, 0) | |
| 151 | + | _, err = s.Capture(context.Background(), target) | |
| 152 | + | require.ErrorIs(t, err, errCompositorFailedCapture, "retries without a last frame are bounded") | |
| 153 | + | srv.Fail(0, 0) | |
| 154 | + | _, err = s.Capture(context.Background(), target) | |
| 155 | + | require.NoError(t, err, "and the Source stays usable") | |
| 200 | 156 | } | |
| 201 | 157 | ||
| 202 | - | func TestCapturePicksSupportedAnnouncement(t *testing.T) { | |
| 203 | - | s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}}) | |
| 204 | - | out := firstOutput(t, s) | |
| 205 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 158 | + | // With a last frame, a transient unknown failure repeats it, like a pending | |
| 159 | + | // capture does; the failures are counted across calls and stay bounded. | |
| 160 | + | func TestFailedUnknownRepeatsLastFrame(t *testing.T) { | |
| 161 | + | s, srv := newSource(t, testserver.Config{}) | |
| 162 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 163 | + | first, err := s.Capture(context.Background(), target) | |
| 206 | 164 | require.NoError(t, err) | |
| 207 | - | require.Equal(t, 3, f.Width) | |
| 208 | - | require.Equal(t, ports.XRGB8888, f.Format) | |
| 165 | + | want := append([]byte(nil), first.Pixels...) | |
| 166 | + | srv.Fail(1, 0) | |
| 167 | + | repeated, err := s.Capture(context.Background(), target) | |
| 168 | + | require.NoError(t, err, "a transient unknown failure repeats the last frame") | |
| 169 | + | require.Equal(t, want, repeated.Pixels) | |
| 170 | + | fresh, err := s.Capture(context.Background(), target) | |
| 171 | + | require.NoError(t, err, "recording continues") | |
| 172 | + | require.NotEqual(t, want, fresh.Pixels, "with a new frame") | |
| 173 | + | ||
| 174 | + | srv.Fail(maxFrameAttempts, 0) | |
| 175 | + | // The capture started after the previous frame was answered before Fail. | |
| 176 | + | _, err = s.Capture(context.Background(), target) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | for range maxFrameAttempts - 1 { | |
| 179 | + | _, err = s.Capture(context.Background(), target) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | } | |
| 182 | + | _, err = s.Capture(context.Background(), target) | |
| 183 | + | require.ErrorIs(t, err, errCompositorFailedCapture, "the failure count is kept across calls") | |
| 184 | + | srv.Fail(0, 0) | |
| 185 | + | _, err = s.Capture(context.Background(), target) | |
| 186 | + | require.NoError(t, err, "and the Source stays usable") | |
| 209 | 187 | } | |
| 210 | 188 | ||
| 211 | - | func TestCaptureWithoutBufferDone(t *testing.T) { | |
| 212 | - | s, _ := newSource(t, compositorConfig{screencopyVersion: 1}) | |
| 213 | - | out := firstOutput(t, s) | |
| 214 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 215 | - | require.NoError(t, err) | |
| 216 | - | require.NoError(t, f.Validate()) | |
| 189 | + | // A session the compositor stops before its first frame is a refusal: the | |
| 190 | + | // error says so, and it is not retried with a second session. | |
| 191 | + | func TestSessionStoppedBeforeFirstFrameIsRefusal(t *testing.T) { | |
| 192 | + | s, srv := newSource(t, testserver.Config{StopAtCreate: true}) | |
| 193 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 194 | + | _, err := s.Capture(context.Background(), target) | |
| 195 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 196 | + | require.ErrorContains(t, err, "compositor refused capture") | |
| 197 | + | require.ErrorContains(t, err, "/etc/neferwl/capture-allow") | |
| 198 | + | require.Equal(t, 1, srv.Stats().Sessions, "no second session within the call") | |
| 217 | 199 | } | |
| 218 | 200 | ||
| 219 | - | func TestMissingScreencopy(t *testing.T) { | |
| 220 | - | comp := startCompositor(t, compositorConfig{noScreencopy: true}) | |
| 221 | - | _, err := New(context.Background(), comp.path) | |
| 222 | - | require.Error(t, err) | |
| 201 | + | // A stop after frames were served is a plain stop, not a refusal. | |
| 202 | + | func TestSessionStoppedAfterFrameIsNotRefusal(t *testing.T) { | |
| 203 | + | s, srv := newSource(t, testserver.Config{}) | |
| 204 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 205 | + | _, err := s.Capture(context.Background(), target) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | srv.StopSessions() | |
| 208 | + | _, err = s.Capture(context.Background(), target) | |
| 209 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 210 | + | require.NotContains(t, err.Error(), "refused") | |
| 223 | 211 | } | |
| 224 | 212 | ||
| 225 | - | func TestReuseAndGeometryChange(t *testing.T) { | |
| 226 | - | s, comp := newSource(t, compositorConfig{}) | |
| 213 | + | func TestFailedBufferConstraintsRenegotiates(t *testing.T) { | |
| 214 | + | s, srv := newSource(t, testserver.Config{}) | |
| 227 | 215 | out := firstOutput(t, s) | |
| 228 | 216 | target := ports.Target{OutputID: out.ID} | |
| 229 | - | f1, err := s.Capture(context.Background(), target) | |
| 217 | + | _, err := s.Capture(context.Background(), target) | |
| 230 | 218 | require.NoError(t, err) | |
| 231 | - | first := &f1.Pixels[0] | |
| 232 | - | for i := 2; i <= 5; i++ { | |
| 233 | - | f, err := s.Capture(context.Background(), target) | |
| 219 | + | // The constraints change while no frame is in flight; the next frame is | |
| 220 | + | // created with a fresh buffer of the new size. | |
| 221 | + | srv.Resize("TEST-1", 16, 8) | |
| 222 | + | var f ports.Frame | |
| 223 | + | require.Eventually(t, func() bool { | |
| 224 | + | var err error | |
| 225 | + | f, err = s.Capture(context.Background(), target) | |
| 234 | 226 | require.NoError(t, err) | |
| 235 | - | require.Same(t, first, &f.Pixels[0], "storage must be reused") | |
| 236 | - | want := pixel(3, 1, uint32(i)) | |
| 237 | - | require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i) | |
| 238 | - | } | |
| 239 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.pools })) | |
| 240 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.buffers })) | |
| 241 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |
| 227 | + | return f.Width == 16 | |
| 228 | + | }, 5*time.Second, time.Millisecond) | |
| 229 | + | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) | |
| 230 | + | } | |
| 242 | 231 | ||
| 243 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 2, Height: 2}}) | |
| 232 | + | // After a layout change the last frame no longer fits: a failed(unknown) | |
| 233 | + | // cannot repeat it. The frame is retried, bounded, and never the stale one. | |
| 234 | + | func TestFailedUnknownAfterLayoutChangeRetriesBounded(t *testing.T) { | |
| 235 | + | s, srv := newSource(t, testserver.Config{}) | |
| 236 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 237 | + | _, err := s.Capture(context.Background(), target) | |
| 244 | 238 | require.NoError(t, err) | |
| 245 | - | require.Equal(t, 2, f.Width) | |
| 246 | - | require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools })) | |
| 247 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers })) | |
| 239 | + | srv.Resize("TEST-1", 16, 8) | |
| 240 | + | _ = firstOutput(t, s) // a roundtrip: the session's new constraints are read | |
| 241 | + | srv.Fail(2, 0) | |
| 242 | + | f, err := s.Capture(context.Background(), target) | |
| 243 | + | require.NoError(t, err, "recoverable failures are retried") | |
| 244 | + | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}, "never the repeated 8x4 frame") | |
| 245 | + | ||
| 246 | + | srv.Resize("TEST-1", 32, 16) | |
| 247 | + | _ = firstOutput(t, s) | |
| 248 | + | srv.Fail(maxFrameAttempts, 0) | |
| 249 | + | _, err = s.Capture(context.Background(), target) | |
| 250 | + | require.ErrorIs(t, err, errCompositorFailedCapture, "the retries are bounded") | |
| 251 | + | srv.Fail(0, 0) | |
| 252 | + | f, err = s.Capture(context.Background(), target) | |
| 253 | + | require.NoError(t, err, "and the Source stays usable") | |
| 254 | + | require.Equal(t, [2]int{32, 16}, [2]int{f.Width, f.Height}) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestConstraintsChangeWhileFrameInFlight(t *testing.T) { | |
| 258 | + | s, srv := newSource(t, testserver.Config{Hold: true}) | |
| 259 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 260 | + | type result struct { | |
| 261 | + | f ports.Frame | |
| 262 | + | err error | |
| 263 | + | } | |
| 264 | + | res := make(chan result, 1) | |
| 265 | + | go func() { f, err := s.Capture(context.Background(), target); res <- result{f, err} }() | |
| 266 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 267 | + | srv.Resize("TEST-1", 16, 8) // the held 8x4 frame now mismatches: failed(buffer_constraints) | |
| 268 | + | srv.Release() | |
| 269 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond, "the retry uses the new size") | |
| 270 | + | srv.Release() | |
| 271 | + | r := <-res | |
| 272 | + | require.NoError(t, r.err) | |
| 273 | + | require.Equal(t, [2]int{16, 8}, [2]int{r.f.Width, r.f.Height}) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestReuseAndBuffers(t *testing.T) { | |
| 277 | + | s, srv := newSource(t, testserver.Config{}) | |
| 278 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 279 | + | for range 5 { | |
| 280 | + | _, err := s.Capture(context.Background(), target) | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | } | |
| 283 | + | st := srv.Stats() | |
| 284 | + | require.Equal(t, 2, st.Buffers, "two shm buffers alternate: the last frame stays readable while the next is written") | |
| 285 | + | require.Equal(t, 1, st.Sessions, "one session serves consecutive frames") | |
| 286 | + | require.GreaterOrEqual(t, st.Ready, 5) | |
| 287 | + | srv.Resize("TEST-1", 16, 8) | |
| 288 | + | var f ports.Frame | |
| 289 | + | require.Eventually(t, func() bool { // a resize is seen by the next calls, never mid-poll | |
| 290 | + | var err error | |
| 291 | + | f, err = s.Capture(context.Background(), target) | |
| 292 | + | require.NoError(t, err) | |
| 293 | + | return f.Width == 16 | |
| 294 | + | }, 5*time.Second, time.Millisecond) | |
| 295 | + | st = srv.Stats() | |
| 296 | + | require.GreaterOrEqual(t, st.Buffers, 3) | |
| 297 | + | require.GreaterOrEqual(t, st.DestroyedBuffers, 1, "a buffer of the old size is destroyed") | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestOneFrameInFlight(t *testing.T) { | |
| 301 | + | // The server raises duplicate_frame when a frame is created before the | |
| 302 | + | // previous one was destroyed, so successful back to back captures prove it. | |
| 303 | + | s, _ := newSource(t, testserver.Config{}) | |
| 304 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 305 | + | for range 20 { | |
| 306 | + | _, err := s.Capture(context.Background(), target) | |
| 307 | + | require.NoError(t, err) | |
| 308 | + | } | |
| 248 | 309 | } | |
| 249 | 310 | ||
| 250 | 311 | func TestNoDescriptorLeak(t *testing.T) { | |
| 251 | - | s, comp := newSource(t, compositorConfig{}) | |
| 252 | - | out := firstOutput(t, s) | |
| 253 | - | target := ports.Target{OutputID: out.ID} | |
| 312 | + | s, _ := newSource(t, testserver.Config{}) | |
| 313 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 254 | 314 | _, err := s.Capture(context.Background(), target) | |
| 255 | 315 | require.NoError(t, err) | |
| 256 | - | steady := fdCount(t) | |
| 257 | - | for i := 0; i < 30; i++ { | |
| 258 | - | // Alternate geometry: every switch creates a pool and must not keep its descriptor. | |
| 259 | - | region := ports.Region{Width: 2 + i%2, Height: 2} | |
| 260 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: region}) | |
| 261 | - | require.NoError(t, err) | |
| 316 | + | before := fdCount(t) | |
| 317 | + | for range 50 { | |
| 262 | 318 | _, err = s.Capture(context.Background(), target) | |
| 263 | 319 | require.NoError(t, err) | |
| 264 | 320 | } | |
| 265 | - | require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |
| 266 | - | require.Equal(t, steady, fdCount(t)) | |
| 321 | + | require.LessOrEqual(t, fdCount(t), before+1) | |
| 322 | + | } | |
| 267 | 323 | ||
| 268 | - | require.NoError(t, s.Close()) | |
| 269 | - | require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond, | |
| 270 | - | "connection descriptors must be closed on Close") | |
| 324 | + | func TestCaptureWaitsForReady(t *testing.T) { | |
| 325 | + | s, srv := newSource(t, testserver.Config{Hold: true}) | |
| 326 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 327 | + | done := make(chan error, 1) | |
| 328 | + | go func() { _, err := s.Capture(context.Background(), target); done <- err }() | |
| 329 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 330 | + | select { | |
| 331 | + | case <-done: | |
| 332 | + | t.Fatal("capture returned before the compositor completed the copy") | |
| 333 | + | case <-time.After(50 * time.Millisecond): | |
| 334 | + | } | |
| 335 | + | srv.Release() | |
| 336 | + | require.NoError(t, <-done) | |
| 271 | 337 | } | |
| 272 | 338 | ||
| 273 | 339 | func TestCancelInterruptsBlockedCapture(t *testing.T) { | |
| 274 | - | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 275 | - | out := firstOutput(t, s) | |
| 340 | + | s, srv := newSource(t, testserver.Config{Hold: true}) | |
| 341 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 276 | 342 | ctx, cancel := context.WithCancel(context.Background()) | |
| 277 | - | errc := make(chan error, 1) | |
| 278 | - | go func() { | |
| 279 | - | _, err := s.Capture(ctx, ports.Target{OutputID: out.ID}) | |
| 280 | - | errc <- err | |
| 281 | - | }() | |
| 282 | - | <-comp.copyEvent | |
| 343 | + | done := make(chan error, 1) | |
| 344 | + | go func() { _, err := s.Capture(ctx, target); done <- err }() | |
| 345 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 283 | 346 | cancel() | |
| 284 | - | select { | |
| 285 | - | case err := <-errc: | |
| 286 | - | require.ErrorIs(t, err, context.Canceled) | |
| 287 | - | case <-time.After(3 * time.Second): | |
| 288 | - | t.Fatal("capture not interrupted") | |
| 289 | - | } | |
| 290 | - | _, err := s.Outputs(context.Background()) | |
| 291 | - | require.ErrorIs(t, err, ports.ErrClosed, "cancellation is not recoverable") | |
| 292 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 347 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 348 | + | _, err := s.Capture(context.Background(), target) | |
| 293 | 349 | require.ErrorIs(t, err, ports.ErrClosed) | |
| 294 | - | require.NoError(t, s.Close()) | |
| 295 | 350 | } | |
| 296 | 351 | ||
| 297 | 352 | func TestCloseInterruptsBlockedCapture(t *testing.T) { | |
| 298 | - | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 299 | - | out := firstOutput(t, s) | |
| 300 | - | errc := make(chan error, 1) | |
| 301 | - | go func() { | |
| 302 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 303 | - | errc <- err | |
| 304 | - | }() | |
| 305 | - | <-comp.copyEvent | |
| 306 | - | require.NoError(t, s.Close()) | |
| 307 | - | select { | |
| 308 | - | case err := <-errc: | |
| 309 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 310 | - | case <-time.After(3 * time.Second): | |
| 311 | - | t.Fatal("capture not interrupted") | |
| 312 | - | } | |
| 353 | + | s, srv := newSource(t, testserver.Config{Hold: true}) | |
| 354 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 355 | + | done := make(chan error, 1) | |
| 356 | + | go func() { _, err := s.Capture(context.Background(), target); done <- err }() | |
| 357 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 313 | 358 | require.NoError(t, s.Close()) | |
| 314 | - | _, err := s.Outputs(context.Background()) | |
| 315 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 359 | + | require.ErrorIs(t, <-done, ports.ErrClosed) | |
| 360 | + | require.NoError(t, s.Close(), "idempotent") | |
| 316 | 361 | } | |
| 317 | 362 | ||
| 318 | 363 | func TestCancelledContextBeforeCall(t *testing.T) { | |
| 319 | - | s, _ := newSource(t, compositorConfig{}) | |
| 364 | + | s, _ := newSource(t, testserver.Config{}) | |
| 320 | 365 | ctx, cancel := context.WithCancel(context.Background()) | |
| 321 | 366 | cancel() | |
| 322 | - | _, err := s.Outputs(ctx) | |
| 367 | + | _, err := s.Capture(ctx, ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 323 | 368 | require.ErrorIs(t, err, context.Canceled) | |
| 324 | - | _, err = s.Outputs(context.Background()) | |
| 325 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | func TestCompositorDisconnect(t *testing.T) { | |
| 329 | - | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 330 | - | out := firstOutput(t, s) | |
| 331 | - | errc := make(chan error, 1) | |
| 332 | - | go func() { | |
| 333 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 334 | - | errc <- err | |
| 335 | - | }() | |
| 336 | - | <-comp.copyEvent | |
| 337 | - | comp.mu.Lock() | |
| 338 | - | comp.conn.Close() | |
| 339 | - | comp.mu.Unlock() | |
| 340 | - | select { | |
| 341 | - | case err := <-errc: | |
| 342 | - | require.Error(t, err) | |
| 343 | - | case <-time.After(3 * time.Second): | |
| 344 | - | t.Fatal("capture not interrupted") | |
| 345 | - | } | |
| 346 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 347 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 348 | 369 | } | |
| 349 | 370 | ||
| 350 | 371 | func TestConstructorContextIsScopedToConstructor(t *testing.T) { | |
| 351 | - | comp := startCompositor(t, compositorConfig{}) | |
| 352 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 353 | - | s, err := New(ctx, comp.path) | |
| 372 | + | srv := testserver.Start(t, testserver.Config{}) | |
| 373 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 374 | + | s, err := New(ctx, srv.Path) | |
| 354 | 375 | require.NoError(t, err) | |
| 355 | 376 | defer s.Close() | |
| 356 | 377 | cancel() | |
| 357 | - | time.Sleep(20 * time.Millisecond) | |
| 358 | - | out := firstOutput(t, s) | |
| 359 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 360 | - | require.NoError(t, err, "cancelling the constructor context must not close the source") | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | func TestConstructorTimeout(t *testing.T) { | |
| 364 | - | comp := startCompositor(t, compositorConfig{silent: true}) | |
| 365 | - | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 366 | - | defer cancel() | |
| 367 | - | start := time.Now() | |
| 368 | - | _, err := New(ctx, comp.path) | |
| 369 | - | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 370 | - | require.Less(t, time.Since(start), 2*time.Second) | |
| 378 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) | |
| 379 | + | require.NoError(t, err) | |
| 371 | 380 | } | |
| 372 | 381 | ||
| 373 | 382 | func TestConstructorConnectFailure(t *testing.T) { | |
| 374 | - | _, err := New(context.Background(), t.TempDir()+"/missing") | |
| 375 | - | require.Error(t, err) | |
| 376 | - | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 377 | - | _, err = New(context.Background(), "relative") | |
| 383 | + | _, err := New(context.Background(), t.TempDir()+"/none") | |
| 378 | 384 | require.Error(t, err) | |
| 379 | 385 | } | |
| 380 | 386 | ||
| 387 | + | func TestIdleCancellationDoesNotKillSource(t *testing.T) { | |
| 388 | + | s, _ := newSource(t, testserver.Config{}) | |
| 389 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 390 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 391 | + | _, err := s.Capture(ctx, target) | |
| 392 | + | require.NoError(t, err) | |
| 393 | + | _, err = s.Capture(ctx, target) // same context: the watcher stays registered | |
| 394 | + | require.NoError(t, err) | |
| 395 | + | cancel() | |
| 396 | + | _, err = s.Capture(context.Background(), target) | |
| 397 | + | require.NoError(t, err, "a cancelled context affects only calls given that context") | |
| 398 | + | } | |
| 399 | + | ||
| 381 | 400 | func TestConcurrentCallsAreSerialized(t *testing.T) { | |
| 382 | - | s, _ := newSource(t, compositorConfig{}) | |
| 383 | - | out := firstOutput(t, s) | |
| 401 | + | s, _ := newSource(t, testserver.Config{}) | |
| 402 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 384 | 403 | errc := make(chan error, 8) | |
| 385 | 404 | for range 8 { | |
| 386 | - | go func() { | |
| 387 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 388 | - | if err == nil { | |
| 389 | - | _, err = s.Outputs(context.Background()) | |
| 390 | - | } | |
| 391 | - | errc <- err | |
| 392 | - | }() | |
| 405 | + | go func() { _, err := s.Capture(context.Background(), target); errc <- err }() | |
| 393 | 406 | } | |
| 394 | 407 | for range 8 { | |
| 395 | 408 | require.NoError(t, <-errc) | |
| 396 | 409 | } | |
| 397 | 410 | } | |
| 398 | 411 | ||
| 412 | + | // allocBudget is measured (see the log of TestCaptureAllocations: 17 to 18), | |
| 413 | + | // with a small margin. The session builds the frame handlers once, so a frame | |
| 414 | + | // only allocates its proxy and the handler slices of the generated binding. | |
| 415 | + | const allocBudget = 20 | |
| 416 | + | ||
| 399 | 417 | // Steady state cost of one capture over the real transport. The measurement | |
| 400 | - | // includes the in-process protocol peer, so it is an upper bound for the | |
| 418 | + | // includes the in-process libwayland server, so it is an upper bound for the | |
| 401 | 419 | // Source alone; the budget is set from measured values (see the benchmark). | |
| 402 | 420 | func TestCaptureAllocations(t *testing.T) { | |
| 403 | 421 | if raceEnabled { | |
| 404 | 422 | t.Skip("race instrumentation changes allocation counts") | |
| 405 | 423 | } | |
| 406 | - | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}}) | |
| 407 | - | out := firstOutput(t, s) | |
| 424 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}}) | |
| 425 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 408 | 426 | ctx := context.Background() | |
| 409 | - | target := ports.Target{OutputID: out.ID} | |
| 410 | 427 | for range 3 { // warm pools and buffers | |
| 411 | 428 | _, err := s.Capture(ctx, target) | |
| 412 | 429 | require.NoError(t, err) | |
| @@ -417,36 +434,16 @@ func TestCaptureAllocations(t *testing.T) { | |||
| 417 | 434 | panic(err) | |
| 418 | 435 | } | |
| 419 | 436 | }) | |
| 420 | - | t.Logf("allocations per capture (client and in-process peer): %g", avg) | |
| 421 | - | const budget = 14 // measured 12 (client and in-process peer), with headroom | |
| 422 | - | if avg > budget { | |
| 423 | - | t.Fatalf("allocations per capture %g exceed budget %d", avg, budget) | |
| 424 | - | } | |
| 425 | - | cancelable, cancel := context.WithCancel(ctx) | |
| 426 | - | defer cancel() | |
| 427 | - | avgCancelable := testing.AllocsPerRun(200, func() { | |
| 428 | - | if _, err := s.Capture(cancelable, target); err != nil { | |
| 429 | - | panic(err) | |
| 430 | - | } | |
| 431 | - | }) | |
| 432 | - | t.Logf("allocations per capture with cancellable context: %g", avgCancelable) | |
| 433 | - | if avgCancelable > budget { | |
| 434 | - | t.Fatalf("cancellable capture allocations %g exceed budget", avgCancelable) | |
| 435 | - | } | |
| 437 | + | t.Logf("allocations per capture (client and in-process server): %g", avg) | |
| 438 | + | require.LessOrEqual(t, avg, float64(allocBudget)) | |
| 436 | 439 | } | |
| 437 | 440 | ||
| 441 | + | // BenchmarkCapture measures Capture in a tight loop. The compositor needs | |
| 442 | + | // longer than the poll to fill a frame, so many calls repeat the last frame; | |
| 443 | + | // fresh-frames/op says how many were new. | |
| 438 | 444 | func BenchmarkCapture(b *testing.B) { | |
| 439 | - | comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}}) | |
| 440 | - | s, err := New(context.Background(), comp.path) | |
| 441 | - | if err != nil { | |
| 442 | - | b.Fatal(err) | |
| 443 | - | } | |
| 444 | - | defer s.Close() | |
| 445 | - | outs, err := s.Outputs(context.Background()) | |
| 446 | - | if err != nil || len(outs) == 0 { | |
| 447 | - | b.Fatal(err) | |
| 448 | - | } | |
| 449 | - | target := ports.Target{OutputID: outs[0].ID} | |
| 445 | + | s, srv := newSource(b, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "BENCH", Width: 1920, Height: 1080, Scale: 1}}}) | |
| 446 | + | target := ports.Target{OutputID: firstOutput(b, s).ID} | |
| 450 | 447 | ctx := context.Background() | |
| 451 | 448 | b.ReportAllocs() | |
| 452 | 449 | b.SetBytes(1920 * 1080 * 4) | |
| @@ -456,35 +453,12 @@ func BenchmarkCapture(b *testing.B) { | |||
| 456 | 453 | b.Fatal(err) | |
| 457 | 454 | } | |
| 458 | 455 | } | |
| 459 | - | } | |
| 460 | - | ||
| 461 | - | func TestCaptureWaitsForReady(t *testing.T) { | |
| 462 | - | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 463 | - | out := firstOutput(t, s) | |
| 464 | - | type result struct { | |
| 465 | - | f ports.Frame | |
| 466 | - | err error | |
| 467 | - | } | |
| 468 | - | res := make(chan result, 1) | |
| 469 | - | go func() { | |
| 470 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 471 | - | res <- result{f, err} | |
| 472 | - | }() | |
| 473 | - | <-comp.copyEvent | |
| 474 | - | select { | |
| 475 | - | case <-res: | |
| 476 | - | t.Fatal("capture returned before the compositor completed the copy") | |
| 477 | - | case <-time.After(50 * time.Millisecond): | |
| 478 | - | } | |
| 479 | - | comp.completePending() | |
| 480 | - | r := <-res | |
| 481 | - | require.NoError(t, r.err) | |
| 482 | - | want := pixel(1, 1, 1) | |
| 483 | - | require.Equal(t, want[:], r.f.Row(1)[4:8]) | |
| 456 | + | b.ReportMetric(float64(srv.Stats().Ready)/float64(b.N), "fresh-frames/op") | |
| 484 | 457 | } | |
| 485 | 458 | ||
| 486 | 459 | // Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless | |
| 487 | - | // compositor that supports wlr-screencopy. Never point it at a real session. | |
| 460 | + | // compositor that supports ext-image-copy-capture. Never point it at a real | |
| 461 | + | // session. | |
| 488 | 462 | func TestHeadlessCompositor(t *testing.T) { | |
| 489 | 463 | socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET") | |
| 490 | 464 | if socket == "" { | |
| @@ -496,7 +470,6 @@ func TestHeadlessCompositor(t *testing.T) { | |||
| 496 | 470 | require.NoError(t, err) | |
| 497 | 471 | defer s.Close() | |
| 498 | 472 | out := firstOutput(t, s) | |
| 499 | - | require.NotEmpty(t, out.Name) | |
| 500 | 473 | for range 3 { | |
| 501 | 474 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 502 | 475 | require.NoError(t, err) | |
| @@ -504,151 +477,157 @@ func TestHeadlessCompositor(t *testing.T) { | |||
| 504 | 477 | } | |
| 505 | 478 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}}) | |
| 506 | 479 | require.NoError(t, err) | |
| 507 | - | require.Equal(t, 16, f.Width) | |
| 508 | - | require.Equal(t, 8, f.Height) | |
| 480 | + | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) | |
| 509 | 481 | } | |
| 510 | 482 | ||
| 511 | - | func TestIdleCancellationDoesNotKillSource(t *testing.T) { | |
| 512 | - | s, _ := newSource(t, compositorConfig{}) | |
| 513 | - | out := firstOutput(t, s) | |
| 514 | - | target := ports.Target{OutputID: out.ID} | |
| 515 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 516 | - | _, err := s.Capture(ctx, target) | |
| 483 | + | // A compositor that waits for damage holds every frame after the first. The | |
| 484 | + | // capture stays pending, at most one frame is in flight, and Capture returns | |
| 485 | + | // the last frame at once instead of blocking a timed recording. | |
| 486 | + | func TestStaticScreenRepeatsLastFrame(t *testing.T) { | |
| 487 | + | s, srv := newSource(t, testserver.Config{HoldAfterFirst: true}) | |
| 488 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 489 | + | first, err := s.Capture(context.Background(), target) | |
| 517 | 490 | require.NoError(t, err) | |
| 518 | - | _, err = s.Capture(ctx, target) // same context: the watcher stays registered | |
| 519 | - | require.NoError(t, err) | |
| 520 | - | cancel() | |
| 521 | - | _, err = s.Capture(context.Background(), target) | |
| 522 | - | require.NoError(t, err, "a context cancelled between calls must not end the source") | |
| 523 | - | _, err = s.Outputs(ctx) | |
| 524 | - | require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it") | |
| 525 | - | _, err = s.Capture(context.Background(), target) | |
| 526 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 527 | - | } | |
| 528 | - | ||
| 529 | - | func TestStaleContextDoesNotAffectLaterCall(t *testing.T) { | |
| 530 | - | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 531 | - | out := firstOutput(t, s) | |
| 532 | - | target := ports.Target{OutputID: out.ID} | |
| 533 | - | ctxA, cancelA := context.WithCancel(context.Background()) | |
| 534 | - | ctxB, cancelB := context.WithCancel(context.Background()) | |
| 535 | - | defer cancelB() | |
| 536 | - | ||
| 537 | - | runCapture := func(ctx context.Context) chan error { | |
| 538 | - | errc := make(chan error, 1) | |
| 539 | - | go func() { | |
| 540 | - | _, err := s.Capture(ctx, target) | |
| 541 | - | errc <- err | |
| 542 | - | }() | |
| 543 | - | <-comp.copyEvent | |
| 544 | - | return errc | |
| 545 | - | } | |
| 546 | - | errc := runCapture(ctxA) | |
| 547 | - | comp.completePending() | |
| 548 | - | require.NoError(t, <-errc) | |
| 549 | - | ||
| 550 | - | errc = runCapture(ctxB) // blocked under B while A's old registration is replaced | |
| 551 | - | cancelA() | |
| 552 | - | select { | |
| 553 | - | case err := <-errc: | |
| 554 | - | t.Fatalf("cancelling a previous call's context ended a later call: %v", err) | |
| 555 | - | case <-time.After(50 * time.Millisecond): | |
| 556 | - | } | |
| 557 | - | comp.completePending() | |
| 558 | - | require.NoError(t, <-errc) | |
| 559 | - | ||
| 560 | - | errc = runCapture(ctxB) | |
| 561 | - | cancelB() | |
| 562 | - | select { | |
| 563 | - | case err := <-errc: | |
| 564 | - | require.ErrorIs(t, err, context.Canceled) | |
| 565 | - | case <-time.After(3 * time.Second): | |
| 566 | - | t.Fatal("blocked capture not interrupted") | |
| 491 | + | want := testserver.Pixel(2, 1, 1) | |
| 492 | + | require.Equal(t, want[:], first.Row(1)[8:12]) | |
| 493 | + | for range 5 { | |
| 494 | + | began := time.Now() | |
| 495 | + | f, err := s.Capture(context.Background(), target) | |
| 496 | + | require.NoError(t, err) | |
| 497 | + | require.Less(t, time.Since(began), time.Second, "a pending capture does not block the caller") | |
| 498 | + | require.Equal(t, want[:], f.Row(1)[8:12], "the last frame is repeated") | |
| 567 | 499 | } | |
| 568 | - | s.opMu.Lock() | |
| 569 | - | released := s.buf.data == nil && s.watchStop == nil | |
| 570 | - | s.opMu.Unlock() | |
| 571 | - | require.True(t, released, "terminal source must release its mapping and watcher") | |
| 572 | - | _, err := s.Outputs(context.Background()) | |
| 573 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 500 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) | |
| 501 | + | st := srv.Stats() | |
| 502 | + | require.Equal(t, 2, st.Frames, "one served frame and one pending: a single frame in flight") | |
| 503 | + | srv.Release() // the screen changed | |
| 504 | + | var f ports.Frame | |
| 505 | + | require.Eventually(t, func() bool { | |
| 506 | + | f, err = s.Capture(context.Background(), target) | |
| 507 | + | require.NoError(t, err) | |
| 508 | + | return f.Row(1)[10] == 2 | |
| 509 | + | }, 5*time.Second, time.Millisecond, "the pending capture completes into the other buffer") | |
| 574 | 510 | } | |
| 575 | 511 | ||
| 576 | - | func TestContextSwitchRace(t *testing.T) { | |
| 577 | - | s, _ := newSource(t, compositorConfig{}) | |
| 578 | - | out := firstOutput(t, s) | |
| 579 | - | target := ports.Target{OutputID: out.ID} | |
| 580 | - | for i := 0; i < 200; i++ { | |
| 581 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 582 | - | _, err := s.Capture(ctx, target) | |
| 583 | - | require.NoError(t, err) | |
| 584 | - | go cancel() // races with the next call, which uses another context | |
| 512 | + | // A timed recording over a static screen keeps its schedule. | |
| 513 | + | func TestTimedRecordingOverStaticScreen(t *testing.T) { | |
| 514 | + | s, _ := newSource(t, testserver.Config{HoldAfterFirst: true}) | |
| 515 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 516 | + | _, err := s.Capture(context.Background(), target) | |
| 517 | + | require.NoError(t, err) | |
| 518 | + | began := time.Now() | |
| 519 | + | for range 20 { // a 30 fps recording would wait 20 × 33 ms: each call must be far shorter | |
| 585 | 520 | _, err = s.Capture(context.Background(), target) | |
| 586 | 521 | require.NoError(t, err) | |
| 587 | - | cancel() | |
| 588 | 522 | } | |
| 523 | + | require.Less(t, time.Since(began), 500*time.Millisecond) | |
| 589 | 524 | } | |
| 590 | 525 | ||
| 591 | - | func TestNoShmOffer(t *testing.T) { | |
| 592 | - | s, comp := newSource(t, compositorConfig{noShmOffer: true}) | |
| 593 | - | out := firstOutput(t, s) | |
| 594 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 595 | - | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | |
| 596 | - | require.ErrorContains(t, err, "no wl_shm buffer") | |
| 597 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |
| 598 | - | _, err = s.Outputs(context.Background()) | |
| 526 | + | // NeferWL sends no new constraint batch after failed(buffer_constraints): the | |
| 527 | + | // frame is retried with the current constraints after a roundtrip. | |
| 528 | + | func TestFailedBufferConstraintsRetriesWithoutNewBatch(t *testing.T) { | |
| 529 | + | s, srv := newSource(t, testserver.Config{}) | |
| 530 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 531 | + | srv.Fail(1, 1) | |
| 532 | + | _, err := s.Capture(context.Background(), target) | |
| 533 | + | require.NoError(t, err) | |
| 534 | + | srv.Fail(maxFrameAttempts, 1) | |
| 535 | + | _, err = s.Capture(context.Background(), target) | |
| 536 | + | require.ErrorIs(t, err, errCompositorFailedCapture, "the retries are bounded") | |
| 537 | + | srv.Fail(0, 0) | |
| 538 | + | _, err = s.Capture(context.Background(), target) | |
| 599 | 539 | require.NoError(t, err) | |
| 600 | 540 | } | |
| 601 | 541 | ||
| 602 | - | func TestMalformedOutputModeAndScale(t *testing.T) { | |
| 603 | - | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 604 | - | {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4}, | |
| 605 | - | {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4}, | |
| 606 | - | }}) | |
| 607 | - | outs, err := s.Outputs(context.Background()) | |
| 608 | - | require.NoError(t, err) | |
| 609 | - | require.Equal(t, []ports.Output{ | |
| 610 | - | {ID: 1, Name: "BAD", Scale: 1}, | |
| 611 | - | {ID: 2, Name: "HUGE", Width: 100, Height: 50, Scale: maxOutputScale}, | |
| 612 | - | }, outs) | |
| 542 | + | func TestNonNormalTransformIsRejected(t *testing.T) { | |
| 543 | + | for _, region := range []ports.Region{{}, {X: 1, Y: 1, Width: 2, Height: 2}} { | |
| 544 | + | s, _ := newSource(t, testserver.Config{Transform: 3}) | |
| 545 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, Region: region}) | |
| 546 | + | require.ErrorIs(t, err, ports.ErrUnsupportedTransform) | |
| 547 | + | require.ErrorContains(t, err, "output transform 3 is not supported") | |
| 548 | + | } | |
| 613 | 549 | } | |
| 614 | 550 | ||
| 615 | - | func TestDuplicateGlobalIgnored(t *testing.T) { | |
| 616 | - | s, _ := newSource(t, compositorConfig{duplicateGlobal: true}) | |
| 617 | - | outs, err := s.Outputs(context.Background()) | |
| 551 | + | // A workspace source stops when its workspace goes away. | |
| 552 | + | func TestWorkspaceRemovedIsUnavailable(t *testing.T) { | |
| 553 | + | cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}) | |
| 554 | + | cfg.NeferwlSource = true | |
| 555 | + | s, srv := newSource(t, cfg) | |
| 556 | + | target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1} | |
| 557 | + | _, err := s.Capture(context.Background(), target) | |
| 618 | 558 | require.NoError(t, err) | |
| 619 | - | require.Len(t, outs, 1) | |
| 620 | - | require.Equal(t, 1, s.bound) | |
| 559 | + | srv.RemoveWorkspace("b") | |
| 560 | + | _, err = s.Capture(context.Background(), target) | |
| 561 | + | require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable) | |
| 621 | 562 | } | |
| 622 | 563 | ||
| 623 | - | func TestReadyWithoutCopyIsTerminal(t *testing.T) { | |
| 624 | - | s, _ := newSource(t, compositorConfig{readyEarly: true}) | |
| 625 | - | out := firstOutput(t, s) | |
| 626 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 627 | - | require.Error(t, err) | |
| 628 | - | _, err = s.Outputs(context.Background()) | |
| 629 | - | require.ErrorIs(t, err, ports.ErrClosed) | |
| 564 | + | // A workspace session that stops because its workspace was removed is a plain | |
| 565 | + | // stop, not a refusal: the allowlist hint is for a live workspace. | |
| 566 | + | func TestWorkspaceRemovedBeforeFirstFrameIsNotRefusal(t *testing.T) { | |
| 567 | + | cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}) | |
| 568 | + | cfg.NeferwlSource = true | |
| 569 | + | s, srv := newSource(t, cfg) | |
| 570 | + | target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1} | |
| 571 | + | srv.RemoveWorkspace("b") // not read yet: the session is created, then stopped | |
| 572 | + | _, err := s.Capture(context.Background(), target) | |
| 573 | + | require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable) | |
| 574 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 575 | + | require.NotContains(t, err.Error(), "capture-allow") | |
| 576 | + | require.NotContains(t, err.Error(), "refused") | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // A live workspace whose session is refused keeps the allowlist hint. | |
| 580 | + | func TestWorkspaceSessionRefusedKeepsAllowlistHint(t *testing.T) { | |
| 581 | + | cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}) | |
| 582 | + | cfg.NeferwlSource, cfg.StopAtCreate = true, true | |
| 583 | + | s, _ := newSource(t, cfg) | |
| 584 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1}) | |
| 585 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 586 | + | require.ErrorContains(t, err, "/etc/neferwl/capture-allow") | |
| 630 | 587 | } | |
| 631 | 588 | ||
| 632 | - | func TestCompositorFailureIsRecoverable(t *testing.T) { | |
| 633 | - | t.Run("before copy", func(t *testing.T) { | |
| 634 | - | s, comp := newSource(t, compositorConfig{failCapture: true}) | |
| 635 | - | out := firstOutput(t, s) | |
| 636 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 637 | - | require.Error(t, err) | |
| 638 | - | comp.mu.Lock() | |
| 639 | - | comp.cfg.failCapture = false | |
| 640 | - | comp.mu.Unlock() | |
| 641 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 589 | + | // A cancellable context costs no more than a background one. | |
| 590 | + | func TestCaptureAllocationsCancellable(t *testing.T) { | |
| 591 | + | if raceEnabled { | |
| 592 | + | t.Skip("race instrumentation changes allocation counts") | |
| 593 | + | } | |
| 594 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}}) | |
| 595 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 596 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 597 | + | defer cancel() | |
| 598 | + | for range 3 { | |
| 599 | + | _, err := s.Capture(ctx, target) | |
| 642 | 600 | require.NoError(t, err) | |
| 601 | + | } | |
| 602 | + | runtime.GC() | |
| 603 | + | avg := testing.AllocsPerRun(200, func() { | |
| 604 | + | if _, err := s.Capture(ctx, target); err != nil { | |
| 605 | + | panic(err) | |
| 606 | + | } | |
| 643 | 607 | }) | |
| 644 | - | t.Run("after copy", func(t *testing.T) { | |
| 645 | - | s, _ := newSource(t, compositorConfig{failCopies: 1}) | |
| 646 | - | out := firstOutput(t, s) | |
| 647 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 648 | - | require.Error(t, err) | |
| 649 | - | require.NotErrorIs(t, err, ports.ErrClosed) | |
| 650 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 608 | + | t.Logf("allocations per capture with a cancellable context: %g", avg) | |
| 609 | + | require.LessOrEqual(t, avg, float64(allocBudget)) | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // A constraint batch that repeats the same layout (a compositor may re-send | |
| 613 | + | // its constraints) must not drop the last frame or the pending capture: on a | |
| 614 | + | // static screen the next Capture would block for damage that never comes. | |
| 615 | + | func TestSameGeometryConstraintBatchKeepsPendingFrame(t *testing.T) { | |
| 616 | + | s, srv := newSource(t, testserver.Config{HoldAfterFirst: true}) | |
| 617 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} | |
| 618 | + | // Every call is bounded, so a regression fails instead of hanging. | |
| 619 | + | capture := func() (ports.Frame, error) { | |
| 620 | + | ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) | |
| 621 | + | defer cancel() | |
| 622 | + | return s.Capture(ctx, target) | |
| 623 | + | } | |
| 624 | + | for range 3 { | |
| 625 | + | _, err := capture() | |
| 651 | 626 | require.NoError(t, err) | |
| 652 | - | require.NoError(t, f.Validate()) | |
| 653 | - | }) | |
| 627 | + | } | |
| 628 | + | srv.Resize("TEST-1", 8, 4) // same size: a new batch, same layout | |
| 629 | + | f, err := capture() | |
| 630 | + | require.NoError(t, err, "the last frame is repeated, not waited for") | |
| 631 | + | require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height}) | |
| 632 | + | require.Equal(t, 2, srv.Stats().Frames, "the pending capture was kept, not replaced") | |
| 654 | 633 | } | |
A internal/adapters/wayland/testserver/capture.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package testserver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 6 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Pixel is the content the server paints: a function of the output pixel and | |
| 10 | + | // the frame sequence, so tests can tell which part of which frame they read. | |
| 11 | + | func Pixel(x, y int, seq uint32) [4]byte { return [4]byte{byte(x), byte(y), byte(seq), 0xff} } | |
| 12 | + | ||
| 13 | + | type copyManager struct{ s *Server } | |
| 14 | + | ||
| 15 | + | func (m copyManager) CreateSession(self *extimagecopycapture.ExtImageCopyCaptureManagerV1, id uint32, src *extimagecapturesource.ExtImageCaptureSourceV1, options uint32) { | |
| 16 | + | s := m.s | |
| 17 | + | c := &session{s: s, src: s.sources[src.Resource]} | |
| 18 | + | if c.src == nil { | |
| 19 | + | self.PostError(1, "unknown source") | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | res, err := extimagecopycapture.NewExtImageCopyCaptureSessionV1(self.Client(), 1, id, sessionHandler{c}) | |
| 23 | + | if err != nil { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | c.res = res | |
| 27 | + | c.out = c.src.out | |
| 28 | + | if c.src.ws != nil { | |
| 29 | + | c.out = s.outputNamed(c.src.ws.spec.Output) | |
| 30 | + | } | |
| 31 | + | s.sessions = append(s.sessions, c) | |
| 32 | + | s.stats.Sessions++ | |
| 33 | + | s.stats.LastSource = c.src.kind | |
| 34 | + | s.stats.LastRegion = c.src.region | |
| 35 | + | res.OnDestroy = func() { | |
| 36 | + | c.dead = true | |
| 37 | + | s.stats.DestroyedSessions++ | |
| 38 | + | } | |
| 39 | + | if s.cfg.StopAtCreate || c.src.gone || c.out == nil || c.out.removed { | |
| 40 | + | c.stop() | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | c.sendConstraints() | |
| 44 | + | } | |
| 45 | + | func (copyManager) CreatePointerCursorSession(self *extimagecopycapture.ExtImageCopyCaptureManagerV1, _ uint32, _ *extimagecapturesource.ExtImageCaptureSourceV1, _ *wayland.Pointer) { | |
| 46 | + | self.PostError(1, "cursor sessions are not supported") | |
| 47 | + | } | |
| 48 | + | func (copyManager) Destroy(*extimagecopycapture.ExtImageCopyCaptureManagerV1) {} | |
| 49 | + | ||
| 50 | + | func (s *Server) outputNamed(name string) *output { | |
| 51 | + | for _, o := range s.outputs { | |
| 52 | + | if o.spec.Name == name { | |
| 53 | + | return o | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type session struct { | |
| 60 | + | s *Server | |
| 61 | + | res *extimagecopycapture.ExtImageCopyCaptureSessionV1 | |
| 62 | + | src *source | |
| 63 | + | out *output | |
| 64 | + | frame *frame // the live frame, nil when none | |
| 65 | + | w, h uint32 // the latest announced buffer size | |
| 66 | + | dead bool | |
| 67 | + | stopped bool | |
| 68 | + | answered int // frames completed with ready | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // size is the buffer size of the session's source. | |
| 72 | + | func (c *session) size() (w, h uint32) { | |
| 73 | + | scale := uint32(max(c.out.spec.Scale, 1)) | |
| 74 | + | if c.src.kind == "region" { | |
| 75 | + | return uint32(c.src.region[2]) * scale, uint32(c.src.region[3]) * scale | |
| 76 | + | } | |
| 77 | + | return c.out.spec.Width, c.out.spec.Height | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (c *session) sendConstraints() { | |
| 81 | + | c.w, c.h = c.size() | |
| 82 | + | c.res.SendBufferSize(c.w, c.h) | |
| 83 | + | if !c.s.cfg.NoShmFormat { | |
| 84 | + | for _, f := range c.s.cfg.Formats { | |
| 85 | + | c.res.SendShmFormat(f) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | c.res.SendDone() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (c *session) stop() { | |
| 92 | + | if c.stopped || c.dead { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | c.stopped = true | |
| 96 | + | c.res.SendStopped() | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | type sessionHandler struct{ c *session } | |
| 100 | + | ||
| 101 | + | func (h sessionHandler) CreateFrame(self *extimagecopycapture.ExtImageCopyCaptureSessionV1, id uint32) { | |
| 102 | + | c := h.c | |
| 103 | + | if c.frame != nil { | |
| 104 | + | self.PostError(uint32(extimagecopycapture.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "create_frame before destroying the previous frame") | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | f := &frame{c: c} | |
| 108 | + | res, err := extimagecopycapture.NewExtImageCopyCaptureFrameV1(self.Client(), 1, id, f) | |
| 109 | + | if err != nil { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | f.res = res | |
| 113 | + | c.frame = f | |
| 114 | + | c.s.stats.Frames++ | |
| 115 | + | res.OnDestroy = func() { | |
| 116 | + | if c.frame == f { | |
| 117 | + | c.frame = nil | |
| 118 | + | } | |
| 119 | + | f.gone = true | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | func (sessionHandler) Destroy(*extimagecopycapture.ExtImageCopyCaptureSessionV1) {} | |
| 123 | + | ||
| 124 | + | type frame struct { | |
| 125 | + | c *session | |
| 126 | + | res *extimagecopycapture.ExtImageCopyCaptureFrameV1 | |
| 127 | + | buf *buffer | |
| 128 | + | captured bool | |
| 129 | + | gone bool | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (f *frame) Destroy(*extimagecopycapture.ExtImageCopyCaptureFrameV1) {} | |
| 133 | + | func (f *frame) AttachBuffer(self *extimagecopycapture.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) { | |
| 134 | + | f.buf = f.c.s.bufs[b.Resource] | |
| 135 | + | } | |
| 136 | + | func (f *frame) DamageBuffer(*extimagecopycapture.ExtImageCopyCaptureFrameV1, int32, int32, int32, int32) { | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (f *frame) Capture(self *extimagecopycapture.ExtImageCopyCaptureFrameV1) { | |
| 140 | + | s := f.c.s | |
| 141 | + | if f.buf == nil { | |
| 142 | + | self.PostError(uint32(extimagecopycapture.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "capture without buffer") | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | if f.captured { | |
| 146 | + | self.PostError(uint32(extimagecopycapture.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "captured twice") | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | f.captured = true | |
| 150 | + | s.stats.Captures++ | |
| 151 | + | if s.cfg.Hold || (s.cfg.HoldAfterFirst && f.c.answered > 0) { | |
| 152 | + | s.held = append(s.held, f) | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | f.complete() | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // complete answers a captured frame: failed, or pixels and ready. | |
| 159 | + | func (f *frame) complete() { | |
| 160 | + | c, s := f.c, f.c.s | |
| 161 | + | switch { | |
| 162 | + | case f.gone || c.dead: | |
| 163 | + | return | |
| 164 | + | case c.stopped: | |
| 165 | + | f.res.SendFailed(uint32(extimagecopycapture.ExtImageCopyCaptureFrameV1FailureReasonStopped)) | |
| 166 | + | return | |
| 167 | + | case s.cfg.FailFrames > 0: | |
| 168 | + | s.cfg.FailFrames-- | |
| 169 | + | f.res.SendFailed(s.cfg.FailReason) | |
| 170 | + | return | |
| 171 | + | case f.buf.width != c.w || f.buf.height != c.h || !f.formatOK(): | |
| 172 | + | f.res.SendFailed(uint32(extimagecopycapture.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints)) | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | s.seq++ | |
| 176 | + | data := f.buf.bytes() | |
| 177 | + | if data == nil { | |
| 178 | + | f.res.SendFailed(uint32(extimagecopycapture.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | ox, oy, scale := 0, 0, int(max(c.out.spec.Scale, 1)) | |
| 182 | + | if c.src.kind == "region" { | |
| 183 | + | ox, oy = int(c.src.region[0])*scale, int(c.src.region[1])*scale | |
| 184 | + | } | |
| 185 | + | for y := 0; y < int(f.buf.height); y++ { | |
| 186 | + | row := data[y*int(f.buf.stride):] | |
| 187 | + | for x := 0; x < int(f.buf.width); x++ { | |
| 188 | + | p := Pixel(ox+x, oy+y, s.seq) | |
| 189 | + | copy(row[x*4:], p[:]) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | s.stats.Ready++ | |
| 193 | + | c.answered++ | |
| 194 | + | f.res.SendTransform(s.cfg.Transform) | |
| 195 | + | f.res.SendDamage(0, 0, int32(f.buf.width), int32(f.buf.height)) | |
| 196 | + | f.res.SendReady() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (f *frame) formatOK() bool { | |
| 200 | + | for _, x := range f.c.s.cfg.Formats { | |
| 201 | + | if x == f.buf.format { | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return false | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Release completes every held frame. | |
| 209 | + | func (s *Server) Release() { | |
| 210 | + | s.Do(func() { | |
| 211 | + | held := s.held | |
| 212 | + | s.held = nil | |
| 213 | + | for _, f := range held { | |
| 214 | + | f.complete() | |
| 215 | + | } | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Held reports how many captured frames wait for Release. | |
| 220 | + | func (s *Server) Held() int { | |
| 221 | + | n := 0 | |
| 222 | + | s.Do(func() { n = len(s.held) }) | |
| 223 | + | return n | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Resize changes an output's mode and sends new constraints to its sessions. | |
| 227 | + | func (s *Server) Resize(name string, w, h uint32) { | |
| 228 | + | s.Do(func() { | |
| 229 | + | o := s.outputNamed(name) | |
| 230 | + | if o == nil { | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | o.spec.Width, o.spec.Height = w, h | |
| 234 | + | for _, c := range s.sessions { | |
| 235 | + | if c.out == o && !c.dead && !c.stopped { | |
| 236 | + | c.sendConstraints() | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | }) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // StopSessions ends every live session with the stopped event. | |
| 243 | + | func (s *Server) StopSessions() { | |
| 244 | + | s.Do(func() { | |
| 245 | + | for _, c := range s.sessions { | |
| 246 | + | c.stop() | |
| 247 | + | } | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // Fail makes the next n captures answer failed(reason). | |
| 252 | + | func (s *Server) Fail(n int, reason uint32) { | |
| 253 | + | s.Do(func() { s.cfg.FailFrames, s.cfg.FailReason = n, reason }) | |
| 254 | + | } | |
A internal/adapters/wayland/testserver/exclusion.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package testserver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver/neferwl" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type exclusionManager struct{ s *Server } | |
| 11 | + | ||
| 12 | + | func (exclusionManager) Destroy(*neferwl.NeferwlCaptureExclusionManagerV1) {} | |
| 13 | + | ||
| 14 | + | func (m exclusionManager) GetExclusion(self *neferwl.NeferwlCaptureExclusionManagerV1, id uint32, sess *extimagecopycapture.ExtImageCopyCaptureSessionV1) { | |
| 15 | + | s := m.s | |
| 16 | + | res, err := neferwl.NewNeferwlCaptureExclusionV1(self.Client(), 1, id, exclusionHandler{s}) | |
| 17 | + | if err != nil { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | s.stats.Exclusions++ | |
| 21 | + | res.OnDestroy = func() { s.stats.DestroyedExclusions++ } | |
| 22 | + | if s.cfg.ExclusionFail != 0 { | |
| 23 | + | res.SendFailed(s.cfg.ExclusionFail - 1) | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | res.SendToken(Token) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (m exclusionManager) AttachSurface(self *neferwl.NeferwlCaptureExclusionManagerV1, id uint32, token string, _ *wayland.Surface) { | |
| 30 | + | s := m.s | |
| 31 | + | res, err := neferwl.NewNeferwlCaptureLayerV1(self.Client(), 1, id, layerHandler{}) | |
| 32 | + | if err != nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | s.stats.Attached = append(s.stats.Attached, token) | |
| 36 | + | s.layers = append(s.layers, res.Resource) | |
| 37 | + | layer := res | |
| 38 | + | switch { | |
| 39 | + | case s.cfg.AttachSilent: | |
| 40 | + | case s.cfg.AttachFail != 0: | |
| 41 | + | layer.SendFailed(s.cfg.AttachFail - 1) | |
| 42 | + | default: | |
| 43 | + | layer.SendAttached() | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type exclusionHandler struct{ s *Server } | |
| 48 | + | ||
| 49 | + | func (exclusionHandler) Destroy(*neferwl.NeferwlCaptureExclusionV1) {} | |
| 50 | + | ||
| 51 | + | type layerHandler struct{} | |
| 52 | + | ||
| 53 | + | func (layerHandler) Destroy(*neferwl.NeferwlCaptureLayerV1) {} | |
| 54 | + | ||
| 55 | + | // Detach sends detached(reason) on the n-th attached layer. | |
| 56 | + | func (s *Server) Detach(n int, reason uint32) { | |
| 57 | + | s.Do(func() { | |
| 58 | + | if n < len(s.layers) { | |
| 59 | + | neferwl.WrapNeferwlCaptureLayerV1(s.layers[n]).SendDetached(reason) | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // SendRaw posts a layer event out of order, to test violations: opcode 0 | |
| 65 | + | // attached, 1 failed, 2 detached. | |
| 66 | + | func (s *Server) SendRaw(n int, opcode uint32, arg uint32) { | |
| 67 | + | s.Do(func() { | |
| 68 | + | if n >= len(s.layers) { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | l := neferwl.WrapNeferwlCaptureLayerV1(s.layers[n]) | |
| 72 | + | switch opcode { | |
| 73 | + | case 0: | |
| 74 | + | l.SendAttached() | |
| 75 | + | case 1: | |
| 76 | + | l.SendFailed(arg) | |
| 77 | + | default: | |
| 78 | + | l.SendDetached(arg) | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
A internal/adapters/wayland/testserver/neferwl/gen.sh +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # Regenerates neferwl.go (test-only server bindings of NeferWL's capture | |
| 3 | + | # extension) from the vendored XML with the wlgen of the purego-libwayland | |
| 4 | + | # module in use. Offline: go.work or the module cache. | |
| 5 | + | set -eu | |
| 6 | + | cd "$(dirname "$0")" | |
| 7 | + | mod=$(go list -m -f '{{.Dir}}' github.com/bnema/purego-libwayland) | |
| 8 | + | p=github.com/bnema/purego-libwayland/protocol | |
| 9 | + | go run github.com/bnema/purego-libwayland/cmd/wlgen -package neferwl -out neferwl.go \ | |
| 10 | + | -import wayland=$p/wayland -import-xml wayland="$mod/protocols/wayland.xml" \ | |
| 11 | + | -import extimagecapturesource=$p/extimagecapturesource -import-xml extimagecapturesource="$mod/protocols/ext-image-capture-source-v1.xml" \ | |
| 12 | + | -import extimagecopycapture=$p/extimagecopycapture -import-xml extimagecopycapture="$mod/protocols/ext-image-copy-capture-v1.xml" \ | |
| 13 | + | -import extworkspace=$p/extworkspace -import-xml extworkspace="$mod/protocols/ext-workspace-v1.xml" \ | |
| 14 | + | neferwl-image-capture-v1.xml | |
A internal/adapters/wayland/testserver/neferwl/neferwl-image-capture-v1.xml +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | + | <protocol name="neferwl_image_capture_v1"> | |
| 3 | + | <copyright> | |
| 4 | + | neferwl private protocol. No stability promise beyond the version of the | |
| 5 | + | interface. | |
| 6 | + | </copyright> | |
| 7 | + | ||
| 8 | + | <description summary="neferwl additions to ext-image-copy-capture"> | |
| 9 | + | Optional additions to ext-image-capture-source-v1, ext-workspace-v1 and | |
| 10 | + | ext-image-copy-capture-v1 for what those protocols do not cover: | |
| 11 | + | workspace and output-region capture sources, the frame of a workspace, | |
| 12 | + | and leaving a client's own surfaces (a recording HUD) out of its | |
| 13 | + | captures. | |
| 14 | + | ||
| 15 | + | Capture always goes through ext-image-copy-capture. The compositor applies | |
| 16 | + | the same rules to every capture, whatever its source: it may refuse a | |
| 17 | + | client (ext_image_copy_capture_session_v1.stopped), and it draws its own | |
| 18 | + | capture indicator on screen, never in a captured image, which no client | |
| 19 | + | can turn off. | |
| 20 | + | </description> | |
| 21 | + | ||
| 22 | + | <interface name="neferwl_image_capture_source_manager_v1" version="1"> | |
| 23 | + | <description summary="workspace and output-region capture sources"> | |
| 24 | + | Global. Sources created here are ext_image_capture_source_v1 objects, | |
| 25 | + | used with ext_image_copy_capture_manager_v1.create_session like any | |
| 26 | + | other source. | |
| 27 | + | </description> | |
| 28 | + | ||
| 29 | + | <enum name="error"> | |
| 30 | + | <entry name="invalid_region" value="0" summary="width or height is not positive"/> | |
| 31 | + | </enum> | |
| 32 | + | ||
| 33 | + | <request name="destroy" type="destructor"> | |
| 34 | + | <description summary="destroy the manager"> | |
| 35 | + | Sources created through it stay valid. | |
| 36 | + | </description> | |
| 37 | + | </request> | |
| 38 | + | ||
| 39 | + | <request name="create_workspace_source"> | |
| 40 | + | <description summary="capture a workspace"> | |
| 41 | + | The source is the workspace frame, without bars, whether or not the | |
| 42 | + | workspace is on screen: a workspace that is not on screen is rendered | |
| 43 | + | for capture only. Its buffer size follows the workspace frame. A | |
| 44 | + | session on it is stopped when the workspace is removed. A handle | |
| 45 | + | already removed gives a source whose sessions stop at once. | |
| 46 | + | </description> | |
| 47 | + | <arg name="source" type="new_id" interface="ext_image_capture_source_v1"/> | |
| 48 | + | <arg name="workspace" type="object" interface="ext_workspace_handle_v1"/> | |
| 49 | + | </request> | |
| 50 | + | ||
| 51 | + | <request name="create_output_region_source"> | |
| 52 | + | <description summary="capture a rectangle of an output"> | |
| 53 | + | The rectangle is in output-local logical pixels and is clipped to the | |
| 54 | + | output. The buffer covers only that rectangle, in physical pixels. A | |
| 55 | + | session on it is stopped when the output goes away or when nothing of | |
| 56 | + | the rectangle is left on the output. | |
| 57 | + | </description> | |
| 58 | + | <arg name="source" type="new_id" interface="ext_image_capture_source_v1"/> | |
| 59 | + | <arg name="output" type="object" interface="wl_output"/> | |
| 60 | + | <arg name="x" type="int"/> | |
| 61 | + | <arg name="y" type="int"/> | |
| 62 | + | <arg name="width" type="int"/> | |
| 63 | + | <arg name="height" type="int"/> | |
| 64 | + | </request> | |
| 65 | + | ||
| 66 | + | <request name="get_workspace_frame"> | |
| 67 | + | <description summary="learn the frame of a workspace"> | |
| 68 | + | ext-workspace-v1 carries no geometry. The frame object reports the | |
| 69 | + | rectangle that a workspace source of the same handle captures. For a | |
| 70 | + | handle already removed, the object never sends frame. | |
| 71 | + | </description> | |
| 72 | + | <arg name="id" type="new_id" interface="neferwl_workspace_frame_v1"/> | |
| 73 | + | <arg name="workspace" type="object" interface="ext_workspace_handle_v1"/> | |
| 74 | + | </request> | |
| 75 | + | </interface> | |
| 76 | + | ||
| 77 | + | <interface name="neferwl_workspace_frame_v1" version="1"> | |
| 78 | + | <description summary="the frame of one workspace"> | |
| 79 | + | The frame is the area of its output that the workspace draws in, | |
| 80 | + | without bars: the whole output, or the smaller size set for the | |
| 81 | + | workspace. | |
| 82 | + | </description> | |
| 83 | + | ||
| 84 | + | <request name="destroy" type="destructor"> | |
| 85 | + | <description summary="destroy the frame object"/> | |
| 86 | + | </request> | |
| 87 | + | ||
| 88 | + | <event name="frame"> | |
| 89 | + | <description summary="the workspace frame"> | |
| 90 | + | The frame in output-local logical pixels. Sent when the object is | |
| 91 | + | created and again whenever the frame changes. Not sent once the | |
| 92 | + | workspace is removed. | |
| 93 | + | </description> | |
| 94 | + | <arg name="x" type="int"/> | |
| 95 | + | <arg name="y" type="int"/> | |
| 96 | + | <arg name="width" type="int"/> | |
| 97 | + | <arg name="height" type="int"/> | |
| 98 | + | </event> | |
| 99 | + | </interface> | |
| 100 | + | ||
| 101 | + | <interface name="neferwl_capture_exclusion_manager_v1" version="1"> | |
| 102 | + | <description summary="leave a client's own surfaces out of its captures"> | |
| 103 | + | Global. A recorder asks for an exclusion on its capture session and | |
| 104 | + | gets a token; its HUD, usually on another connection of the same user, | |
| 105 | + | attaches layer surfaces with that token. Frames of that session leave | |
| 106 | + | the attached surfaces out. Every other capture shows them. | |
| 107 | + | </description> | |
| 108 | + | ||
| 109 | + | <enum name="error"> | |
| 110 | + | <entry name="surface_role" value="0" summary="the surface is not a wlr-layer-shell surface"/> | |
| 111 | + | <entry name="invalid_layer" value="1" summary="the layer is neither top nor overlay"/> | |
| 112 | + | <entry name="exclusive_keyboard" value="2" summary="the layer takes the keyboard exclusively"/> | |
| 113 | + | <entry name="invalid_surface" value="3" summary="the surface is already mapped"/> | |
| 114 | + | <entry name="already_excluded" value="4" summary="the capture session already has an exclusion"/> | |
| 115 | + | </enum> | |
| 116 | + | ||
| 117 | + | <request name="destroy" type="destructor"> | |
| 118 | + | <description summary="destroy the manager"> | |
| 119 | + | Exclusions and attachments created through it stay alive. | |
| 120 | + | </description> | |
| 121 | + | </request> | |
| 122 | + | ||
| 123 | + | <request name="get_exclusion"> | |
| 124 | + | <description summary="create the exclusion of a capture session"> | |
| 125 | + | The exclusion answers with exactly one of token or failed. It ends | |
| 126 | + | with the capture session. | |
| 127 | + | </description> | |
| 128 | + | <arg name="id" type="new_id" interface="neferwl_capture_exclusion_v1"/> | |
| 129 | + | <arg name="session" type="object" interface="ext_image_copy_capture_session_v1"/> | |
| 130 | + | </request> | |
| 131 | + | ||
| 132 | + | <request name="attach_surface"> | |
| 133 | + | <description summary="attach a layer surface to an exclusion by token"> | |
| 134 | + | Marks a layer surface of this client as excluded from the frames of | |
| 135 | + | the session that owns the token. The client must have the same user | |
| 136 | + | ID as the compositor and must also be allowed to capture: its | |
| 137 | + | executable is on the capture allowlist (or the allowlist is off), it | |
| 138 | + | is not sandboxed, and the session is not locked. The surface must already have the | |
| 139 | + | wlr-layer-shell role, on the top or overlay layer, must not have | |
| 140 | + | mapped yet and must not ask for exclusive keyboard interactivity. At | |
| 141 | + | most 4 layers per exclusion. An attached layer never gets keyboard | |
| 142 | + | interactivity; if it moves to another layer the compositor closes it | |
| 143 | + | and sends detached. | |
| 144 | + | ||
| 145 | + | The attachment answers with exactly one of attached or failed. The | |
| 146 | + | surface is excluded from this request on, before it can map; the | |
| 147 | + | client must not show it before attached. | |
| 148 | + | </description> | |
| 149 | + | <arg name="id" type="new_id" interface="neferwl_capture_layer_v1"/> | |
| 150 | + | <arg name="token" type="string"/> | |
| 151 | + | <arg name="surface" type="object" interface="wl_surface"/> | |
| 152 | + | </request> | |
| 153 | + | </interface> | |
| 154 | + | ||
| 155 | + | <interface name="neferwl_capture_exclusion_v1" version="1"> | |
| 156 | + | <description summary="the exclusion of one capture session"> | |
| 157 | + | Destroying it detaches every attached layer. | |
| 158 | + | </description> | |
| 159 | + | ||
| 160 | + | <enum name="failure"> | |
| 161 | + | <entry name="busy" value="0" summary="another exclusion is live in the compositor"/> | |
| 162 | + | <entry name="session_stopped" value="1" summary="the capture session is stopped"/> | |
| 163 | + | </enum> | |
| 164 | + | ||
| 165 | + | <request name="destroy" type="destructor"> | |
| 166 | + | <description summary="destroy the exclusion"/> | |
| 167 | + | </request> | |
| 168 | + | ||
| 169 | + | <event name="token"> | |
| 170 | + | <description summary="the attachment token"> | |
| 171 | + | 64 lowercase hexadecimal characters (32 random bytes). A secret for | |
| 172 | + | the owner and its HUD, never logged, valid until the exclusion ends. | |
| 173 | + | </description> | |
| 174 | + | <arg name="token" type="string"/> | |
| 175 | + | </event> | |
| 176 | + | ||
| 177 | + | <event name="failed"> | |
| 178 | + | <description summary="no exclusion"> | |
| 179 | + | Final. Destroy the object. | |
| 180 | + | </description> | |
| 181 | + | <arg name="reason" type="uint" enum="failure"/> | |
| 182 | + | </event> | |
| 183 | + | </interface> | |
| 184 | + | ||
| 185 | + | <interface name="neferwl_capture_layer_v1" version="1"> | |
| 186 | + | <description summary="a layer surface attached to an exclusion"> | |
| 187 | + | Created by attach_surface. Exactly one of attached or failed comes | |
| 188 | + | first; detached comes at most once, after attached. | |
| 189 | + | </description> | |
| 190 | + | ||
| 191 | + | <enum name="failure"> | |
| 192 | + | <entry name="unknown_token" value="0" summary="no live exclusion has this token"/> | |
| 193 | + | <entry name="unauthorized" value="1" summary="peer credentials differ from the compositor user's or are unreadable, or the client may not capture (not on the allowlist, sandboxed, or session locked)"/> | |
| 194 | + | <entry name="too_many_layers" value="2" summary="the exclusion already has 4 attached layers"/> | |
| 195 | + | <entry name="already_attached" value="3" summary="the surface is already attached"/> | |
| 196 | + | <entry name="exclusion_ended" value="4" summary="the exclusion ended before the compositor confirmed the layer"/> | |
| 197 | + | <entry name="layer_destroyed" value="5" summary="the layer role was destroyed before the compositor confirmed it"/> | |
| 198 | + | </enum> | |
| 199 | + | ||
| 200 | + | <enum name="detach_reason"> | |
| 201 | + | <entry name="exclusion_ended" value="0" summary="the exclusion ended; the compositor closed the layer surface"/> | |
| 202 | + | <entry name="layer_destroyed" value="1" summary="the layer role was destroyed"/> | |
| 203 | + | </enum> | |
| 204 | + | ||
| 205 | + | <request name="destroy" type="destructor"> | |
| 206 | + | <description summary="destroy the attachment object"> | |
| 207 | + | The surface stays excluded until its layer role is destroyed or the | |
| 208 | + | exclusion ends. | |
| 209 | + | </description> | |
| 210 | + | </request> | |
| 211 | + | ||
| 212 | + | <event name="attached"> | |
| 213 | + | <description summary="the layer is excluded"> | |
| 214 | + | The compositor confirmed the attachment. Safe to show. | |
| 215 | + | </description> | |
| 216 | + | </event> | |
| 217 | + | ||
| 218 | + | <event name="failed"> | |
| 219 | + | <description summary="the attachment was refused"/> | |
| 220 | + | <arg name="reason" type="uint" enum="failure"/> | |
| 221 | + | </event> | |
| 222 | + | ||
| 223 | + | <event name="detached"> | |
| 224 | + | <description summary="the layer left the exclusion"> | |
| 225 | + | Sent once after attached. When the exclusion ended, the compositor | |
| 226 | + | also closed the layer surface (zwlr_layer_surface_v1.closed). | |
| 227 | + | </description> | |
| 228 | + | <arg name="reason" type="uint" enum="detach_reason"/> | |
| 229 | + | </event> | |
| 230 | + | </interface> | |
| 231 | + | </protocol> | |
A internal/adapters/wayland/testserver/neferwl/neferwl.go +403 -0
| @@ -0,0 +1,403 @@ | |||
| 1 | + | // Code generated by wlgen; DO NOT EDIT. | |
| 2 | + | package neferwl | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | extimagecapturesource "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |
| 7 | + | extimagecopycapture "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 8 | + | extworkspace "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 9 | + | wayland "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 10 | + | "github.com/bnema/purego-libwayland/server" | |
| 11 | + | "runtime" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var NeferwlImageCaptureSourceManagerV1Interface *server.Interface | |
| 15 | + | var NeferwlWorkspaceFrameV1Interface *server.Interface | |
| 16 | + | var NeferwlCaptureExclusionManagerV1Interface *server.Interface | |
| 17 | + | var NeferwlCaptureExclusionV1Interface *server.Interface | |
| 18 | + | var NeferwlCaptureLayerV1Interface *server.Interface | |
| 19 | + | ||
| 20 | + | func init() { | |
| 21 | + | NeferwlImageCaptureSourceManagerV1Interface = &server.Interface{Name: "neferwl_image_capture_source_manager_v1", Version: 1} | |
| 22 | + | NeferwlWorkspaceFrameV1Interface = &server.Interface{Name: "neferwl_workspace_frame_v1", Version: 1} | |
| 23 | + | NeferwlCaptureExclusionManagerV1Interface = &server.Interface{Name: "neferwl_capture_exclusion_manager_v1", Version: 1} | |
| 24 | + | NeferwlCaptureExclusionV1Interface = &server.Interface{Name: "neferwl_capture_exclusion_v1", Version: 1} | |
| 25 | + | NeferwlCaptureLayerV1Interface = &server.Interface{Name: "neferwl_capture_layer_v1", Version: 1} | |
| 26 | + | NeferwlImageCaptureSourceManagerV1Interface.Requests = []server.Message{ | |
| 27 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 28 | + | {Name: "create_workspace_source", Signature: "no", Types: []*server.Interface{extimagecapturesource.ExtImageCaptureSourceV1Interface, extworkspace.ExtWorkspaceHandleV1Interface}}, | |
| 29 | + | {Name: "create_output_region_source", Signature: "noiiii", Types: []*server.Interface{extimagecapturesource.ExtImageCaptureSourceV1Interface, wayland.OutputInterface, nil, nil, nil, nil}}, | |
| 30 | + | {Name: "get_workspace_frame", Signature: "no", Types: []*server.Interface{NeferwlWorkspaceFrameV1Interface, extworkspace.ExtWorkspaceHandleV1Interface}}, | |
| 31 | + | } | |
| 32 | + | NeferwlImageCaptureSourceManagerV1Interface.Events = []server.Message{} | |
| 33 | + | NeferwlWorkspaceFrameV1Interface.Requests = []server.Message{ | |
| 34 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 35 | + | } | |
| 36 | + | NeferwlWorkspaceFrameV1Interface.Events = []server.Message{ | |
| 37 | + | {Name: "frame", Signature: "iiii", Types: []*server.Interface{nil, nil, nil, nil}}, | |
| 38 | + | } | |
| 39 | + | NeferwlCaptureExclusionManagerV1Interface.Requests = []server.Message{ | |
| 40 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 41 | + | {Name: "get_exclusion", Signature: "no", Types: []*server.Interface{NeferwlCaptureExclusionV1Interface, extimagecopycapture.ExtImageCopyCaptureSessionV1Interface}}, | |
| 42 | + | {Name: "attach_surface", Signature: "nso", Types: []*server.Interface{NeferwlCaptureLayerV1Interface, nil, wayland.SurfaceInterface}}, | |
| 43 | + | } | |
| 44 | + | NeferwlCaptureExclusionManagerV1Interface.Events = []server.Message{} | |
| 45 | + | NeferwlCaptureExclusionV1Interface.Requests = []server.Message{ | |
| 46 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 47 | + | } | |
| 48 | + | NeferwlCaptureExclusionV1Interface.Events = []server.Message{ | |
| 49 | + | {Name: "token", Signature: "s", Types: []*server.Interface{nil}}, | |
| 50 | + | {Name: "failed", Signature: "u", Types: []*server.Interface{nil}}, | |
| 51 | + | } | |
| 52 | + | NeferwlCaptureLayerV1Interface.Requests = []server.Message{ | |
| 53 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 54 | + | } | |
| 55 | + | NeferwlCaptureLayerV1Interface.Events = []server.Message{ | |
| 56 | + | {Name: "attached", Signature: "", Types: []*server.Interface{}}, | |
| 57 | + | {Name: "failed", Signature: "u", Types: []*server.Interface{nil}}, | |
| 58 | + | {Name: "detached", Signature: "u", Types: []*server.Interface{nil}}, | |
| 59 | + | } | |
| 60 | + | if err := server.NewInterfaces(NeferwlImageCaptureSourceManagerV1Interface, NeferwlWorkspaceFrameV1Interface, NeferwlCaptureExclusionManagerV1Interface, NeferwlCaptureExclusionV1Interface, NeferwlCaptureLayerV1Interface); err != nil { | |
| 61 | + | panic(err) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // NeferwlImageCaptureSourceManagerV1 workspace and output-region capture sources. | |
| 66 | + | type NeferwlImageCaptureSourceManagerV1 struct{ *server.Resource } | |
| 67 | + | ||
| 68 | + | func WrapNeferwlImageCaptureSourceManagerV1(r *server.Resource) *NeferwlImageCaptureSourceManagerV1 { | |
| 69 | + | if r == nil { | |
| 70 | + | return nil | |
| 71 | + | } | |
| 72 | + | return &NeferwlImageCaptureSourceManagerV1{r} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | const NeferwlImageCaptureSourceManagerV1RequestDestroy uint32 = 0 | |
| 76 | + | const NeferwlImageCaptureSourceManagerV1RequestCreateWorkspaceSource uint32 = 1 | |
| 77 | + | const NeferwlImageCaptureSourceManagerV1RequestCreateOutputRegionSource uint32 = 2 | |
| 78 | + | const NeferwlImageCaptureSourceManagerV1RequestGetWorkspaceFrame uint32 = 3 | |
| 79 | + | ||
| 80 | + | // NeferwlImageCaptureSourceManagerV1Error . | |
| 81 | + | type NeferwlImageCaptureSourceManagerV1Error uint32 | |
| 82 | + | ||
| 83 | + | const ( | |
| 84 | + | NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion NeferwlImageCaptureSourceManagerV1Error = 0 | |
| 85 | + | ) | |
| 86 | + | ||
| 87 | + | // NeferwlImageCaptureSourceManagerV1Handler handles neferwl_image_capture_source_manager_v1 requests. | |
| 88 | + | type NeferwlImageCaptureSourceManagerV1Handler interface { | |
| 89 | + | Destroy(self *NeferwlImageCaptureSourceManagerV1) | |
| 90 | + | CreateWorkspaceSource(self *NeferwlImageCaptureSourceManagerV1, source uint32, workspace *extworkspace.ExtWorkspaceHandleV1) | |
| 91 | + | CreateOutputRegionSource(self *NeferwlImageCaptureSourceManagerV1, source uint32, output *wayland.Output, x int32, y int32, width int32, height int32) | |
| 92 | + | GetWorkspaceFrame(self *NeferwlImageCaptureSourceManagerV1, id uint32, workspace *extworkspace.ExtWorkspaceHandleV1) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func NewNeferwlImageCaptureSourceManagerV1(c server.Client, version int32, id uint32, h NeferwlImageCaptureSourceManagerV1Handler) (*NeferwlImageCaptureSourceManagerV1, error) { | |
| 96 | + | if h == nil { | |
| 97 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_image_capture_source_manager_v1") | |
| 98 | + | } | |
| 99 | + | var self *NeferwlImageCaptureSourceManagerV1 | |
| 100 | + | r, err := c.CreateResource(NeferwlImageCaptureSourceManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 101 | + | switch opcode { | |
| 102 | + | case 0: | |
| 103 | + | h.Destroy(self) | |
| 104 | + | self.Destroy() | |
| 105 | + | case 1: | |
| 106 | + | h.CreateWorkspaceSource(self, args[0].NewID(), extworkspace.WrapExtWorkspaceHandleV1(args[1].Resource())) | |
| 107 | + | case 2: | |
| 108 | + | h.CreateOutputRegionSource(self, args[0].NewID(), wayland.WrapOutput(args[1].Resource()), args[2].Int(), args[3].Int(), args[4].Int(), args[5].Int()) | |
| 109 | + | case 3: | |
| 110 | + | h.GetWorkspaceFrame(self, args[0].NewID(), extworkspace.WrapExtWorkspaceHandleV1(args[1].Resource())) | |
| 111 | + | } | |
| 112 | + | }) | |
| 113 | + | if err != nil { | |
| 114 | + | return nil, err | |
| 115 | + | } | |
| 116 | + | self = WrapNeferwlImageCaptureSourceManagerV1(r) | |
| 117 | + | r.Data = self | |
| 118 | + | return self, nil | |
| 119 | + | } | |
| 120 | + | func NewNeferwlImageCaptureSourceManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 121 | + | return d.CreateGlobal(NeferwlImageCaptureSourceManagerV1Interface, version, bind) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // NeferwlWorkspaceFrameV1 the frame of one workspace. | |
| 125 | + | type NeferwlWorkspaceFrameV1 struct{ *server.Resource } | |
| 126 | + | ||
| 127 | + | func WrapNeferwlWorkspaceFrameV1(r *server.Resource) *NeferwlWorkspaceFrameV1 { | |
| 128 | + | if r == nil { | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | return &NeferwlWorkspaceFrameV1{r} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | const NeferwlWorkspaceFrameV1RequestDestroy uint32 = 0 | |
| 135 | + | const NeferwlWorkspaceFrameV1EventFrame uint32 = 0 | |
| 136 | + | ||
| 137 | + | // NeferwlWorkspaceFrameV1Handler handles neferwl_workspace_frame_v1 requests. | |
| 138 | + | type NeferwlWorkspaceFrameV1Handler interface { | |
| 139 | + | Destroy(self *NeferwlWorkspaceFrameV1) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func NewNeferwlWorkspaceFrameV1(c server.Client, version int32, id uint32, h NeferwlWorkspaceFrameV1Handler) (*NeferwlWorkspaceFrameV1, error) { | |
| 143 | + | if h == nil { | |
| 144 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_workspace_frame_v1") | |
| 145 | + | } | |
| 146 | + | var self *NeferwlWorkspaceFrameV1 | |
| 147 | + | r, err := c.CreateResource(NeferwlWorkspaceFrameV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 148 | + | switch opcode { | |
| 149 | + | case 0: | |
| 150 | + | h.Destroy(self) | |
| 151 | + | self.Destroy() | |
| 152 | + | } | |
| 153 | + | }) | |
| 154 | + | if err != nil { | |
| 155 | + | return nil, err | |
| 156 | + | } | |
| 157 | + | self = WrapNeferwlWorkspaceFrameV1(r) | |
| 158 | + | r.Data = self | |
| 159 | + | return self, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // SendFrame the workspace frame. | |
| 163 | + | func (self *NeferwlWorkspaceFrameV1) SendFrame(x int32, y int32, width int32, height int32) { | |
| 164 | + | self.PostEvent(NeferwlWorkspaceFrameV1EventFrame, server.Int(x), server.Int(y), server.Int(width), server.Int(height)) | |
| 165 | + | } | |
| 166 | + | func NewNeferwlWorkspaceFrameV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 167 | + | return d.CreateGlobal(NeferwlWorkspaceFrameV1Interface, version, bind) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // NeferwlCaptureExclusionManagerV1 leave a client's own surfaces out of its captures. | |
| 171 | + | type NeferwlCaptureExclusionManagerV1 struct{ *server.Resource } | |
| 172 | + | ||
| 173 | + | func WrapNeferwlCaptureExclusionManagerV1(r *server.Resource) *NeferwlCaptureExclusionManagerV1 { | |
| 174 | + | if r == nil { | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | return &NeferwlCaptureExclusionManagerV1{r} | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | const NeferwlCaptureExclusionManagerV1RequestDestroy uint32 = 0 | |
| 181 | + | const NeferwlCaptureExclusionManagerV1RequestGetExclusion uint32 = 1 | |
| 182 | + | const NeferwlCaptureExclusionManagerV1RequestAttachSurface uint32 = 2 | |
| 183 | + | ||
| 184 | + | // NeferwlCaptureExclusionManagerV1Error . | |
| 185 | + | type NeferwlCaptureExclusionManagerV1Error uint32 | |
| 186 | + | ||
| 187 | + | const ( | |
| 188 | + | NeferwlCaptureExclusionManagerV1ErrorSurfaceRole NeferwlCaptureExclusionManagerV1Error = 0 | |
| 189 | + | NeferwlCaptureExclusionManagerV1ErrorInvalidLayer NeferwlCaptureExclusionManagerV1Error = 1 | |
| 190 | + | NeferwlCaptureExclusionManagerV1ErrorExclusiveKeyboard NeferwlCaptureExclusionManagerV1Error = 2 | |
| 191 | + | NeferwlCaptureExclusionManagerV1ErrorInvalidSurface NeferwlCaptureExclusionManagerV1Error = 3 | |
| 192 | + | NeferwlCaptureExclusionManagerV1ErrorAlreadyExcluded NeferwlCaptureExclusionManagerV1Error = 4 | |
| 193 | + | ) | |
| 194 | + | ||
| 195 | + | // NeferwlCaptureExclusionManagerV1Handler handles neferwl_capture_exclusion_manager_v1 requests. | |
| 196 | + | type NeferwlCaptureExclusionManagerV1Handler interface { | |
| 197 | + | Destroy(self *NeferwlCaptureExclusionManagerV1) | |
| 198 | + | GetExclusion(self *NeferwlCaptureExclusionManagerV1, id uint32, session *extimagecopycapture.ExtImageCopyCaptureSessionV1) | |
| 199 | + | AttachSurface(self *NeferwlCaptureExclusionManagerV1, id uint32, token string, surface *wayland.Surface) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func NewNeferwlCaptureExclusionManagerV1(c server.Client, version int32, id uint32, h NeferwlCaptureExclusionManagerV1Handler) (*NeferwlCaptureExclusionManagerV1, error) { | |
| 203 | + | if h == nil { | |
| 204 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_exclusion_manager_v1") | |
| 205 | + | } | |
| 206 | + | var self *NeferwlCaptureExclusionManagerV1 | |
| 207 | + | r, err := c.CreateResource(NeferwlCaptureExclusionManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 208 | + | switch opcode { | |
| 209 | + | case 0: | |
| 210 | + | h.Destroy(self) | |
| 211 | + | self.Destroy() | |
| 212 | + | case 1: | |
| 213 | + | h.GetExclusion(self, args[0].NewID(), extimagecopycapture.WrapExtImageCopyCaptureSessionV1(args[1].Resource())) | |
| 214 | + | case 2: | |
| 215 | + | h.AttachSurface(self, args[0].NewID(), args[1].String(), wayland.WrapSurface(args[2].Resource())) | |
| 216 | + | } | |
| 217 | + | }) | |
| 218 | + | if err != nil { | |
| 219 | + | return nil, err | |
| 220 | + | } | |
| 221 | + | self = WrapNeferwlCaptureExclusionManagerV1(r) | |
| 222 | + | r.Data = self | |
| 223 | + | return self, nil | |
| 224 | + | } | |
| 225 | + | func NewNeferwlCaptureExclusionManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 226 | + | return d.CreateGlobal(NeferwlCaptureExclusionManagerV1Interface, version, bind) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // NeferwlCaptureExclusionV1 the exclusion of one capture session. | |
| 230 | + | type NeferwlCaptureExclusionV1 struct{ *server.Resource } | |
| 231 | + | ||
| 232 | + | func WrapNeferwlCaptureExclusionV1(r *server.Resource) *NeferwlCaptureExclusionV1 { | |
| 233 | + | if r == nil { | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | return &NeferwlCaptureExclusionV1{r} | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | const NeferwlCaptureExclusionV1RequestDestroy uint32 = 0 | |
| 240 | + | const NeferwlCaptureExclusionV1EventToken uint32 = 0 | |
| 241 | + | const NeferwlCaptureExclusionV1EventFailed uint32 = 1 | |
| 242 | + | ||
| 243 | + | // NeferwlCaptureExclusionV1Failure . | |
| 244 | + | type NeferwlCaptureExclusionV1Failure uint32 | |
| 245 | + | ||
| 246 | + | const ( | |
| 247 | + | NeferwlCaptureExclusionV1FailureBusy NeferwlCaptureExclusionV1Failure = 0 | |
| 248 | + | NeferwlCaptureExclusionV1FailureSessionStopped NeferwlCaptureExclusionV1Failure = 1 | |
| 249 | + | ) | |
| 250 | + | ||
| 251 | + | // NeferwlCaptureExclusionV1Handler handles neferwl_capture_exclusion_v1 requests. | |
| 252 | + | type NeferwlCaptureExclusionV1Handler interface { | |
| 253 | + | Destroy(self *NeferwlCaptureExclusionV1) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func NewNeferwlCaptureExclusionV1(c server.Client, version int32, id uint32, h NeferwlCaptureExclusionV1Handler) (*NeferwlCaptureExclusionV1, error) { | |
| 257 | + | if h == nil { | |
| 258 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_exclusion_v1") | |
| 259 | + | } | |
| 260 | + | var self *NeferwlCaptureExclusionV1 | |
| 261 | + | r, err := c.CreateResource(NeferwlCaptureExclusionV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 262 | + | switch opcode { | |
| 263 | + | case 0: | |
| 264 | + | h.Destroy(self) | |
| 265 | + | self.Destroy() | |
| 266 | + | } | |
| 267 | + | }) | |
| 268 | + | if err != nil { | |
| 269 | + | return nil, err | |
| 270 | + | } | |
| 271 | + | self = WrapNeferwlCaptureExclusionV1(r) | |
| 272 | + | r.Data = self | |
| 273 | + | return self, nil | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // SendToken the attachment token. | |
| 277 | + | func (self *NeferwlCaptureExclusionV1) SendToken(token string) { | |
| 278 | + | var p runtime.Pinner | |
| 279 | + | defer p.Unpin() | |
| 280 | + | self.PostEvent(NeferwlCaptureExclusionV1EventToken, server.String(&p, token, false)) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // SendFailed no exclusion. | |
| 284 | + | func (self *NeferwlCaptureExclusionV1) SendFailed(reason uint32) { | |
| 285 | + | self.PostEvent(NeferwlCaptureExclusionV1EventFailed, server.Uint(reason)) | |
| 286 | + | } | |
| 287 | + | func NewNeferwlCaptureExclusionV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 288 | + | return d.CreateGlobal(NeferwlCaptureExclusionV1Interface, version, bind) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // NeferwlCaptureLayerV1 a layer surface attached to an exclusion. | |
| 292 | + | type NeferwlCaptureLayerV1 struct{ *server.Resource } | |
| 293 | + | ||
| 294 | + | func WrapNeferwlCaptureLayerV1(r *server.Resource) *NeferwlCaptureLayerV1 { | |
| 295 | + | if r == nil { | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | return &NeferwlCaptureLayerV1{r} | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | const NeferwlCaptureLayerV1RequestDestroy uint32 = 0 | |
| 302 | + | const NeferwlCaptureLayerV1EventAttached uint32 = 0 | |
| 303 | + | const NeferwlCaptureLayerV1EventFailed uint32 = 1 | |
| 304 | + | const NeferwlCaptureLayerV1EventDetached uint32 = 2 | |
| 305 | + | ||
| 306 | + | // NeferwlCaptureLayerV1Failure . | |
| 307 | + | type NeferwlCaptureLayerV1Failure uint32 | |
| 308 | + | ||
| 309 | + | const ( | |
| 310 | + | NeferwlCaptureLayerV1FailureUnknownToken NeferwlCaptureLayerV1Failure = 0 | |
| 311 | + | NeferwlCaptureLayerV1FailureUnauthorized NeferwlCaptureLayerV1Failure = 1 | |
| 312 | + | NeferwlCaptureLayerV1FailureTooManyLayers NeferwlCaptureLayerV1Failure = 2 | |
| 313 | + | NeferwlCaptureLayerV1FailureAlreadyAttached NeferwlCaptureLayerV1Failure = 3 | |
| 314 | + | NeferwlCaptureLayerV1FailureExclusionEnded NeferwlCaptureLayerV1Failure = 4 | |
| 315 | + | NeferwlCaptureLayerV1FailureLayerDestroyed NeferwlCaptureLayerV1Failure = 5 | |
| 316 | + | ) | |
| 317 | + | ||
| 318 | + | // NeferwlCaptureLayerV1DetachReason . | |
| 319 | + | type NeferwlCaptureLayerV1DetachReason uint32 | |
| 320 | + | ||
| 321 | + | const ( | |
| 322 | + | NeferwlCaptureLayerV1DetachReasonExclusionEnded NeferwlCaptureLayerV1DetachReason = 0 | |
| 323 | + | NeferwlCaptureLayerV1DetachReasonLayerDestroyed NeferwlCaptureLayerV1DetachReason = 1 | |
| 324 | + | ) | |
| 325 | + | ||
| 326 | + | // NeferwlCaptureLayerV1Handler handles neferwl_capture_layer_v1 requests. | |
| 327 | + | type NeferwlCaptureLayerV1Handler interface { | |
| 328 | + | Destroy(self *NeferwlCaptureLayerV1) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func NewNeferwlCaptureLayerV1(c server.Client, version int32, id uint32, h NeferwlCaptureLayerV1Handler) (*NeferwlCaptureLayerV1, error) { | |
| 332 | + | if h == nil { | |
| 333 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_layer_v1") | |
| 334 | + | } | |
| 335 | + | var self *NeferwlCaptureLayerV1 | |
| 336 | + | r, err := c.CreateResource(NeferwlCaptureLayerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 337 | + | switch opcode { | |
| 338 | + | case 0: | |
| 339 | + | h.Destroy(self) | |
| 340 | + | self.Destroy() | |
| 341 | + | } | |
| 342 | + | }) | |
| 343 | + | if err != nil { | |
| 344 | + | return nil, err | |
| 345 | + | } | |
| 346 | + | self = WrapNeferwlCaptureLayerV1(r) | |
| 347 | + | r.Data = self | |
| 348 | + | return self, nil | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // SendAttached the layer is excluded. | |
| 352 | + | func (self *NeferwlCaptureLayerV1) SendAttached() { | |
| 353 | + | self.PostEvent(NeferwlCaptureLayerV1EventAttached) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // SendFailed the attachment was refused. | |
| 357 | + | func (self *NeferwlCaptureLayerV1) SendFailed(reason uint32) { | |
| 358 | + | self.PostEvent(NeferwlCaptureLayerV1EventFailed, server.Uint(reason)) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // SendDetached the layer left the exclusion. | |
| 362 | + | func (self *NeferwlCaptureLayerV1) SendDetached(reason uint32) { | |
| 363 | + | self.PostEvent(NeferwlCaptureLayerV1EventDetached, server.Uint(reason)) | |
| 364 | + | } | |
| 365 | + | func NewNeferwlCaptureLayerV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 366 | + | return d.CreateGlobal(NeferwlCaptureLayerV1Interface, version, bind) | |
| 367 | + | } | |
| 368 | + | func resourceOf(v interface{ GetResource() *server.Resource }) *server.Resource { | |
| 369 | + | if v == nil { | |
| 370 | + | return nil | |
| 371 | + | } | |
| 372 | + | return v.GetResource() | |
| 373 | + | } | |
| 374 | + | func (self *NeferwlImageCaptureSourceManagerV1) GetResource() *server.Resource { | |
| 375 | + | if self == nil { | |
| 376 | + | return nil | |
| 377 | + | } | |
| 378 | + | return self.Resource | |
| 379 | + | } | |
| 380 | + | func (self *NeferwlWorkspaceFrameV1) GetResource() *server.Resource { | |
| 381 | + | if self == nil { | |
| 382 | + | return nil | |
| 383 | + | } | |
| 384 | + | return self.Resource | |
| 385 | + | } | |
| 386 | + | func (self *NeferwlCaptureExclusionManagerV1) GetResource() *server.Resource { | |
| 387 | + | if self == nil { | |
| 388 | + | return nil | |
| 389 | + | } | |
| 390 | + | return self.Resource | |
| 391 | + | } | |
| 392 | + | func (self *NeferwlCaptureExclusionV1) GetResource() *server.Resource { | |
| 393 | + | if self == nil { | |
| 394 | + | return nil | |
| 395 | + | } | |
| 396 | + | return self.Resource | |
| 397 | + | } | |
| 398 | + | func (self *NeferwlCaptureLayerV1) GetResource() *server.Resource { | |
| 399 | + | if self == nil { | |
| 400 | + | return nil | |
| 401 | + | } | |
| 402 | + | return self.Resource | |
| 403 | + | } | |
A internal/adapters/wayland/testserver/outputs.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package testserver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 6 | + | "github.com/bnema/purego-libwayland/server" | |
| 7 | + | "golang.org/x/sys/unix" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func (s *Server) addOutput(spec OutputSpec, must func(error)) { | |
| 11 | + | o := &output{spec: spec} | |
| 12 | + | v := spec.Version | |
| 13 | + | if v == 0 { | |
| 14 | + | v = 4 | |
| 15 | + | } | |
| 16 | + | g, err := s.d.AddGlobal(wayland.OutputInterface, int32(v), func(c server.Client, ver, id uint32) { | |
| 17 | + | res, err := wayland.NewOutput(c, int32(ver), id, outputHandler{}) | |
| 18 | + | if err != nil { | |
| 19 | + | must(err) | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | o.res = append(o.res, res) | |
| 23 | + | res.SendGeometry(0, 0, 0, 0, 0, "test", "test", 0) | |
| 24 | + | res.SendMode(1, int32(spec.Width), int32(spec.Height), 60000) | |
| 25 | + | if ver >= 2 { | |
| 26 | + | scale := spec.Scale | |
| 27 | + | if scale == 0 { | |
| 28 | + | scale = 1 | |
| 29 | + | } | |
| 30 | + | res.SendScale(scale) | |
| 31 | + | } | |
| 32 | + | if ver >= 4 { | |
| 33 | + | res.SendName(spec.Name) | |
| 34 | + | } | |
| 35 | + | if ver >= 2 { | |
| 36 | + | res.SendDone() | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | must(err) | |
| 40 | + | o.global = g | |
| 41 | + | s.outputs = append(s.outputs, o) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type outputHandler struct{} | |
| 45 | + | ||
| 46 | + | func (outputHandler) Release(*wayland.Output) {} | |
| 47 | + | ||
| 48 | + | func (s *Server) outputOf(r *server.Resource) *output { | |
| 49 | + | for _, o := range s.outputs { | |
| 50 | + | for _, res := range o.res { | |
| 51 | + | if res.Resource == r { | |
| 52 | + | return o | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // RemoveOutput withdraws the named output's global and stops its sessions. | |
| 60 | + | func (s *Server) RemoveOutput(name string) { | |
| 61 | + | s.Do(func() { | |
| 62 | + | for _, o := range s.outputs { | |
| 63 | + | if o.spec.Name == name && !o.removed { | |
| 64 | + | o.removed = true | |
| 65 | + | o.global.Remove() | |
| 66 | + | for _, c := range s.sessions { | |
| 67 | + | if c.out == o { | |
| 68 | + | c.stop() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // pool is one wl_shm_pool mapping, shared by the buffers made from it. | |
| 77 | + | type pool struct { | |
| 78 | + | data []byte | |
| 79 | + | refs int | |
| 80 | + | destroyed bool | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (p *pool) release() { | |
| 84 | + | if p.refs == 0 && p.destroyed && p.data != nil { | |
| 85 | + | _ = unix.Munmap(p.data) | |
| 86 | + | p.data = nil | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type buffer struct { | |
| 91 | + | p *pool | |
| 92 | + | offset int | |
| 93 | + | width, height, stride, format uint32 | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (b *buffer) bytes() []byte { | |
| 97 | + | n := int(b.stride) * int(b.height) | |
| 98 | + | if b.p.data == nil || b.offset+n > len(b.p.data) { | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | return b.p.data[b.offset : b.offset+n] | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type shmHandler struct{ s *Server } | |
| 105 | + | ||
| 106 | + | func (h shmHandler) CreatePool(self *wayland.Shm, id uint32, fd int, size int32) { | |
| 107 | + | defer unix.Close(fd) | |
| 108 | + | data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 109 | + | if err != nil { | |
| 110 | + | self.PostError(0, "mmap failed") | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | _, _ = wayland.NewShmPool(self.Client(), 1, id, &poolHandler{s: h.s, p: &pool{data: data}}) | |
| 114 | + | } | |
| 115 | + | func (shmHandler) Release(*wayland.Shm) {} | |
| 116 | + | ||
| 117 | + | type poolHandler struct { | |
| 118 | + | s *Server | |
| 119 | + | p *pool | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (h *poolHandler) CreateBuffer(self *wayland.ShmPool, id uint32, offset, width, height, stride int32, format uint32) { | |
| 123 | + | b := &buffer{p: h.p, offset: int(offset), width: uint32(width), height: uint32(height), stride: uint32(stride), format: format} | |
| 124 | + | res, err := wayland.NewBuffer(self.Client(), 1, id, bufferHandler{h.s, b}) | |
| 125 | + | if err != nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | h.p.refs++ | |
| 129 | + | h.s.bufs[res.Resource] = b | |
| 130 | + | h.s.stats.Buffers++ | |
| 131 | + | res.OnDestroy = func() { | |
| 132 | + | delete(h.s.bufs, res.Resource) | |
| 133 | + | h.s.stats.DestroyedBuffers++ | |
| 134 | + | h.p.refs-- | |
| 135 | + | h.p.release() | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | func (h *poolHandler) Destroy(*wayland.ShmPool) { h.p.destroyed = true; h.p.release() } | |
| 139 | + | func (h *poolHandler) Resize(*wayland.ShmPool, int32) {} | |
| 140 | + | ||
| 141 | + | type bufferHandler struct { | |
| 142 | + | s *Server | |
| 143 | + | b *buffer | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (bufferHandler) Destroy(*wayland.Buffer) {} | |
| 147 | + | ||
| 148 | + | type outputSourceManager struct{ s *Server } | |
| 149 | + | ||
| 150 | + | func (m outputSourceManager) CreateSource(self *extimagecapturesource.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) { | |
| 151 | + | m.s.newSource(self.Client(), id, &source{kind: "output", out: m.s.outputOf(out.Resource)}) | |
| 152 | + | } | |
| 153 | + | func (outputSourceManager) Destroy(*extimagecapturesource.ExtOutputImageCaptureSourceManagerV1) {} | |
| 154 | + | ||
| 155 | + | type source struct { | |
| 156 | + | kind string // output, region, workspace | |
| 157 | + | out *output | |
| 158 | + | region [4]int32 | |
| 159 | + | ws *workspace | |
| 160 | + | gone bool // the workspace handle was already removed | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (s *Server) newSource(c server.Client, id uint32, src *source) { | |
| 164 | + | res, err := extimagecapturesource.NewExtImageCaptureSourceV1(c, 1, id, sourceHandler{}) | |
| 165 | + | if err != nil { | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | s.sources[res.Resource] = src | |
| 169 | + | res.OnDestroy = func() { delete(s.sources, res.Resource) } // sessions keep their own pointer | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | type sourceHandler struct{} | |
| 173 | + | ||
| 174 | + | func (sourceHandler) Destroy(*extimagecapturesource.ExtImageCaptureSourceV1) {} | |
A internal/adapters/wayland/testserver/server.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | // Package testserver is a minimal in-process Wayland compositor for tests: a | |
| 2 | + | // real libwayland server (purego-libwayland) that speaks wl_output, wl_shm, | |
| 3 | + | // ext-image-capture-source-v1, ext-image-copy-capture-v1, ext-workspace-v1 and | |
| 4 | + | // NeferWL's capture extension. It is a protocol peer, not a substitute for a | |
| 5 | + | // project type: clients connect to its socket like to any compositor. | |
| 6 | + | // | |
| 7 | + | // All server state is touched only on the display goroutine: handlers run | |
| 8 | + | // there, and tests reach it through Do. | |
| 9 | + | package testserver | |
| 10 | + | ||
| 11 | + | import ( | |
| 12 | + | "context" | |
| 13 | + | "os" | |
| 14 | + | "path/filepath" | |
| 15 | + | "testing" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |
| 18 | + | "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 19 | + | "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 20 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 21 | + | "github.com/bnema/purego-libwayland/server" | |
| 22 | + | "golang.org/x/sys/unix" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver/neferwl" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Token is the exclusion token the server hands out. | |
| 28 | + | const Token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 29 | + | ||
| 30 | + | // wl_shm formats. | |
| 31 | + | const ( | |
| 32 | + | ARGB8888 = 0 | |
| 33 | + | XRGB8888 = 1 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // OutputSpec describes one wl_output. The capture buffer of an output source | |
| 37 | + | // is its mode size. | |
| 38 | + | type OutputSpec struct { | |
| 39 | + | Name string | |
| 40 | + | Width, Height uint32 | |
| 41 | + | Scale int32 | |
| 42 | + | Version uint32 // 0 selects 4 | |
| 43 | + | // LogicalWidth/Height, when set, are announced through xdg-output. | |
| 44 | + | LogicalWidth, LogicalHeight int32 | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // WorkspaceSpec describes one ext-workspace handle; all of them live in one | |
| 48 | + | // group that holds every output. | |
| 49 | + | type WorkspaceSpec struct { | |
| 50 | + | ID string // empty: a temporary workspace, no id event | |
| 51 | + | Name string | |
| 52 | + | Output string // the output whose buffer size a workspace source has | |
| 53 | + | Active bool | |
| 54 | + | Hidden bool | |
| 55 | + | // Frame is the output-local logical rectangle (x, y, width, height) that | |
| 56 | + | // neferwl_workspace_frame_v1 reports; zero reports nothing. | |
| 57 | + | Frame [4]int32 | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Config selects what the server offers. | |
| 61 | + | type Config struct { | |
| 62 | + | Outputs []OutputSpec // default: one 8x4 output "TEST-1" | |
| 63 | + | // NoCopyCapture leaves out ext-image-copy-capture and the output source. | |
| 64 | + | NoCopyCapture bool | |
| 65 | + | Formats []uint32 // shm formats announced, default XRGB8888 | |
| 66 | + | // NoShmFormat announces size and done but no shm_format. | |
| 67 | + | NoShmFormat bool | |
| 68 | + | // Hold keeps every captured frame pending until Release. | |
| 69 | + | Hold bool | |
| 70 | + | // HoldAfterFirst answers the first frame of each session at once and holds | |
| 71 | + | // the following ones until Release, like a compositor that waits for | |
| 72 | + | // damage on a static screen. | |
| 73 | + | HoldAfterFirst bool | |
| 74 | + | // Workspaces enables ext-workspace-v1 when non-nil (possibly empty). | |
| 75 | + | Workspaces []WorkspaceSpec | |
| 76 | + | HasWorkspaces bool | |
| 77 | + | // NeferwlSource and NeferwlExclusion enable the extension's two globals. | |
| 78 | + | NeferwlSource bool | |
| 79 | + | NeferwlExclusion bool | |
| 80 | + | // ExclusionFail answers get_exclusion with failed(ExclusionFailReason-1) | |
| 81 | + | // when non-zero. | |
| 82 | + | ExclusionFail uint32 | |
| 83 | + | // AttachFail answers attach_surface with failed(AttachFail-1) when non-zero. | |
| 84 | + | AttachFail uint32 | |
| 85 | + | // AttachSilent never answers attach_surface. | |
| 86 | + | AttachSilent bool | |
| 87 | + | // StopAtCreate answers every create_session with stopped and no | |
| 88 | + | // constraints, like a compositor that refuses the client. | |
| 89 | + | StopAtCreate bool | |
| 90 | + | // Transform is reported by every ready frame (wl_output.transform). | |
| 91 | + | Transform uint32 | |
| 92 | + | // FailFrames answers that many capture requests with failed(FailReason). | |
| 93 | + | FailFrames int | |
| 94 | + | FailReason uint32 | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Stats are counters observed by tests. | |
| 98 | + | type Stats struct { | |
| 99 | + | Sessions, Frames, Captures, Ready int | |
| 100 | + | DestroyedSessions int | |
| 101 | + | Buffers, DestroyedBuffers int | |
| 102 | + | LastSource string // "output", "region", "workspace" | |
| 103 | + | LastRegion [4]int32 | |
| 104 | + | Exclusions int | |
| 105 | + | Attached []string | |
| 106 | + | DestroyedExclusions int | |
| 107 | + | WorkspaceFrames int // neferwl_workspace_frame_v1 objects created | |
| 108 | + | DestroyedWorkspaceFrames int | |
| 109 | + | DestroyedXdgOutputs int | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Server is a running test compositor. | |
| 113 | + | type Server struct { | |
| 114 | + | // Path is the socket path for wayland.New. | |
| 115 | + | Path string | |
| 116 | + | ||
| 117 | + | d *server.Display | |
| 118 | + | cfg Config | |
| 119 | + | ||
| 120 | + | outputs []*output | |
| 121 | + | bufs map[*server.Resource]*buffer | |
| 122 | + | sources map[*server.Resource]*source | |
| 123 | + | sessions []*session | |
| 124 | + | workspaces []*workspace | |
| 125 | + | wsMgrs []*wsManager | |
| 126 | + | layers []*server.Resource | |
| 127 | + | frames []*frameObject | |
| 128 | + | stats Stats | |
| 129 | + | held []*frame | |
| 130 | + | seq uint32 | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | type output struct { | |
| 134 | + | spec OutputSpec | |
| 135 | + | global *server.Global | |
| 136 | + | res []*wayland.Output | |
| 137 | + | removed bool | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Start runs a compositor on a fresh socket until the test ends. | |
| 141 | + | func Start(t testing.TB, cfg Config) *Server { | |
| 142 | + | t.Helper() | |
| 143 | + | if len(cfg.Outputs) == 0 { | |
| 144 | + | cfg.Outputs = []OutputSpec{{Name: "TEST-1", Width: 8, Height: 4, Scale: 1}} | |
| 145 | + | } | |
| 146 | + | if len(cfg.Formats) == 0 { | |
| 147 | + | cfg.Formats = []uint32{XRGB8888} | |
| 148 | + | } | |
| 149 | + | d, err := server.NewDisplay() | |
| 150 | + | if err != nil { | |
| 151 | + | // NEFERCAP_REQUIRE_WAYLAND=1 (set by make test and make race) turns a | |
| 152 | + | // missing libwayland-server into a failure, so the suite cannot pass | |
| 153 | + | // without its protocol tests. | |
| 154 | + | if os.Getenv("NEFERCAP_REQUIRE_WAYLAND") == "1" { | |
| 155 | + | t.Fatalf("libwayland-server unavailable: %v", err) | |
| 156 | + | } | |
| 157 | + | t.Skipf("libwayland-server unavailable: %v", err) | |
| 158 | + | } | |
| 159 | + | dir, err := filepath.EvalSymlinks(t.TempDir()) | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | path := filepath.Join(dir, "wl-test") | |
| 164 | + | fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatal(err) | |
| 167 | + | } | |
| 168 | + | if err := unix.Bind(fd, &unix.SockaddrUnix{Name: path}); err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | if err := unix.Listen(fd, 16); err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | if err := d.AddSocketFD(fd); err != nil { // libwayland owns fd now | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | s := &Server{Path: path, d: d, cfg: cfg, bufs: map[*server.Resource]*buffer{}, sources: map[*server.Resource]*source{}} | |
| 178 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 179 | + | done := make(chan error, 1) | |
| 180 | + | go func() { done <- d.Run(ctx) }() | |
| 181 | + | if !d.Do(func() { s.setup(t) }) { | |
| 182 | + | t.Fatal("test compositor stopped") | |
| 183 | + | } | |
| 184 | + | t.Cleanup(func() { | |
| 185 | + | cancel() | |
| 186 | + | <-done | |
| 187 | + | }) | |
| 188 | + | return s | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Do runs f on the display goroutine, where server state may be touched. | |
| 192 | + | func (s *Server) Do(f func()) { | |
| 193 | + | if !s.d.Do(f) { | |
| 194 | + | // stopped: nothing to observe | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Stats returns a snapshot of the counters. | |
| 200 | + | func (s *Server) Stats() Stats { | |
| 201 | + | var st Stats | |
| 202 | + | s.Do(func() { | |
| 203 | + | st = s.stats | |
| 204 | + | st.Attached = append([]string(nil), s.stats.Attached...) | |
| 205 | + | }) | |
| 206 | + | return st | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (s *Server) setup(t testing.TB) { | |
| 210 | + | must := func(err error) { | |
| 211 | + | if err != nil { | |
| 212 | + | t.Error(err) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | for _, spec := range s.cfg.Outputs { | |
| 216 | + | s.addOutput(spec, must) | |
| 217 | + | } | |
| 218 | + | must(wayland.NewShmGlobal(s.d, 1, func(c server.Client, v, id uint32) { | |
| 219 | + | shm, err := wayland.NewShm(c, int32(v), id, shmHandler{s}) | |
| 220 | + | if err != nil { | |
| 221 | + | t.Error(err) | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | for _, f := range []uint32{ARGB8888, XRGB8888} { | |
| 225 | + | shm.SendFormat(f) | |
| 226 | + | } | |
| 227 | + | })) | |
| 228 | + | if s.xdgOutputWanted() { | |
| 229 | + | s.addXdgOutput(must) | |
| 230 | + | } | |
| 231 | + | must(wayland.NewCompositorGlobal(s.d, 4, func(c server.Client, v, id uint32) { | |
| 232 | + | if _, err := wayland.NewCompositor(c, int32(v), id, compositorHandler{}); err != nil { | |
| 233 | + | t.Error(err) | |
| 234 | + | } | |
| 235 | + | })) | |
| 236 | + | if !s.cfg.NoCopyCapture { | |
| 237 | + | must(extimagecapturesource.NewExtOutputImageCaptureSourceManagerV1Global(s.d, 1, func(c server.Client, v, id uint32) { | |
| 238 | + | if _, err := extimagecapturesource.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputSourceManager{s}); err != nil { | |
| 239 | + | t.Error(err) | |
| 240 | + | } | |
| 241 | + | })) | |
| 242 | + | must(extimagecopycapture.NewExtImageCopyCaptureManagerV1Global(s.d, 1, func(c server.Client, v, id uint32) { | |
| 243 | + | if _, err := extimagecopycapture.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyManager{s}); err != nil { | |
| 244 | + | t.Error(err) | |
| 245 | + | } | |
| 246 | + | })) | |
| 247 | + | } | |
| 248 | + | if s.cfg.HasWorkspaces { | |
| 249 | + | for _, w := range s.cfg.Workspaces { | |
| 250 | + | s.workspaces = append(s.workspaces, &workspace{spec: w}) | |
| 251 | + | } | |
| 252 | + | must(extworkspace.NewExtWorkspaceManagerV1Global(s.d, 1, func(c server.Client, v, id uint32) { | |
| 253 | + | m := &wsManager{s: s} | |
| 254 | + | res, err := extworkspace.NewExtWorkspaceManagerV1(c, int32(v), id, m) | |
| 255 | + | if err != nil { | |
| 256 | + | t.Error(err) | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | m.res = res | |
| 260 | + | s.wsMgrs = append(s.wsMgrs, m) | |
| 261 | + | m.announce(c) | |
| 262 | + | })) | |
| 263 | + | } | |
| 264 | + | if s.cfg.NeferwlSource { | |
| 265 | + | must(neferwl.NewNeferwlImageCaptureSourceManagerV1Global(s.d, 1, func(c server.Client, v, id uint32) { | |
| 266 | + | if _, err := neferwl.NewNeferwlImageCaptureSourceManagerV1(c, int32(v), id, neferwlSources{s}); err != nil { | |
| 267 | + | t.Error(err) | |
| 268 | + | } | |
| 269 | + | })) | |
| 270 | + | } | |
| 271 | + | if s.cfg.NeferwlExclusion { | |
| 272 | + | must(neferwl.NewNeferwlCaptureExclusionManagerV1Global(s.d, 1, func(c server.Client, v, id uint32) { | |
| 273 | + | if _, err := neferwl.NewNeferwlCaptureExclusionManagerV1(c, int32(v), id, exclusionManager{s}); err != nil { | |
| 274 | + | t.Error(err) | |
| 275 | + | } | |
| 276 | + | })) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | type compositorHandler struct{} | |
| 281 | + | ||
| 282 | + | func (compositorHandler) CreateSurface(self *wayland.Compositor, id uint32) { | |
| 283 | + | _, _ = wayland.NewSurface(self.Client(), 4, id, surfaceHandler{}) | |
| 284 | + | } | |
| 285 | + | func (compositorHandler) CreateRegion(*wayland.Compositor, uint32) {} | |
| 286 | + | func (compositorHandler) Release(*wayland.Compositor) {} | |
| 287 | + | ||
| 288 | + | type surfaceHandler struct{} | |
| 289 | + | ||
| 290 | + | func (surfaceHandler) Destroy(*wayland.Surface) {} | |
| 291 | + | func (surfaceHandler) Attach(*wayland.Surface, *wayland.Buffer, int32, int32) {} | |
| 292 | + | func (surfaceHandler) Damage(*wayland.Surface, int32, int32, int32, int32) {} | |
| 293 | + | func (surfaceHandler) Frame(*wayland.Surface, uint32) {} | |
| 294 | + | func (surfaceHandler) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {} | |
| 295 | + | func (surfaceHandler) SetInputRegion(*wayland.Surface, *wayland.Region) {} | |
| 296 | + | func (surfaceHandler) Commit(*wayland.Surface) {} | |
| 297 | + | func (surfaceHandler) SetBufferTransform(*wayland.Surface, int32) {} | |
| 298 | + | func (surfaceHandler) SetBufferScale(*wayland.Surface, int32) {} | |
| 299 | + | func (surfaceHandler) DamageBuffer(*wayland.Surface, int32, int32, int32, int32) {} | |
| 300 | + | func (surfaceHandler) Offset(*wayland.Surface, int32, int32) {} | |
| 301 | + | func (surfaceHandler) GetRelease(*wayland.Surface, uint32) {} | |
A internal/adapters/wayland/testserver/workspaces.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package testserver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 7 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 8 | + | "github.com/bnema/purego-libwayland/server" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver/neferwl" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type workspace struct { | |
| 14 | + | spec WorkspaceSpec | |
| 15 | + | removed bool | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type wsHandle struct { | |
| 19 | + | w *workspace | |
| 20 | + | res *extworkspace.ExtWorkspaceHandleV1 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type wsManager struct { | |
| 24 | + | s *Server | |
| 25 | + | res *extworkspace.ExtWorkspaceManagerV1 | |
| 26 | + | group *extworkspace.ExtWorkspaceGroupHandleV1 | |
| 27 | + | handles []*wsHandle | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (m *wsManager) Commit(*extworkspace.ExtWorkspaceManagerV1) {} | |
| 31 | + | func (m *wsManager) Stop(*extworkspace.ExtWorkspaceManagerV1) {} | |
| 32 | + | ||
| 33 | + | type groupHandler struct{} | |
| 34 | + | ||
| 35 | + | func (groupHandler) CreateWorkspace(*extworkspace.ExtWorkspaceGroupHandleV1, string) {} | |
| 36 | + | func (groupHandler) Destroy(*extworkspace.ExtWorkspaceGroupHandleV1) {} | |
| 37 | + | ||
| 38 | + | type handleHandler struct{} | |
| 39 | + | ||
| 40 | + | func (handleHandler) Destroy(*extworkspace.ExtWorkspaceHandleV1) {} | |
| 41 | + | func (handleHandler) Activate(*extworkspace.ExtWorkspaceHandleV1) {} | |
| 42 | + | func (handleHandler) Deactivate(*extworkspace.ExtWorkspaceHandleV1) {} | |
| 43 | + | func (handleHandler) Assign(*extworkspace.ExtWorkspaceHandleV1, *extworkspace.ExtWorkspaceGroupHandleV1) { | |
| 44 | + | } | |
| 45 | + | func (handleHandler) Remove(*extworkspace.ExtWorkspaceHandleV1) {} | |
| 46 | + | ||
| 47 | + | func stateBits(w WorkspaceSpec) uint32 { | |
| 48 | + | var st uint32 | |
| 49 | + | if w.Active { | |
| 50 | + | st |= uint32(extworkspace.ExtWorkspaceHandleV1StateActive) | |
| 51 | + | } | |
| 52 | + | if w.Hidden { | |
| 53 | + | st |= uint32(extworkspace.ExtWorkspaceHandleV1StateHidden) | |
| 54 | + | } | |
| 55 | + | return st | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // announce sends the group, its outputs and every workspace, then done. | |
| 59 | + | func (m *wsManager) announce(c server.Client) { | |
| 60 | + | g, err := extworkspace.NewExtWorkspaceGroupHandleV1(c, 1, 0, groupHandler{}) | |
| 61 | + | if err != nil { | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | m.group = g | |
| 65 | + | m.res.SendWorkspaceGroup(g) | |
| 66 | + | g.SendCapabilities(0) | |
| 67 | + | for _, o := range m.s.outputs { | |
| 68 | + | for _, r := range o.res { | |
| 69 | + | if r.Client() == c { | |
| 70 | + | g.SendOutputEnter(r) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | for i, w := range m.s.workspaces { | |
| 75 | + | if !w.removed { | |
| 76 | + | m.add(c, g, w, i) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | m.res.SendDone() | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (m *wsManager) add(c server.Client, g *extworkspace.ExtWorkspaceGroupHandleV1, w *workspace, index int) { | |
| 83 | + | r, err := extworkspace.NewExtWorkspaceHandleV1(c, 1, 0, handleHandler{}) | |
| 84 | + | if err != nil { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | m.handles = append(m.handles, &wsHandle{w: w, res: r}) | |
| 88 | + | m.res.SendWorkspace(r) | |
| 89 | + | if w.spec.ID != "" { | |
| 90 | + | r.SendId(w.spec.ID) | |
| 91 | + | } | |
| 92 | + | r.SendName(w.spec.Name) | |
| 93 | + | var b [4]byte | |
| 94 | + | binary.NativeEndian.PutUint32(b[:], uint32(index)) | |
| 95 | + | r.SendCoordinates(b[:]) | |
| 96 | + | r.SendState(stateBits(w.spec)) | |
| 97 | + | r.SendCapabilities(0) | |
| 98 | + | g.SendWorkspaceEnter(r) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (s *Server) workspaceNamed(name string) *workspace { | |
| 102 | + | for _, w := range s.workspaces { | |
| 103 | + | if w.spec.Name == name { | |
| 104 | + | return w | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // SetActive changes the active state of the named workspace. | |
| 111 | + | func (s *Server) SetActive(name string, active bool) { | |
| 112 | + | s.Do(func() { | |
| 113 | + | w := s.workspaceNamed(name) | |
| 114 | + | if w == nil { | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | w.spec.Active = active | |
| 118 | + | for _, m := range s.wsMgrs { | |
| 119 | + | for _, h := range m.handles { | |
| 120 | + | if h.w == w && !w.removed { | |
| 121 | + | h.res.SendState(stateBits(w.spec)) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | m.res.SendDone() | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // RemoveWorkspace removes the named workspace from every manager. | |
| 130 | + | func (s *Server) RemoveWorkspace(name string) { | |
| 131 | + | s.Do(func() { | |
| 132 | + | w := s.workspaceNamed(name) | |
| 133 | + | if w == nil || w.removed { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | w.removed = true | |
| 137 | + | for _, m := range s.wsMgrs { | |
| 138 | + | for _, h := range m.handles { | |
| 139 | + | if h.w == w { | |
| 140 | + | m.group.SendWorkspaceLeave(h.res) | |
| 141 | + | h.res.SendRemoved() | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | m.res.SendDone() | |
| 145 | + | } | |
| 146 | + | for _, c := range s.sessions { | |
| 147 | + | if c.src.ws == w { | |
| 148 | + | c.stop() | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // workspaceOf finds the workspace behind a handle resource. | |
| 155 | + | func (s *Server) workspaceOf(r *server.Resource) *workspace { | |
| 156 | + | for _, m := range s.wsMgrs { | |
| 157 | + | for _, h := range m.handles { | |
| 158 | + | if h.res.Resource == r { | |
| 159 | + | return h.w | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | type neferwlSources struct{ s *Server } | |
| 167 | + | ||
| 168 | + | // frameObject is one neferwl_workspace_frame_v1. | |
| 169 | + | type frameObject struct { | |
| 170 | + | w *workspace | |
| 171 | + | res *neferwl.NeferwlWorkspaceFrameV1 | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (frameObject) Destroy(*neferwl.NeferwlWorkspaceFrameV1) {} | |
| 175 | + | ||
| 176 | + | func (f *frameObject) send() { | |
| 177 | + | if r := f.w.spec.Frame; r != ([4]int32{}) { | |
| 178 | + | f.res.SendFrame(r[0], r[1], r[2], r[3]) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (m neferwlSources) GetWorkspaceFrame(self *neferwl.NeferwlImageCaptureSourceManagerV1, id uint32, h *extworkspace.ExtWorkspaceHandleV1) { | |
| 183 | + | s := m.s | |
| 184 | + | f := &frameObject{w: s.workspaceOf(h.Resource)} | |
| 185 | + | res, err := neferwl.NewNeferwlWorkspaceFrameV1(self.Client(), 1, id, f) | |
| 186 | + | if err != nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | f.res = res | |
| 190 | + | s.stats.WorkspaceFrames++ | |
| 191 | + | res.OnDestroy = func() { | |
| 192 | + | s.stats.DestroyedWorkspaceFrames++ | |
| 193 | + | s.frames = removeFrame(s.frames, f) | |
| 194 | + | } | |
| 195 | + | if f.w == nil || f.w.removed { | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | s.frames = append(s.frames, f) | |
| 199 | + | f.send() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func removeFrame(list []*frameObject, f *frameObject) []*frameObject { | |
| 203 | + | for i, g := range list { | |
| 204 | + | if g == f { | |
| 205 | + | return append(list[:i], list[i+1:]...) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | return list | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // SetFrame changes the frame of the named workspace and reports it to every | |
| 212 | + | // frame object that watches it. | |
| 213 | + | func (s *Server) SetFrame(name string, frame [4]int32) { | |
| 214 | + | s.Do(func() { | |
| 215 | + | w := s.workspaceNamed(name) | |
| 216 | + | if w == nil { | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | w.spec.Frame = frame | |
| 220 | + | for _, f := range s.frames { | |
| 221 | + | if f.w == w && !w.removed { | |
| 222 | + | f.send() | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | }) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (neferwlSources) Destroy(*neferwl.NeferwlImageCaptureSourceManagerV1) {} | |
| 229 | + | func (m neferwlSources) CreateWorkspaceSource(self *neferwl.NeferwlImageCaptureSourceManagerV1, id uint32, h *extworkspace.ExtWorkspaceHandleV1) { | |
| 230 | + | w := m.s.workspaceOf(h.Resource) | |
| 231 | + | m.s.newSource(self.Client(), id, &source{kind: "workspace", ws: w, gone: w == nil || w.removed}) | |
| 232 | + | } | |
| 233 | + | func (m neferwlSources) CreateOutputRegionSource(self *neferwl.NeferwlImageCaptureSourceManagerV1, id uint32, out *wayland.Output, x, y, w, h int32) { | |
| 234 | + | if w < 1 || h < 1 { | |
| 235 | + | self.PostError(uint32(neferwl.NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion), "empty region") | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | m.s.newSource(self.Client(), id, &source{kind: "region", out: m.s.outputOf(out.Resource), region: [4]int32{x, y, w, h}}) | |
| 239 | + | } | |
A internal/adapters/wayland/testserver/xdgoutput.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package testserver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/xdgoutput" | |
| 6 | + | "github.com/bnema/purego-libwayland/server" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // xdg-output is offered only when an output announces a logical size. | |
| 10 | + | func (s *Server) xdgOutputWanted() bool { | |
| 11 | + | for _, o := range s.outputs { | |
| 12 | + | if o.spec.LogicalWidth > 0 { | |
| 13 | + | return true | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *Server) addXdgOutput(must func(error)) { | |
| 20 | + | must(xdgoutput.NewZxdgOutputManagerV1Global(s.d, 3, func(c server.Client, v, id uint32) { | |
| 21 | + | if _, err := xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgManager{s}); err != nil { | |
| 22 | + | must(err) | |
| 23 | + | } | |
| 24 | + | })) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type xdgManager struct{ s *Server } | |
| 28 | + | ||
| 29 | + | func (xdgManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | |
| 30 | + | func (m xdgManager) GetXdgOutput(self *xdgoutput.ZxdgOutputManagerV1, id uint32, out *wayland.Output) { | |
| 31 | + | res, err := xdgoutput.NewZxdgOutputV1(self.Client(), int32(self.Version()), id, xdgHandler{}) | |
| 32 | + | if err != nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | res.OnDestroy = func() { m.s.stats.DestroyedXdgOutputs++ } | |
| 36 | + | if o := m.s.outputOf(out.Resource); o != nil && o.spec.LogicalWidth > 0 { | |
| 37 | + | res.SendLogicalPosition(0, 0) | |
| 38 | + | res.SendLogicalSize(o.spec.LogicalWidth, o.spec.LogicalHeight) | |
| 39 | + | res.SendDone() | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type xdgHandler struct{} | |
| 44 | + | ||
| 45 | + | func (xdgHandler) Destroy(*xdgoutput.ZxdgOutputV1) {} | |
A internal/adapters/wayland/workspaces.go +319 -0
| @@ -0,0 +1,319 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "context" | |
| 6 | + | "slices" | |
| 7 | + | "strconv" | |
| 8 | + | "unicode" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | extworkspace "github.com/bnema/wlturbo/protocol/workspace" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefercap/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ext-workspace-v1 client. Only the state needed to list workspaces and to | |
| 17 | + | // name one as a capture source is kept; nothing is ever requested from the | |
| 18 | + | // compositor (no activate, no commit). | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | workspaceVersion = 1 | |
| 22 | + | maxWorkspaces = 256 // bound of the inventory | |
| 23 | + | maxNameLen = 256 // bytes, for workspace names | |
| 24 | + | maxGroupOutputs = 64 | |
| 25 | + | ||
| 26 | + | maxInterned = 4 * maxWorkspaces // id strings remembered across removals | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // workspaceState is the ext-workspace inventory of a Source. | |
| 30 | + | type workspaceState struct { | |
| 31 | + | mgr *extworkspace.ExtWorkspaceManager // nil when the compositor has no ext-workspace | |
| 32 | + | handles map[uint32]*workspace // by proxy ID | |
| 33 | + | byID map[uint64]*workspace // by Source-local identity | |
| 34 | + | groups map[uint32]*workspaceGroup | |
| 35 | + | next uint64 // last identity handed out; identities start at 1 | |
| 36 | + | interned map[string]uint64 // id string to identity, bounded by maxInterned | |
| 37 | + | overflow bool // the compositor announced more objects than maxWorkspaces | |
| 38 | + | shown uint64 // workspace last verified as displayed, without the extension | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // watchWorkspaces subscribes to the creation events of the manager. | |
| 42 | + | func (s *Source) watchWorkspaces(m *extworkspace.ExtWorkspaceManager) { | |
| 43 | + | m.OnWorkspaceGroup(func(proxy *extworkspace.ExtWorkspaceGroupHandle) { | |
| 44 | + | // wlturbo registered the proxy already, which absorbs its events. | |
| 45 | + | if len(s.ws.groups) >= maxWorkspaces { | |
| 46 | + | s.ws.overflow = true | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | g := &workspaceGroup{proxy: proxy, outputs: make(map[uint32]bool)} | |
| 50 | + | s.ws.groups[proxy.ID()] = g | |
| 51 | + | s.watchGroup(g) | |
| 52 | + | }) | |
| 53 | + | m.OnWorkspace(func(proxy *extworkspace.ExtWorkspaceHandle) { | |
| 54 | + | if len(s.ws.handles) >= maxWorkspaces { | |
| 55 | + | s.ws.overflow = true | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | w := &workspace{proxy: proxy} | |
| 59 | + | s.ws.handles[proxy.ID()] = w | |
| 60 | + | s.watchWorkspace(w) | |
| 61 | + | }) | |
| 62 | + | m.OnDone(s.ws.assignIDs) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type workspaceGroup struct { | |
| 66 | + | proxy *extworkspace.ExtWorkspaceGroupHandle | |
| 67 | + | outputs map[uint32]bool // wl_output proxy IDs | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (s *Source) watchGroup(g *workspaceGroup) { | |
| 71 | + | g.proxy.OnOutputEnter(func(id uint32) { | |
| 72 | + | if len(g.outputs) < maxGroupOutputs { | |
| 73 | + | g.outputs[id] = true | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | g.proxy.OnOutputLeave(func(id uint32) { delete(g.outputs, id) }) | |
| 77 | + | g.proxy.OnWorkspaceEnter(func(id uint32) { | |
| 78 | + | if w := s.ws.handles[id]; w != nil { | |
| 79 | + | w.group = g | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | g.proxy.OnWorkspaceLeave(func(id uint32) { | |
| 83 | + | if w := s.ws.handles[id]; w != nil && w.group == g { | |
| 84 | + | w.group = nil | |
| 85 | + | } | |
| 86 | + | }) | |
| 87 | + | g.proxy.OnRemoved(func() { | |
| 88 | + | delete(s.ws.groups, g.proxy.ID()) | |
| 89 | + | for _, w := range s.ws.handles { | |
| 90 | + | if w.group == g { | |
| 91 | + | w.group = nil | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | _ = g.proxy.Destroy() | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // workspace is one ext_workspace_handle_v1. Its proxy is the argument of | |
| 99 | + | // create_workspace_source. | |
| 100 | + | type workspace struct { | |
| 101 | + | proxy *extworkspace.ExtWorkspaceHandle | |
| 102 | + | idStr string // the id event: empty for a temporary workspace | |
| 103 | + | name string | |
| 104 | + | state uint32 | |
| 105 | + | group *workspaceGroup | |
| 106 | + | id uint64 // Source-local identity, assigned at the first done | |
| 107 | + | removed bool | |
| 108 | + | frame *workspaceFrame // NeferWL's extension only; nil until asked for | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // watchWorkspace subscribes to the events of w. Coordinates are not used: | |
| 112 | + | // nothing here depends on the grid. | |
| 113 | + | func (s *Source) watchWorkspace(w *workspace) { | |
| 114 | + | w.proxy.OnId(func(id string) { | |
| 115 | + | if w.idStr == "" { | |
| 116 | + | w.idStr = id | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | w.proxy.OnName(func(n string) { | |
| 120 | + | if validName(n) { | |
| 121 | + | w.name = n | |
| 122 | + | } | |
| 123 | + | }) | |
| 124 | + | w.proxy.OnState(func(state uint32) { w.state = state }) | |
| 125 | + | w.proxy.OnRemoved(func() { | |
| 126 | + | w.removed = true | |
| 127 | + | s.releaseWorkspaceFrame(w) | |
| 128 | + | delete(s.ws.handles, w.proxy.ID()) | |
| 129 | + | if w.id != 0 && s.ws.byID[w.id] == w { | |
| 130 | + | delete(s.ws.byID, w.id) | |
| 131 | + | } | |
| 132 | + | _ = w.proxy.Destroy() | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // assignIDs gives every workspace announced since the last done its | |
| 137 | + | // identity. The id event is an opaque string: it is interned into a number | |
| 138 | + | // local to this Source, from one counter, so identities cannot collide and the | |
| 139 | + | // string is never parsed. The same string keeps its identity while the Source | |
| 140 | + | // lives (up to maxInterned strings). A workspace without an id, or whose id | |
| 141 | + | // is already held by a live workspace, is temporary: it gets a fresh number | |
| 142 | + | // that is not remembered. | |
| 143 | + | func (ws *workspaceState) assignIDs() { | |
| 144 | + | fresh := make([]*workspace, 0, len(ws.handles)) | |
| 145 | + | for _, w := range ws.handles { | |
| 146 | + | if w.id == 0 && !w.removed { | |
| 147 | + | fresh = append(fresh, w) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | // Map order is random: numbers follow the announcement order. | |
| 151 | + | slices.SortFunc(fresh, func(a, b *workspace) int { return cmp.Compare(a.proxy.ID(), b.proxy.ID()) }) | |
| 152 | + | for _, w := range fresh { | |
| 153 | + | w.id = ws.intern(w.idStr) | |
| 154 | + | ws.byID[w.id] = w | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (ws *workspaceState) intern(idStr string) uint64 { | |
| 159 | + | if idStr != "" { | |
| 160 | + | if id, ok := ws.interned[idStr]; ok { | |
| 161 | + | if ws.byID[id] == nil { | |
| 162 | + | return id | |
| 163 | + | } | |
| 164 | + | } else if len(ws.interned) < maxInterned { | |
| 165 | + | ws.next++ | |
| 166 | + | ws.interned[idStr] = ws.next | |
| 167 | + | return ws.next | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ws.next++ | |
| 171 | + | return ws.next | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // validName accepts a bounded, valid UTF-8, single-line name without control | |
| 175 | + | // or bidirectional formatting characters. | |
| 176 | + | func validName(n string) bool { | |
| 177 | + | if len(n) > maxNameLen || !utf8.ValidString(n) { | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | for _, r := range n { | |
| 181 | + | switch { | |
| 182 | + | case unicode.IsControl(r), r == 0x2028, r == 0x2029, r == 0x061C, r == 0x200E, r == 0x200F, | |
| 183 | + | r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069: | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return true | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func outputName(o *output) string { | |
| 191 | + | if o.name == "" { // wl_output older than version 4 has no name | |
| 192 | + | return "output-" + strconv.FormatUint(uint64(o.id), 10) | |
| 193 | + | } | |
| 194 | + | return o.name | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // forgetOutput drops a removed wl_output proxy from every group. | |
| 198 | + | func (ws *workspaceState) forgetOutput(proxyID uint32) { | |
| 199 | + | for _, g := range ws.groups { | |
| 200 | + | delete(g.outputs, proxyID) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // bindWorkspaces binds ext-workspace-v1 when the compositor offers it. | |
| 205 | + | func (s *Source) bindWorkspaces() error { | |
| 206 | + | s.ws.handles = make(map[uint32]*workspace) | |
| 207 | + | s.ws.byID = make(map[uint64]*workspace) | |
| 208 | + | s.ws.groups = make(map[uint32]*workspaceGroup) | |
| 209 | + | s.ws.interned = make(map[string]uint64) | |
| 210 | + | m := extworkspace.NewExtWorkspaceManager(s.wl) | |
| 211 | + | ok, err := s.bindOptional(extworkspace.ExtWorkspaceManagerInterface, workspaceVersion, m) | |
| 212 | + | if err != nil || !ok { | |
| 213 | + | return err | |
| 214 | + | } | |
| 215 | + | s.watchWorkspaces(m) | |
| 216 | + | s.ws.mgr = m | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // Workspaces lists the workspaces ext-workspace-v1 advertises, one entry per | |
| 221 | + | // output of the workspace's group, ordered by identity. It returns nil when | |
| 222 | + | // the compositor has no ext-workspace. | |
| 223 | + | func (s *Source) Workspaces(ctx context.Context) ([]ports.Workspace, error) { | |
| 224 | + | s.opMu.Lock() | |
| 225 | + | defer s.opMu.Unlock() | |
| 226 | + | if err := s.begin(ctx); err != nil { | |
| 227 | + | return nil, err | |
| 228 | + | } | |
| 229 | + | err := s.settle() | |
| 230 | + | if err == nil { | |
| 231 | + | // The frame of a workspace comes from NeferWL's extension: one | |
| 232 | + | // object per workspace, answered at creation. | |
| 233 | + | var asked bool | |
| 234 | + | if asked, err = s.requestFrames(); asked && err == nil { | |
| 235 | + | err = s.roundtrip() | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | var list []ports.Workspace | |
| 239 | + | if err == nil { | |
| 240 | + | list = s.workspaceList() | |
| 241 | + | } | |
| 242 | + | if err = s.finish(ctx, err); err != nil { | |
| 243 | + | return nil, err | |
| 244 | + | } | |
| 245 | + | return list, nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (s *Source) workspaceList() []ports.Workspace { | |
| 249 | + | if s.ws.mgr == nil || s.ws.overflow { | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | var list []ports.Workspace | |
| 253 | + | for _, w := range s.ws.byID { | |
| 254 | + | if w.removed || w.group == nil { | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | for proxyID := range w.group.outputs { | |
| 258 | + | o := s.byProxy[proxyID] | |
| 259 | + | if o == nil { | |
| 260 | + | continue | |
| 261 | + | } | |
| 262 | + | list = append(list, ports.Workspace{ | |
| 263 | + | ID: w.id, OutputID: o.id, Name: w.name, | |
| 264 | + | Region: workspaceRegion(w, o), | |
| 265 | + | Active: w.state&extworkspace.STATE_ACTIVE != 0, | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | slices.SortFunc(list, func(a, b ports.Workspace) int { | |
| 270 | + | return cmp.Or(cmp.Compare(a.ID, b.ID), cmp.Compare(a.OutputID, b.OutputID)) | |
| 271 | + | }) | |
| 272 | + | return list | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // workspaceRegion is where the workspace is on o, in output-local logical | |
| 276 | + | // pixels: its frame from NeferWL's extension, clipped to the output, else the | |
| 277 | + | // whole output (ext-workspace carries no geometry). | |
| 278 | + | func workspaceRegion(w *workspace, o *output) ports.Region { | |
| 279 | + | lw, lh := o.logicalSize() | |
| 280 | + | whole := ports.Region{Width: lw, Height: lh} | |
| 281 | + | r, ok := w.frame.region() | |
| 282 | + | if !ok { | |
| 283 | + | return whole | |
| 284 | + | } | |
| 285 | + | x0, y0 := max(r.X, 0), max(r.Y, 0) | |
| 286 | + | x1, y1 := min(r.X+r.Width, lw), min(r.Y+r.Height, lh) | |
| 287 | + | if x1 <= x0 || y1 <= y0 { | |
| 288 | + | return whole | |
| 289 | + | } | |
| 290 | + | return ports.Region{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0} | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // workspaceKey resolves a workspace target. With NeferWL's extension the | |
| 294 | + | // session follows the workspace itself, shown or not. Without it the | |
| 295 | + | // workspace is captured as its output, and only while it is displayed: that is | |
| 296 | + | // checked when the workspace is first targeted, so the recording then follows | |
| 297 | + | // the output whatever it shows, and never starts on hidden content. | |
| 298 | + | func (s *Source) workspaceKey(t ports.Target, out *output) (sessionKey, ports.Region, error) { | |
| 299 | + | if t.Region != (ports.Region{}) { | |
| 300 | + | return sessionKey{}, ports.Region{}, ports.ErrInvalidRegion | |
| 301 | + | } | |
| 302 | + | w := s.ws.byID[t.WorkspaceID] | |
| 303 | + | if w == nil || w.removed { | |
| 304 | + | return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable | |
| 305 | + | } | |
| 306 | + | if s.ext.source != nil { | |
| 307 | + | return sessionKey{output: out.id, workspace: w.id}, ports.Region{}, nil | |
| 308 | + | } | |
| 309 | + | if s.ws.shown != w.id { | |
| 310 | + | if err := s.roundtrip(); err != nil { | |
| 311 | + | return sessionKey{}, ports.Region{}, err | |
| 312 | + | } | |
| 313 | + | if w.removed || w.state&extworkspace.STATE_ACTIVE == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] { | |
| 314 | + | return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable | |
| 315 | + | } | |
| 316 | + | s.ws.shown = w.id | |
| 317 | + | } | |
| 318 | + | return sessionKey{output: out.id}, ports.Region{}, nil | |
| 319 | + | } | |
A internal/adapters/wayland/workspaces_test.go +246 -0
| @@ -0,0 +1,246 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strconv" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func wsConfig(ws ...testserver.WorkspaceSpec) testserver.Config { | |
| 16 | + | return testserver.Config{ | |
| 17 | + | Outputs: []testserver.OutputSpec{{Name: "TEST-1", Width: 8, Height: 4, Scale: 1}}, | |
| 18 | + | HasWorkspaces: true, Workspaces: ws, | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func listWorkspaces(t *testing.T, s *Source) []ports.Workspace { | |
| 23 | + | t.Helper() | |
| 24 | + | ws, err := s.Workspaces(context.Background()) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | return ws | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestWorkspacesAbsent(t *testing.T) { | |
| 30 | + | s, _ := newSource(t, testserver.Config{}) | |
| 31 | + | require.Nil(t, listWorkspaces(t, s)) | |
| 32 | + | require.False(t, s.Capabilities().Workspaces) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestWorkspacesListing(t *testing.T) { | |
| 36 | + | s, _ := newSource(t, wsConfig( | |
| 37 | + | testserver.WorkspaceSpec{ID: "name:dev", Name: "dev", Output: "TEST-1", Active: true}, | |
| 38 | + | testserver.WorkspaceSpec{ID: "7f3a-12", Name: "2", Output: "TEST-1"}, | |
| 39 | + | testserver.WorkspaceSpec{ID: "7f3a-13", Name: "3", Output: "TEST-1", Hidden: true}, | |
| 40 | + | )) | |
| 41 | + | out := firstOutput(t, s) | |
| 42 | + | ws := listWorkspaces(t, s) | |
| 43 | + | require.Equal(t, []ports.Workspace{ | |
| 44 | + | {ID: 1, OutputID: out.ID, Name: "dev", Region: ports.Region{Width: 8, Height: 4}, Active: true}, | |
| 45 | + | {ID: 2, OutputID: out.ID, Name: "2", Region: ports.Region{Width: 8, Height: 4}}, | |
| 46 | + | {ID: 3, OutputID: out.ID, Name: "3", Region: ports.Region{Width: 8, Height: 4}}, | |
| 47 | + | }, ws, "id strings are interned into Source-local numbers, in announcement order") | |
| 48 | + | require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities(), "no hidden workspaces without the extension") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestWorkspaceIdentityOpaqueAndTemporary(t *testing.T) { | |
| 52 | + | s, _ := newSource(t, wsConfig( | |
| 53 | + | testserver.WorkspaceSpec{ID: "name:main", Name: "main", Output: "TEST-1", Active: true}, | |
| 54 | + | testserver.WorkspaceSpec{Name: "tmp1", Output: "TEST-1"}, | |
| 55 | + | testserver.WorkspaceSpec{Name: "tmp2", Output: "TEST-1"}, | |
| 56 | + | )) | |
| 57 | + | ws := listWorkspaces(t, s) | |
| 58 | + | require.Len(t, ws, 3) | |
| 59 | + | seen := map[uint64]bool{} | |
| 60 | + | for _, w := range ws { | |
| 61 | + | require.NotZero(t, w.ID) | |
| 62 | + | require.False(t, seen[w.ID], "identities are unique") | |
| 63 | + | seen[w.ID] = true | |
| 64 | + | } | |
| 65 | + | again := listWorkspaces(t, s) | |
| 66 | + | require.Equal(t, ws, again, "identities are stable for the connection") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestWorkspaceStateChangesAndRemoval(t *testing.T) { | |
| 70 | + | s, srv := newSource(t, wsConfig( | |
| 71 | + | testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true}, | |
| 72 | + | testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}, | |
| 73 | + | )) | |
| 74 | + | srv.SetActive("a", false) | |
| 75 | + | srv.SetActive("b", true) | |
| 76 | + | ws := listWorkspaces(t, s) | |
| 77 | + | require.False(t, ws[0].Active) | |
| 78 | + | require.True(t, ws[1].Active) | |
| 79 | + | srv.RemoveWorkspace("a") | |
| 80 | + | ws = listWorkspaces(t, s) | |
| 81 | + | require.Len(t, ws, 1) | |
| 82 | + | require.Equal(t, "b", ws[0].Name) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestWorkspaceCaptureActiveOnlyWithoutExtension(t *testing.T) { | |
| 86 | + | s, srv := newSource(t, wsConfig( | |
| 87 | + | testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true}, | |
| 88 | + | testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}, | |
| 89 | + | )) | |
| 90 | + | out := firstOutput(t, s) | |
| 91 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1}) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height}) | |
| 94 | + | require.Equal(t, "output", srv.Stats().LastSource, "the active workspace is captured as its output") | |
| 95 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2}) | |
| 96 | + | require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable, "a hidden workspace is never captured as the output") | |
| 97 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 99}) | |
| 98 | + | require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable) | |
| 99 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1, Region: ports.Region{Width: 1, Height: 1}}) | |
| 100 | + | require.ErrorIs(t, err, ports.ErrInvalidRegion) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestWorkspaceCaptureUsesExtensionSource(t *testing.T) { | |
| 104 | + | cfg := wsConfig( | |
| 105 | + | testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true}, | |
| 106 | + | testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true}, | |
| 107 | + | ) | |
| 108 | + | cfg.NeferwlSource = true | |
| 109 | + | s, srv := newSource(t, cfg) | |
| 110 | + | require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities()) | |
| 111 | + | out := firstOutput(t, s) | |
| 112 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2}) | |
| 113 | + | require.NoError(t, err, "a hidden workspace is captured through the extension") | |
| 114 | + | require.NoError(t, f.Validate()) | |
| 115 | + | require.Equal(t, "workspace", srv.Stats().LastSource) | |
| 116 | + | // it follows the workspace: still captured after the active one changes | |
| 117 | + | srv.SetActive("a", false) | |
| 118 | + | srv.SetActive("b", true) | |
| 119 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2}) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.Equal(t, 1, srv.Stats().Sessions, "the same session serves the workspace") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // With NeferWL's extension a workspace lists its own frame, not the output. | |
| 125 | + | func TestWorkspaceRegionIsTheFrameFromTheExtension(t *testing.T) { | |
| 126 | + | cfg := wsConfig( | |
| 127 | + | testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}}, | |
| 128 | + | testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true, Frame: [4]int32{0, 0, 8, 4}}, | |
| 129 | + | testserver.WorkspaceSpec{ID: "7f3a-3", Name: "c", Output: "TEST-1", Frame: [4]int32{6, 3, 9, 9}}, | |
| 130 | + | testserver.WorkspaceSpec{ID: "7f3a-4", Name: "d", Output: "TEST-1"}, | |
| 131 | + | ) | |
| 132 | + | cfg.NeferwlSource = true | |
| 133 | + | s, srv := newSource(t, cfg) | |
| 134 | + | regions := func() map[string]ports.Region { | |
| 135 | + | m := map[string]ports.Region{} | |
| 136 | + | for _, w := range listWorkspaces(t, s) { | |
| 137 | + | m[w.Name] = w.Region | |
| 138 | + | } | |
| 139 | + | return m | |
| 140 | + | } | |
| 141 | + | require.Equal(t, map[string]ports.Region{ | |
| 142 | + | "a": {X: 2, Y: 1, Width: 4, Height: 2}, | |
| 143 | + | "b": {Width: 8, Height: 4}, | |
| 144 | + | "c": {X: 6, Y: 3, Width: 2, Height: 1}, // clipped to the output | |
| 145 | + | "d": {Width: 8, Height: 4}, // no frame sent: the whole output | |
| 146 | + | }, regions()) | |
| 147 | + | require.Equal(t, 4, srv.Stats().WorkspaceFrames) | |
| 148 | + | // Listing again asks for nothing more, and a change is followed. | |
| 149 | + | srv.SetFrame("a", [4]int32{1, 1, 3, 3}) | |
| 150 | + | require.Equal(t, ports.Region{X: 1, Y: 1, Width: 3, Height: 3}, regions()["a"]) | |
| 151 | + | require.Equal(t, 4, srv.Stats().WorkspaceFrames) | |
| 152 | + | // A removed workspace releases its frame object. | |
| 153 | + | srv.RemoveWorkspace("a") | |
| 154 | + | require.NotContains(t, regions(), "a") | |
| 155 | + | require.Eventually(t, func() bool { return srv.Stats().DestroyedWorkspaceFrames == 1 }, 2*time.Second, time.Millisecond) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // An off-screen frame, or one that is nowhere on the output, falls back to the | |
| 159 | + | // whole output rather than offering an empty workspace. | |
| 160 | + | func TestWorkspaceRegionFallsBackToTheOutput(t *testing.T) { | |
| 161 | + | cfg := wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{20, 20, 4, 4}}) | |
| 162 | + | cfg.NeferwlSource = true | |
| 163 | + | s, _ := newSource(t, cfg) | |
| 164 | + | require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestWorkspaceRegionIsTheOutputWithoutTheExtension(t *testing.T) { | |
| 168 | + | s, srv := newSource(t, wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}})) | |
| 169 | + | require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region) | |
| 170 | + | require.Zero(t, srv.Stats().WorkspaceFrames) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestWorkspaceLimit(t *testing.T) { | |
| 174 | + | var specs []testserver.WorkspaceSpec | |
| 175 | + | for i := range maxWorkspaces + 4 { | |
| 176 | + | specs = append(specs, testserver.WorkspaceSpec{ID: strconv.Itoa(i + 1), Name: "w", Output: "TEST-1"}) | |
| 177 | + | } | |
| 178 | + | s, _ := newSource(t, wsConfig(specs...)) | |
| 179 | + | require.Nil(t, listWorkspaces(t, s), "an inventory beyond the bound is rejected, not truncated") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // The id event is an opaque string: it is interned, never parsed. | |
| 183 | + | func TestWorkspaceIdentityIsInterned(t *testing.T) { | |
| 184 | + | ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}} | |
| 185 | + | a, b := ws.intern("7f3a-5"), ws.intern("name:5") | |
| 186 | + | require.NotEqual(t, a, b, "both id forms are just strings") | |
| 187 | + | require.Equal(t, a, ws.intern("7f3a-5"), "the same string keeps its identity") | |
| 188 | + | require.Equal(t, uint64(1), a, "identities come from one counter") | |
| 189 | + | require.Equal(t, uint64(2), b) | |
| 190 | + | require.NotContains(t, []uint64{a, b}, ws.intern("5"), "a bare decimal is another string too") | |
| 191 | + | ws.byID[a] = &workspace{} | |
| 192 | + | dup := ws.intern("7f3a-5") | |
| 193 | + | require.NotEqual(t, a, dup, "an id held by a live workspace is not shared") | |
| 194 | + | require.Equal(t, uint64(4), dup) | |
| 195 | + | t1, t2 := ws.intern(""), ws.intern("") | |
| 196 | + | require.NotEqual(t, t1, t2, "workspaces without an id are temporary") | |
| 197 | + | delete(ws.byID, a) | |
| 198 | + | require.Equal(t, a, ws.intern("7f3a-5"), "a returning workspace gets its identity back") | |
| 199 | + | require.False(t, validName("a\nb")) | |
| 200 | + | require.False(t, validName("\u202e")) | |
| 201 | + | require.True(t, validName("Bureau 2")) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestWorkspaceInternTableIsBounded(t *testing.T) { | |
| 205 | + | ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}} | |
| 206 | + | for i := range maxInterned + 10 { | |
| 207 | + | ws.intern("p-" + strconv.Itoa(i)) | |
| 208 | + | } | |
| 209 | + | require.Len(t, ws.interned, maxInterned, "the string table is bounded") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Hotplug: an output that goes away leaves no stale group entry, and no | |
| 213 | + | // workspace is listed on it. | |
| 214 | + | func TestOutputHotplugPurgesWorkspaceAndXdgState(t *testing.T) { | |
| 215 | + | cfg := testserver.Config{ | |
| 216 | + | Outputs: []testserver.OutputSpec{ | |
| 217 | + | {Name: "A", Width: 8, Height: 4, Scale: 1, LogicalWidth: 8, LogicalHeight: 4}, | |
| 218 | + | {Name: "B", Width: 16, Height: 8, Scale: 1, LogicalWidth: 16, LogicalHeight: 8}, | |
| 219 | + | }, | |
| 220 | + | HasWorkspaces: true, | |
| 221 | + | Workspaces: []testserver.WorkspaceSpec{{ID: "name:a", Name: "a", Output: "A", Active: true}}, | |
| 222 | + | } | |
| 223 | + | s, srv := newSource(t, cfg) | |
| 224 | + | ws := listWorkspaces(t, s) | |
| 225 | + | require.Len(t, ws, 2, "one entry per output of the group") | |
| 226 | + | srv.RemoveOutput("B") | |
| 227 | + | ws = listWorkspaces(t, s) | |
| 228 | + | require.Len(t, ws, 1) | |
| 229 | + | require.Equal(t, "A", outputNameOf(t, s, ws[0].OutputID)) | |
| 230 | + | for _, g := range s.ws.groups { | |
| 231 | + | require.Len(t, g.outputs, 1, "the removed output proxy is purged from the group") | |
| 232 | + | } | |
| 233 | + | require.Equal(t, 1, srv.Stats().DestroyedXdgOutputs, "the zxdg_output_v1 of the removed output is destroyed") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func outputNameOf(t *testing.T, s *Source, id uint32) string { | |
| 237 | + | t.Helper() | |
| 238 | + | outs, err := s.Outputs(context.Background()) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | for _, o := range outs { | |
| 241 | + | if o.ID == id { | |
| 242 | + | return o.Name | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return "" | |
| 246 | + | } | |
A internal/adapters/wayland/xdgoutput.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/nefercap/internal/ports" | |
| 4 | + | ||
| 5 | + | // zxdg_output_v1 gives the logical size of an output, which maps a logical | |
| 6 | + | // region onto buffer pixels when a region has to be cropped from the output | |
| 7 | + | // frame. It is optional: the current mode over the integer scale is the | |
| 8 | + | // fallback. | |
| 9 | + | ||
| 10 | + | // watchLogicalSize subscribes to the logical size of o. | |
| 11 | + | func (s *Source) watchLogicalSize(o *output) { | |
| 12 | + | if s.xdgOutputs == nil { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | x, err := s.xdgOutputs.GetXdgOutput(o.proxy) | |
| 16 | + | if err != nil { | |
| 17 | + | s.log.Warn().Err(err).Msg("xdg-output request failed") | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | x.OnLogicalSize(func(w, h int32) { | |
| 21 | + | if w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension { | |
| 22 | + | o.lw, o.lh = w, h | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | o.xdg = x | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // releaseXdg destroys the zxdg_output_v1 of an output that went away. | |
| 29 | + | func (s *Source) releaseXdg(o *output) { | |
| 30 | + | if x := o.xdg; x != nil { | |
| 31 | + | o.xdg = nil | |
| 32 | + | _ = x.Destroy() | |
| 33 | + | } | |
| 34 | + | } | |
M internal/app/app.go +1 -8
| @@ -84,16 +84,9 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 84 | 84 | var saved bool | |
| 85 | 85 | if selection.Mode == ports.Record { | |
| 86 | 86 | var outcome recordingOutcome | |
| 87 | - | outcome, err = recordInteractive(ctx, source, outputs, selection) | |
| 87 | + | outcome, err = recordInteractive(ctx, output, source, outputs, selection) | |
| 88 | 88 | saved = outcome.Saved | |
| 89 | 89 | } else { | |
| 90 | - | if selection.Target.WorkspaceID != 0 { | |
| 91 | - | state, sessionErr := source.BeginSession(ctx, selection.Target, false) | |
| 92 | - | if sessionErr != nil { | |
| 93 | - | return sessionError(ctx, false, sessionErr) | |
| 94 | - | } | |
| 95 | - | selection.Target = state.Target | |
| 96 | - | } | |
| 97 | 90 | var writer ports.ScreenshotWriter = png.New() | |
| 98 | 91 | var copied *clipboard.Writer | |
| 99 | 92 | if selection.Clipboard { | |
A internal/app/exclusion_test.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 13 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 14 | + | "github.com/bnema/nefercap/internal/logging" | |
| 15 | + | "github.com/bnema/nefercap/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func sourceFor(t *testing.T, cfg testserver.Config) (context.Context, *bytes.Buffer, *wayland.Source, ports.Target) { | |
| 19 | + | t.Helper() | |
| 20 | + | var logs bytes.Buffer | |
| 21 | + | ctx := logging.With(context.Background(), &logs, false) | |
| 22 | + | srv := testserver.Start(t, cfg) | |
| 23 | + | source, err := wayland.New(ctx, srv.Path) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | t.Cleanup(func() { _ = source.Close() }) | |
| 26 | + | outs, err := source.Outputs(ctx) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | return ctx, &logs, source, ports.Target{OutputID: outs[0].ID} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestStartExclusionWithExtension(t *testing.T) { | |
| 32 | + | ctx, _, source, target := sourceFor(t, testserver.Config{NeferwlExclusion: true}) | |
| 33 | + | token, withHUD, err := startExclusion(ctx, io.Discard, source, target) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.True(t, withHUD) | |
| 36 | + | require.Equal(t, testserver.Token, token) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestStartExclusionWithoutExtensionHasNoHUD(t *testing.T) { | |
| 40 | + | ctx, _, source, target := sourceFor(t, testserver.Config{}) | |
| 41 | + | token, withHUD, err := startExclusion(ctx, io.Discard, source, target) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.False(t, withHUD) | |
| 44 | + | require.Empty(t, token) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // A busy exclusion records without HUD and logs it at warn. | |
| 48 | + | func TestStartExclusionBusyFallsBackWithoutHUD(t *testing.T) { | |
| 49 | + | ctx, logs, source, target := sourceFor(t, testserver.Config{NeferwlExclusion: true, ExclusionFail: 1}) | |
| 50 | + | var notices bytes.Buffer | |
| 51 | + | token, withHUD, err := startExclusion(ctx, ¬ices, source, target) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.False(t, withHUD) | |
| 54 | + | require.Empty(t, token) | |
| 55 | + | require.Contains(t, notices.String(), "recording without indicator") | |
| 56 | + | require.Contains(t, logs.String(), `"level":"warn"`) | |
| 57 | + | require.Contains(t, logs.String(), "recording without indicator") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Other failures stay fatal. | |
| 61 | + | func TestStartExclusionOtherErrorsAreFatal(t *testing.T) { | |
| 62 | + | ctx, _, source, _ := sourceFor(t, testserver.Config{NeferwlExclusion: true}) | |
| 63 | + | _, _, err := startExclusion(ctx, io.Discard, source, ports.Target{OutputID: 9999}) | |
| 64 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 65 | + | require.False(t, errors.Is(err, wayland.ErrExclusionUnavailable)) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Any other refusal reason is fatal too, never a fallback. | |
| 69 | + | func TestStartExclusionOtherReasonIsFatal(t *testing.T) { | |
| 70 | + | ctx, _, source, target := sourceFor(t, testserver.Config{NeferwlExclusion: true, ExclusionFail: 4}) | |
| 71 | + | var notices bytes.Buffer | |
| 72 | + | _, withHUD, err := startExclusion(ctx, ¬ices, source, target) | |
| 73 | + | require.ErrorIs(t, err, wayland.ErrExclusionUnavailable) | |
| 74 | + | require.False(t, errors.Is(err, wayland.ErrExclusionBusy)) | |
| 75 | + | require.False(t, withHUD) | |
| 76 | + | require.Empty(t, notices.String()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // A session the compositor stops is fatal, not a fallback: no second session. | |
| 80 | + | func TestStartExclusionStoppedSessionIsFatal(t *testing.T) { | |
| 81 | + | ctx, _, source, target := sourceFor(t, testserver.Config{NeferwlExclusion: true, ExclusionFail: 2}) | |
| 82 | + | _, _, err := startExclusion(ctx, io.Discard, source, target) | |
| 83 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 84 | + | require.False(t, errors.Is(err, wayland.ErrExclusionUnavailable)) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // A compositor that refuses the client stops the session at creation: the | |
| 88 | + | // error says so and nothing retries. | |
| 89 | + | func TestStartExclusionRefusedClientIsFatal(t *testing.T) { | |
| 90 | + | ctx, _, source, target := sourceFor(t, testserver.Config{NeferwlExclusion: true, StopAtCreate: true}) | |
| 91 | + | _, _, err := startExclusion(ctx, io.Discard, source, target) | |
| 92 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) | |
| 93 | + | require.ErrorContains(t, err, "/etc/neferwl/capture-allow") | |
| 94 | + | } | |
M internal/app/interactive.go +6 -7
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | "github.com/bnema/nefercap/internal/ports" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | // chooseInteractive uses native workspace metadata only when the compositor | |
| 19 | - | // exposes it. Monitor and region screenshots still work with standard capture. | |
| 18 | + | // chooseInteractive offers workspaces only when the compositor lists them. | |
| 19 | + | // Monitor and region captures work with the standard protocols. | |
| 20 | 20 | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options, settings config.Settings) (ports.Selection, bool, error) { | |
| 21 | 21 | mode := ports.Screenshot | |
| 22 | 22 | if options.Command == cli.Rec { | |
| @@ -30,7 +30,7 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 30 | 30 | outputs = []ports.Output{chosen} | |
| 31 | 31 | } | |
| 32 | 32 | workspaces, err := source.Workspaces(ctx) | |
| 33 | - | if err != nil && !errors.Is(err, wayland.ErrSessionUnsupported) { | |
| 33 | + | if err != nil { | |
| 34 | 34 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | |
| 35 | 35 | return ports.Selection{}, false, ctx.Err() | |
| 36 | 36 | } | |
| @@ -39,7 +39,7 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 39 | 39 | if err := checkDirs(options, settings); err != nil { | |
| 40 | 40 | return ports.Selection{}, false, err | |
| 41 | 41 | } | |
| 42 | - | selector := selection.New(mode, options.Video, workspaces, settings.Grid) | |
| 42 | + | selector := selection.New(mode, options.Video, workspaces, settings.Grid).WithCapabilities(source.Capabilities()) | |
| 43 | 43 | if options.Command == cli.AllInOne { | |
| 44 | 44 | selector.AllowToggle() | |
| 45 | 45 | } | |
| @@ -103,9 +103,8 @@ func rejectCompetingSelector(ctx context.Context) error { | |||
| 103 | 103 | return err | |
| 104 | 104 | } | |
| 105 | 105 | if active { | |
| 106 | - | // A selector on another capture connection is not automatically part | |
| 107 | - | // of the recording's excluded set. Refuse rather than leak it into | |
| 108 | - | // an existing recording until cross-session selection is authorized. | |
| 106 | + | // A selector on another capture connection is not part of the | |
| 107 | + | // recording's excluded set: it would show up in the video. | |
| 109 | 108 | return errors.New("recording is active; stop it before opening a capture selector") | |
| 110 | 109 | } | |
| 111 | 110 | return nil | |
M internal/app/record_interactive.go +90 -34
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/nefercap/internal/adapters/control" | |
| 9 | 10 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | |
| @@ -11,18 +12,17 @@ import ( | |||
| 11 | 12 | "github.com/bnema/nefercap/internal/adapters/uierrors" | |
| 12 | 13 | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 13 | 14 | "github.com/bnema/nefercap/internal/core" | |
| 15 | + | "github.com/bnema/nefercap/internal/logging" | |
| 14 | 16 | "github.com/bnema/nefercap/internal/ports" | |
| 15 | 17 | "github.com/bnema/nefergui" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | - | // recordInteractive records with the HUD and the control socket. A stop | |
| 19 | - | // requested by the user is success; the outcome says whether a file exists. | |
| 20 | - | func recordInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) { | |
| 21 | - | session, err := source.BeginSession(ctx, sel.Target, true) | |
| 22 | - | if err != nil { | |
| 23 | - | return outcome, sessionError(ctx, true, err) | |
| 24 | - | } | |
| 25 | - | sel.Target = session.Target | |
| 20 | + | // recordInteractive records and listens for stop requests. With the | |
| 21 | + | // compositor's capture exclusion it also shows the HUD, excluded from the | |
| 22 | + | // video; without it nothing is drawn, so the user stops with the same | |
| 23 | + | // shortcut or `nefercap stop`. A stop requested by the user is success; the | |
| 24 | + | // outcome says whether a file exists. | |
| 25 | + | func recordInteractive(ctx context.Context, notices io.Writer, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) { | |
| 26 | 26 | var output ports.Output | |
| 27 | 27 | for _, candidate := range outputs { | |
| 28 | 28 | if candidate.ID == sel.Target.OutputID { | |
| @@ -33,6 +33,10 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 33 | 33 | if output.ID == 0 { | |
| 34 | 34 | return outcome, ports.ErrOutputNotFound | |
| 35 | 35 | } | |
| 36 | + | token, withHUD, err := startExclusion(ctx, notices, source, sel.Target) | |
| 37 | + | if err != nil { | |
| 38 | + | return outcome, err | |
| 39 | + | } | |
| 36 | 40 | server, err := control.Listen() | |
| 37 | 41 | if err != nil { | |
| 38 | 42 | return outcome, err | |
| @@ -54,31 +58,40 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 54 | 58 | buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop) | |
| 55 | 59 | defer func() { stopWatch(); <-controlDone; <-buttonDone }() | |
| 56 | 60 | ||
| 57 | - | hud := startHUD(hudCtx, output, session.Token, hudStop, life) | |
| 58 | 61 | end := ending{} | |
| 59 | 62 | var video *ffmpeg.Writer | |
| 60 | - | // Do not start the encoder until the compositor acknowledges exclusion. | |
| 61 | - | // A protocol roundtrip alone does not prove the core applied the marking. | |
| 62 | - | select { | |
| 63 | - | case <-hud.ready: | |
| 63 | + | run := func() <-chan error { | |
| 64 | 64 | video = ffmpeg.New() | |
| 65 | 65 | captureDone := make(chan error, 1) | |
| 66 | 66 | go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }() | |
| 67 | + | return captureDone | |
| 68 | + | } | |
| 69 | + | if !withHUD { | |
| 70 | + | end.runErr = <-run() | |
| 71 | + | life.beginShutdown() | |
| 72 | + | } else { | |
| 73 | + | hud := startHUD(hudCtx, output, recordedFrame(ctx, source, sel.Target), token, hudStop, life) | |
| 74 | + | // Do not start the encoder until the compositor acknowledges the | |
| 75 | + | // exclusion: a HUD that is not excluded would be in the video. | |
| 67 | 76 | select { | |
| 68 | - | case end.runErr = <-captureDone: | |
| 77 | + | case <-hud.ready: | |
| 78 | + | captureDone := run() | |
| 79 | + | select { | |
| 80 | + | case end.runErr = <-captureDone: | |
| 81 | + | life.beginShutdown() | |
| 82 | + | cancelHUD() | |
| 83 | + | end.hudErr = <-hud.done | |
| 84 | + | case hudErr := <-hud.done: | |
| 85 | + | end.hudErr = life.hudEnded(hudErr, false) | |
| 86 | + | end.runErr = <-captureDone | |
| 87 | + | } | |
| 88 | + | case hudErr := <-hud.done: | |
| 89 | + | end.hudErr = life.hudEnded(hudErr, !authorized(hud.ready)) | |
| 90 | + | case <-recordCtx.Done(): | |
| 69 | 91 | life.beginShutdown() | |
| 70 | 92 | cancelHUD() | |
| 71 | 93 | end.hudErr = <-hud.done | |
| 72 | - | case hudErr := <-hud.done: | |
| 73 | - | end.hudErr = life.hudEnded(hudErr, false) | |
| 74 | - | end.runErr = <-captureDone | |
| 75 | 94 | } | |
| 76 | - | case hudErr := <-hud.done: | |
| 77 | - | end.hudErr = life.hudEnded(hudErr, !authorized(hud.ready)) | |
| 78 | - | case <-recordCtx.Done(): | |
| 79 | - | life.beginShutdown() | |
| 80 | - | cancelHUD() | |
| 81 | - | end.hudErr = <-hud.done | |
| 82 | 95 | } | |
| 83 | 96 | end.failure = life.failed() | |
| 84 | 97 | end.parentErr = ctx.Err() | |
| @@ -92,18 +105,36 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 92 | 105 | return outcome, err | |
| 93 | 106 | } | |
| 94 | 107 | ||
| 95 | - | // sessionError classifies a BeginSession failure. The caller's own | |
| 96 | - | // cancellation stays the bare context error, a missing native capability is | |
| 97 | - | // named for a recording, and every other failure gets the phase and keeps its | |
| 98 | - | // identity. | |
| 99 | - | func sessionError(ctx context.Context, record bool, err error) error { | |
| 108 | + | // startExclusion asks the compositor to keep the HUD out of the recording. It | |
| 109 | + | // reports whether a HUD can be shown: without the extension, or when the | |
| 110 | + | // compositor refuses the exclusion because another one is live (busy), the | |
| 111 | + | // recording goes on without HUD, like on a compositor with no extension. Any | |
| 112 | + | // other failure is fatal, including a session the compositor stopped. | |
| 113 | + | func startExclusion(ctx context.Context, notices io.Writer, source *wayland.Source, target ports.Target) (token string, withHUD bool, err error) { | |
| 114 | + | if !source.Capabilities().Exclusion { | |
| 115 | + | return "", false, nil | |
| 116 | + | } | |
| 117 | + | token, err = source.BeginExclusion(ctx, target) | |
| 118 | + | switch { | |
| 119 | + | case err == nil: | |
| 120 | + | return token, true, nil | |
| 121 | + | case errors.Is(err, wayland.ErrExclusionBusy) && ctx.Err() == nil: | |
| 122 | + | log := logging.For(ctx, "app") | |
| 123 | + | log.Warn().Err(err).Msg("capture exclusion refused: recording without indicator") | |
| 124 | + | fmt.Fprintln(notices, "recording without indicator: stop with the same shortcut or `nefercap stop`") | |
| 125 | + | return "", false, nil | |
| 126 | + | } | |
| 127 | + | return "", false, exclusionError(ctx, err) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // exclusionError classifies a BeginExclusion failure. The caller's own | |
| 131 | + | // cancellation stays the bare context error; every other failure gets the | |
| 132 | + | // phase and keeps its identity. | |
| 133 | + | func exclusionError(ctx context.Context, err error) error { | |
| 100 | 134 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | |
| 101 | 135 | return ctx.Err() | |
| 102 | 136 | } | |
| 103 | - | if record && errors.Is(err, wayland.ErrSessionUnsupported) { | |
| 104 | - | return fmt.Errorf("recording indicator requires native capture-session support: %w", err) | |
| 105 | - | } | |
| 106 | - | return fmt.Errorf("begin capture session: %w", err) | |
| 137 | + | return fmt.Errorf("begin capture exclusion: %w", err) | |
| 107 | 138 | } | |
| 108 | 139 | ||
| 109 | 140 | // authorized reports, without blocking, whether the HUD signalled ready. Ready | |
| @@ -126,7 +157,7 @@ type hudHandle struct { | |||
| 126 | 157 | ||
| 127 | 158 | // startHUD runs the indicator on its own goroutine. Its detach callback and | |
| 128 | 159 | // button only signal the lifecycle; they never touch the recording. | |
| 129 | - | func startHUD(ctx context.Context, output ports.Output, token string, stop chan<- struct{}, life *lifecycle) hudHandle { | |
| 160 | + | func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle { | |
| 130 | 161 | ready := make(chan struct{}, 1) | |
| 131 | 162 | done := make(chan error, 1) | |
| 132 | 163 | authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error { | |
| @@ -136,6 +167,31 @@ func startHUD(ctx context.Context, output ports.Output, token string, stop chan< | |||
| 136 | 167 | ready <- struct{}{} | |
| 137 | 168 | return nil | |
| 138 | 169 | } | |
| 139 | - | go func() { done <- indicator.New(authorize, stop).Run(ctx, output) }() | |
| 170 | + | go func() { | |
| 171 | + | hud := indicator.New(authorize, stop) | |
| 172 | + | hud.Frame = frame | |
| 173 | + | done <- hud.Run(ctx, output) | |
| 174 | + | }() | |
| 140 | 175 | return hudHandle{ready: ready, done: done} | |
| 141 | 176 | } | |
| 177 | + | ||
| 178 | + | // recordedFrame is where a workspace target is on its output, so that the HUD | |
| 179 | + | // sits in it. Anything else, or a workspace that cannot be found, is the whole | |
| 180 | + | // output (the zero Region). | |
| 181 | + | func recordedFrame(ctx context.Context, source *wayland.Source, t ports.Target) ports.Region { | |
| 182 | + | if t.WorkspaceID == 0 { | |
| 183 | + | return ports.Region{} | |
| 184 | + | } | |
| 185 | + | list, err := source.Workspaces(ctx) | |
| 186 | + | if err != nil { | |
| 187 | + | log := logging.For(ctx, "app") | |
| 188 | + | log.Warn().Err(err).Msg("workspace frame unavailable: HUD on the whole output") | |
| 189 | + | return ports.Region{} | |
| 190 | + | } | |
| 191 | + | for _, w := range list { | |
| 192 | + | if w.ID == t.WorkspaceID && w.OutputID == t.OutputID { | |
| 193 | + | return w.Region | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | return ports.Region{} | |
| 197 | + | } | |
M internal/app/record_lifecycle_test.go +17 -31
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | 10 | "testing" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| @@ -51,19 +50,19 @@ func TestUserStopIsNotAFailure(t *testing.T) { | |||
| 51 | 50 | ||
| 52 | 51 | func TestDetachAfterReadyKeepsRealError(t *testing.T) { | |
| 53 | 52 | life, recordCtx, hudCtx, _ := newTestLifecycle(t) | |
| 54 | - | detach := fmt.Errorf("compositor: %w", wayland.ErrSessionUnavailable) | |
| 53 | + | detach := fmt.Errorf("compositor: %w", wayland.ErrExclusionUnavailable) | |
| 55 | 54 | life.detached(detach) | |
| 56 | 55 | if recordCtx.Err() == nil || hudCtx.Err() == nil { | |
| 57 | 56 | t.Fatal("detach did not stop recording and HUD") | |
| 58 | 57 | } | |
| 59 | 58 | // The core observes only the cancellation the detach caused. | |
| 60 | 59 | err := classify(ending{runErr: fmt.Errorf("capture first frame: %w", context.Canceled), failure: life.failed(), userStop: context.Cause(recordCtx) == errUserStop}) | |
| 61 | - | if !errors.Is(err, wayland.ErrSessionUnavailable) { | |
| 60 | + | if !errors.Is(err, wayland.ErrExclusionUnavailable) { | |
| 62 | 61 | t.Fatalf("detach error lost: %v", err) | |
| 63 | 62 | } | |
| 64 | 63 | // A second detach does not replace the first cause. | |
| 65 | 64 | life.detached(errors.New("later")) | |
| 66 | - | if cause := context.Cause(recordCtx); !errors.Is(cause, wayland.ErrSessionUnavailable) { | |
| 65 | + | if cause := context.Cause(recordCtx); !errors.Is(cause, wayland.ErrExclusionUnavailable) { | |
| 67 | 66 | t.Fatalf("first cause replaced: %v", cause) | |
| 68 | 67 | } | |
| 69 | 68 | } | |
| @@ -172,36 +171,23 @@ func TestClassify(t *testing.T) { | |||
| 172 | 171 | } | |
| 173 | 172 | } | |
| 174 | 173 | ||
| 175 | - | func TestSessionError(t *testing.T) { | |
| 174 | + | func TestExclusionError(t *testing.T) { | |
| 176 | 175 | live := context.Background() | |
| 177 | 176 | done, cancel := context.WithCancel(context.Background()) | |
| 178 | 177 | cancel() | |
| 179 | - | for _, record := range []bool{true, false} { | |
| 180 | - | if err := sessionError(done, record, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled { | |
| 181 | - | t.Fatalf("record=%t: cancellation not bare: %v", record, err) | |
| 182 | - | } | |
| 183 | - | real := errors.New("protocol failure") | |
| 184 | - | if err := sessionError(done, record, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled { | |
| 185 | - | t.Fatalf("record=%t: real failure masked by cancel: %v", record, err) | |
| 186 | - | } | |
| 187 | - | // A cancel error while the caller is live is not the caller's cancel. | |
| 188 | - | if err := sessionError(live, record, context.Canceled); err == context.Canceled { | |
| 189 | - | t.Fatalf("record=%t: foreign cancel treated as caller's", record) | |
| 190 | - | } | |
| 191 | - | generic := errors.New("protocol failure") | |
| 192 | - | if err := sessionError(live, record, generic); !errors.Is(err, generic) || err == generic { | |
| 193 | - | t.Fatalf("record=%t: phase missing: %v", record, err) | |
| 194 | - | } | |
| 195 | - | if err := sessionError(live, record, wayland.ErrWorkspaceNotFound); !errors.Is(err, wayland.ErrWorkspaceNotFound) || errors.Is(err, wayland.ErrSessionUnsupported) { | |
| 196 | - | t.Fatalf("record=%t: workspace error mislabeled: %v", record, err) | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | recorded := sessionError(live, true, wayland.ErrSessionUnsupported) | |
| 200 | - | if !errors.Is(recorded, wayland.ErrSessionUnsupported) || !strings.Contains(recorded.Error(), "recording indicator") { | |
| 201 | - | t.Fatalf("recording unsupported not explained: %v", recorded) | |
| 202 | - | } | |
| 203 | - | if shot := sessionError(live, false, wayland.ErrSessionUnsupported); strings.Contains(shot.Error(), "recording indicator") { | |
| 204 | - | t.Fatalf("screenshot got recording text: %v", shot) | |
| 178 | + | if err := exclusionError(done, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled { | |
| 179 | + | t.Fatalf("cancellation not bare: %v", err) | |
| 180 | + | } | |
| 181 | + | real := errors.New("protocol failure") | |
| 182 | + | if err := exclusionError(done, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled { | |
| 183 | + | t.Fatalf("real failure masked by cancel: %v", err) | |
| 184 | + | } | |
| 185 | + | // A cancel error while the caller is live is not the caller's cancel. | |
| 186 | + | if err := exclusionError(live, context.Canceled); err == context.Canceled { | |
| 187 | + | t.Fatal("foreign cancel treated as caller's") | |
| 188 | + | } | |
| 189 | + | if err := exclusionError(live, real); !errors.Is(err, real) || err == real { | |
| 190 | + | t.Fatalf("phase missing: %v", err) | |
| 205 | 191 | } | |
| 206 | 192 | } | |
| 207 | 193 | ||
A internal/app/record_nohud_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefercap/internal/adapters/control" | |
| 15 | + | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 16 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |
| 17 | + | "github.com/bnema/nefercap/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Without NeferWL's extension recording shows no HUD, runs against a real | |
| 21 | + | // (in-process) compositor and is stopped through the control socket, as | |
| 22 | + | // `nefercap stop` or the shortcut do. | |
| 23 | + | func TestRecordWithoutHUDStopsViaControlSocket(t *testing.T) { | |
| 24 | + | if _, err := exec.LookPath("ffmpeg"); err != nil { | |
| 25 | + | t.Skip("ffmpeg unavailable") | |
| 26 | + | } | |
| 27 | + | runtime := filepath.Join(t.TempDir(), "runtime") | |
| 28 | + | require.NoError(t, os.Mkdir(runtime, 0o700)) | |
| 29 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |
| 30 | + | ||
| 31 | + | ctx := context.Background() | |
| 32 | + | srv := testserver.Start(t, testserver.Config{ | |
| 33 | + | Outputs: []testserver.OutputSpec{{Name: "TEST-1", Width: 64, Height: 48, Scale: 1}}, | |
| 34 | + | }) | |
| 35 | + | source, err := wayland.New(ctx, srv.Path) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | defer source.Close() | |
| 38 | + | require.False(t, source.Capabilities().Exclusion) | |
| 39 | + | outputs, err := source.Outputs(ctx) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | ||
| 42 | + | path := filepath.Join(t.TempDir(), "clip.mp4") | |
| 43 | + | sel := ports.Selection{ | |
| 44 | + | Mode: ports.Record, Target: ports.Target{OutputID: outputs[0].ID}, Path: path, | |
| 45 | + | Video: ports.VideoSettings{FPS: 30}, | |
| 46 | + | } | |
| 47 | + | type result struct { | |
| 48 | + | outcome recordingOutcome | |
| 49 | + | err error | |
| 50 | + | } | |
| 51 | + | done := make(chan result, 1) | |
| 52 | + | go func() { | |
| 53 | + | o, err := recordInteractive(ctx, io.Discard, source, outputs, sel) | |
| 54 | + | done <- result{o, err} | |
| 55 | + | }() | |
| 56 | + | require.Eventually(t, func() bool { | |
| 57 | + | active, err := control.Query(ctx) | |
| 58 | + | return err == nil && active | |
| 59 | + | }, 5*time.Second, 10*time.Millisecond, "the recording listens on the control socket") | |
| 60 | + | time.Sleep(200 * time.Millisecond) // a few frames | |
| 61 | + | stopped, err := control.Stop(ctx) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.True(t, stopped) | |
| 64 | + | ||
| 65 | + | select { | |
| 66 | + | case r := <-done: | |
| 67 | + | require.NoError(t, r.err, "a user stop is success") | |
| 68 | + | require.True(t, r.outcome.Saved) | |
| 69 | + | case <-time.After(15 * time.Second): | |
| 70 | + | t.Fatal("recording did not stop") | |
| 71 | + | } | |
| 72 | + | info, err := os.Stat(path) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.NotZero(t, info.Size()) | |
| 75 | + | } | |
M internal/core/selection.go +1 -1
| @@ -58,7 +58,7 @@ type PickResult struct { | |||
| 58 | 58 | // Target converts the result to a capture target; selectors must use it | |
| 59 | 59 | // rather than map results themselves. A monitor is the zero region of its | |
| 60 | 60 | // output. A region carries its geometry. A workspace carries only its exact | |
| 61 | - | // native WorkspaceID: the compositor supplies live geometry at capture, so | |
| 61 | + | // Source-local WorkspaceID: the compositor supplies live geometry at capture, so | |
| 62 | 62 | // the Region snapshot in PickResult is for drawing only and is not cached in | |
| 63 | 63 | // the target. A workspace is never converted to a monitor. Sizes are logical: | |
| 64 | 64 | // the caller decides physical resolution and rounding. | |
M internal/mocks/ports/capture_mock.go +106 -0
| @@ -48,6 +48,50 @@ func (_m *MockSource) EXPECT() *MockSource_Expecter { | |||
| 48 | 48 | return &MockSource_Expecter{mock: &_m.Mock} | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // Capabilities provides a mock function for the type MockSource | |
| 52 | + | func (_mock *MockSource) Capabilities() ports.Capabilities { | |
| 53 | + | ret := _mock.Called() | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Capabilities") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.Capabilities | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func() ports.Capabilities); ok { | |
| 61 | + | r0 = returnFunc() | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(ports.Capabilities) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MockSource_Capabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capabilities' | |
| 69 | + | type MockSource_Capabilities_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Capabilities is a helper method to define mock.On call | |
| 74 | + | func (_e *MockSource_Expecter) Capabilities() *MockSource_Capabilities_Call { | |
| 75 | + | return &MockSource_Capabilities_Call{Call: _e.mock.On("Capabilities")} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockSource_Capabilities_Call) Run(run func()) *MockSource_Capabilities_Call { | |
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | + | run() | |
| 81 | + | }) | |
| 82 | + | return _c | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (_c *MockSource_Capabilities_Call) Return(capabilities ports.Capabilities) *MockSource_Capabilities_Call { | |
| 86 | + | _c.Call.Return(capabilities) | |
| 87 | + | return _c | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *MockSource_Capabilities_Call) RunAndReturn(run func() ports.Capabilities) *MockSource_Capabilities_Call { | |
| 91 | + | _c.Call.Return(run) | |
| 92 | + | return _c | |
| 93 | + | } | |
| 94 | + | ||
| 51 | 95 | // Capture provides a mock function for the type MockSource | |
| 52 | 96 | func (_mock *MockSource) Capture(context1 context.Context, target ports.Target) (ports.Frame, error) { | |
| 53 | 97 | ret := _mock.Called(context1, target) | |
| @@ -220,6 +264,68 @@ func (_c *MockSource_Outputs_Call) RunAndReturn(run func(context1 context.Contex | |||
| 220 | 264 | return _c | |
| 221 | 265 | } | |
| 222 | 266 | ||
| 267 | + | // Workspaces provides a mock function for the type MockSource | |
| 268 | + | func (_mock *MockSource) Workspaces(context1 context.Context) ([]ports.Workspace, error) { | |
| 269 | + | ret := _mock.Called(context1) | |
| 270 | + | ||
| 271 | + | if len(ret) == 0 { | |
| 272 | + | panic("no return value specified for Workspaces") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | var r0 []ports.Workspace | |
| 276 | + | var r1 error | |
| 277 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.Workspace, error)); ok { | |
| 278 | + | return returnFunc(context1) | |
| 279 | + | } | |
| 280 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.Workspace); ok { | |
| 281 | + | r0 = returnFunc(context1) | |
| 282 | + | } else { | |
| 283 | + | if ret.Get(0) != nil { | |
| 284 | + | r0 = ret.Get(0).([]ports.Workspace) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 288 | + | r1 = returnFunc(context1) | |
| 289 | + | } else { | |
| 290 | + | r1 = ret.Error(1) | |
| 291 | + | } | |
| 292 | + | return r0, r1 | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // MockSource_Workspaces_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Workspaces' | |
| 296 | + | type MockSource_Workspaces_Call struct { | |
| 297 | + | *mock.Call | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // Workspaces is a helper method to define mock.On call | |
| 301 | + | // - context1 context.Context | |
| 302 | + | func (_e *MockSource_Expecter) Workspaces(context1 any) *MockSource_Workspaces_Call { | |
| 303 | + | return &MockSource_Workspaces_Call{Call: _e.mock.On("Workspaces", context1)} | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (_c *MockSource_Workspaces_Call) Run(run func(context1 context.Context)) *MockSource_Workspaces_Call { | |
| 307 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 308 | + | var arg0 context.Context | |
| 309 | + | if args[0] != nil { | |
| 310 | + | arg0 = args[0].(context.Context) | |
| 311 | + | } | |
| 312 | + | run( | |
| 313 | + | arg0, | |
| 314 | + | ) | |
| 315 | + | }) | |
| 316 | + | return _c | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (_c *MockSource_Workspaces_Call) Return(workspaces []ports.Workspace, err error) *MockSource_Workspaces_Call { | |
| 320 | + | _c.Call.Return(workspaces, err) | |
| 321 | + | return _c | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func (_c *MockSource_Workspaces_Call) RunAndReturn(run func(context1 context.Context) ([]ports.Workspace, error)) *MockSource_Workspaces_Call { | |
| 325 | + | _c.Call.Return(run) | |
| 326 | + | return _c | |
| 327 | + | } | |
| 328 | + | ||
| 223 | 329 | // NewMockScreenshotWriter creates a new instance of MockScreenshotWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 224 | 330 | // The first argument is typically a *testing.T value. | |
| 225 | 331 | func NewMockScreenshotWriter(t interface { | |
M internal/ports/capture.go +20 -7
| @@ -21,14 +21,25 @@ type Region struct{ X, Y, Width, Height int } | |||
| 21 | 21 | type Target struct { | |
| 22 | 22 | OutputID uint32 | |
| 23 | 23 | Region Region | |
| 24 | - | // WorkspaceID is a stable compositor identity, never an index or monitor | |
| 25 | - | // alias. Nonzero requires a native capture session and a zero Region: | |
| 26 | - | // its geometry is supplied live by the compositor, not a cached crop. | |
| 24 | + | // WorkspaceID is a Source-local identity from Source.Workspaces, never an | |
| 25 | + | // index or monitor alias. Nonzero requires a zero Region. A workspace | |
| 26 | + | // that is not displayed can be captured only if the compositor supports | |
| 27 | + | // it; otherwise it is captured as its output while Active. | |
| 27 | 28 | WorkspaceID uint64 | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | - | // Workspace is native compositor target metadata. Region is output-local, | |
| 31 | - | // logical geometry. Active means that this workspace is currently displayed. | |
| 31 | + | // Capabilities says what the compositor behind a Source offers beyond | |
| 32 | + | // capturing whole outputs. It is fixed once the Source is created. | |
| 33 | + | type Capabilities struct { | |
| 34 | + | // Workspaces: Source.Workspaces lists the compositor's workspaces. | |
| 35 | + | Workspaces bool | |
| 36 | + | // Exclusion: a client's own layer surfaces can be left out of its frames, | |
| 37 | + | // so a recording indicator can be shown without appearing in the video. | |
| 38 | + | Exclusion bool | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Workspace is compositor workspace metadata. Region is output-local, logical | |
| 42 | + | // geometry (the whole output). Active means this workspace is displayed. | |
| 32 | 43 | type Workspace struct { | |
| 33 | 44 | ID uint64 | |
| 34 | 45 | OutputID uint32 | |
| @@ -47,19 +58,21 @@ const ( | |||
| 47 | 58 | ||
| 48 | 59 | // Frame borrows storage from Source until the next Capture or Close call. | |
| 49 | 60 | // Consumers must finish reading synchronously and must not retain Pixels. | |
| 50 | - | // YInvert means the first stored row is the bottom row. Output is opaque SDR. | |
| 61 | + | // Output is opaque SDR. | |
| 51 | 62 | type Frame struct { | |
| 52 | 63 | Pixels []byte | |
| 53 | 64 | Width, Height, Stride int | |
| 54 | 65 | Format PixelFormat | |
| 55 | - | YInvert bool | |
| 56 | 66 | } | |
| 57 | 67 | ||
| 58 | 68 | // Source has one sequential owner; cancellation must interrupt blocked I/O. | |
| 59 | 69 | // Cancellation errors wrap ctx.Err(), not a custom cancellation cause. | |
| 60 | 70 | // A failed Capture invalidates its borrowed frame. Close is idempotent. | |
| 61 | 71 | type Source interface { | |
| 72 | + | Capabilities() Capabilities | |
| 62 | 73 | Outputs(context.Context) ([]Output, error) | |
| 74 | + | // Workspaces returns nil without error when the compositor has none. | |
| 75 | + | Workspaces(context.Context) ([]Workspace, error) | |
| 63 | 76 | Capture(context.Context, Target) (Frame, error) | |
| 64 | 77 | Close() error | |
| 65 | 78 | } | |
M internal/ports/errors.go +12 -5
| @@ -6,11 +6,18 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | var ( | |
| 9 | - | ErrClosed = errors.New("capture resource is closed") | |
| 10 | - | ErrGeometryChanged = errors.New("capture geometry or pixel format changed") | |
| 11 | - | ErrUnsupportedFormat = errors.New("unsupported capture pixel format") | |
| 12 | - | ErrInvalidRegion = errors.New("invalid output-local capture region") | |
| 13 | - | ErrOutputNotFound = errors.New("capture output not found") | |
| 9 | + | ErrClosed = errors.New("capture resource is closed") | |
| 10 | + | ErrGeometryChanged = errors.New("capture geometry or pixel format changed") | |
| 11 | + | ErrUnsupportedFormat = errors.New("unsupported capture pixel format") | |
| 12 | + | ErrInvalidRegion = errors.New("invalid output-local capture region") | |
| 13 | + | ErrUnsupportedTransform = errors.New("unsupported output transform") | |
| 14 | + | ErrOutputNotFound = errors.New("capture output not found") | |
| 15 | + | // ErrCaptureStopped means the compositor ended the capture session or | |
| 16 | + | // refused the client. | |
| 17 | + | ErrCaptureStopped = errors.New("capture stopped by the compositor") | |
| 18 | + | // ErrWorkspaceUnavailable means the workspace is gone, or is not displayed | |
| 19 | + | // on a compositor that cannot capture hidden workspaces. | |
| 20 | + | ErrWorkspaceUnavailable = errors.New("capture workspace unavailable") | |
| 14 | 21 | // ErrPathExists is the standard filesystem conflict sentinel. | |
| 15 | 22 | ErrPathExists = fs.ErrExist | |
| 16 | 23 | ) | |
M internal/ports/frame.go +3 -4
| @@ -20,7 +20,9 @@ func (f Frame) Validate() error { | |||
| 20 | 20 | if f.Format != XRGB8888 && f.Format != ARGB8888 { | |
| 21 | 21 | return fmt.Errorf("%w: %d", ErrUnsupportedFormat, f.Format) | |
| 22 | 22 | } | |
| 23 | - | if len(f.Pixels) < f.Stride*f.Height { | |
| 23 | + | // The last row needs only its pixels: a cropped frame borrows a window of | |
| 24 | + | // a larger buffer, with no stride padding after its final row. | |
| 25 | + | if len(f.Pixels) < (f.Height-1)*f.Stride+f.Width*BytesPerPixel { | |
| 24 | 26 | return fmt.Errorf("short frame storage: got %d bytes", len(f.Pixels)) | |
| 25 | 27 | } | |
| 26 | 28 | return nil | |
| @@ -29,9 +31,6 @@ func (f Frame) Validate() error { | |||
| 29 | 31 | // Row returns a packed view in top-to-bottom order, excluding stride padding. | |
| 30 | 32 | // Call Validate before using Row. The returned slice borrows frame storage. | |
| 31 | 33 | func (f Frame) Row(y int) []byte { | |
| 32 | - | if f.YInvert { | |
| 33 | - | y = f.Height - 1 - y | |
| 34 | - | } | |
| 35 | 34 | offset := y * f.Stride | |
| 36 | 35 | return f.Pixels[offset : offset+f.Width*BytesPerPixel] | |
| 37 | 36 | } | |
M internal/ports/frame_test.go +3 -4
| @@ -27,7 +27,7 @@ func TestFrameValidation(t *testing.T) { | |||
| 27 | 27 | {"excessive stride", Frame{Width: 1, Height: 2, Stride: MaxFrameBytes/2 + 4}, "invalid frame stride", nil}, | |
| 28 | 28 | {"overflow stride", Frame{Width: 1, Height: 2, Stride: int(^uint(0)>>1) &^ 3}, "invalid frame stride", nil}, | |
| 29 | 29 | {"unsupported format", Frame{Width: 1, Height: 1, Stride: 4, Pixels: make([]byte, 4), Format: 42}, "", ErrUnsupportedFormat}, | |
| 30 | - | {"short storage", Frame{Width: 2, Height: 2, Stride: 12, Pixels: make([]byte, 23)}, "short frame storage", nil}, | |
| 30 | + | {"short storage", Frame{Width: 2, Height: 2, Stride: 12, Pixels: make([]byte, 19)}, "short frame storage", nil}, | |
| 31 | 31 | } | |
| 32 | 32 | for _, tc := range cases { | |
| 33 | 33 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -53,9 +53,8 @@ func TestFrameRow(t *testing.T) { | |||
| 53 | 53 | if got := f.Row(0); len(got) != 4 || got[0] != 1 { | |
| 54 | 54 | t.Fatalf("row: %v", got) | |
| 55 | 55 | } | |
| 56 | - | f.YInvert = true | |
| 57 | - | if got := f.Row(0); len(got) != 4 || got[0] != 5 { | |
| 58 | - | t.Fatalf("inverted row: %v", got) | |
| 56 | + | if got := f.Row(1); len(got) != 4 || got[0] != 5 { | |
| 57 | + | t.Fatalf("row: %v", got) | |
| 59 | 58 | } | |
| 60 | 59 | } | |
| 61 | 60 | ||