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 | ruleSettings: | 18 | ruleSettings: | ||
| 19 | suspicious-business-logic-in-adapter: | 19 | suspicious-business-logic-in-adapter: | ||
| 20 | # Run/resolveOutput and recordInteractive wire sessions, GUI and core. | 20 | # Run/resolveOutput and recordInteractive wire sessions, GUI and core. | ||
| 21 | - | # resolveCapture maps compositor-granted session state to a screencopy | 21 | + | excludePaths: [internal/app/app.go, internal/app/record_interactive.go] |
| 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] | |||
M Makefile +4 -4
| @@ -10,12 +10,12 @@ bin: | |||||
| 10 | build: | 10 | build: | ||
| 11 | CGO_ENABLED=0 go build ./... | 11 | CGO_ENABLED=0 go build ./... | ||
| 12 | test: | 12 | test: | ||
| 13 | - | CGO_ENABLED=0 go test ./... | 13 | + | NEFERCAP_REQUIRE_WAYLAND=1 CGO_ENABLED=0 go test ./... |
| 14 | vet: | 14 | vet: | ||
| 15 | CGO_ENABLED=0 go vet ./... | 15 | CGO_ENABLED=0 go vet ./... | ||
| 16 | race: | 16 | race: | ||
| 17 | # cgo is enabled only for the race test binary. | 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 | # go.mod and go.sum must be tidy (prints the diff and fails otherwise). | 19 | # go.mod and go.sum must be tidy (prints the diff and fails otherwise). | ||
| 20 | mod-check: | 20 | mod-check: | ||
| 21 | go mod tidy -diff | 21 | go mod tidy -diff | ||
| @@ -45,7 +45,7 @@ arch: | |||||
| 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | ||
| 46 | # the packages holding them. Every named test must exist, so the guard cannot | 46 | # the packages holding them. Every named test must exist, so the guard cannot | ||
| 47 | # pass vacuously when a frame-path test is removed or renamed. | 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 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | ||
| 50 | perf_empty := | 50 | perf_empty := | ||
| 51 | perf_space := $(perf_empty) $(perf_empty) | 51 | perf_space := $(perf_empty) $(perf_empty) | ||
| @@ -55,5 +55,5 @@ perf-check: | |||||
| 55 | for t in $(PERF_TESTS); do \ | 55 | for t in $(PERF_TESTS); do \ | ||
| 56 | printf '%s\n' "$$out" | grep -qx "$$t" || { echo "perf-check: test $$t not found in $(PERF_PKGS)" >&2; exit 1; }; \ | 56 | printf '%s\n' "$$out" | grep -qx "$$t" || { echo "perf-check: test $$t not found in $(PERF_PKGS)" >&2; exit 1; }; \ | ||
| 57 | done | 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 | check: mod-check vet test arch fakes-check perf-check | 59 | check: mod-check vet test arch fakes-check perf-check | ||
M README.md +22 -8
| @@ -10,7 +10,7 @@ | |||||
| 10 | <p align="center">Screenshots and silent screen recording for Wayland, from one shortcut.</p> | 10 | <p align="center">Screenshots and silent screen recording for Wayland, from one shortcut.</p> | ||
| 11 | 11 | ||||
| 12 | > [!WARNING] | 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 | 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`. | 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 | | Input | Action | | 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 | | `Tab` | Switch shot ⇄ rec | | 38 | | `Tab` | Switch shot ⇄ rec | | ||
| 39 | | `Esc` | Cancel | | 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 | ## Features | 43 | ## Features | ||
| 44 | 44 | ||||
| 45 | - **One shortcut.** Screenshot and recording share one selector; the same key stops a recording. | 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 | - **Clipboard.** Screenshots can be saved, copied, or both. | 47 | - **Clipboard.** Screenshots can be saved, copied, or both. | ||
| 48 | - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths. | 48 | - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths. | ||
| 49 | - **Scriptable.** `nefercap screenshot` and `nefercap record` run without the selector. | 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 | ## Requirements | 92 | ## Requirements | ||
| 93 | 93 | ||||
| 94 | - Go 1.27 to build. | 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 | - Vulkan, libxkbcommon, linux-dmabuf and linux-drm-syncobj for the selector. | 96 | - Vulkan, libxkbcommon, linux-dmabuf and linux-drm-syncobj for the selector. | ||
| 97 | - | - NeferWL's capture-session protocol to record. | |||
| 98 | - FFmpeg with `libx264` to record. | 97 | - FFmpeg with `libx264` to record. | ||
| 99 | - `wl-copy` (wl-clipboard) for clipboard screenshots. | 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 | ## Install | 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 | make bin # builds bin/nefercap | 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 | ## Development | 125 | ## Development | ||
| 112 | 126 | ||||
M go.mod +4 -3
| @@ -3,8 +3,9 @@ module github.com/bnema/nefercap | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/nefergui v0.2.0 | 6 | + | github.com/bnema/nefergui v0.3.0 |
| 7 | - | github.com/bnema/wlturbo v0.3.0 | 7 | + | github.com/bnema/purego-libwayland v0.8.0 |
| 8 | + | github.com/bnema/wlturbo v0.5.0 | |||
| 8 | github.com/bnema/zerowrap v1.4.1 | 9 | github.com/bnema/zerowrap v1.4.1 | ||
| 9 | github.com/stretchr/testify v1.12.1 | 10 | github.com/stretchr/testify v1.12.1 | ||
| 10 | golang.org/x/sys v0.48.0 | 11 | golang.org/x/sys v0.48.0 | ||
| @@ -13,7 +14,7 @@ require ( | |||||
| 13 | require ( | 14 | require ( | ||
| 14 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | 15 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | ||
| 15 | github.com/bnema/purego-vulkan v0.6.0 // indirect | 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 | github.com/go-text/typesetting v0.3.5 // indirect | 18 | github.com/go-text/typesetting v0.3.5 // indirect | ||
| 18 | github.com/mattn/go-colorable v0.1.15 // indirect | 19 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 19 | github.com/mattn/go-isatty v0.0.22 // indirect | 20 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
M go.sum +8 -6 generated file: collapsed
M internal/adapters/ffmpeg/ffmpeg.go +2 -2
| @@ -351,10 +351,10 @@ func (w *Writer) diagnostics() string { | |||||
| 351 | } | 351 | } | ||
| 352 | 352 | ||||
| 353 | // writeRows writes the frame in top-to-bottom row order without copying it. | 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 | func writeRows(dst io.Writer, f ports.Frame) error { | 355 | func writeRows(dst io.Writer, f ports.Frame) error { | ||
| 356 | rowLen := f.Width * ports.BytesPerPixel | 356 | rowLen := f.Width * ports.BytesPerPixel | ||
| 357 | - | if !f.YInvert && f.Stride == rowLen { | 357 | + | if f.Stride == rowLen { |
| 358 | return writeFull(dst, f.Pixels[:rowLen*f.Height]) | 358 | return writeFull(dst, f.Pixels[:rowLen*f.Height]) | ||
| 359 | } | 359 | } | ||
| 360 | for y := 0; y < f.Height; y++ { | 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 | if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil { | 234 | if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil { | ||
| 235 | t.Fatalf("stride change rejected: %v", err) | 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 | for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} { | 237 | for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} { | ||
| 243 | if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) { | 238 | if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) { | ||
| 244 | t.Fatalf("got %v, want ErrGeometryChanged", err) | 239 | t.Fatalf("got %v, want ErrGeometryChanged", err) | ||
| @@ -272,8 +267,8 @@ func TestDrainLifecycle(t *testing.T) { | |||||
| 272 | if !gone(pid) { | 267 | if !gone(pid) { | ||
| 273 | t.Fatal("encoder not reaped") | 268 | t.Fatal("encoder not reaped") | ||
| 274 | } | 269 | } | ||
| 275 | - | // 4 frames of 4x4x4 bytes reached the encoder exactly (no padding). | 270 | + | // 3 frames of 4x4x4 bytes reached the encoder exactly (no padding). |
| 276 | - | if got, _ := os.ReadFile(path); string(got) != "bytes=256" { | 271 | + | if got, _ := os.ReadFile(path); string(got) != "bytes=192" { |
| 277 | t.Fatalf("encoder output %q", got) | 272 | t.Fatalf("encoder output %q", got) | ||
| 278 | } | 273 | } | ||
| 279 | } | 274 | } | ||
| @@ -722,13 +717,12 @@ func TestRealFFmpeg(t *testing.T) { | |||||
| 722 | w, h, stride int | 717 | w, h, stride int | ||
| 723 | set ports.VideoSettings | 718 | set ports.VideoSettings | ||
| 724 | wantW, wantH int | 719 | wantW, wantH int | ||
| 725 | - | invert bool | |||
| 726 | cancelInstead bool | 720 | cancelInstead bool | ||
| 727 | }{ | 721 | }{ | ||
| 728 | - | {"retain padded inverted", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, true, false}, | 722 | + | {"retain padded", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, false}, |
| 729 | - | {"odd source scaled", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30, Width: 62, Height: 46}, 62, 46, false, false}, | 723 | + | {"odd source scaled", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30, Width: 62, Height: 46}, 62, 46, false}, |
| 730 | - | {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false, false}, | 724 | + | {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false}, |
| 731 | - | {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, false, true}, | 725 | + | {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, true}, |
| 732 | } { | 726 | } { | ||
| 733 | t.Run(tc.name, func(t *testing.T) { | 727 | t.Run(tc.name, func(t *testing.T) { | ||
| 734 | w := New() | 728 | w := New() | ||
| @@ -736,7 +730,6 @@ func TestRealFFmpeg(t *testing.T) { | |||||
| 736 | ctx, cancel := context.WithCancel(context.Background()) | 730 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 737 | defer cancel() | 731 | defer cancel() | ||
| 738 | f := newFrame(tc.w, tc.h, tc.stride, 0) | 732 | f := newFrame(tc.w, tc.h, tc.stride, 0) | ||
| 739 | - | f.YInvert = tc.invert | |||
| 740 | if err := w.Start(ctx, f, path, tc.set); err != nil { | 733 | if err := w.Start(ctx, f, path, tc.set); err != nil { | ||
| 741 | t.Fatal(err) | 734 | t.Fatal(err) | ||
| 742 | } | 735 | } | ||
| @@ -793,13 +786,9 @@ func TestRealFFmpegRefusesExistingPath(t *testing.T) { | |||||
| 793 | 786 | ||||
| 794 | func TestVideoRowsAllocations(t *testing.T) { | 787 | func TestVideoRowsAllocations(t *testing.T) { | ||
| 795 | cases := map[string]ports.Frame{ | 788 | cases := map[string]ports.Frame{ | ||
| 796 | - | "packed": newFrame(64, 32, 256, 1), | 789 | + | "packed": newFrame(64, 32, 256, 1), |
| 797 | - | "padded": newFrame(64, 32, 320, 1), | 790 | + | "padded": newFrame(64, 32, 320, 1), |
| 798 | - | "inverted": newFrame(64, 32, 320, 1), | |||
| 799 | } | 791 | } | ||
| 800 | - | inv := cases["inverted"] | |||
| 801 | - | inv.YInvert = true | |||
| 802 | - | cases["inverted"] = inv | |||
| 803 | for name, f := range cases { | 792 | for name, f := range cases { | ||
| 804 | t.Run(name, func(t *testing.T) { | 793 | t.Run(name, func(t *testing.T) { | ||
| 805 | if err := f.Validate(); err != nil { | 794 | if err := f.Validate(); err != nil { | ||
| @@ -827,12 +816,12 @@ func TestVideoRowsAllocations(t *testing.T) { | |||||
| 827 | } | 816 | } | ||
| 828 | 817 | ||||
| 829 | func TestWriteRowsLayout(t *testing.T) { | 818 | func TestWriteRowsLayout(t *testing.T) { | ||
| 830 | - | // 2x2, stride 12, stored bottom row first. | 819 | + | // 2x2, stride 12: the padding is left out. |
| 831 | pix := []byte{ | 820 | pix := []byte{ | ||
| 832 | - | 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9, | |||
| 833 | 1, 1, 1, 1, 2, 2, 2, 2, 9, 9, 9, 9, | 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 | var buf bytes.Buffer | 825 | var buf bytes.Buffer | ||
| 837 | if err := writeRows(&buf, f); err != nil { | 826 | if err := writeRows(&buf, f); err != nil { | ||
| 838 | t.Fatal(err) | 827 | t.Fatal(err) | ||
| @@ -857,13 +846,11 @@ func TestStderrBounded(t *testing.T) { | |||||
| 857 | 846 | ||||
| 858 | func BenchmarkVideoRows(b *testing.B) { | 847 | func BenchmarkVideoRows(b *testing.B) { | ||
| 859 | for _, size := range []struct { | 848 | for _, size := range []struct { | ||
| 860 | - | name string | 849 | + | name string |
| 861 | - | w, h int | 850 | + | w, h int |
| 862 | - | invert bool | 851 | + | }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} { |
| 863 | - | }{{"1080p", 1920, 1080, false}, {"1080p-inverted", 1920, 1080, true}, {"4K", 3840, 2160, false}, {"4K-inverted", 3840, 2160, true}} { | |||
| 864 | b.Run(size.name, func(b *testing.B) { | 852 | b.Run(size.name, func(b *testing.B) { | ||
| 865 | f := newFrame(size.w, size.h, size.w*4, 1) | 853 | f := newFrame(size.w, size.h, size.w*4, 1) | ||
| 866 | - | f.YInvert = size.invert | |||
| 867 | b.SetBytes(int64(size.w * size.h * 4)) | 854 | b.SetBytes(int64(size.w * size.h * 4)) | ||
| 868 | b.ReportAllocs() | 855 | b.ReportAllocs() | ||
| 869 | for b.Loop() { | 856 | for b.Loop() { | ||
M internal/adapters/indicator/indicator.go +20 -7
| @@ -1,10 +1,11 @@ | |||||
| 1 | // Package indicator is the recording HUD: a small layer surface at the top | 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 | // It is one thin surface, not a full-output overlay: it takes no keyboard | 5 | // It is one thin surface, not a full-output overlay: it takes no keyboard | ||
| 5 | // focus, and only the Stop button receives pointer input; the rest of the HUD | 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 | + | // and everything around it pass clicks through. The border around the |
| 7 | - | // recorded area is not drawn here; the compositor owns it. | 8 | + | // recorded area is not drawn here: the compositor marks it. |
| 8 | // | 9 | // | ||
| 9 | // Capture exclusion is explicit authorization, not a property of how the HUD is | 10 | // Capture exclusion is explicit authorization, not a property of how the HUD is | ||
| 10 | // built. The caller supplies authorize, which NeferGUI calls with the surface | 11 | // built. The caller supplies authorize, which NeferGUI calls with the surface | ||
| @@ -58,6 +59,11 @@ type Indicator struct { | |||||
| 58 | // name). It is shown after the timer, cut to 12 characters, and only as | 59 | // name). It is shown after the timer, cut to 12 characters, and only as | ||
| 59 | // text: the HUD never draws geometry for it. Set it before Run. | 60 | // text: the HUD never draws geometry for it. Set it before Run. | ||
| 60 | TargetLabel string | 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 | authorize func(context.Context, nefergui.WaylandSurface) error | 68 | authorize func(context.Context, nefergui.WaylandSurface) error | ||
| 63 | stop chan<- struct{} | 69 | stop chan<- struct{} | ||
| @@ -114,7 +120,7 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim | |||||
| 114 | nefergui.Size(Width, Height), | 120 | nefergui.Size(Width, Height), | ||
| 115 | nefergui.Styles(sheet), | 121 | nefergui.Styles(sheet), | ||
| 116 | nefergui.Transparent(), | 122 | nefergui.Transparent(), | ||
| 117 | - | nefergui.Layer(layerConfig(output.Name)), | 123 | + | nefergui.Layer(layerConfig(output.Name, i.Frame)), |
| 118 | nefergui.OnSurface(i.authorize), | 124 | nefergui.OnSurface(i.authorize), | ||
| 119 | nefergui.Wake(wake), | 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 | // layerConfig is a top-centre HUD that never takes keyboard focus and accepts | 135 | // layerConfig is a top-centre HUD that never takes keyboard focus and accepts | ||
| 130 | - | // pointer input only inside the Stop button. | 136 | + | // pointer input only inside the Stop button. Centre is that of the output, or |
| 131 | - | func layerConfig(output string) nefergui.LayerConfig { | 137 | + | // of frame when it is set: then the HUD is anchored to the top left of the |
| 132 | - | return nefergui.LayerConfig{ | 138 | + | // output and moved by margins. |
| 139 | + | func layerConfig(output string, frame ports.Region) nefergui.LayerConfig { | |||
| 140 | + | c := nefergui.LayerConfig{ | |||
| 133 | Output: output, | 141 | Output: output, | ||
| 134 | Namespace: namespace, | 142 | Namespace: namespace, | ||
| 135 | Level: nefergui.LayerOverlay, | 143 | Level: nefergui.LayerOverlay, | ||
| @@ -139,6 +147,11 @@ func layerConfig(output string) nefergui.LayerConfig { | |||||
| 139 | Margin: [4]int32{topMargin, 0, 0, 0}, | 147 | Margin: [4]int32{topMargin, 0, 0, 0}, | ||
| 140 | InputRects: []nefergui.Rect{stopRect()}, | 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 | func stopRect() nefergui.Rect { | 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 | func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | 124 | func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | ||
| 125 | - | c := layerConfig("DP-1") | 125 | + | c := layerConfig("DP-1", ports.Region{}) |
| 126 | assert.Equal(t, nefergui.KeyboardNone, c.Keyboard) | 126 | assert.Equal(t, nefergui.KeyboardNone, c.Keyboard) | ||
| 127 | assert.Equal(t, nefergui.LayerOverlay, c.Level) | 127 | assert.Equal(t, nefergui.LayerOverlay, c.Level) | ||
| 128 | assert.Equal(t, nefergui.AnchorTop, c.Anchors) | 128 | assert.Equal(t, nefergui.AnchorTop, c.Anchors) | ||
| @@ -133,6 +133,20 @@ func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | |||||
| 133 | assert.LessOrEqual(t, r.X+r.Width, int32(Width)) | 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 | func TestHUDReadabilityAndStopGeometry(t *testing.T) { | 150 | func TestHUDReadabilityAndStopGeometry(t *testing.T) { | ||
| 137 | assert.Equal(t, 384, Width) | 151 | assert.Equal(t, 384, Width) | ||
| 138 | assert.Equal(t, 40, Height) | 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 | return nil | 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 | // to 255 because output is opaque (XRGB padding and ARGB alpha are ignored). | 90 | // to 255 because output is opaque (XRGB padding and ARGB alpha are ignored). | ||
| 91 | func toRGBA(ctx context.Context, f ports.Frame) (*image.RGBA, error) { | 91 | func toRGBA(ctx context.Context, f ports.Frame) (*image.RGBA, error) { | ||
| 92 | rowLen := f.Width * ports.BytesPerPixel | 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 | return img | 29 | return img | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | func TestSaveConvertsBGRAWithStrideAndInvert(t *testing.T) { | 32 | + | func TestSaveConvertsBGRAWithStride(t *testing.T) { |
| 33 | - | // 2x2 pixels, stride 12 (4 padding bytes per row), stored bottom-first. | 33 | + | // 2x2 pixels, stride 12 (4 padding bytes per row). |
| 34 | // Pixel bytes are B,G,R,A; alpha values must be ignored. | 34 | // Pixel bytes are B,G,R,A; alpha values must be ignored. | ||
| 35 | pix := []byte{ | 35 | pix := []byte{ | ||
| 36 | - | // stored row 0 == bottom row | 36 | + | // top row |
| 37 | - | 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE, | |||
| 38 | - | // stored row 1 == top row | |||
| 39 | 3, 2, 1, 255, 6, 5, 4, 0, 0xEE, 0xEE, 0xEE, 0xEE, | 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 | for _, format := range []ports.PixelFormat{ports.ARGB8888, ports.XRGB8888} { | 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 | path := filepath.Join(t.TempDir(), "shot.png") | 43 | path := filepath.Join(t.TempDir(), "shot.png") | ||
| 44 | if err := New().Save(context.Background(), f, path); err != nil { | 44 | if err := New().Save(context.Background(), f, path); err != nil { | ||
| 45 | t.Fatal(err) | 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 | m := newTestModel(t) | 15 | m := newTestModel(t) | ||
| 16 | m.base, m.mode, m.toggle = mode, mode, toggle | 16 | m.base, m.mode, m.toggle = mode, mode, toggle | ||
| 17 | m.footerMode = mode | 17 | m.footerMode = mode | ||
| 18 | - | m.footerText = footerText(mode, core.PickRegion, false, toggle) | 18 | + | m.footerText = footerText(mode, core.PickRegion, false, toggle, false) |
| 19 | return m | 19 | return m | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
M internal/adapters/selection/model.go +12 -9
| @@ -27,13 +27,16 @@ const ( | |||||
| 27 | // here is locked. The decision leaves through the shared session, never through | 27 | // here is locked. The decision leaves through the shared session, never through | ||
| 28 | // this struct. | 28 | // this struct. | ||
| 29 | type model struct { | 29 | type model struct { | ||
| 30 | - | base ports.Mode // mode the selector started in | 30 | + | base ports.Mode // mode the selector started in |
| 31 | - | mode ports.Mode // current mode: base flipped by the session's Tab toggles | 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) | 32 | + | toggle bool // Tab switches shot <-> rec (all-in-one only) |
| 33 | - | seen uint32 // session toggles already applied to mode | 33 | + | // noIndicator: the compositor cannot exclude a HUD from the video, so the |
| 34 | - | wake chan struct{} | 34 | + | // record footer says there is none. |
| 35 | - | outputs []ports.Output | 35 | + | noIndicator bool |
| 36 | - | index int // the output this overlay covers; every output has its own model | 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 | picker core.Picker | 41 | picker core.Picker | ||
| 39 | sized bool | 42 | sized bool | ||
| @@ -58,7 +61,7 @@ type model struct { | |||||
| 58 | } | 61 | } | ||
| 59 | 62 | ||||
| 60 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | 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 | // otherMode is the mode a Tab switches to. | 67 | // otherMode is the mode a Tab switches to. | ||
| @@ -276,7 +279,7 @@ func (m *model) refreshFooter() { | |||||
| 276 | _, _, workspace := m.picker.Workspace() | 279 | _, _, workspace := m.picker.Workspace() | ||
| 277 | if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode { | 280 | if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode { | ||
| 278 | m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode | 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 | margin = baseFont | 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 | // footerText is rebuilt only when the capture mode, picker kind or workspace | 27 | // footerText is rebuilt only when the capture mode, picker kind or workspace | ||
| 24 | // availability changes; pointer motion never formats hints. | 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 | action, name, other := "capture", "shot", "rec" | 30 | action, name, other := "capture", "shot", "rec" | ||
| 27 | if mode == ports.Record { | 31 | if mode == ports.Record { | ||
| 28 | action, name, other = "record", "rec", "shot" | 32 | action, name, other = "record", "rec", "shot" | ||
| 29 | } | 33 | } | ||
| 30 | var b strings.Builder | 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 | b.WriteString(name) | 36 | b.WriteString(name) | ||
| 33 | if toggle { | 37 | if toggle { | ||
| 34 | b.WriteString(" · Tab ") | 38 | b.WriteString(" · Tab ") | ||
| @@ -58,6 +62,10 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) str | |||||
| 58 | b.WriteString(action) | 62 | b.WriteString(action) | ||
| 59 | b.WriteString(" monitor") | 63 | b.WriteString(" monitor") | ||
| 60 | } | 64 | } | ||
| 65 | + | if noIndicator && mode == ports.Record { | |||
| 66 | + | b.WriteByte('\n') | |||
| 67 | + | b.WriteString(noIndicatorHint) | |||
| 68 | + | } | |||
| 61 | return b.String() | 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 | func TestFooterAndHeaderText(t *testing.T) { | 662 | func TestFooterAndHeaderText(t *testing.T) { | ||
| 663 | - | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · 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), "rec · 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 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | 668 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | ||
| 666 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | 669 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | ||
| 667 | i, ok := outputKey("2", 2) | 670 | i, ok := outputKey("2", 2) | ||
| @@ -779,3 +782,13 @@ func TestInputReportsClosing(t *testing.T) { | |||||
| 779 | m.input(motion(200, 200)) | 782 | m.input(motion(200, 200)) | ||
| 780 | assert.True(t, m.input(button(200, 200, false)), "a drag release accepts the region") | 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 | // Selector shows the layer-shell selection overlay. | 46 | // Selector shows the layer-shell selection overlay. | ||
| 47 | type Selector struct { | 47 | type Selector struct { | ||
| 48 | - | mode ports.Mode | 48 | + | mode ports.Mode |
| 49 | - | video ports.VideoSettings | 49 | + | video ports.VideoSettings |
| 50 | - | workspaces []ports.Workspace | 50 | + | workspaces []ports.Workspace |
| 51 | - | grid bool | 51 | + | grid bool |
| 52 | - | toggle bool | 52 | + | toggle bool |
| 53 | + | noIndicator bool | |||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 55 | var _ ports.Selector = (*Selector)(nil) | 56 | var _ ports.Selector = (*Selector)(nil) | ||
| 56 | 57 | ||||
| 57 | // New returns a selector for mode. Record selections carry video; a zero FPS | 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 | // offered only for the Active workspace with a valid (nonzero) ID on the output | 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 | + | // an overlay covers. Other workspaces are never offered and nothing is |
| 61 | - | // invented when no workspace qualifies. grid is the initial state of the | 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 | // whole-monitor guides; the G key toggles them per overlay. The caller assigns | 64 | // whole-monitor guides; the G key toggles them per overlay. The caller assigns | ||
| 63 | // Selection.Path. The first output passed to Select opens first, so the caller | 65 | // Selection.Path. The first output passed to Select opens first, so the caller | ||
| 64 | // orders outputs to put the preferred one first. | 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 | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | 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 | // AllowToggle lets Tab switch between screenshot and record selection. The | 85 | // AllowToggle lets Tab switch between screenshot and record selection. The | ||
| 73 | // starting mode is New's; the target selection is kept across a switch. | 86 | // starting mode is New's; the target selection is kept across a switch. | ||
| 74 | func (s *Selector) AllowToggle() *Selector { | 87 | func (s *Selector) AllowToggle() *Selector { | ||
| @@ -113,10 +126,12 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 113 | for i := range outputs { | 126 | for i := range outputs { | ||
| 114 | m := newModel(s.mode, outputs, i, sess, s.grid) | 127 | m := newModel(s.mode, outputs, i, sess, s.grid) | ||
| 115 | m.setWorkspaces(s.workspaces) | 128 | m.setWorkspaces(s.workspaces) | ||
| 129 | + | m.noIndicator = s.noIndicator | |||
| 116 | if s.toggle { | 130 | if s.toggle { | ||
| 117 | m.toggle, m.wake = true, make(chan struct{}, 1) | 131 | m.toggle, m.wake = true, make(chan struct{}, 1) | ||
| 118 | sess.wakes = append(sess.wakes, m.wake) | 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 | wg.Add(1) | 135 | wg.Add(1) | ||
| 121 | go func() { | 136 | go func() { | ||
| 122 | defer wg.Done() | 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 | return nil | 30 | return nil | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | // shmBuffer is the single wl_shm mapping and wl_buffer reused between | 33 | + | // shmBuffer is one wl_shm mapping and wl_buffer reused between captures. A |
| 34 | - | // captures. The pool proxy is destroyed right after buffer creation, which the | 34 | + | // Source has two: one holds the last frame while the next is written. The pool |
| 35 | - | // protocol allows: the buffer keeps the compositor-side mapping alive. | 35 | + | // proxy is destroyed right after buffer creation, which the protocol allows: |
| 36 | + | // the buffer keeps the compositor-side mapping alive. | |||
| 36 | type shmBuffer struct { | 37 | type shmBuffer struct { | ||
| 37 | geom bufGeom | 38 | geom bufGeom | ||
| 38 | data []byte | 39 | data []byte | ||
| 39 | buffer *core.Buffer | 40 | buffer *core.Buffer | ||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | - | // ensureBuffer makes s.buf match g, reusing it when unchanged. | 43 | + | // ensureBuffer makes s.bufs[i] match g, reusing it when unchanged. |
| 43 | - | func (s *Source) ensureBuffer(g bufGeom) error { | 44 | + | func (s *Source) ensureBuffer(i int, g bufGeom) error { |
| 44 | - | if s.buf.buffer != nil && s.buf.geom == g { | 45 | + | if s.bufs[i].buffer != nil && s.bufs[i].geom == g { |
| 45 | return nil | 46 | return nil | ||
| 46 | } | 47 | } | ||
| 47 | - | if err := s.releaseBuffer(true); err != nil { | 48 | + | if err := s.releaseBuffer(i, true); err != nil { |
| 48 | s.terminate() | 49 | s.terminate() | ||
| 49 | return err | 50 | return err | ||
| 50 | } | 51 | } | ||
| @@ -74,15 +75,15 @@ func (s *Source) ensureBuffer(g bufGeom) error { | |||||
| 74 | s.terminate() | 75 | s.terminate() | ||
| 75 | return fmt.Errorf("wayland: create buffer: %w", err) | 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 | return nil | 79 | return nil | ||
| 79 | } | 80 | } | ||
| 80 | 81 | ||||
| 81 | - | // releaseBuffer unmaps the storage. With destroy set, and while connected, it | 82 | + | // releaseBuffer unmaps the storage of s.bufs[i]. With destroy set, and while |
| 82 | - | // also destroys the wl_buffer. The caller holds opMu. | 83 | + | // connected, it also destroys the wl_buffer. The caller holds opMu. |
| 83 | - | func (s *Source) releaseBuffer(destroy bool) error { | 84 | + | func (s *Source) releaseBuffer(i int, destroy bool) error { |
| 84 | - | b := s.buf | 85 | + | b := s.bufs[i] |
| 85 | - | s.buf = shmBuffer{} | 86 | + | s.bufs[i] = shmBuffer{} |
| 86 | var err error | 87 | var err error | ||
| 87 | if b.buffer != nil && destroy && !s.dead.Load() { | 88 | if b.buffer != nil && destroy && !s.dead.Load() { | ||
| 88 | err = b.buffer.Destroy() | 89 | err = b.buffer.Destroy() | ||
| @@ -92,3 +93,9 @@ func (s *Source) releaseBuffer(destroy bool) error { | |||||
| 92 | } | 93 | } | ||
| 93 | return err | 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 | 1 | + | // Package wayland captures frames over ext-image-copy-capture-v1 with the |
| 2 | - | // using the wlturbo transport and wl_shm buffers. | 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 | package wayland | 6 | package wayland | ||
| 4 | 7 | ||||
| 5 | import ( | 8 | import ( | ||
| @@ -17,6 +20,9 @@ import ( | |||||
| 17 | 20 | ||||
| 18 | "github.com/bnema/wlturbo" | 21 | "github.com/bnema/wlturbo" | ||
| 19 | "github.com/bnema/wlturbo/protocol/core" | 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 | "github.com/bnema/zerowrap" | 26 | "github.com/bnema/zerowrap" | ||
| 21 | 27 | ||||
| 22 | "github.com/bnema/nefercap/internal/logging" | 28 | "github.com/bnema/nefercap/internal/logging" | ||
| @@ -24,12 +30,15 @@ import ( | |||||
| 24 | ) | 30 | ) | ||
| 25 | 31 | ||||
| 26 | const ( | 32 | const ( | ||
| 27 | - | outputVersion = 4 // wl_output.name arrived in version 4 | 33 | + | outputVersion = 4 // wl_output.name arrived in version 4 |
| 28 | - | shmVersion = 1 | 34 | + | shmVersion = 1 |
| 29 | - | screencopyVersion = 3 | 35 | + | copyVersion = 1 |
| 30 | - | screencopyIface = "zwlr_screencopy_manager_v1" | 36 | + | xdgOutputVersion = 3 |
| 31 | - | maxSettleRounds = 4 | 37 | + | maxSettleRounds = 4 |
| 32 | - | maxOutputScale = 16 // informational; larger announcements are clamped | 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 | // output is the client-side state of one bound wl_output global. | 44 | // output is the client-side state of one bound wl_output global. | ||
| @@ -40,6 +49,8 @@ type output struct { | |||||
| 40 | name string | 49 | name string | ||
| 41 | width, height int32 | 50 | width, height int32 | ||
| 42 | scale int32 | 51 | scale int32 | ||
| 52 | + | lw, lh int32 // logical size from xdg-output, 0 when unknown | |||
| 53 | + | xdg *xdgoutput.ZxdgOutput | |||
| 43 | removed bool | 54 | removed bool | ||
| 44 | } | 55 | } | ||
| 45 | 56 | ||||
| @@ -69,18 +80,20 @@ type Source struct { | |||||
| 69 | watchGen atomic.Uint64 | 80 | watchGen atomic.Uint64 | ||
| 70 | watchActive atomic.Bool | 81 | watchActive atomic.Bool | ||
| 71 | 82 | ||||
| 72 | - | shm *core.Shm | 83 | + | shm *core.Shm |
| 73 | - | screencopy *screencopyManager | 84 | + | copyManager *imagecopycapture.ExtImageCopyCaptureManager |
| 74 | - | outputs map[uint32]*output | 85 | + | outSource *imagecapturesource.ExtOutputImageCaptureSourceManager |
| 75 | - | bound int // wl_output globals bound so far | 86 | + | xdgOutputs *xdgoutput.ZxdgOutputManager // nil when absent |
| 76 | - | buf shmBuffer | 87 | + | outputs map[uint32]*output |
| 77 | - | 88 | + | byProxy map[uint32]*output // wl_output proxy ID to output | |
| 78 | - | // Private capture session state, see capture_session.go. | 89 | + | bound int // wl_output globals bound so far |
| 79 | - | private *captureManager // nil when the compositor lacks the protocol | 90 | + | bufs [2]shmBuffer |
| 80 | - | workspaces map[uint64]*workspace | 91 | + | conn net.Conn |
| 81 | - | metaErr error // set once the private inventory was rejected | 92 | + | pollWait time.Duration // how long Capture waits for a pending frame |
| 82 | - | session *captureSession | 93 | + | |
| 83 | - | pingEvery time.Duration | 94 | + | sess *session // the one live capture session, nil before the first capture |
| 95 | + | ext extensions | |||
| 96 | + | ws workspaceState | |||
| 84 | } | 97 | } | ||
| 85 | 98 | ||||
| 86 | var _ ports.Source = (*Source)(nil) | 99 | var _ ports.Source = (*Source)(nil) | ||
| @@ -105,11 +118,13 @@ func New(ctx context.Context, socket string) (*Source, error) { | |||||
| 105 | return nil, fmt.Errorf("wayland: %w", err) | 118 | return nil, fmt.Errorf("wayland: %w", err) | ||
| 106 | } | 119 | } | ||
| 107 | s := &Source{ | 120 | s := &Source{ | ||
| 108 | - | log: logging.For(ctx, "wayland"), | 121 | + | log: logging.For(ctx, "wayland"), |
| 109 | - | display: display, | 122 | + | display: display, |
| 110 | - | wl: display.Context(), | 123 | + | wl: display.Context(), |
| 111 | - | outputs: make(map[uint32]*output), | 124 | + | outputs: make(map[uint32]*output), |
| 112 | - | pingEvery: sessionPingInterval, | 125 | + | byProxy: make(map[uint32]*output), |
| 126 | + | conn: conn, | |||
| 127 | + | pollWait: defaultPollWait, | |||
| 113 | } | 128 | } | ||
| 114 | stop := context.AfterFunc(ctx, s.terminate) | 129 | stop := context.AfterFunc(ctx, s.terminate) | ||
| 115 | err = s.discover() | 130 | err = s.discover() | ||
| @@ -122,7 +137,10 @@ func New(ctx context.Context, socket string) (*Source, error) { | |||||
| 122 | s.log.Error().Err(err).Msg("wayland source failed to start") | 137 | s.log.Error().Err(err).Msg("wayland source failed to start") | ||
| 123 | return nil, err | 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 | return s, nil | 144 | return s, nil | ||
| 127 | } | 145 | } | ||
| 128 | 146 | ||||
| @@ -143,26 +161,53 @@ func socketPath(socket string) (string, error) { | |||||
| 143 | return filepath.Join(dir, socket), nil | 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 | reg := s.display.Registry() | 165 | reg := s.display.Registry() | ||
| 148 | reg.AddHandler(core.OutputInterface, s.onOutputGlobal) | 166 | reg.AddHandler(core.OutputInterface, s.onOutputGlobal) | ||
| 149 | reg.AddGlobalRemoveHandler(globalRemoved{s}) | 167 | reg.AddGlobalRemoveHandler(globalRemoved{s}) | ||
| 150 | - | if err := s.roundtrip(); err != nil { | 168 | + | if err = s.roundtrip(); err != nil { |
| 151 | return err | 169 | return err | ||
| 152 | } | 170 | } | ||
| 153 | s.shm = core.NewShm(s.wl) | 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 | return fmt.Errorf("wayland: bind wl_shm: %w", err) | 173 | return fmt.Errorf("wayland: bind wl_shm: %w", err) | ||
| 156 | } | 174 | } | ||
| 157 | - | s.screencopy = &screencopyManager{} | 175 | + | s.copyManager = imagecopycapture.NewExtImageCopyCaptureManager(s.wl) |
| 158 | - | s.screencopy.SetContext(s.wl) | 176 | + | if err = s.bindRequired(imagecopycapture.ExtImageCopyCaptureManagerInterface, s.copyManager); err != nil { |
| 159 | - | if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil { | 177 | + | return err |
| 160 | - | return fmt.Errorf("wayland: bind %s: %w", screencopyIface, 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 | return err | 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 | func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) { | 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 | return | 228 | return | ||
| 184 | } | 229 | } | ||
| 185 | s.outputs[name] = o | 230 | s.outputs[name] = o | ||
| 231 | + | s.byProxy[o.proxy.ID()] = o | |||
| 186 | s.bound++ | 232 | s.bound++ | ||
| 233 | + | if s.xdgOutputs != nil { | |||
| 234 | + | s.watchLogicalSize(o) | |||
| 235 | + | } | |||
| 187 | } | 236 | } | ||
| 188 | 237 | ||||
| 189 | type globalRemoved struct{ s *Source } | 238 | type globalRemoved struct{ s *Source } | ||
| @@ -194,7 +243,10 @@ func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemove | |||||
| 194 | return | 243 | return | ||
| 195 | } | 244 | } | ||
| 196 | delete(g.s.outputs, e.Name) | 245 | delete(g.s.outputs, e.Name) | ||
| 246 | + | delete(g.s.byProxy, o.proxy.ID()) | |||
| 197 | o.removed = true | 247 | o.removed = true | ||
| 248 | + | g.s.releaseXdg(o) | |||
| 249 | + | g.s.ws.forgetOutput(o.proxy.ID()) | |||
| 198 | if o.version >= 3 { | 250 | if o.version >= 3 { | ||
| 199 | _ = o.proxy.Release() | 251 | _ = o.proxy.Release() | ||
| 200 | } // older wl_output has no destructor: the proxy must stay to absorb events | 252 | } // older wl_output has no destructor: the proxy must stay to absorb events | ||
| @@ -216,6 +268,25 @@ func (s *Source) dispatch() error { | |||||
| 216 | return nil | 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 | // settle round-trips until no output was bound meanwhile, so that the events | 290 | // settle round-trips until no output was bound meanwhile, so that the events | ||
| 220 | // of an output announced during the last round trip have been received too. | 291 | // of an output announced during the last round trip have been received too. | ||
| 221 | func (s *Source) settle() error { | 292 | func (s *Source) settle() error { | ||
| @@ -260,7 +331,7 @@ func (s *Source) begin(ctx context.Context) error { | |||||
| 260 | s.watchActive.Store(false) | 331 | s.watchActive.Store(false) | ||
| 261 | s.terminate() | 332 | s.terminate() | ||
| 262 | s.detachWatcher() | 333 | s.detachWatcher() | ||
| 263 | - | s.releaseBuffer(false) | 334 | + | s.releaseBuffers() |
| 264 | return err | 335 | return err | ||
| 265 | } | 336 | } | ||
| 266 | return nil | 337 | return nil | ||
| @@ -301,7 +372,7 @@ func (s *Source) finish(ctx context.Context, err error) error { | |||||
| 301 | return err | 372 | return err | ||
| 302 | } | 373 | } | ||
| 303 | s.detachWatcher() | 374 | s.detachWatcher() | ||
| 304 | - | s.releaseBuffer(false) | 375 | + | s.releaseBuffers() |
| 305 | if cerr := ctx.Err(); cerr != nil { | 376 | if cerr := ctx.Err(); cerr != nil { | ||
| 306 | return cerr | 377 | return cerr | ||
| 307 | } | 378 | } | ||
| @@ -352,6 +423,14 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro | |||||
| 352 | return frame, nil | 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 | // Close terminates the connection, waits for a blocked operation to observe | 434 | // Close terminates the connection, waits for a blocked operation to observe | ||
| 356 | // it, and releases the mapped storage. It is idempotent. | 435 | // it, and releases the mapped storage. It is idempotent. | ||
| 357 | func (s *Source) Close() error { | 436 | func (s *Source) Close() error { | ||
| @@ -359,5 +438,5 @@ func (s *Source) Close() error { | |||||
| 359 | s.opMu.Lock() | 438 | s.opMu.Lock() | ||
| 360 | defer s.opMu.Unlock() | 439 | defer s.opMu.Unlock() | ||
| 361 | s.detachWatcher() | 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 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/wayland/testserver" | |||
| 12 | "github.com/bnema/nefercap/internal/ports" | 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 | t.Helper() | 17 | t.Helper() | ||
| 17 | - | comp := startCompositor(t, cfg) | 18 | + | srv := testserver.Start(t, cfg) |
| 18 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | 19 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| 19 | defer cancel() | 20 | defer cancel() | ||
| 20 | - | s, err := New(ctx, comp.path) | 21 | + | s, err := New(ctx, srv.Path) |
| 21 | require.NoError(t, err) | 22 | require.NoError(t, err) | ||
| 22 | t.Cleanup(func() { _ = s.Close() }) | 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 | t.Helper() | 28 | t.Helper() | ||
| 28 | outs, err := s.Outputs(context.Background()) | 29 | outs, err := s.Outputs(context.Background()) | ||
| 29 | require.NoError(t, err) | 30 | require.NoError(t, err) | ||
| @@ -39,374 +40,390 @@ func fdCount(t *testing.T) int { | |||||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | func TestOutputs(t *testing.T) { | 42 | func TestOutputs(t *testing.T) { | ||
| 42 | - | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | 43 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{ |
| 43 | - | {name: "DP-1", width: 8, height: 4, scale: 2, version: 4}, | 44 | + | {Name: "A-1", Width: 8, Height: 4, Scale: 1}, {Name: "B-2", Width: 16, Height: 8, Scale: 2}, |
| 44 | - | {name: "ignored", width: 6, height: 2, scale: 1, version: 3}, | |||
| 45 | }}) | 45 | }}) | ||
| 46 | outs, err := s.Outputs(context.Background()) | 46 | outs, err := s.Outputs(context.Background()) | ||
| 47 | require.NoError(t, err) | 47 | require.NoError(t, err) | ||
| 48 | - | require.Equal(t, []ports.Output{ | 48 | + | require.Len(t, outs, 2) |
| 49 | - | {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2}, | 49 | + | require.Equal(t, "A-1", outs[0].Name) |
| 50 | - | {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4 | 50 | + | require.Equal(t, 16, outs[1].Width) |
| 51 | - | }, outs) | 51 | + | require.Equal(t, 2, outs[1].Scale) |
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | func TestOutputRemovedBetweenCalls(t *testing.T) { | 54 | + | func TestCapabilitiesBaseline(t *testing.T) { |
| 55 | - | s, comp := newSource(t, compositorConfig{outputs: []outputSpec{ | 55 | + | s, _ := newSource(t, testserver.Config{}) |
| 56 | - | {name: "A", width: 4, height: 4, scale: 1, version: 4}, | 56 | + | require.Equal(t, ports.Capabilities{}, s.Capabilities()) |
| 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) | |||
| 79 | } | 57 | } | ||
| 80 | 58 | ||||
| 81 | func TestCaptureFullOutput(t *testing.T) { | 59 | func TestCaptureFullOutput(t *testing.T) { | ||
| 82 | - | s, comp := newSource(t, compositorConfig{}) | 60 | + | s, srv := newSource(t, testserver.Config{}) |
| 83 | out := firstOutput(t, s) | 61 | out := firstOutput(t, s) | ||
| 84 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 62 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | ||
| 85 | require.NoError(t, err) | 63 | require.NoError(t, err) | ||
| 86 | require.NoError(t, f.Validate()) | 64 | require.NoError(t, f.Validate()) | ||
| 87 | - | require.Equal(t, 8, f.Width) | 65 | + | require.Equal(t, [3]int{8, 4, 32}, [3]int{f.Width, f.Height, f.Stride}) |
| 88 | - | require.Equal(t, 4, f.Height) | |||
| 89 | - | require.Equal(t, 32, f.Stride) | |||
| 90 | require.Equal(t, ports.XRGB8888, f.Format) | 66 | require.Equal(t, ports.XRGB8888, f.Format) | ||
| 91 | - | require.False(t, f.YInvert) | 67 | + | want := testserver.Pixel(3, 2, 1) |
| 92 | - | for y := 0; y < f.Height; y++ { | 68 | + | require.Equal(t, want[:], f.Row(2)[12:16]) |
| 93 | - | row := f.Row(y) | 69 | + | require.Equal(t, "output", srv.Stats().LastSource) |
| 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") | |||
| 100 | } | 70 | } | ||
| 101 | 71 | ||||
| 102 | - | func TestCaptureStrideFormatAndYInvert(t *testing.T) { | 72 | + | func TestCapturePicksSupportedFormat(t *testing.T) { |
| 103 | - | s, _ := newSource(t, compositorConfig{ | 73 | + | s, _ := newSource(t, testserver.Config{Formats: []uint32{0x34325258 /* XR24 big */, testserver.ARGB8888}}) |
| 104 | - | announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}}, | 74 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) |
| 105 | - | flags: 1, | |||
| 106 | - | }) | |||
| 107 | - | out := firstOutput(t, s) | |||
| 108 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 109 | require.NoError(t, err) | 75 | require.NoError(t, err) | ||
| 110 | - | require.NoError(t, f.Validate()) | |||
| 111 | require.Equal(t, ports.ARGB8888, f.Format) | 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) { | 79 | + | func TestCaptureUnsupportedFormat(t *testing.T) { |
| 139 | - | s, comp := newSource(t, compositorConfig{}) | 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 | out := firstOutput(t, s) | 97 | out := firstOutput(t, s) | ||
| 141 | - | for _, r := range []ports.Region{ | 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}} { |
| 142 | - | {X: -1, Y: 0, Width: 1, Height: 1}, | 99 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r}) |
| 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}) | |||
| 150 | require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r) | 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) { | 104 | + | func TestMissingCopyCapture(t *testing.T) { |
| 158 | - | s, _ := newSource(t, compositorConfig{}) | 105 | + | srv := testserver.Start(t, testserver.Config{NoCopyCapture: true}) |
| 159 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: 99}) | 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 | require.ErrorIs(t, err, ports.ErrOutputNotFound) | 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 | require.NoError(t, err) | 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) { | 135 | + | func TestFailedStoppedReasonEndsCapture(t *testing.T) { |
| 166 | - | const xrgb = 1 | 136 | + | s, srv := newSource(t, testserver.Config{}) |
| 167 | - | cases := map[string]announce{ | 137 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 168 | - | "zero width": {xrgb, 0, 4, 16}, | 138 | + | srv.Fail(1, 2) |
| 169 | - | "zero height": {xrgb, 4, 0, 16}, | 139 | + | _, err := s.Capture(context.Background(), target) |
| 170 | - | "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4}, | 140 | + | require.ErrorIs(t, err, ports.ErrCaptureStopped) |
| 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 | - | } | |||
| 190 | } | 141 | } | ||
| 191 | 142 | ||||
| 192 | - | func TestCaptureUnsupportedFormat(t *testing.T) { | 143 | + | func TestFailedUnknownRetries(t *testing.T) { |
| 193 | - | for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage | 144 | + | s, srv := newSource(t, testserver.Config{}) |
| 194 | - | s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}}) | 145 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 195 | - | out := firstOutput(t, s) | 146 | + | srv.Fail(2, 0) |
| 196 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 147 | + | _, err := s.Capture(context.Background(), target) |
| 197 | - | require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format) | 148 | + | require.NoError(t, err, "recoverable failures are retried") |
| 198 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | 149 | + | s, srv = newSource(t, testserver.Config{}) |
| 199 | - | } | 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) { | 158 | + | // With a last frame, a transient unknown failure repeats it, like a pending |
| 203 | - | s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}}) | 159 | + | // capture does; the failures are counted across calls and stay bounded. |
| 204 | - | out := firstOutput(t, s) | 160 | + | func TestFailedUnknownRepeatsLastFrame(t *testing.T) { |
| 205 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 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 | require.NoError(t, err) | 164 | require.NoError(t, err) | ||
| 207 | - | require.Equal(t, 3, f.Width) | 165 | + | want := append([]byte(nil), first.Pixels...) |
| 208 | - | require.Equal(t, ports.XRGB8888, f.Format) | 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) { | 189 | + | // A session the compositor stops before its first frame is a refusal: the |
| 212 | - | s, _ := newSource(t, compositorConfig{screencopyVersion: 1}) | 190 | + | // error says so, and it is not retried with a second session. |
| 213 | - | out := firstOutput(t, s) | 191 | + | func TestSessionStoppedBeforeFirstFrameIsRefusal(t *testing.T) { |
| 214 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 192 | + | s, srv := newSource(t, testserver.Config{StopAtCreate: true}) |
| 215 | - | require.NoError(t, err) | 193 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 216 | - | require.NoError(t, f.Validate()) | 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) { | 201 | + | // A stop after frames were served is a plain stop, not a refusal. |
| 220 | - | comp := startCompositor(t, compositorConfig{noScreencopy: true}) | 202 | + | func TestSessionStoppedAfterFrameIsNotRefusal(t *testing.T) { |
| 221 | - | _, err := New(context.Background(), comp.path) | 203 | + | s, srv := newSource(t, testserver.Config{}) |
| 222 | - | require.Error(t, err) | 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) { | 213 | + | func TestFailedBufferConstraintsRenegotiates(t *testing.T) { |
| 226 | - | s, comp := newSource(t, compositorConfig{}) | 214 | + | s, srv := newSource(t, testserver.Config{}) |
| 227 | out := firstOutput(t, s) | 215 | out := firstOutput(t, s) | ||
| 228 | target := ports.Target{OutputID: out.ID} | 216 | target := ports.Target{OutputID: out.ID} | ||
| 229 | - | f1, err := s.Capture(context.Background(), target) | 217 | + | _, err := s.Capture(context.Background(), target) |
| 230 | require.NoError(t, err) | 218 | require.NoError(t, err) | ||
| 231 | - | first := &f1.Pixels[0] | 219 | + | // The constraints change while no frame is in flight; the next frame is |
| 232 | - | for i := 2; i <= 5; i++ { | 220 | + | // created with a fresh buffer of the new size. |
| 233 | - | f, err := s.Capture(context.Background(), target) | 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 | require.NoError(t, err) | 226 | require.NoError(t, err) | ||
| 235 | - | require.Same(t, first, &f.Pixels[0], "storage must be reused") | 227 | + | return f.Width == 16 |
| 236 | - | want := pixel(3, 1, uint32(i)) | 228 | + | }, 5*time.Second, time.Millisecond) |
| 237 | - | require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i) | 229 | + | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) |
| 238 | - | } | 230 | + | } |
| 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 })) | |||
| 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 | require.NoError(t, err) | 238 | require.NoError(t, err) | ||
| 245 | - | require.Equal(t, 2, f.Width) | 239 | + | srv.Resize("TEST-1", 16, 8) |
| 246 | - | require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools })) | 240 | + | _ = firstOutput(t, s) // a roundtrip: the session's new constraints are read |
| 247 | - | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers })) | 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 | func TestNoDescriptorLeak(t *testing.T) { | 311 | func TestNoDescriptorLeak(t *testing.T) { | ||
| 251 | - | s, comp := newSource(t, compositorConfig{}) | 312 | + | s, _ := newSource(t, testserver.Config{}) |
| 252 | - | out := firstOutput(t, s) | 313 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 253 | - | target := ports.Target{OutputID: out.ID} | |||
| 254 | _, err := s.Capture(context.Background(), target) | 314 | _, err := s.Capture(context.Background(), target) | ||
| 255 | require.NoError(t, err) | 315 | require.NoError(t, err) | ||
| 256 | - | steady := fdCount(t) | 316 | + | before := fdCount(t) |
| 257 | - | for i := 0; i < 30; i++ { | 317 | + | for range 50 { |
| 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) | |||
| 262 | _, err = s.Capture(context.Background(), target) | 318 | _, err = s.Capture(context.Background(), target) | ||
| 263 | require.NoError(t, err) | 319 | require.NoError(t, err) | ||
| 264 | } | 320 | } | ||
| 265 | - | require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived })) | 321 | + | require.LessOrEqual(t, fdCount(t), before+1) |
| 266 | - | require.Equal(t, steady, fdCount(t)) | 322 | + | } |
| 267 | 323 | ||||
| 268 | - | require.NoError(t, s.Close()) | 324 | + | func TestCaptureWaitsForReady(t *testing.T) { |
| 269 | - | require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond, | 325 | + | s, srv := newSource(t, testserver.Config{Hold: true}) |
| 270 | - | "connection descriptors must be closed on Close") | 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 | func TestCancelInterruptsBlockedCapture(t *testing.T) { | 339 | func TestCancelInterruptsBlockedCapture(t *testing.T) { | ||
| 274 | - | s, comp := newSource(t, compositorConfig{hold: true}) | 340 | + | s, srv := newSource(t, testserver.Config{Hold: true}) |
| 275 | - | out := firstOutput(t, s) | 341 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 276 | ctx, cancel := context.WithCancel(context.Background()) | 342 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 277 | - | errc := make(chan error, 1) | 343 | + | done := make(chan error, 1) |
| 278 | - | go func() { | 344 | + | go func() { _, err := s.Capture(ctx, target); done <- err }() |
| 279 | - | _, err := s.Capture(ctx, ports.Target{OutputID: out.ID}) | 345 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) |
| 280 | - | errc <- err | |||
| 281 | - | }() | |||
| 282 | - | <-comp.copyEvent | |||
| 283 | cancel() | 346 | cancel() | ||
| 284 | - | select { | 347 | + | require.ErrorIs(t, <-done, context.Canceled) |
| 285 | - | case err := <-errc: | 348 | + | _, err := s.Capture(context.Background(), target) |
| 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}) | |||
| 293 | require.ErrorIs(t, err, ports.ErrClosed) | 349 | require.ErrorIs(t, err, ports.ErrClosed) | ||
| 294 | - | require.NoError(t, s.Close()) | |||
| 295 | } | 350 | } | ||
| 296 | 351 | ||||
| 297 | func TestCloseInterruptsBlockedCapture(t *testing.T) { | 352 | func TestCloseInterruptsBlockedCapture(t *testing.T) { | ||
| 298 | - | s, comp := newSource(t, compositorConfig{hold: true}) | 353 | + | s, srv := newSource(t, testserver.Config{Hold: true}) |
| 299 | - | out := firstOutput(t, s) | 354 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 300 | - | errc := make(chan error, 1) | 355 | + | done := make(chan error, 1) |
| 301 | - | go func() { | 356 | + | go func() { _, err := s.Capture(context.Background(), target); done <- err }() |
| 302 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 357 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) |
| 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 | - | } | |||
| 313 | require.NoError(t, s.Close()) | 358 | require.NoError(t, s.Close()) | ||
| 314 | - | _, err := s.Outputs(context.Background()) | 359 | + | require.ErrorIs(t, <-done, ports.ErrClosed) |
| 315 | - | require.ErrorIs(t, err, ports.ErrClosed) | 360 | + | require.NoError(t, s.Close(), "idempotent") |
| 316 | } | 361 | } | ||
| 317 | 362 | ||||
| 318 | func TestCancelledContextBeforeCall(t *testing.T) { | 363 | func TestCancelledContextBeforeCall(t *testing.T) { | ||
| 319 | - | s, _ := newSource(t, compositorConfig{}) | 364 | + | s, _ := newSource(t, testserver.Config{}) |
| 320 | ctx, cancel := context.WithCancel(context.Background()) | 365 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 321 | cancel() | 366 | cancel() | ||
| 322 | - | _, err := s.Outputs(ctx) | 367 | + | _, err := s.Capture(ctx, ports.Target{OutputID: firstOutput(t, s).ID}) |
| 323 | require.ErrorIs(t, err, context.Canceled) | 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 | func TestConstructorContextIsScopedToConstructor(t *testing.T) { | 371 | func TestConstructorContextIsScopedToConstructor(t *testing.T) { | ||
| 351 | - | comp := startCompositor(t, compositorConfig{}) | 372 | + | srv := testserver.Start(t, testserver.Config{}) |
| 352 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | 373 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 353 | - | s, err := New(ctx, comp.path) | 374 | + | s, err := New(ctx, srv.Path) |
| 354 | require.NoError(t, err) | 375 | require.NoError(t, err) | ||
| 355 | defer s.Close() | 376 | defer s.Close() | ||
| 356 | cancel() | 377 | cancel() | ||
| 357 | - | time.Sleep(20 * time.Millisecond) | 378 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID}) |
| 358 | - | out := firstOutput(t, s) | 379 | + | require.NoError(t, err) |
| 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) | |||
| 371 | } | 380 | } | ||
| 372 | 381 | ||||
| 373 | func TestConstructorConnectFailure(t *testing.T) { | 382 | func TestConstructorConnectFailure(t *testing.T) { | ||
| 374 | - | _, err := New(context.Background(), t.TempDir()+"/missing") | 383 | + | _, err := New(context.Background(), t.TempDir()+"/none") |
| 375 | - | require.Error(t, err) | |||
| 376 | - | t.Setenv("XDG_RUNTIME_DIR", "") | |||
| 377 | - | _, err = New(context.Background(), "relative") | |||
| 378 | require.Error(t, err) | 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 | func TestConcurrentCallsAreSerialized(t *testing.T) { | 400 | func TestConcurrentCallsAreSerialized(t *testing.T) { | ||
| 382 | - | s, _ := newSource(t, compositorConfig{}) | 401 | + | s, _ := newSource(t, testserver.Config{}) |
| 383 | - | out := firstOutput(t, s) | 402 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 384 | errc := make(chan error, 8) | 403 | errc := make(chan error, 8) | ||
| 385 | for range 8 { | 404 | for range 8 { | ||
| 386 | - | go func() { | 405 | + | go func() { _, err := s.Capture(context.Background(), target); errc <- err }() |
| 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 | - | }() | |||
| 393 | } | 406 | } | ||
| 394 | for range 8 { | 407 | for range 8 { | ||
| 395 | require.NoError(t, <-errc) | 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 | // Steady state cost of one capture over the real transport. The measurement | 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 | // Source alone; the budget is set from measured values (see the benchmark). | 419 | // Source alone; the budget is set from measured values (see the benchmark). | ||
| 402 | func TestCaptureAllocations(t *testing.T) { | 420 | func TestCaptureAllocations(t *testing.T) { | ||
| 403 | if raceEnabled { | 421 | if raceEnabled { | ||
| 404 | t.Skip("race instrumentation changes allocation counts") | 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}}}) | 424 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}}) |
| 407 | - | out := firstOutput(t, s) | 425 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 408 | ctx := context.Background() | 426 | ctx := context.Background() | ||
| 409 | - | target := ports.Target{OutputID: out.ID} | |||
| 410 | for range 3 { // warm pools and buffers | 427 | for range 3 { // warm pools and buffers | ||
| 411 | _, err := s.Capture(ctx, target) | 428 | _, err := s.Capture(ctx, target) | ||
| 412 | require.NoError(t, err) | 429 | require.NoError(t, err) | ||
| @@ -417,36 +434,16 @@ func TestCaptureAllocations(t *testing.T) { | |||||
| 417 | panic(err) | 434 | panic(err) | ||
| 418 | } | 435 | } | ||
| 419 | }) | 436 | }) | ||
| 420 | - | t.Logf("allocations per capture (client and in-process peer): %g", avg) | 437 | + | t.Logf("allocations per capture (client and in-process server): %g", avg) |
| 421 | - | const budget = 14 // measured 12 (client and in-process peer), with headroom | 438 | + | require.LessOrEqual(t, avg, float64(allocBudget)) |
| 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 | - | } | |||
| 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 | func BenchmarkCapture(b *testing.B) { | 444 | func BenchmarkCapture(b *testing.B) { | ||
| 439 | - | comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}}) | 445 | + | s, srv := newSource(b, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "BENCH", Width: 1920, Height: 1080, Scale: 1}}}) |
| 440 | - | s, err := New(context.Background(), comp.path) | 446 | + | target := ports.Target{OutputID: firstOutput(b, s).ID} |
| 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} | |||
| 450 | ctx := context.Background() | 447 | ctx := context.Background() | ||
| 451 | b.ReportAllocs() | 448 | b.ReportAllocs() | ||
| 452 | b.SetBytes(1920 * 1080 * 4) | 449 | b.SetBytes(1920 * 1080 * 4) | ||
| @@ -456,35 +453,12 @@ func BenchmarkCapture(b *testing.B) { | |||||
| 456 | b.Fatal(err) | 453 | b.Fatal(err) | ||
| 457 | } | 454 | } | ||
| 458 | } | 455 | } | ||
| 459 | - | } | 456 | + | b.ReportMetric(float64(srv.Stats().Ready)/float64(b.N), "fresh-frames/op") |
| 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]) | |||
| 484 | } | 457 | } | ||
| 485 | 458 | ||||
| 486 | // Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless | 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 | func TestHeadlessCompositor(t *testing.T) { | 462 | func TestHeadlessCompositor(t *testing.T) { | ||
| 489 | socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET") | 463 | socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET") | ||
| 490 | if socket == "" { | 464 | if socket == "" { | ||
| @@ -496,7 +470,6 @@ func TestHeadlessCompositor(t *testing.T) { | |||||
| 496 | require.NoError(t, err) | 470 | require.NoError(t, err) | ||
| 497 | defer s.Close() | 471 | defer s.Close() | ||
| 498 | out := firstOutput(t, s) | 472 | out := firstOutput(t, s) | ||
| 499 | - | require.NotEmpty(t, out.Name) | |||
| 500 | for range 3 { | 473 | for range 3 { | ||
| 501 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 474 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | ||
| 502 | require.NoError(t, err) | 475 | require.NoError(t, err) | ||
| @@ -504,151 +477,157 @@ func TestHeadlessCompositor(t *testing.T) { | |||||
| 504 | } | 477 | } | ||
| 505 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}}) | 478 | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}}) | ||
| 506 | require.NoError(t, err) | 479 | require.NoError(t, err) | ||
| 507 | - | require.Equal(t, 16, f.Width) | 480 | + | require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}) |
| 508 | - | require.Equal(t, 8, f.Height) | |||
| 509 | } | 481 | } | ||
| 510 | 482 | ||||
| 511 | - | func TestIdleCancellationDoesNotKillSource(t *testing.T) { | 483 | + | // A compositor that waits for damage holds every frame after the first. The |
| 512 | - | s, _ := newSource(t, compositorConfig{}) | 484 | + | // capture stays pending, at most one frame is in flight, and Capture returns |
| 513 | - | out := firstOutput(t, s) | 485 | + | // the last frame at once instead of blocking a timed recording. |
| 514 | - | target := ports.Target{OutputID: out.ID} | 486 | + | func TestStaticScreenRepeatsLastFrame(t *testing.T) { |
| 515 | - | ctx, cancel := context.WithCancel(context.Background()) | 487 | + | s, srv := newSource(t, testserver.Config{HoldAfterFirst: true}) |
| 516 | - | _, err := s.Capture(ctx, target) | 488 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 489 | + | first, err := s.Capture(context.Background(), target) | |||
| 517 | require.NoError(t, err) | 490 | require.NoError(t, err) | ||
| 518 | - | _, err = s.Capture(ctx, target) // same context: the watcher stays registered | 491 | + | want := testserver.Pixel(2, 1, 1) |
| 519 | - | require.NoError(t, err) | 492 | + | require.Equal(t, want[:], first.Row(1)[8:12]) |
| 520 | - | cancel() | 493 | + | for range 5 { |
| 521 | - | _, err = s.Capture(context.Background(), target) | 494 | + | began := time.Now() |
| 522 | - | require.NoError(t, err, "a context cancelled between calls must not end the source") | 495 | + | f, err := s.Capture(context.Background(), target) |
| 523 | - | _, err = s.Outputs(ctx) | 496 | + | require.NoError(t, err) |
| 524 | - | require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it") | 497 | + | require.Less(t, time.Since(began), time.Second, "a pending capture does not block the caller") |
| 525 | - | _, err = s.Capture(context.Background(), target) | 498 | + | require.Equal(t, want[:], f.Row(1)[8:12], "the last frame is repeated") |
| 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") | |||
| 567 | } | 499 | } | ||
| 568 | - | s.opMu.Lock() | 500 | + | require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond) |
| 569 | - | released := s.buf.data == nil && s.watchStop == nil | 501 | + | st := srv.Stats() |
| 570 | - | s.opMu.Unlock() | 502 | + | require.Equal(t, 2, st.Frames, "one served frame and one pending: a single frame in flight") |
| 571 | - | require.True(t, released, "terminal source must release its mapping and watcher") | 503 | + | srv.Release() // the screen changed |
| 572 | - | _, err := s.Outputs(context.Background()) | 504 | + | var f ports.Frame |
| 573 | - | require.ErrorIs(t, err, ports.ErrClosed) | 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) { | 512 | + | // A timed recording over a static screen keeps its schedule. |
| 577 | - | s, _ := newSource(t, compositorConfig{}) | 513 | + | func TestTimedRecordingOverStaticScreen(t *testing.T) { |
| 578 | - | out := firstOutput(t, s) | 514 | + | s, _ := newSource(t, testserver.Config{HoldAfterFirst: true}) |
| 579 | - | target := ports.Target{OutputID: out.ID} | 515 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 580 | - | for i := 0; i < 200; i++ { | 516 | + | _, err := s.Capture(context.Background(), target) |
| 581 | - | ctx, cancel := context.WithCancel(context.Background()) | 517 | + | require.NoError(t, err) |
| 582 | - | _, err := s.Capture(ctx, target) | 518 | + | began := time.Now() |
| 583 | - | require.NoError(t, err) | 519 | + | for range 20 { // a 30 fps recording would wait 20 × 33 ms: each call must be far shorter |
| 584 | - | go cancel() // races with the next call, which uses another context | |||
| 585 | _, err = s.Capture(context.Background(), target) | 520 | _, err = s.Capture(context.Background(), target) | ||
| 586 | require.NoError(t, err) | 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) { | 526 | + | // NeferWL sends no new constraint batch after failed(buffer_constraints): the |
| 592 | - | s, comp := newSource(t, compositorConfig{noShmOffer: true}) | 527 | + | // frame is retried with the current constraints after a roundtrip. |
| 593 | - | out := firstOutput(t, s) | 528 | + | func TestFailedBufferConstraintsRetriesWithoutNewBatch(t *testing.T) { |
| 594 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 529 | + | s, srv := newSource(t, testserver.Config{}) |
| 595 | - | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | 530 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 596 | - | require.ErrorContains(t, err, "no wl_shm buffer") | 531 | + | srv.Fail(1, 1) |
| 597 | - | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | 532 | + | _, err := s.Capture(context.Background(), target) |
| 598 | - | _, err = s.Outputs(context.Background()) | 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 | require.NoError(t, err) | 539 | require.NoError(t, err) | ||
| 600 | } | 540 | } | ||
| 601 | 541 | ||||
| 602 | - | func TestMalformedOutputModeAndScale(t *testing.T) { | 542 | + | func TestNonNormalTransformIsRejected(t *testing.T) { |
| 603 | - | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | 543 | + | for _, region := range []ports.Region{{}, {X: 1, Y: 1, Width: 2, Height: 2}} { |
| 604 | - | {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4}, | 544 | + | s, _ := newSource(t, testserver.Config{Transform: 3}) |
| 605 | - | {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4}, | 545 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, Region: region}) |
| 606 | - | }}) | 546 | + | require.ErrorIs(t, err, ports.ErrUnsupportedTransform) |
| 607 | - | outs, err := s.Outputs(context.Background()) | 547 | + | require.ErrorContains(t, err, "output transform 3 is not supported") |
| 608 | - | require.NoError(t, err) | 548 | + | } |
| 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) | |||
| 613 | } | 549 | } | ||
| 614 | 550 | ||||
| 615 | - | func TestDuplicateGlobalIgnored(t *testing.T) { | 551 | + | // A workspace source stops when its workspace goes away. |
| 616 | - | s, _ := newSource(t, compositorConfig{duplicateGlobal: true}) | 552 | + | func TestWorkspaceRemovedIsUnavailable(t *testing.T) { |
| 617 | - | outs, err := s.Outputs(context.Background()) | 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 | require.NoError(t, err) | 558 | require.NoError(t, err) | ||
| 619 | - | require.Len(t, outs, 1) | 559 | + | srv.RemoveWorkspace("b") |
| 620 | - | require.Equal(t, 1, s.bound) | 560 | + | _, err = s.Capture(context.Background(), target) |
| 561 | + | require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable) | |||
| 621 | } | 562 | } | ||
| 622 | 563 | ||||
| 623 | - | func TestReadyWithoutCopyIsTerminal(t *testing.T) { | 564 | + | // A workspace session that stops because its workspace was removed is a plain |
| 624 | - | s, _ := newSource(t, compositorConfig{readyEarly: true}) | 565 | + | // stop, not a refusal: the allowlist hint is for a live workspace. |
| 625 | - | out := firstOutput(t, s) | 566 | + | func TestWorkspaceRemovedBeforeFirstFrameIsNotRefusal(t *testing.T) { |
| 626 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 567 | + | cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"}) |
| 627 | - | require.Error(t, err) | 568 | + | cfg.NeferwlSource = true |
| 628 | - | _, err = s.Outputs(context.Background()) | 569 | + | s, srv := newSource(t, cfg) |
| 629 | - | require.ErrorIs(t, err, ports.ErrClosed) | 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) { | 589 | + | // A cancellable context costs no more than a background one. |
| 633 | - | t.Run("before copy", func(t *testing.T) { | 590 | + | func TestCaptureAllocationsCancellable(t *testing.T) { |
| 634 | - | s, comp := newSource(t, compositorConfig{failCapture: true}) | 591 | + | if raceEnabled { |
| 635 | - | out := firstOutput(t, s) | 592 | + | t.Skip("race instrumentation changes allocation counts") |
| 636 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 593 | + | } |
| 637 | - | require.Error(t, err) | 594 | + | s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}}) |
| 638 | - | comp.mu.Lock() | 595 | + | target := ports.Target{OutputID: firstOutput(t, s).ID} |
| 639 | - | comp.cfg.failCapture = false | 596 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 640 | - | comp.mu.Unlock() | 597 | + | defer cancel() |
| 641 | - | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 598 | + | for range 3 { |
| 599 | + | _, err := s.Capture(ctx, target) | |||
| 642 | require.NoError(t, err) | 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) { | 608 | + | t.Logf("allocations per capture with a cancellable context: %g", avg) |
| 645 | - | s, _ := newSource(t, compositorConfig{failCopies: 1}) | 609 | + | require.LessOrEqual(t, avg, float64(allocBudget)) |
| 646 | - | out := firstOutput(t, s) | 610 | + | } |
| 647 | - | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 611 | + | |
| 648 | - | require.Error(t, err) | 612 | + | // A constraint batch that repeats the same layout (a compositor may re-send |
| 649 | - | require.NotErrorIs(t, err, ports.ErrClosed) | 613 | + | // its constraints) must not drop the last frame or the pending capture: on a |
| 650 | - | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | 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 | require.NoError(t, err) | 626 | require.NoError(t, err) | ||
| 652 | - | require.NoError(t, f.Validate()) | 627 | + | } |
| 653 | - | }) | 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 | var saved bool | 84 | var saved bool | ||
| 85 | if selection.Mode == ports.Record { | 85 | if selection.Mode == ports.Record { | ||
| 86 | var outcome recordingOutcome | 86 | var outcome recordingOutcome | ||
| 87 | - | outcome, err = recordInteractive(ctx, source, outputs, selection) | 87 | + | outcome, err = recordInteractive(ctx, output, source, outputs, selection) |
| 88 | saved = outcome.Saved | 88 | saved = outcome.Saved | ||
| 89 | } else { | 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 | var writer ports.ScreenshotWriter = png.New() | 90 | var writer ports.ScreenshotWriter = png.New() | ||
| 98 | var copied *clipboard.Writer | 91 | var copied *clipboard.Writer | ||
| 99 | if selection.Clipboard { | 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 | "github.com/bnema/nefercap/internal/ports" | 15 | "github.com/bnema/nefercap/internal/ports" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | // chooseInteractive uses native workspace metadata only when the compositor | 18 | + | // chooseInteractive offers workspaces only when the compositor lists them. |
| 19 | - | // exposes it. Monitor and region screenshots still work with standard capture. | 19 | + | // Monitor and region captures work with the standard protocols. |
| 20 | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options, settings config.Settings) (ports.Selection, bool, error) { | 20 | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options, settings config.Settings) (ports.Selection, bool, error) { | ||
| 21 | mode := ports.Screenshot | 21 | mode := ports.Screenshot | ||
| 22 | if options.Command == cli.Rec { | 22 | if options.Command == cli.Rec { | ||
| @@ -30,7 +30,7 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 30 | outputs = []ports.Output{chosen} | 30 | outputs = []ports.Output{chosen} | ||
| 31 | } | 31 | } | ||
| 32 | workspaces, err := source.Workspaces(ctx) | 32 | workspaces, err := source.Workspaces(ctx) | ||
| 33 | - | if err != nil && !errors.Is(err, wayland.ErrSessionUnsupported) { | 33 | + | if err != nil { |
| 34 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | 34 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | ||
| 35 | return ports.Selection{}, false, ctx.Err() | 35 | return ports.Selection{}, false, ctx.Err() | ||
| 36 | } | 36 | } | ||
| @@ -39,7 +39,7 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 39 | if err := checkDirs(options, settings); err != nil { | 39 | if err := checkDirs(options, settings); err != nil { | ||
| 40 | return ports.Selection{}, false, err | 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 | if options.Command == cli.AllInOne { | 43 | if options.Command == cli.AllInOne { | ||
| 44 | selector.AllowToggle() | 44 | selector.AllowToggle() | ||
| 45 | } | 45 | } | ||
| @@ -103,9 +103,8 @@ func rejectCompetingSelector(ctx context.Context) error { | |||||
| 103 | return err | 103 | return err | ||
| 104 | } | 104 | } | ||
| 105 | if active { | 105 | if active { | ||
| 106 | - | // A selector on another capture connection is not automatically part | 106 | + | // A selector on another capture connection is not part of the |
| 107 | - | // of the recording's excluded set. Refuse rather than leak it into | 107 | + | // recording's excluded set: it would show up in the video. |
| 108 | - | // an existing recording until cross-session selection is authorized. | |||
| 109 | return errors.New("recording is active; stop it before opening a capture selector") | 108 | return errors.New("recording is active; stop it before opening a capture selector") | ||
| 110 | } | 109 | } | ||
| 111 | return nil | 110 | return nil | ||
M internal/app/record_interactive.go +90 -34
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/nefercap/internal/adapters/control" | 9 | "github.com/bnema/nefercap/internal/adapters/control" | ||
| 9 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | 10 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | ||
| @@ -11,18 +12,17 @@ import ( | |||||
| 11 | "github.com/bnema/nefercap/internal/adapters/uierrors" | 12 | "github.com/bnema/nefercap/internal/adapters/uierrors" | ||
| 12 | "github.com/bnema/nefercap/internal/adapters/wayland" | 13 | "github.com/bnema/nefercap/internal/adapters/wayland" | ||
| 13 | "github.com/bnema/nefercap/internal/core" | 14 | "github.com/bnema/nefercap/internal/core" | ||
| 15 | + | "github.com/bnema/nefercap/internal/logging" | |||
| 14 | "github.com/bnema/nefercap/internal/ports" | 16 | "github.com/bnema/nefercap/internal/ports" | ||
| 15 | "github.com/bnema/nefergui" | 17 | "github.com/bnema/nefergui" | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 18 | - | // recordInteractive records with the HUD and the control socket. A stop | 20 | + | // recordInteractive records and listens for stop requests. With the |
| 19 | - | // requested by the user is success; the outcome says whether a file exists. | 21 | + | // compositor's capture exclusion it also shows the HUD, excluded from the |
| 20 | - | func recordInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) { | 22 | + | // video; without it nothing is drawn, so the user stops with the same |
| 21 | - | session, err := source.BeginSession(ctx, sel.Target, true) | 23 | + | // shortcut or `nefercap stop`. A stop requested by the user is success; the |
| 22 | - | if err != nil { | 24 | + | // outcome says whether a file exists. |
| 23 | - | return outcome, sessionError(ctx, true, err) | 25 | + | func recordInteractive(ctx context.Context, notices io.Writer, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) { |
| 24 | - | } | |||
| 25 | - | sel.Target = session.Target | |||
| 26 | var output ports.Output | 26 | var output ports.Output | ||
| 27 | for _, candidate := range outputs { | 27 | for _, candidate := range outputs { | ||
| 28 | if candidate.ID == sel.Target.OutputID { | 28 | if candidate.ID == sel.Target.OutputID { | ||
| @@ -33,6 +33,10 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 33 | if output.ID == 0 { | 33 | if output.ID == 0 { | ||
| 34 | return outcome, ports.ErrOutputNotFound | 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 | server, err := control.Listen() | 40 | server, err := control.Listen() | ||
| 37 | if err != nil { | 41 | if err != nil { | ||
| 38 | return outcome, err | 42 | return outcome, err | ||
| @@ -54,31 +58,40 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 54 | buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop) | 58 | buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop) | ||
| 55 | defer func() { stopWatch(); <-controlDone; <-buttonDone }() | 59 | defer func() { stopWatch(); <-controlDone; <-buttonDone }() | ||
| 56 | 60 | ||||
| 57 | - | hud := startHUD(hudCtx, output, session.Token, hudStop, life) | |||
| 58 | end := ending{} | 61 | end := ending{} | ||
| 59 | var video *ffmpeg.Writer | 62 | var video *ffmpeg.Writer | ||
| 60 | - | // Do not start the encoder until the compositor acknowledges exclusion. | 63 | + | run := func() <-chan error { |
| 61 | - | // A protocol roundtrip alone does not prove the core applied the marking. | |||
| 62 | - | select { | |||
| 63 | - | case <-hud.ready: | |||
| 64 | video = ffmpeg.New() | 64 | video = ffmpeg.New() | ||
| 65 | captureDone := make(chan error, 1) | 65 | captureDone := make(chan error, 1) | ||
| 66 | go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }() | 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 | select { | 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 | life.beginShutdown() | 91 | life.beginShutdown() | ||
| 70 | cancelHUD() | 92 | cancelHUD() | ||
| 71 | end.hudErr = <-hud.done | 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 | end.failure = life.failed() | 96 | end.failure = life.failed() | ||
| 84 | end.parentErr = ctx.Err() | 97 | end.parentErr = ctx.Err() | ||
| @@ -92,18 +105,36 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 92 | return outcome, err | 105 | return outcome, err | ||
| 93 | } | 106 | } | ||
| 94 | 107 | ||||
| 95 | - | // sessionError classifies a BeginSession failure. The caller's own | 108 | + | // startExclusion asks the compositor to keep the HUD out of the recording. It |
| 96 | - | // cancellation stays the bare context error, a missing native capability is | 109 | + | // reports whether a HUD can be shown: without the extension, or when the |
| 97 | - | // named for a recording, and every other failure gets the phase and keeps its | 110 | + | // compositor refuses the exclusion because another one is live (busy), the |
| 98 | - | // identity. | 111 | + | // recording goes on without HUD, like on a compositor with no extension. Any |
| 99 | - | func sessionError(ctx context.Context, record bool, err error) error { | 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 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | 134 | if ctx.Err() != nil && uierrors.IsCancellation(err) { | ||
| 101 | return ctx.Err() | 135 | return ctx.Err() | ||
| 102 | } | 136 | } | ||
| 103 | - | if record && errors.Is(err, wayland.ErrSessionUnsupported) { | 137 | + | return fmt.Errorf("begin capture exclusion: %w", err) |
| 104 | - | return fmt.Errorf("recording indicator requires native capture-session support: %w", err) | |||
| 105 | - | } | |||
| 106 | - | return fmt.Errorf("begin capture session: %w", err) | |||
| 107 | } | 138 | } | ||
| 108 | 139 | ||||
| 109 | // authorized reports, without blocking, whether the HUD signalled ready. Ready | 140 | // authorized reports, without blocking, whether the HUD signalled ready. Ready | ||
| @@ -126,7 +157,7 @@ type hudHandle struct { | |||||
| 126 | 157 | ||||
| 127 | // startHUD runs the indicator on its own goroutine. Its detach callback and | 158 | // startHUD runs the indicator on its own goroutine. Its detach callback and | ||
| 128 | // button only signal the lifecycle; they never touch the recording. | 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 | ready := make(chan struct{}, 1) | 161 | ready := make(chan struct{}, 1) | ||
| 131 | done := make(chan error, 1) | 162 | done := make(chan error, 1) | ||
| 132 | authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error { | 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 | ready <- struct{}{} | 167 | ready <- struct{}{} | ||
| 137 | return nil | 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 | return hudHandle{ready: ready, done: done} | 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 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | - | "strings" | |||
| 11 | "testing" | 10 | "testing" | ||
| 12 | "time" | 11 | "time" | ||
| 13 | 12 | ||||
| @@ -51,19 +50,19 @@ func TestUserStopIsNotAFailure(t *testing.T) { | |||||
| 51 | 50 | ||||
| 52 | func TestDetachAfterReadyKeepsRealError(t *testing.T) { | 51 | func TestDetachAfterReadyKeepsRealError(t *testing.T) { | ||
| 53 | life, recordCtx, hudCtx, _ := newTestLifecycle(t) | 52 | life, recordCtx, hudCtx, _ := newTestLifecycle(t) | ||
| 54 | - | detach := fmt.Errorf("compositor: %w", wayland.ErrSessionUnavailable) | 53 | + | detach := fmt.Errorf("compositor: %w", wayland.ErrExclusionUnavailable) |
| 55 | life.detached(detach) | 54 | life.detached(detach) | ||
| 56 | if recordCtx.Err() == nil || hudCtx.Err() == nil { | 55 | if recordCtx.Err() == nil || hudCtx.Err() == nil { | ||
| 57 | t.Fatal("detach did not stop recording and HUD") | 56 | t.Fatal("detach did not stop recording and HUD") | ||
| 58 | } | 57 | } | ||
| 59 | // The core observes only the cancellation the detach caused. | 58 | // The core observes only the cancellation the detach caused. | ||
| 60 | err := classify(ending{runErr: fmt.Errorf("capture first frame: %w", context.Canceled), failure: life.failed(), userStop: context.Cause(recordCtx) == errUserStop}) | 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 | t.Fatalf("detach error lost: %v", err) | 61 | t.Fatalf("detach error lost: %v", err) | ||
| 63 | } | 62 | } | ||
| 64 | // A second detach does not replace the first cause. | 63 | // A second detach does not replace the first cause. | ||
| 65 | life.detached(errors.New("later")) | 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 | t.Fatalf("first cause replaced: %v", cause) | 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 | live := context.Background() | 175 | live := context.Background() | ||
| 177 | done, cancel := context.WithCancel(context.Background()) | 176 | done, cancel := context.WithCancel(context.Background()) | ||
| 178 | cancel() | 177 | cancel() | ||
| 179 | - | for _, record := range []bool{true, false} { | 178 | + | if err := exclusionError(done, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled { |
| 180 | - | if err := sessionError(done, record, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled { | 179 | + | t.Fatalf("cancellation not bare: %v", err) |
| 181 | - | t.Fatalf("record=%t: cancellation not bare: %v", record, err) | 180 | + | } |
| 182 | - | } | 181 | + | real := errors.New("protocol failure") |
| 183 | - | real := errors.New("protocol failure") | 182 | + | if err := exclusionError(done, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled { |
| 184 | - | if err := sessionError(done, record, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled { | 183 | + | t.Fatalf("real failure masked by cancel: %v", err) |
| 185 | - | t.Fatalf("record=%t: real failure masked by cancel: %v", record, err) | 184 | + | } |
| 186 | - | } | 185 | + | // A cancel error while the caller is live is not the caller's cancel. |
| 187 | - | // A cancel error while the caller is live is not the caller's cancel. | 186 | + | if err := exclusionError(live, context.Canceled); err == context.Canceled { |
| 188 | - | if err := sessionError(live, record, context.Canceled); err == context.Canceled { | 187 | + | t.Fatal("foreign cancel treated as caller's") |
| 189 | - | t.Fatalf("record=%t: foreign cancel treated as caller's", record) | 188 | + | } |
| 190 | - | } | 189 | + | if err := exclusionError(live, real); !errors.Is(err, real) || err == real { |
| 191 | - | generic := errors.New("protocol failure") | 190 | + | t.Fatalf("phase missing: %v", err) |
| 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) | |||
| 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 | // Target converts the result to a capture target; selectors must use it | 58 | // Target converts the result to a capture target; selectors must use it | ||
| 59 | // rather than map results themselves. A monitor is the zero region of its | 59 | // rather than map results themselves. A monitor is the zero region of its | ||
| 60 | // output. A region carries its geometry. A workspace carries only its exact | 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 | // the Region snapshot in PickResult is for drawing only and is not cached in | 62 | // the Region snapshot in PickResult is for drawing only and is not cached in | ||
| 63 | // the target. A workspace is never converted to a monitor. Sizes are logical: | 63 | // the target. A workspace is never converted to a monitor. Sizes are logical: | ||
| 64 | // the caller decides physical resolution and rounding. | 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 | return &MockSource_Expecter{mock: &_m.Mock} | 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 | // Capture provides a mock function for the type MockSource | 95 | // Capture provides a mock function for the type MockSource | ||
| 52 | func (_mock *MockSource) Capture(context1 context.Context, target ports.Target) (ports.Frame, error) { | 96 | func (_mock *MockSource) Capture(context1 context.Context, target ports.Target) (ports.Frame, error) { | ||
| 53 | ret := _mock.Called(context1, target) | 97 | ret := _mock.Called(context1, target) | ||
| @@ -220,6 +264,68 @@ func (_c *MockSource_Outputs_Call) RunAndReturn(run func(context1 context.Contex | |||||
| 220 | return _c | 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 | // 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. | 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 | // The first argument is typically a *testing.T value. | 330 | // The first argument is typically a *testing.T value. | ||
| 225 | func NewMockScreenshotWriter(t interface { | 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 | type Target struct { | 21 | type Target struct { | ||
| 22 | OutputID uint32 | 22 | OutputID uint32 | ||
| 23 | Region Region | 23 | Region Region | ||
| 24 | - | // WorkspaceID is a stable compositor identity, never an index or monitor | 24 | + | // WorkspaceID is a Source-local identity from Source.Workspaces, never an |
| 25 | - | // alias. Nonzero requires a native capture session and a zero Region: | 25 | + | // index or monitor alias. Nonzero requires a zero Region. A workspace |
| 26 | - | // its geometry is supplied live by the compositor, not a cached crop. | 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 | WorkspaceID uint64 | 28 | WorkspaceID uint64 | ||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | - | // Workspace is native compositor target metadata. Region is output-local, | 31 | + | // Capabilities says what the compositor behind a Source offers beyond |
| 31 | - | // logical geometry. Active means that this workspace is currently displayed. | 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 | type Workspace struct { | 43 | type Workspace struct { | ||
| 33 | ID uint64 | 44 | ID uint64 | ||
| 34 | OutputID uint32 | 45 | OutputID uint32 | ||
| @@ -47,19 +58,21 @@ const ( | |||||
| 47 | 58 | ||||
| 48 | // Frame borrows storage from Source until the next Capture or Close call. | 59 | // Frame borrows storage from Source until the next Capture or Close call. | ||
| 49 | // Consumers must finish reading synchronously and must not retain Pixels. | 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 | type Frame struct { | 62 | type Frame struct { | ||
| 52 | Pixels []byte | 63 | Pixels []byte | ||
| 53 | Width, Height, Stride int | 64 | Width, Height, Stride int | ||
| 54 | Format PixelFormat | 65 | Format PixelFormat | ||
| 55 | - | YInvert bool | |||
| 56 | } | 66 | } | ||
| 57 | 67 | ||||
| 58 | // Source has one sequential owner; cancellation must interrupt blocked I/O. | 68 | // Source has one sequential owner; cancellation must interrupt blocked I/O. | ||
| 59 | // Cancellation errors wrap ctx.Err(), not a custom cancellation cause. | 69 | // Cancellation errors wrap ctx.Err(), not a custom cancellation cause. | ||
| 60 | // A failed Capture invalidates its borrowed frame. Close is idempotent. | 70 | // A failed Capture invalidates its borrowed frame. Close is idempotent. | ||
| 61 | type Source interface { | 71 | type Source interface { | ||
| 72 | + | Capabilities() Capabilities | |||
| 62 | Outputs(context.Context) ([]Output, error) | 73 | Outputs(context.Context) ([]Output, error) | ||
| 74 | + | // Workspaces returns nil without error when the compositor has none. | |||
| 75 | + | Workspaces(context.Context) ([]Workspace, error) | |||
| 63 | Capture(context.Context, Target) (Frame, error) | 76 | Capture(context.Context, Target) (Frame, error) | ||
| 64 | Close() error | 77 | Close() error | ||
| 65 | } | 78 | } | ||
M internal/ports/errors.go +12 -5
| @@ -6,11 +6,18 @@ import ( | |||||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | var ( | 8 | var ( | ||
| 9 | - | ErrClosed = errors.New("capture resource is closed") | 9 | + | ErrClosed = errors.New("capture resource is closed") |
| 10 | - | ErrGeometryChanged = errors.New("capture geometry or pixel format changed") | 10 | + | ErrGeometryChanged = errors.New("capture geometry or pixel format changed") |
| 11 | - | ErrUnsupportedFormat = errors.New("unsupported capture pixel format") | 11 | + | ErrUnsupportedFormat = errors.New("unsupported capture pixel format") |
| 12 | - | ErrInvalidRegion = errors.New("invalid output-local capture region") | 12 | + | ErrInvalidRegion = errors.New("invalid output-local capture region") |
| 13 | - | ErrOutputNotFound = errors.New("capture output not found") | 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 | // ErrPathExists is the standard filesystem conflict sentinel. | 21 | // ErrPathExists is the standard filesystem conflict sentinel. | ||
| 15 | ErrPathExists = fs.ErrExist | 22 | ErrPathExists = fs.ErrExist | ||
| 16 | ) | 23 | ) | ||
M internal/ports/frame.go +3 -4
| @@ -20,7 +20,9 @@ func (f Frame) Validate() error { | |||||
| 20 | if f.Format != XRGB8888 && f.Format != ARGB8888 { | 20 | if f.Format != XRGB8888 && f.Format != ARGB8888 { | ||
| 21 | return fmt.Errorf("%w: %d", ErrUnsupportedFormat, f.Format) | 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 | return fmt.Errorf("short frame storage: got %d bytes", len(f.Pixels)) | 26 | return fmt.Errorf("short frame storage: got %d bytes", len(f.Pixels)) | ||
| 25 | } | 27 | } | ||
| 26 | return nil | 28 | return nil | ||
| @@ -29,9 +31,6 @@ func (f Frame) Validate() error { | |||||
| 29 | // Row returns a packed view in top-to-bottom order, excluding stride padding. | 31 | // Row returns a packed view in top-to-bottom order, excluding stride padding. | ||
| 30 | // Call Validate before using Row. The returned slice borrows frame storage. | 32 | // Call Validate before using Row. The returned slice borrows frame storage. | ||
| 31 | func (f Frame) Row(y int) []byte { | 33 | func (f Frame) Row(y int) []byte { | ||
| 32 | - | if f.YInvert { | |||
| 33 | - | y = f.Height - 1 - y | |||
| 34 | - | } | |||
| 35 | offset := y * f.Stride | 34 | offset := y * f.Stride | ||
| 36 | return f.Pixels[offset : offset+f.Width*BytesPerPixel] | 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 | {"excessive stride", Frame{Width: 1, Height: 2, Stride: MaxFrameBytes/2 + 4}, "invalid frame stride", nil}, | 27 | {"excessive stride", Frame{Width: 1, Height: 2, Stride: MaxFrameBytes/2 + 4}, "invalid frame stride", nil}, | ||
| 28 | {"overflow stride", Frame{Width: 1, Height: 2, Stride: int(^uint(0)>>1) &^ 3}, "invalid frame stride", nil}, | 28 | {"overflow stride", Frame{Width: 1, Height: 2, Stride: int(^uint(0)>>1) &^ 3}, "invalid frame stride", nil}, | ||
| 29 | {"unsupported format", Frame{Width: 1, Height: 1, Stride: 4, Pixels: make([]byte, 4), Format: 42}, "", ErrUnsupportedFormat}, | 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 | for _, tc := range cases { | 32 | for _, tc := range cases { | ||
| 33 | t.Run(tc.name, func(t *testing.T) { | 33 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -53,9 +53,8 @@ func TestFrameRow(t *testing.T) { | |||||
| 53 | if got := f.Row(0); len(got) != 4 || got[0] != 1 { | 53 | if got := f.Row(0); len(got) != 4 || got[0] != 1 { | ||
| 54 | t.Fatalf("row: %v", got) | 54 | t.Fatalf("row: %v", got) | ||
| 55 | } | 55 | } | ||
| 56 | - | f.YInvert = true | 56 | + | if got := f.Row(1); len(got) != 4 || got[0] != 5 { |
| 57 | - | if got := f.Row(0); len(got) != 4 || got[0] != 5 { | 57 | + | t.Fatalf("row: %v", got) |
| 58 | - | t.Fatalf("inverted row: %v", got) | |||
| 59 | } | 58 | } | ||
| 60 | } | 59 | } | ||
| 61 | 60 | ||||