detached a558f49

Merge pull request #3 from bnema/feat/capture-generic

feat(wayland): capture through ext-image-copy-capture and ext-workspace
commit
a558f491a06323cffa495b398eac28843db3a00f
author
bnema · · 2026-10-01 02:43 UTC
committer
GitHub · · 2026-10-01 02:43 UTC
merge of
709e2cd + dbe6e8a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.0
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 screencopy21+ 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 -diff21 go mod tidy -diff
@@ -45,7 +45,7 @@ arch:
45 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS45 # 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 cannot46 # 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 TestCaptureAllocations48+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/wayland49 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 done57 done
58- CGO_ENABLED=0 go test $(PERF_PKGS) -run '$(PERF_RE)' -count=158+ 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-check59 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 ## Features43 ## 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 ## Requirements92 ## 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 ## Install117 ## 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/nefercap120 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 ## Development125 ## Development
112 126
M go.mod +4 -3
@@ -3,8 +3,9 @@ module github.com/bnema/nefercap
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/nefergui v0.2.06+ github.com/bnema/nefergui v0.3.0
7- github.com/bnema/wlturbo v0.3.07+ github.com/bnema/purego-libwayland v0.8.0
8+ github.com/bnema/wlturbo v0.5.0
8 github.com/bnema/zerowrap v1.4.19 github.com/bnema/zerowrap v1.4.1
9 github.com/stretchr/testify v1.12.110 github.com/stretchr/testify v1.12.1
10 golang.org/x/sys v0.48.011 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 // indirect15 github.com/bnema/purego v0.13.0-bnema.1 // indirect
15 github.com/bnema/purego-vulkan v0.6.0 // indirect16 github.com/bnema/purego-vulkan v0.6.0 // indirect
16- github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 // indirect17+ github.com/bnema/purego-xkbcommon v0.1.0 // indirect
17 github.com/go-text/typesetting v0.3.5 // indirect18 github.com/go-text/typesetting v0.3.5 // indirect
18 github.com/mattn/go-colorable v0.1.15 // indirect19 github.com/mattn/go-colorable v0.1.15 // indirect
19 github.com/mattn/go-isatty v0.0.22 // indirect20 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.BytesPerPixel356 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 int717 w, h, stride int
723 set ports.VideoSettings718 set ports.VideoSettings
724 wantW, wantH int719 wantW, wantH int
725- invert bool
726 cancelInstead bool720 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.Buffer825 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 string849+ name string
861- w, h int850+ w, h int
862- invert bool851+ }{{"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 top1 // 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 keyboard5 // 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 HUD6 // 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 the7+// 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 is10 // Capture exclusion is explicit authorization, not a property of how the HUD is
10 // built. The caller supplies authorize, which NeferGUI calls with the surface11 // 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 as59 // 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 string61 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) error68 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 accepts135 // 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 nil86 return nil
87 }87 }
88 88
89-// toRGBA converts top-to-bottom, honoring stride and YInvert, and forces alpha89+// 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.BytesPerPixel92 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 img29 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 row36+ // 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, toggle16 m.base, m.mode, m.toggle = mode, mode, toggle
17 m.footerMode = mode17 m.footerMode = mode
18- m.footerText = footerText(mode, core.PickRegion, false, toggle)18+ m.footerText = footerText(mode, core.PickRegion, false, toggle, false)
19 return m19 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 through27 // 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 in30+ base ports.Mode // mode the selector started in
31- mode ports.Mode // current mode: base flipped by the session's Tab toggles31+ 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 mode33+ // 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.Output35+ noIndicator bool
36- index int // the output this overlay covers; every output has its own model36+ 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.Picker41 picker core.Picker
39 sized bool42 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.mode281 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 = baseFont20 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 workspace27 // 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.Builder34 var b strings.Builder
31- b.Grow(192) // one allocation for the whole legend35+ 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.Mode48+ mode ports.Mode
49- video ports.VideoSettings49+ video ports.VideoSettings
50- workspaces []ports.Workspace50+ workspaces []ports.Workspace
51- grid bool51+ grid bool
52- toggle bool52+ 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 FPS58 // New returns a selector for mode. Record selections carry video; a zero FPS
58-// becomes DefaultFPS. workspaces is native compositor metadata: the W key is59+// 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 output60 // 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 is61+// an overlay covers. Other workspaces are never offered and nothing is
61-// invented when no workspace qualifies. grid is the initial state of the62+// 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 assigns64 // 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 caller65 // 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. The85 // 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 nil30 return nil
31 }31 }
32 32
33-// shmBuffer is the single wl_shm mapping and wl_buffer reused between33+// 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 the34+// 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 bufGeom38 geom bufGeom
38 data []byte39 data []byte
39 buffer *core.Buffer40 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 nil46 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 err50 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 nil79 return nil
79 }80 }
80 81
81-// releaseBuffer unmaps the storage. With destroy set, and while connected, it82+// 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.buf85+ b := s.bufs[i]
85- s.buf = shmBuffer{}86+ s.bufs[i] = shmBuffer{}
86 var err error87 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 err94 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 protocol1+// 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 wayland6 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 433+ outputVersion = 4 // wl_output.name arrived in version 4
28- shmVersion = 134+ shmVersion = 1
29- screencopyVersion = 335+ copyVersion = 1
30- screencopyIface = "zwlr_screencopy_manager_v1"36+ xdgOutputVersion = 3
31- maxSettleRounds = 437+ maxSettleRounds = 4
32- maxOutputScale = 16 // informational; larger announcements are clamped38+ 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 string49 name string
41 width, height int3250 width, height int32
42 scale int3251 scale int32
52+ lw, lh int32 // logical size from xdg-output, 0 when unknown
53+ xdg *xdgoutput.ZxdgOutput
43 removed bool54 removed bool
44 }55 }
45 56
@@ -69,18 +80,20 @@ type Source struct {
69 watchGen atomic.Uint6480 watchGen atomic.Uint64
70 watchActive atomic.Bool81 watchActive atomic.Bool
71 82
72- shm *core.Shm83+ shm *core.Shm
73- screencopy *screencopyManager84+ copyManager *imagecopycapture.ExtImageCopyCaptureManager
74- outputs map[uint32]*output85+ outSource *imagecapturesource.ExtOutputImageCaptureSourceManager
75- bound int // wl_output globals bound so far86+ xdgOutputs *xdgoutput.ZxdgOutputManager // nil when absent
76- buf shmBuffer87+ 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 protocol90+ bufs [2]shmBuffer
80- workspaces map[uint64]*workspace91+ conn net.Conn
81- metaErr error // set once the private inventory was rejected92+ pollWait time.Duration // how long Capture waits for a pending frame
82- session *captureSession93+
83- pingEvery time.Duration94+ 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, err138 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, nil144 return s, nil
127 }145 }
128 146
@@ -143,26 +161,53 @@ func socketPath(socket string) (string, error) {
143 return filepath.Join(dir, socket), nil161 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 err169 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 err184 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 return228 return
184 }229 }
185 s.outputs[name] = o230 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 return243 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 = true247 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 events252 } // older wl_output has no destructor: the proxy must stay to absorb events
@@ -216,6 +268,25 @@ func (s *Source) dispatch() error {
216 return nil268 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 events290 // 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 err335 return err
265 }336 }
266 return nil337 return nil
@@ -301,7 +372,7 @@ func (s *Source) finish(ctx context.Context, err error) error {
301 return err372 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 cerr377 return cerr
307 }378 }
@@ -352,6 +423,14 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro
352 return frame, nil423 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 observe434 // 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, comp24+ 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 450+ 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 = 1136+ 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, garbage144+ 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 measurement417 // 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 the418+// 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 buffers427 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 headroom438+ 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 headless459 // 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 registered491+ 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 == nil501+ 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 = false596+ 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 bool84 var saved bool
85 if selection.Mode == ports.Record {85 if selection.Mode == ports.Record {
86 var outcome recordingOutcome86 var outcome recordingOutcome
87- outcome, err = recordInteractive(ctx, source, outputs, selection)87+ outcome, err = recordInteractive(ctx, output, source, outputs, selection)
88 saved = outcome.Saved88 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.Writer91 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, &notices, 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, &notices, 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 compositor18+// 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.Screenshot21 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, err40 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 err103 return err
104 }104 }
105 if active {105 if active {
106- // A selector on another capture connection is not automatically part106+ // A selector on another capture connection is not part of the
107- // of the recording's excluded set. Refuse rather than leak it into107+ // 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 nil110 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 stop20+// 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.Output26 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.ErrOutputNotFound34 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, err42 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.Writer62 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.done93 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, err105 return outcome, err
93 }106 }
94 107
95-// sessionError classifies a BeginSession failure. The caller's own108+// startExclusion asks the compositor to keep the HUD out of the recording. It
96-// cancellation stays the bare context error, a missing native capability is109+// 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 its110+// 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. Ready140 // 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 and158 // 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 nil168 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 it58 // 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 its59 // 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 exact60 // output. A region carries its geometry. A workspace carries only its exact
61-// native WorkspaceID: the compositor supplies live geometry at capture, so61+// 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 in62 // 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 MockSource95 // 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 _c264 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 uint3222 OutputID uint32
23 Region Region23 Region Region
24- // WorkspaceID is a stable compositor identity, never an index or monitor24+ // 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 uint6428 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 uint6444 ID uint64
34 OutputID uint3245 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 []byte63 Pixels []byte
53 Width, Height, Stride int64 Width, Height, Stride int
54 Format PixelFormat65 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() error77 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.ErrExist22 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 nil28 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.Stride34 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 = true56+ 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

Keyboard shortcuts

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

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

Branches 2

Tags 9

↑↓ moveenter openesc close