detached c739b25

feat(wayland): capture through ext-image-copy-capture and ext-workspace

Replace the wlr-screencopy client and NeferWL's private capture-session client
with ext-image-copy-capture-v1 sessions on output sources.

- One session serves consecutive frames with one frame in flight. The last
  frame stays in its own buffer while the next is written, so a compositor that
  waits for damage on a static screen never blocks a timed recording: the last
  frame is repeated.
- buffer_constraints and unknown failures are retried after a roundtrip, at
  most four times in a row; stopped ends the capture; a session that stopped
  because its workspace or output went away reports it.
- Non-normal output transforms are rejected with ErrUnsupportedTransform.
- A region uses NeferWL's region source when present, else it is a window of
  the borrowed shm buffer (no copy).
- Workspaces come from ext-workspace-v1; identity is the id event. NeferWL's
  workspace source captures a hidden workspace; without it only the displayed
  one is captured, as its output.
- NeferWL's exclusion manager keeps the recording HUD out of the video. Without
  it, or when it refuses, no HUD is drawn and recording goes on.
- ports.Source reports Capabilities and lists workspaces; the selector offers W
  only with workspaces and says when no recording indicator can be shown.
commit
c739b2501d6cf2cd78882f49a46c3fc3af774f3f
author
brice · · 2026-09-30 19:56 UTC
committer
brice · · 2026-10-01 02:18 UTC
parent
3115564
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
39 files · +3242 -3445
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 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 +541 -0
@@ -0,0 +1,541 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/wlturbo"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// wl_shm format values for the two supported native little-endian layouts.
14+const (
15+ shmARGB8888 = uint32(ports.ARGB8888)
16+ shmXRGB8888 = uint32(ports.XRGB8888)
17+)
18+
19+// Hand-written proxies for ext-image-capture-source-v1 and
20+// ext-image-copy-capture-v1: the wlturbo release in use does not generate
21+// them. Only the requests and events nefercap uses are declared.
22+
23+// requestOnly is a bound global or created object that never sends events.
24+type requestOnly struct{ wlturbo.BaseProxy }
25+
26+func (*requestOnly) EventSignature(uint16) (string, bool) { return "", false }
27+
28+func newRequestOnly(ctx *wlturbo.Context) *requestOnly {
29+ p := &requestOnly{}
30+ p.SetContext(ctx)
31+ return p
32+}
33+
34+const (
35+ reqOutputSourceCreate = 0 // ext_output_image_capture_source_manager_v1.create_source
36+ reqSourceDestroy = 0 // ext_image_capture_source_v1.destroy
37+ reqCopyCreateSession = 0 // ext_image_copy_capture_manager_v1.create_session
38+ reqSessionCreateFrame = 0
39+ reqSessionDestroy = 1
40+ reqFrameDestroy = 0
41+ reqFrameAttachBuffer = 1
42+ reqFrameDamageBuffer = 2
43+ reqFrameCapture = 3
44+)
45+
46+// ext_image_copy_capture_frame_v1.failure_reason
47+const (
48+ failUnknown = 0
49+ failBufferConstraints = 1
50+ failStopped = 2
51+)
52+
53+// maxFrameAttempts bounds the retries of one frame after failures that the
54+// protocol calls recoverable.
55+const maxFrameAttempts = 4
56+
57+var errCompositorFailedCapture = errors.New("wayland: compositor failed the capture")
58+
59+// errCaptureRefused is ErrCaptureStopped for a session that stopped before
60+// serving any frame.
61+var errCaptureRefused = fmt.Errorf("%w: compositor refused capture (on NeferWL, list this executable in /etc/neferwl/capture-allow)", ports.ErrCaptureStopped)
62+
63+// constraints is one batch of buffer constraints, valid once done arrived.
64+type constraints struct {
65+ width, height uint32
66+ haveSize bool
67+ format uint32 // first supported shm format of the batch
68+ haveFormat bool
69+ sawShm bool
70+ unsupported uint32 // last rejected shm format
71+}
72+
73+// session is one ext_image_copy_capture_session_v1. Its events are dispatched
74+// on the goroutine that owns the operation.
75+type session struct {
76+ wlturbo.BaseProxy
77+ key sessionKey
78+
79+ pending constraints
80+ current constraints
81+ gen uint64 // number of constraint batches completed
82+ stopped bool
83+ dead bool // destroy sent
84+
85+ exclusion *exclusion // nil until requested
86+
87+ // Frame pacing. At most one frame is in flight. The last completed frame
88+ // lives in buffer cur and stays untouched while the next one is written to
89+ // the other buffer, so a Capture that finds the compositor idle (it waits
90+ // for damage after the first frame) can repeat the last frame at once.
91+ inflight *frameProxy
92+ inflightBuf int
93+ inflightGeom bufGeom // buffer layout the in-flight frame was made for
94+ haveLast bool
95+ cur int
96+ lastGeom bufGeom
97+ served int // frames handed out by this session
98+ fails int // consecutive failed frames
99+}
100+
101+func (*session) EventSignature(op uint16) (string, bool) {
102+ switch op {
103+ case 0:
104+ return "uint,uint,", true // buffer_size
105+ case 1:
106+ return "uint,", true // shm_format
107+ case 2:
108+ return "array,", true // dmabuf_device
109+ case 3:
110+ return "uint,array,", true // dmabuf_format
111+ case 4, 5:
112+ return "", true // done, stopped
113+ }
114+ return "", false
115+}
116+
117+func (c *session) Dispatch(e *wlturbo.Event) {
118+ switch e.Opcode {
119+ case 0:
120+ c.pending.width, c.pending.height, c.pending.haveSize = e.Uint32(), e.Uint32(), true
121+ case 1:
122+ format := e.Uint32()
123+ c.pending.sawShm = true
124+ switch {
125+ case format != shmARGB8888 && format != shmXRGB8888:
126+ c.pending.unsupported = format
127+ case !c.pending.haveFormat:
128+ c.pending.format, c.pending.haveFormat = format, true
129+ }
130+ case 4:
131+ c.current, c.pending = c.pending, constraints{}
132+ c.gen++
133+ case 5:
134+ c.stopped = true
135+ }
136+}
137+
138+// frameProxy is one ext_image_copy_capture_frame_v1.
139+type frameProxy struct {
140+ wlturbo.BaseProxy
141+ transform uint32
142+ ready bool
143+ failed bool
144+ reason uint32
145+}
146+
147+func (*frameProxy) EventSignature(op uint16) (string, bool) {
148+ switch op {
149+ case 0, 4:
150+ return "uint,", true // transform, failed
151+ case 1:
152+ return "int,int,int,int,", true // damage
153+ case 2:
154+ return "uint,uint,uint,", true // presentation_time
155+ case 3:
156+ return "", true // ready
157+ }
158+ return "", false
159+}
160+
161+func (f *frameProxy) Dispatch(e *wlturbo.Event) {
162+ switch e.Opcode {
163+ case 0:
164+ f.transform = e.Uint32()
165+ case 3:
166+ f.ready = true
167+ case 4:
168+ f.failed, f.reason = true, e.Uint32()
169+ }
170+}
171+
172+// sessionKey identifies what a session captures. Crop sessions have a zero
173+// region: the region is cut from each frame.
174+type sessionKey struct {
175+ output uint32
176+ workspace uint64
177+ region ports.Region
178+}
179+
180+func validRegion(r ports.Region) error {
181+ if r == (ports.Region{}) {
182+ return nil
183+ }
184+ if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 ||
185+ r.X > ports.MaxDimension || r.Y > ports.MaxDimension ||
186+ r.Width > ports.MaxDimension || r.Height > ports.MaxDimension {
187+ return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r)
188+ }
189+ return nil
190+}
191+
192+// captureTarget resolves t to a session, creating or replacing it, and copies
193+// one frame. The caller holds opMu.
194+func (s *Source) captureTarget(t ports.Target) (ports.Frame, error) {
195+ out := s.outputs[t.OutputID]
196+ if out == nil {
197+ return ports.Frame{}, ports.ErrOutputNotFound
198+ }
199+ key, crop, err := s.resolve(t, out)
200+ if err != nil {
201+ return ports.Frame{}, err
202+ }
203+ sess, err := s.sessionFor(key, out)
204+ if err != nil {
205+ return ports.Frame{}, err
206+ }
207+ frame, err := s.nextFrame(sess, out)
208+ if err != nil {
209+ return ports.Frame{}, err
210+ }
211+ if crop == (ports.Region{}) {
212+ return frame, nil
213+ }
214+ return cropFrame(frame, crop, out)
215+}
216+
217+// resolve maps a target to its session key and the crop to apply per frame.
218+func (s *Source) resolve(t ports.Target, out *output) (sessionKey, ports.Region, error) {
219+ if t.WorkspaceID != 0 {
220+ return s.workspaceKey(t, out)
221+ }
222+ if t.Region != (ports.Region{}) && s.ext.source != nil {
223+ return sessionKey{output: out.id, region: t.Region}, ports.Region{}, nil
224+ }
225+ return sessionKey{output: out.id}, t.Region, nil
226+}
227+
228+// sessionFor returns the live session for key. A different key replaces the
229+// current session, which ends its exclusion.
230+func (s *Source) sessionFor(key sessionKey, out *output) (*session, error) {
231+ if c := s.sess; c != nil {
232+ if c.key == key && (!c.stopped || c.exclusion != nil) {
233+ // A stopped session with an exclusion stays stopped: replacing it
234+ // would quietly lose the exclusion in the middle of a recording.
235+ return c, nil
236+ }
237+ s.dropSession()
238+ }
239+ src := newRequestOnly(s.wl)
240+ var err error
241+ switch {
242+ case key.workspace != 0:
243+ err = s.createWorkspaceSource(src, key.workspace)
244+ case key.region != (ports.Region{}):
245+ r := key.region
246+ 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},
247+ src, out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height))
248+ default:
249+ err = s.wl.Request(wlturbo.Request{Proxy: s.outSource, Opcode: reqOutputSourceCreate, Name: "ext_output_image_capture_source_manager_v1.create_source", Child: src}, src, out.proxy)
250+ }
251+ if err != nil {
252+ s.terminate()
253+ return nil, fmt.Errorf("wayland: create capture source: %w", err)
254+ }
255+ c := &session{key: key}
256+ c.SetContext(s.wl)
257+ err = s.wl.Request(wlturbo.Request{Proxy: s.copyManager, Opcode: reqCopyCreateSession, Name: "ext_image_copy_capture_manager_v1.create_session", Child: c}, c, src, uint32(0))
258+ _ = s.wl.Request(wlturbo.Request{Proxy: src, Opcode: reqSourceDestroy, Name: "ext_image_capture_source_v1.destroy", Destructor: true})
259+ if err != nil {
260+ s.terminate()
261+ return nil, fmt.Errorf("wayland: create capture session: %w", err)
262+ }
263+ s.sess = c
264+ return c, nil
265+}
266+
267+// dropSession destroys the current session, if any.
268+func (s *Source) dropSession() {
269+ c := s.sess
270+ s.sess = nil
271+ if c == nil || c.dead {
272+ return
273+ }
274+ c.dead = true
275+ s.destroyFrame(c)
276+ if c.exclusion != nil {
277+ c.exclusion.destroy(s)
278+ }
279+ _ = s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionDestroy, Name: "ext_image_copy_capture_session_v1.destroy", Destructor: true})
280+}
281+
282+// nextFrame returns the session's current frame.
283+//
284+// The first frame of a session is waited for: the protocol guarantees it does
285+// not wait for damage. After that the compositor may hold a capture back until
286+// the screen changes, so a capture stays pending across calls (still one frame
287+// in flight) and a call that finds it unfinished after pollWait returns the
288+// last completed frame again. The caller's schedule is then never stretched by
289+// a static screen, and a recording repeats that frame.
290+//
291+// A failed frame is retried after a roundtrip, at most maxFrameAttempts
292+// failures in a row (the count survives calls and restarts at each completed
293+// frame): stopped ends the capture, buffer_constraints is retried with the
294+// latest constraints, which are the current ones when the compositor sent no
295+// new batch. An unknown failure is retried the same way, unless a last frame
296+// exists: that one is repeated, like when a capture is still pending.
297+func (s *Source) nextFrame(c *session, out *output) (ports.Frame, error) {
298+ for {
299+ for c.gen == 0 && !c.stopped {
300+ if err := s.dispatch(); err != nil {
301+ return ports.Frame{}, err
302+ }
303+ }
304+ if c.stopped {
305+ return ports.Frame{}, s.stoppedError(c, out)
306+ }
307+ g, err := s.geometry(c)
308+ if err != nil {
309+ return ports.Frame{}, err
310+ }
311+ // A new constraint batch with the same layout changes nothing: the last
312+ // frame stays valid and the pending capture stays in flight.
313+ if c.haveLast && c.lastGeom != g {
314+ c.haveLast = false
315+ }
316+ if c.inflight != nil && c.inflightGeom != g {
317+ s.destroyFrame(c)
318+ }
319+ if c.inflight == nil {
320+ if err := s.startFrame(c, g); err != nil {
321+ return ports.Frame{}, err
322+ }
323+ }
324+ if err := s.awaitFrame(c, c.haveLast); err != nil {
325+ return ports.Frame{}, err
326+ }
327+ f := c.inflight
328+ switch {
329+ case c.stopped:
330+ return ports.Frame{}, s.stoppedError(c, out)
331+ case f.ready:
332+ return s.complete(c, g, f)
333+ case f.failed:
334+ if err := s.retryAfterFailure(c, f); err != nil {
335+ if c.stopped {
336+ return ports.Frame{}, s.stoppedError(c, out)
337+ }
338+ c.fails = 0 // the next call starts afresh
339+ return ports.Frame{}, err
340+ }
341+ if c.stopped {
342+ return ports.Frame{}, s.stoppedError(c, out)
343+ }
344+ if c.haveLast && f.reason == failUnknown {
345+ return s.lastFrame(c)
346+ }
347+ default: // still pending after the poll: repeat the last frame
348+ return s.lastFrame(c)
349+ }
350+ }
351+}
352+
353+// awaitFrame dispatches until the in-flight frame ended or the session
354+// stopped. With poll set it gives up after pollWait.
355+func (s *Source) awaitFrame(c *session, poll bool) error {
356+ f := c.inflight
357+ var deadline time.Time
358+ if poll {
359+ deadline = time.Now().Add(s.pollWait)
360+ }
361+ for !f.ready && !f.failed && !c.stopped {
362+ timedOut, err := s.dispatchUntil(deadline)
363+ if err != nil {
364+ return err
365+ }
366+ if timedOut {
367+ return nil
368+ }
369+ }
370+ return nil
371+}
372+
373+// retryAfterFailure handles a failed frame: one roundtrip, so that a stop or a
374+// new constraint batch that follows the failure is seen, then a retry.
375+func (s *Source) retryAfterFailure(c *session, f *frameProxy) error {
376+ s.destroyFrame(c)
377+ if f.reason == failStopped {
378+ c.stopped = true
379+ return nil
380+ }
381+ if c.fails++; c.fails >= maxFrameAttempts {
382+ return errCompositorFailedCapture
383+ }
384+ switch f.reason {
385+ case failBufferConstraints, failUnknown:
386+ return s.roundtrip()
387+ }
388+ return fmt.Errorf("%w: failure reason %d", errCompositorFailedCapture, f.reason)
389+}
390+
391+// complete hands out a frame the compositor finished.
392+func (s *Source) complete(c *session, g bufGeom, f *frameProxy) (ports.Frame, error) {
393+ transform := f.transform
394+ idx := c.inflightBuf
395+ s.destroyFrame(c)
396+ if transform != 0 {
397+ return ports.Frame{}, fmt.Errorf("%w: output transform %d is not supported", ports.ErrUnsupportedTransform, transform)
398+ }
399+ c.haveLast, c.cur, c.lastGeom = true, idx, g
400+ c.served++
401+ c.fails = 0
402+ frame, err := s.frameOf(idx, g)
403+ // From the second frame on the next capture starts right away, so the
404+ // compositor can answer during the wait for the next slot. A one-shot
405+ // screenshot never reaches it and allocates a single buffer.
406+ if err == nil && c.served > 1 {
407+ err = s.startFrame(c, g)
408+ }
409+ return frame, err
410+}
411+
412+// lastFrame repeats the last completed frame, still held in its own buffer.
413+func (s *Source) lastFrame(c *session) (ports.Frame, error) {
414+ return s.frameOf(c.cur, c.lastGeom)
415+}
416+
417+// startFrame sends one capture into the buffer the last frame is not in.
418+func (s *Source) startFrame(c *session, g bufGeom) error {
419+ idx := 0
420+ if c.haveLast {
421+ idx = 1 - c.cur
422+ }
423+ if err := s.ensureBuffer(idx, g); err != nil {
424+ return err
425+ }
426+ f := &frameProxy{}
427+ f.SetContext(s.wl)
428+ if err := s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionCreateFrame, Name: "ext_image_copy_capture_session_v1.create_frame", Child: f}, f); err != nil {
429+ s.terminate()
430+ return fmt.Errorf("wayland: create frame: %w", err)
431+ }
432+ c.inflight, c.inflightBuf, c.inflightGeom = f, idx, g
433+ if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameAttachBuffer, Name: "ext_image_copy_capture_frame_v1.attach_buffer"}, s.bufs[idx].buffer); err != nil {
434+ s.terminate()
435+ return fmt.Errorf("wayland: attach buffer: %w", err)
436+ }
437+ if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDamageBuffer, Name: "ext_image_copy_capture_frame_v1.damage_buffer"}, int32(0), int32(0), int32(g.width), int32(g.height)); err != nil {
438+ s.terminate()
439+ return fmt.Errorf("wayland: damage buffer: %w", err)
440+ }
441+ if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCapture, Name: "ext_image_copy_capture_frame_v1.capture"}); err != nil {
442+ s.terminate()
443+ return fmt.Errorf("wayland: capture request: %w", err)
444+ }
445+ return nil
446+}
447+
448+// destroyFrame destroys the in-flight frame object once it ended.
449+func (s *Source) destroyFrame(c *session) {
450+ if f := c.inflight; f != nil {
451+ c.inflight = nil
452+ _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "ext_image_copy_capture_frame_v1.destroy", Destructor: true})
453+ }
454+}
455+
456+// stoppedError classifies a stopped session. An output that went away is the
457+// usual cause: read its removal first. A workspace session stops when its
458+// workspace is removed: that is a plain stop, whatever the session served. Any
459+// other session that stopped before serving a frame was refused by the
460+// compositor, which the error says.
461+func (s *Source) stoppedError(c *session, out *output) error {
462+ if err := s.roundtrip(); err != nil {
463+ return err
464+ }
465+ switch {
466+ case out.removed:
467+ return ports.ErrOutputNotFound
468+ case c.key.workspace != 0 && s.ws.byID[c.key.workspace] == nil:
469+ return fmt.Errorf("%w: %w", ports.ErrWorkspaceUnavailable, ports.ErrCaptureStopped)
470+ case c.served == 0:
471+ return errCaptureRefused
472+ }
473+ return ports.ErrCaptureStopped
474+}
475+
476+// geometry validates the session's current constraints and returns the buffer
477+// layout to allocate.
478+func (s *Source) geometry(c *session) (bufGeom, error) {
479+ k := c.current
480+ switch {
481+ case !k.sawShm:
482+ return bufGeom{}, fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat)
483+ case !k.haveFormat:
484+ return bufGeom{}, fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, k.unsupported)
485+ case !k.haveSize:
486+ return bufGeom{}, errors.New("wayland: compositor sent no buffer size")
487+ }
488+ g := bufGeom{format: k.format, width: k.width, height: k.height, stride: k.width * ports.BytesPerPixel}
489+ if err := g.validate(); err != nil {
490+ return bufGeom{}, err
491+ }
492+ return g, nil
493+}
494+
495+func (s *Source) frameOf(idx int, g bufGeom) (ports.Frame, error) {
496+ frame := ports.Frame{
497+ Pixels: s.bufs[idx].data[:g.size:g.size],
498+ Width: int(g.width), Height: int(g.height), Stride: int(g.stride),
499+ Format: ports.PixelFormat(g.format),
500+ }
501+ if err := frame.Validate(); err != nil {
502+ return ports.Frame{}, err
503+ }
504+ return frame, nil
505+}
506+
507+// logicalSize is the output's size in logical pixels: xdg-output when the
508+// compositor offers it, else the current mode divided by the integer scale.
509+func (o *output) logicalSize() (w, h int) {
510+ if o.lw > 0 && o.lh > 0 {
511+ return int(o.lw), int(o.lh)
512+ }
513+ return int(o.width) / int(max(o.scale, 1)), int(o.height) / int(max(o.scale, 1))
514+}
515+
516+// cropFrame returns the window of f that covers region r, given in logical
517+// pixels of out. The result borrows f's storage: no pixel is copied.
518+func cropFrame(f ports.Frame, r ports.Region, out *output) (ports.Frame, error) {
519+ lw, lh := out.logicalSize()
520+ if lw < 1 || lh < 1 {
521+ return ports.Frame{}, fmt.Errorf("%w: output %s has no known logical size", ports.ErrInvalidRegion, outputName(out))
522+ }
523+ edge := func(v, buf, logical int) int { // round to the nearest buffer pixel
524+ return int((int64(v)*int64(buf) + int64(logical)/2) / int64(logical))
525+ }
526+ x0, x1 := edge(r.X, f.Width, lw), min(edge(r.X+r.Width, f.Width, lw), f.Width)
527+ y0, y1 := edge(r.Y, f.Height, lh), min(edge(r.Y+r.Height, f.Height, lh), f.Height)
528+ if x0 >= x1 || y0 >= y1 {
529+ return ports.Frame{}, fmt.Errorf("%w: %+v is outside output %s", ports.ErrInvalidRegion, r, outputName(out))
530+ }
531+ w, h := x1-x0, y1-y0
532+ start := y0*f.Stride + x0*ports.BytesPerPixel
533+ end := start + (h-1)*f.Stride + w*ports.BytesPerPixel
534+ cropped := f
535+ cropped.Pixels = f.Pixels[start:end:end]
536+ cropped.Width, cropped.Height = w, h
537+ if err := cropped.Validate(); err != nil {
538+ return ports.Frame{}, err
539+ }
540+ return cropped, nil
541+}
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 +526 -0
@@ -0,0 +1,526 @@
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+
13+ "github.com/bnema/nefercap/internal/ports"
14+)
15+
16+// NeferWL's optional extension to ext-image-copy-capture-v1, see
17+// neferwl-image-capture-v1.xml in the NeferWL repository. Every part is
18+// optional: without it, capture still works through the standard protocols.
19+//
20+// neferwl_image_capture_source_manager_v1
21+// requests: 0 destroy, 1 create_workspace_source(new_id, workspace),
22+// 2 create_output_region_source(new_id, output, x, y, w, h),
23+// 3 get_workspace_frame(new_id, workspace)
24+// neferwl_workspace_frame_v1
25+// requests: 0 destroy events: 0 frame(x, y, width, height)
26+// neferwl_capture_exclusion_manager_v1
27+// requests: 0 destroy, 1 get_exclusion(new_id, session),
28+// 2 attach_surface(new_id, token, surface)
29+// neferwl_capture_exclusion_v1
30+// requests: 0 destroy events: 0 token(string), 1 failed(reason)
31+// neferwl_capture_layer_v1
32+// requests: 0 destroy events: 0 attached, 1 failed(reason), 2 detached(reason)
33+
34+const (
35+ sourceManagerIface = "neferwl_image_capture_source_manager_v1"
36+ exclusionManagerIface = "neferwl_capture_exclusion_manager_v1"
37+ extensionVersion = 1
38+ tokenLen = 64 // lowercase hexadecimal characters of 32 random bytes
39+
40+ reqNeferwlWorkspaceSource = 1
41+ reqNeferwlRegionSource = 2
42+ reqGetWorkspaceFrame = 3
43+ reqWorkspaceFrameDestroy = 0
44+ reqGetExclusion = 1
45+ reqAttachSurface = 2
46+ reqManagerDestroy = 0
47+ reqExclusionDestroy = 0
48+ reqLayerDestroy = 0
49+
50+ // DefaultAttachTimeout bounds AuthorizeLayer.
51+ DefaultAttachTimeout = 5 * time.Second
52+)
53+
54+var (
55+ // ErrExclusionUnsupported means the compositor cannot leave the client's
56+ // own surfaces out of its captures.
57+ ErrExclusionUnsupported = errors.New("wayland: capture exclusion unsupported")
58+ // ErrExclusionUnavailable means the exclusion is gone, refused or never
59+ // existed. Callers that rely on it must stop recording.
60+ ErrExclusionUnavailable = errors.New("wayland: capture exclusion unavailable")
61+ // ErrExclusionBusy is an ErrExclusionUnavailable: another exclusion is live
62+ // in the compositor.
63+ ErrExclusionBusy = fmt.Errorf("%w: another exclusion is live", ErrExclusionUnavailable)
64+ // ErrAttachFailed is matched by every AttachFailedError.
65+ ErrAttachFailed = errors.New("wayland: attach layer failed")
66+ // ErrUnauthorized means the compositor refused the peer.
67+ ErrUnauthorized = errors.New("wayland: capture exclusion peer unauthorized")
68+ // errLayerProtocol is a violation of the layer attachment state machine.
69+ errLayerProtocol = fmt.Errorf("%w: capture layer protocol violation", ErrExclusionUnavailable)
70+)
71+
72+// neferwl_capture_exclusion_v1.failure
73+const (
74+ exclusionBusy = 0
75+ exclusionSessionStopped = 1
76+)
77+
78+// extensions holds the bound optional NeferWL globals.
79+type extensions struct {
80+ source *requestOnly
81+ exclusion *requestOnly
82+}
83+
84+// bindExtras binds every optional global. None is an error when absent.
85+func (s *Source) bindExtras() (err error) {
86+ if s.xdgOutputs, err = s.bindOptional(xdgOutputIface, xdgOutputVersion); err != nil {
87+ return err
88+ }
89+ for _, o := range s.outputs {
90+ s.watchLogicalSize(o)
91+ }
92+ if s.ext.source, err = s.bindOptional(sourceManagerIface, extensionVersion); err != nil {
93+ return err
94+ }
95+ if s.ext.exclusion, err = s.bindOptional(exclusionManagerIface, extensionVersion); err != nil {
96+ return err
97+ }
98+ return s.bindWorkspaces()
99+}
100+
101+func (s *Source) createWorkspaceSource(src *requestOnly, id uint64) error {
102+ h := s.ws.byID[id]
103+ if h == nil || h.removed {
104+ return ports.ErrWorkspaceUnavailable
105+ }
106+ return 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)
107+}
108+
109+// workspaceFrame is one neferwl_workspace_frame_v1: the frame of a workspace
110+// in output-local logical pixels. It is read under opMu, with every other
111+// state of the connection, so it needs no lock of its own.
112+type workspaceFrame struct {
113+ wlturbo.BaseProxy
114+ x, y, w, h int32
115+ known bool
116+}
117+
118+func (*workspaceFrame) EventSignature(op uint16) (string, bool) {
119+ if op == 0 {
120+ return "int,int,int,int,", true
121+ }
122+ return "", false
123+}
124+
125+func (f *workspaceFrame) Dispatch(ev *wlturbo.Event) {
126+ if ev.Opcode == 0 {
127+ f.x, f.y, f.w, f.h = ev.Int32(), ev.Int32(), ev.Int32(), ev.Int32()
128+ f.known = true
129+ }
130+}
131+
132+// region is the frame when it is a sane rectangle.
133+func (f *workspaceFrame) region() (ports.Region, bool) {
134+ if f == nil || !f.known || f.w < 1 || f.h < 1 || f.w > ports.MaxDimension || f.h > ports.MaxDimension ||
135+ f.x < -ports.MaxDimension || f.x > ports.MaxDimension || f.y < -ports.MaxDimension || f.y > ports.MaxDimension {
136+ return ports.Region{}, false
137+ }
138+ return ports.Region{X: int(f.x), Y: int(f.y), Width: int(f.w), Height: int(f.h)}, true
139+}
140+
141+// requestFrames asks for the frame of every workspace that has none yet and
142+// reports whether it asked for any. It does nothing without the extension.
143+func (s *Source) requestFrames() (bool, error) {
144+ if s.ext.source == nil {
145+ return false, nil
146+ }
147+ asked := false
148+ for _, w := range s.ws.byID {
149+ if w.frame != nil || w.removed {
150+ continue
151+ }
152+ f := &workspaceFrame{}
153+ f.SetContext(s.wl)
154+ 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); err != nil {
155+ s.terminate()
156+ return false, fmt.Errorf("wayland: get workspace frame: %w", err)
157+ }
158+ w.frame, asked = f, true
159+ }
160+ return asked, nil
161+}
162+
163+// releaseWorkspaceFrame releases the frame object of a workspace that is gone.
164+func (s *Source) releaseWorkspaceFrame(w *workspace) {
165+ if w.frame == nil {
166+ return
167+ }
168+ _ = s.wl.Request(wlturbo.Request{Proxy: w.frame, Opcode: reqWorkspaceFrameDestroy, Name: "neferwl_workspace_frame_v1.destroy", Destructor: true})
169+ w.frame = nil
170+}
171+
172+// exclusion is one neferwl_capture_exclusion_v1: it answers with a token or a
173+// failure, and ends with its capture session.
174+type exclusion struct {
175+ wlturbo.BaseProxy
176+ token string
177+ failed bool
178+ reason uint32
179+}
180+
181+func (*exclusion) EventSignature(op uint16) (string, bool) {
182+ switch op {
183+ case 0:
184+ return "string,", true
185+ case 1:
186+ return "uint,", true
187+ }
188+ return "", false
189+}
190+
191+func (e *exclusion) Dispatch(ev *wlturbo.Event) {
192+ switch ev.Opcode {
193+ case 0:
194+ if tok := ev.String(); validToken(tok) && !e.failed && e.token == "" {
195+ e.token = tok
196+ } else {
197+ e.failed, e.reason = true, ^uint32(0)
198+ }
199+ case 1:
200+ if e.token == "" && !e.failed {
201+ e.failed, e.reason = true, ev.Uint32()
202+ }
203+ }
204+}
205+
206+func (e *exclusion) destroy(s *Source) {
207+ _ = s.wl.Request(wlturbo.Request{Proxy: e, Opcode: reqExclusionDestroy, Name: "neferwl_capture_exclusion_v1.destroy", Destructor: true})
208+}
209+
210+// BeginExclusion asks the compositor to leave the client's own layer surfaces
211+// out of the frames of the session that captures t, and returns the token a
212+// HUD on another connection passes to AuthorizeLayer. The token is a secret:
213+// never log it. The exclusion ends with the session, which later Capture
214+// calls on the same target keep using; capturing another target replaces the
215+// session and ends the exclusion.
216+func (s *Source) BeginExclusion(ctx context.Context, t ports.Target) (string, error) {
217+ if err := validRegion(t.Region); err != nil {
218+ return "", err
219+ }
220+ s.opMu.Lock()
221+ defer s.opMu.Unlock()
222+ if err := s.begin(ctx); err != nil {
223+ return "", err
224+ }
225+ token, err := s.beginExclusion(t)
226+ if err = s.finish(ctx, err); err != nil {
227+ return "", err
228+ }
229+ return token, nil
230+}
231+
232+func (s *Source) beginExclusion(t ports.Target) (string, error) {
233+ if s.ext.exclusion == nil {
234+ return "", ErrExclusionUnsupported
235+ }
236+ if err := s.settle(); err != nil {
237+ return "", err
238+ }
239+ out := s.outputs[t.OutputID]
240+ if out == nil {
241+ return "", ports.ErrOutputNotFound
242+ }
243+ key, _, err := s.resolve(t, out)
244+ if err != nil {
245+ return "", err
246+ }
247+ c, err := s.sessionFor(key, out)
248+ if err != nil {
249+ return "", err
250+ }
251+ if c.exclusion != nil {
252+ return "", errors.New("wayland: the capture session already has an exclusion")
253+ }
254+ e := &exclusion{}
255+ e.SetContext(s.wl)
256+ 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); err != nil {
257+ s.terminate()
258+ return "", fmt.Errorf("wayland: get exclusion: %w", err)
259+ }
260+ for e.token == "" && !e.failed && !c.stopped {
261+ if err := s.dispatch(); err != nil {
262+ return "", err
263+ }
264+ }
265+ if e.token != "" {
266+ c.exclusion = e // only a live exclusion makes a stopped session sticky
267+ return e.token, nil
268+ }
269+ // Every other outcome (refused, invalid token, session stopped first)
270+ // leaves no exclusion behind.
271+ e.destroy(s)
272+ switch {
273+ case c.stopped || (e.failed && e.reason == exclusionSessionStopped):
274+ return "", s.stoppedError(c, out)
275+ case e.failed && e.reason == exclusionBusy:
276+ return "", ErrExclusionBusy
277+ default:
278+ return "", fmt.Errorf("%w: refused (reason %d)", ErrExclusionUnavailable, e.reason)
279+ }
280+}
281+
282+// AttachFailure is neferwl_capture_layer_v1.failure.
283+type AttachFailure uint32
284+
285+const (
286+ AttachUnknownToken AttachFailure = 0
287+ AttachUnauthorized AttachFailure = 1
288+ AttachTooManyLayers AttachFailure = 2
289+ AttachAlreadyAttached AttachFailure = 3
290+ AttachExclusionEnded AttachFailure = 4
291+ AttachLayerDestroyed AttachFailure = 5
292+)
293+
294+// AttachFailedError is the compositor's refusal of AuthorizeLayer. It matches
295+// ErrAttachFailed; a missing or ended exclusion also matches
296+// ErrExclusionUnavailable, and an unauthorized peer ErrUnauthorized.
297+type AttachFailedError struct{ Reason AttachFailure }
298+
299+func (e *AttachFailedError) Error() string {
300+ return fmt.Sprintf("%v (reason %d)", ErrAttachFailed, e.Reason)
301+}
302+
303+func (e *AttachFailedError) Is(target error) bool {
304+ switch target {
305+ case ErrAttachFailed:
306+ return true
307+ case ErrExclusionUnavailable:
308+ return e.Reason == AttachUnknownToken || e.Reason == AttachExclusionEnded
309+ case ErrUnauthorized:
310+ return e.Reason == AttachUnauthorized
311+ }
312+ return false
313+}
314+
315+// DetachReason is neferwl_capture_layer_v1.detach_reason.
316+type DetachReason uint32
317+
318+const (
319+ // DetachExclusionEnded: the exclusion ended and the compositor closed the
320+ // layer surface, which the client must destroy.
321+ DetachExclusionEnded DetachReason = 0
322+ // DetachLayerDestroyed: the layer role was destroyed.
323+ DetachLayerDestroyed DetachReason = 1
324+)
325+
326+// LayerDetachedError is what onDetached receives. An exclusion end also
327+// matches ErrExclusionUnavailable.
328+type LayerDetachedError struct{ Reason DetachReason }
329+
330+func (e *LayerDetachedError) Error() string {
331+ return fmt.Sprintf("wayland: layer detached from the capture exclusion (reason %d)", e.Reason)
332+}
333+
334+func (e *LayerDetachedError) Is(target error) bool {
335+ return target == ErrExclusionUnavailable && e.Reason == DetachExclusionEnded
336+}
337+
338+// layerProxy is one neferwl_capture_layer_v1 attachment. Its events are
339+// dispatched by whichever goroutine reads the GUI connection: during
340+// authorizeLayer that is the caller, afterwards the connection's reader.
341+//
342+// State machine: pending -> attached | failed; attached -> detached. Any other
343+// event is a protocol violation: the exclusion cannot be trusted any more.
344+type layerProxy struct {
345+ wlturbo.BaseProxy
346+ display *wlturbo.Display
347+ onDetached func(error)
348+
349+ mu sync.Mutex
350+ state layerState
351+ failReason uint32
352+ violation bool
353+ returned bool // authorizeLayer handed the surface to its caller
354+ notified bool
355+}
356+
357+type layerState int
358+
359+const (
360+ layerPending layerState = iota
361+ layerAttached
362+ layerFailed
363+ layerDetached
364+)
365+
366+func (*layerProxy) EventSignature(op uint16) (string, bool) {
367+ switch op {
368+ case 0:
369+ return "", true
370+ case 1, 2:
371+ return "uint,", true
372+ }
373+ return "", false
374+}
375+
376+func (l *layerProxy) Dispatch(e *wlturbo.Event) {
377+ var arg uint32
378+ if e.Opcode != 0 {
379+ arg = e.Uint32()
380+ }
381+ var notify error
382+ closeDisplay := false
383+ l.mu.Lock()
384+ returned := l.returned
385+ switch {
386+ case l.violation:
387+ // Terminal: the display is closed already.
388+ case e.Opcode == 0 && l.state == layerPending:
389+ l.state = layerAttached
390+ case e.Opcode == 1 && l.state == layerPending:
391+ l.state, l.failReason = layerFailed, arg
392+ case e.Opcode == 2 && l.state == layerAttached:
393+ l.state = layerDetached
394+ if returned {
395+ l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)}
396+ }
397+ default:
398+ l.violation, closeDisplay = true, true
399+ if returned && !l.notified {
400+ l.notified, notify = true, errLayerProtocol
401+ }
402+ }
403+ l.mu.Unlock()
404+ if closeDisplay && returned {
405+ _ = l.display.Close() // the surface could be visible in captures
406+ }
407+ if notify != nil && l.onDetached != nil {
408+ l.onDetached(notify)
409+ }
410+}
411+
412+// settled reports that the answer arrived, or the layer misbehaved.
413+func (l *layerProxy) settled() bool {
414+ l.mu.Lock()
415+ defer l.mu.Unlock()
416+ return l.state != layerPending || l.violation
417+}
418+
419+// hand marks the layer as owned by the caller of authorizeLayer, and reports
420+// its outcome so far.
421+func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
422+ l.mu.Lock()
423+ defer l.mu.Unlock()
424+ l.returned = true
425+ return l.state, l.failReason, l.violation
426+}
427+
428+// AuthorizeLayer tells the compositor, from a connection other than the
429+// capture connection, that surface is a layer surface of the capturing peer
430+// and must be excluded from the frames of the session that owns token (from
431+// Source.BeginExclusion). Call it after the layer-shell role is assigned and
432+// before the surface is mapped; when it returns nil the compositor has
433+// confirmed the exclusion for this very attachment, so the surface is safe to
434+// show.
435+//
436+// AuthorizeLayer dispatches display's events itself until the attachment
437+// answers, so it must run before the connection's event loop starts. A
438+// refusal returns an *AttachFailedError. A protocol error is returned as the
439+// display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
440+// (an error wrapping context.DeadlineExceeded), closes display before
441+// returning: the surface must then stay unmapped. So does a violation of the
442+// attachment protocol (an answer twice, a detach before the answer, ...).
443+//
444+// After it returns nil the attachment stays until the exclusion ends or the
445+// layer is destroyed; then onDetached (may be nil) receives a
446+// *LayerDetachedError, once. A violation at that point closes display and
447+// calls onDetached with an error matching ErrExclusionUnavailable; the surface
448+// may still draw until the caller stops, so onDetached must make the caller
449+// stop. It runs on the goroutine that reads display, so it must only do
450+// thread-safe, non-blocking work such as cancelling a context. It never sees
451+// the token. The attachment object is never destroyed after a success: that
452+// would not release the exclusion, and closing the connection ends it.
453+func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error)) error {
454+ return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
455+}
456+
457+func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error), timeout time.Duration) error {
458+ if display == nil || surface == nil || surface.Context() != display.Context() {
459+ return errors.New("wayland: authorize layer: surface does not belong to display")
460+ }
461+ if !validToken(token) {
462+ return errors.New("wayland: authorize layer: invalid token")
463+ }
464+ if err := ctx.Err(); err != nil {
465+ return err
466+ }
467+ m := newRequestOnly(display.Context())
468+ if _, err := display.Registry().BindNegotiated(exclusionManagerIface, extensionVersion, m); err != nil {
469+ if errors.Is(err, wlturbo.ErrGlobalNotFound) {
470+ return ErrExclusionUnsupported
471+ }
472+ return fmt.Errorf("wayland: bind %s: %w", exclusionManagerIface, err)
473+ }
474+ wctx, cancel := context.WithTimeout(ctx, timeout)
475+ defer cancel()
476+ stop := context.AfterFunc(wctx, func() { _ = display.Close() })
477+ defer stop()
478+
479+ l := &layerProxy{display: display, onDetached: onDetached}
480+ l.SetContext(display.Context())
481+ err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)
482+ for err == nil && !l.settled() {
483+ err = display.Dispatch()
484+ }
485+ // Detach the watcher before anything else is decided or sent. When stop
486+ // reports false the watcher has fired or is firing: cancellation and the
487+ // deadline win over any outcome, and the display is closed here too so it
488+ // is closed on return whatever the watcher goroutine is doing. When it
489+ // reports true the watcher can never fire, and the caller owns the
490+ // connection; a context that is already done is still caught below.
491+ if !stop() || wctx.Err() != nil {
492+ _ = display.Close()
493+ if ctx.Err() != nil {
494+ return ctx.Err()
495+ }
496+ return fmt.Errorf("wayland: attach surface: %w", wctx.Err())
497+ }
498+ if err != nil {
499+ return fmt.Errorf("wayland: attach surface: %w", err)
500+ }
501+ state, reason, violation := l.hand()
502+ if violation {
503+ _ = display.Close()
504+ return errLayerProtocol
505+ }
506+ // The manager binding is not needed any more.
507+ _ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_exclusion_manager_v1.destroy", Destructor: true})
508+ if state == layerFailed {
509+ _ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true})
510+ return &AttachFailedError{Reason: AttachFailure(reason)}
511+ }
512+ return nil
513+}
514+
515+// validToken accepts exactly the compositor's token format.
516+func validToken(token string) bool {
517+ if len(token) != tokenLen {
518+ return false
519+ }
520+ for i := range len(token) {
521+ if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
522+ return false
523+ }
524+ }
525+ return true
526+}
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 +120 -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 (
@@ -24,12 +27,22 @@ import (
24 )27 )
25 28
26 const (29 const (
27- outputVersion = 4 // wl_output.name arrived in version 430+ outputVersion = 4 // wl_output.name arrived in version 4
28- shmVersion = 131+ shmVersion = 1
29- screencopyVersion = 332+ copyVersion = 1
30- screencopyIface = "zwlr_screencopy_manager_v1"33+ xdgOutputVersion = 3
31- maxSettleRounds = 434+ maxSettleRounds = 4
32- maxOutputScale = 16 // informational; larger announcements are clamped35+ maxOutputScale = 16 // informational; larger announcements are clamped
36+ // defaultPollWait is how long Capture waits for a frame the compositor may
37+ // hold back until the screen changes; short against a frame interval.
38+ defaultPollWait = 2 * time.Millisecond
39+)
40+
41+// Globals of the capture protocols.
42+const (
43+ outputSourceIface = "ext_output_image_capture_source_manager_v1"
44+ copyManagerIface = "ext_image_copy_capture_manager_v1"
45+ xdgOutputIface = "zxdg_output_manager_v1"
33 )46 )
34 47
35 // output is the client-side state of one bound wl_output global.48 // output is the client-side state of one bound wl_output global.
@@ -40,6 +53,8 @@ type output struct {
40 name string53 name string
41 width, height int3254 width, height int32
42 scale int3255 scale int32
56+ lw, lh int32 // logical size from xdg-output, 0 when unknown
57+ xdg *xdgOutput
43 removed bool58 removed bool
44 }59 }
45 60
@@ -69,18 +84,20 @@ type Source struct {
69 watchGen atomic.Uint6484 watchGen atomic.Uint64
70 watchActive atomic.Bool85 watchActive atomic.Bool
71 86
72- shm *core.Shm87+ shm *core.Shm
73- screencopy *screencopyManager88+ copyManager *requestOnly // ext_image_copy_capture_manager_v1
74- outputs map[uint32]*output89+ outSource *requestOnly // ext_output_image_capture_source_manager_v1
75- bound int // wl_output globals bound so far90+ xdgOutputs *requestOnly // zxdg_output_manager_v1, nil when absent
76- buf shmBuffer91+ outputs map[uint32]*output
77-92+ byProxy map[uint32]*output // wl_output proxy ID to output
78- // Private capture session state, see capture_session.go.93+ bound int // wl_output globals bound so far
79- private *captureManager // nil when the compositor lacks the protocol94+ bufs [2]shmBuffer
80- workspaces map[uint64]*workspace95+ conn net.Conn
81- metaErr error // set once the private inventory was rejected96+ pollWait time.Duration // how long Capture waits for a pending frame
82- session *captureSession97+
83- pingEvery time.Duration98+ sess *session // the one live capture session, nil before the first capture
99+ ext extensions
100+ ws workspaceState
84 }101 }
85 102
86 var _ ports.Source = (*Source)(nil)103 var _ ports.Source = (*Source)(nil)
@@ -105,11 +122,13 @@ func New(ctx context.Context, socket string) (*Source, error) {
105 return nil, fmt.Errorf("wayland: %w", err)122 return nil, fmt.Errorf("wayland: %w", err)
106 }123 }
107 s := &Source{124 s := &Source{
108- log: logging.For(ctx, "wayland"),125+ log: logging.For(ctx, "wayland"),
109- display: display,126+ display: display,
110- wl: display.Context(),127+ wl: display.Context(),
111- outputs: make(map[uint32]*output),128+ outputs: make(map[uint32]*output),
112- pingEvery: sessionPingInterval,129+ byProxy: make(map[uint32]*output),
130+ conn: conn,
131+ pollWait: defaultPollWait,
113 }132 }
114 stop := context.AfterFunc(ctx, s.terminate)133 stop := context.AfterFunc(ctx, s.terminate)
115 err = s.discover()134 err = s.discover()
@@ -122,7 +141,10 @@ func New(ctx context.Context, socket string) (*Source, error) {
122 s.log.Error().Err(err).Msg("wayland source failed to start")141 s.log.Error().Err(err).Msg("wayland source failed to start")
123 return nil, err142 return nil, err
124 }143 }
125- s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started")144+ s.log.Info().Int("outputs", len(s.outputs)).Interface("capabilities", s.Capabilities()).
145+ Bool("region_source", s.ext.source != nil).
146+ Bool("hidden_workspaces", s.ws.mgr != nil && s.ext.source != nil).
147+ Msg("wayland source started")
126 return s, nil148 return s, nil
127 }149 }
128 150
@@ -143,26 +165,52 @@ func socketPath(socket string) (string, error) {
143 return filepath.Join(dir, socket), nil165 return filepath.Join(dir, socket), nil
144 }166 }
145 167
146-func (s *Source) discover() error {168+func (s *Source) discover() (err error) {
147 reg := s.display.Registry()169 reg := s.display.Registry()
148 reg.AddHandler(core.OutputInterface, s.onOutputGlobal)170 reg.AddHandler(core.OutputInterface, s.onOutputGlobal)
149 reg.AddGlobalRemoveHandler(globalRemoved{s})171 reg.AddGlobalRemoveHandler(globalRemoved{s})
150- if err := s.roundtrip(); err != nil {172+ if err = s.roundtrip(); err != nil {
151 return err173 return err
152 }174 }
153 s.shm = core.NewShm(s.wl)175 s.shm = core.NewShm(s.wl)
154- if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {176+ if _, err = reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {
155 return fmt.Errorf("wayland: bind wl_shm: %w", err)177 return fmt.Errorf("wayland: bind wl_shm: %w", err)
156 }178 }
157- s.screencopy = &screencopyManager{}179+ if s.copyManager, err = s.bindRequired(copyManagerIface, copyVersion); err != nil {
158- s.screencopy.SetContext(s.wl)180+ return err
159- if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil {181+ }
160- return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err)182+ if s.outSource, err = s.bindRequired(outputSourceIface, copyVersion); err != nil {
183+ return err
161 }184 }
162- if err := s.bindCaptureManager(); err != nil {185+ if err = s.bindExtras(); err != nil {
163 return err186 return err
164 }187 }
165- return s.roundtrip()188+ return s.settle()
189+}
190+
191+// bindRequired binds a global that capture cannot work without.
192+func (s *Source) bindRequired(iface string, version uint32) (*requestOnly, error) {
193+ p := newRequestOnly(s.wl)
194+ if _, err := s.display.Registry().BindNegotiated(iface, version, p); err != nil {
195+ if errors.Is(err, wlturbo.ErrGlobalNotFound) {
196+ return nil, fmt.Errorf("wayland: the compositor does not support ext-image-copy-capture-v1 (%s missing)", iface)
197+ }
198+ return nil, fmt.Errorf("wayland: bind %s: %w", iface, err)
199+ }
200+ return p, nil
201+}
202+
203+// bindOptional binds a global when the compositor offers it.
204+func (s *Source) bindOptional(iface string, version uint32) (*requestOnly, error) {
205+ p := newRequestOnly(s.wl)
206+ _, err := s.display.Registry().BindNegotiated(iface, version, p)
207+ switch {
208+ case err == nil:
209+ return p, nil
210+ case errors.Is(err, wlturbo.ErrGlobalNotFound):
211+ return nil, nil
212+ }
213+ return nil, fmt.Errorf("wayland: bind %s: %w", iface, err)
166 }214 }
167 215
168 func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {216 func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
@@ -183,7 +231,11 @@ func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
183 return231 return
184 }232 }
185 s.outputs[name] = o233 s.outputs[name] = o
234+ s.byProxy[o.proxy.ID()] = o
186 s.bound++235 s.bound++
236+ if s.xdgOutputs != nil {
237+ s.watchLogicalSize(o)
238+ }
187 }239 }
188 240
189 type globalRemoved struct{ s *Source }241 type globalRemoved struct{ s *Source }
@@ -194,7 +246,10 @@ func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemove
194 return246 return
195 }247 }
196 delete(g.s.outputs, e.Name)248 delete(g.s.outputs, e.Name)
249+ delete(g.s.byProxy, o.proxy.ID())
197 o.removed = true250 o.removed = true
251+ g.s.releaseXdg(o)
252+ g.s.ws.forgetOutput(o.proxy.ID())
198 if o.version >= 3 {253 if o.version >= 3 {
199 _ = o.proxy.Release()254 _ = o.proxy.Release()
200 } // older wl_output has no destructor: the proxy must stay to absorb events255 } // older wl_output has no destructor: the proxy must stay to absorb events
@@ -216,6 +271,25 @@ func (s *Source) dispatch() error {
216 return nil271 return nil
217 }272 }
218 273
274+// dispatchUntil is dispatch with a read deadline; a zero deadline waits
275+// forever. It reports a missed deadline, which is not an error.
276+func (s *Source) dispatchUntil(deadline time.Time) (timedOut bool, err error) {
277+ if deadline.IsZero() {
278+ return false, s.dispatch()
279+ }
280+ _ = s.conn.SetReadDeadline(deadline)
281+ err = s.display.Dispatch()
282+ _ = s.conn.SetReadDeadline(time.Time{})
283+ if errors.Is(err, os.ErrDeadlineExceeded) {
284+ return true, nil
285+ }
286+ if err != nil {
287+ s.terminate()
288+ return false, fmt.Errorf("wayland: dispatch: %w", err)
289+ }
290+ return false, nil
291+}
292+
219 // settle round-trips until no output was bound meanwhile, so that the events293 // 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.294 // of an output announced during the last round trip have been received too.
221 func (s *Source) settle() error {295 func (s *Source) settle() error {
@@ -260,7 +334,7 @@ func (s *Source) begin(ctx context.Context) error {
260 s.watchActive.Store(false)334 s.watchActive.Store(false)
261 s.terminate()335 s.terminate()
262 s.detachWatcher()336 s.detachWatcher()
263- s.releaseBuffer(false)337+ s.releaseBuffers()
264 return err338 return err
265 }339 }
266 return nil340 return nil
@@ -301,7 +375,7 @@ func (s *Source) finish(ctx context.Context, err error) error {
301 return err375 return err
302 }376 }
303 s.detachWatcher()377 s.detachWatcher()
304- s.releaseBuffer(false)378+ s.releaseBuffers()
305 if cerr := ctx.Err(); cerr != nil {379 if cerr := ctx.Err(); cerr != nil {
306 return cerr380 return cerr
307 }381 }
@@ -352,6 +426,14 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro
352 return frame, nil426 return frame, nil
353 }427 }
354 428
429+// Capabilities reports what the compositor offers beyond output capture.
430+func (s *Source) Capabilities() ports.Capabilities {
431+ return ports.Capabilities{
432+ Workspaces: s.ws.mgr != nil,
433+ Exclusion: s.ext.exclusion != nil,
434+ }
435+}
436+
355 // Close terminates the connection, waits for a blocked operation to observe437 // Close terminates the connection, waits for a blocked operation to observe
356 // it, and releases the mapped storage. It is idempotent.438 // it, and releases the mapped storage. It is idempotent.
357 func (s *Source) Close() error {439 func (s *Source) Close() error {
@@ -359,5 +441,5 @@ func (s *Source) Close() error {
359 s.opMu.Lock()441 s.opMu.Lock()
360 defer s.opMu.Unlock()442 defer s.opMu.Unlock()
361 s.detachWatcher()443 s.detachWatcher()
362- return s.releaseBuffer(false)444+ return s.releaseBuffers()
363 }445 }
M internal/adapters/wayland/source_test.go +426 -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,389 @@ 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), with
413+// headroom.
414+const allocBudget = 20
415+
399 // Steady state cost of one capture over the real transport. The measurement416 // 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 the417+// 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).418 // Source alone; the budget is set from measured values (see the benchmark).
402 func TestCaptureAllocations(t *testing.T) {419 func TestCaptureAllocations(t *testing.T) {
403 if raceEnabled {420 if raceEnabled {
404 t.Skip("race instrumentation changes allocation counts")421 t.Skip("race instrumentation changes allocation counts")
405 }422 }
406- s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}})423+ s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}})
407- out := firstOutput(t, s)424+ target := ports.Target{OutputID: firstOutput(t, s).ID}
408 ctx := context.Background()425 ctx := context.Background()
409- target := ports.Target{OutputID: out.ID}
410 for range 3 { // warm pools and buffers426 for range 3 { // warm pools and buffers
411 _, err := s.Capture(ctx, target)427 _, err := s.Capture(ctx, target)
412 require.NoError(t, err)428 require.NoError(t, err)
@@ -417,36 +433,13 @@ func TestCaptureAllocations(t *testing.T) {
417 panic(err)433 panic(err)
418 }434 }
419 })435 })
420- t.Logf("allocations per capture (client and in-process peer): %g", avg)436+ t.Logf("allocations per capture (client and in-process server): %g", avg)
421- const budget = 14 // measured 12 (client and in-process peer), with headroom437+ 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 }438 }
437 439
438 func BenchmarkCapture(b *testing.B) {440 func BenchmarkCapture(b *testing.B) {
439- comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}})441+ s, _ := newSource(b, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "BENCH", Width: 1920, Height: 1080, Scale: 1}}})
440- s, err := New(context.Background(), comp.path)442+ 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()443 ctx := context.Background()
451 b.ReportAllocs()444 b.ReportAllocs()
452 b.SetBytes(1920 * 1080 * 4)445 b.SetBytes(1920 * 1080 * 4)
@@ -458,33 +451,9 @@ func BenchmarkCapture(b *testing.B) {
458 }451 }
459 }452 }
460 453
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-}
485-
486 // Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless454 // 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.455+// compositor that supports ext-image-copy-capture. Never point it at a real
456+// session.
488 func TestHeadlessCompositor(t *testing.T) {457 func TestHeadlessCompositor(t *testing.T) {
489 socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET")458 socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET")
490 if socket == "" {459 if socket == "" {
@@ -496,7 +465,6 @@ func TestHeadlessCompositor(t *testing.T) {
496 require.NoError(t, err)465 require.NoError(t, err)
497 defer s.Close()466 defer s.Close()
498 out := firstOutput(t, s)467 out := firstOutput(t, s)
499- require.NotEmpty(t, out.Name)
500 for range 3 {468 for range 3 {
501 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})469 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
502 require.NoError(t, err)470 require.NoError(t, err)
@@ -504,151 +472,157 @@ func TestHeadlessCompositor(t *testing.T) {
504 }472 }
505 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}})473 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}})
506 require.NoError(t, err)474 require.NoError(t, err)
507- require.Equal(t, 16, f.Width)475+ require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
508- require.Equal(t, 8, f.Height)
509 }476 }
510 477
511-func TestIdleCancellationDoesNotKillSource(t *testing.T) {478+// A compositor that waits for damage holds every frame after the first. The
512- s, _ := newSource(t, compositorConfig{})479+// capture stays pending, at most one frame is in flight, and Capture returns
513- out := firstOutput(t, s)480+// the last frame at once instead of blocking a timed recording.
514- target := ports.Target{OutputID: out.ID}481+func TestStaticScreenRepeatsLastFrame(t *testing.T) {
515- ctx, cancel := context.WithCancel(context.Background())482+ s, srv := newSource(t, testserver.Config{HoldAfterFirst: true})
516- _, err := s.Capture(ctx, target)483+ target := ports.Target{OutputID: firstOutput(t, s).ID}
484+ first, err := s.Capture(context.Background(), target)
517 require.NoError(t, err)485 require.NoError(t, err)
518- _, err = s.Capture(ctx, target) // same context: the watcher stays registered486+ want := testserver.Pixel(2, 1, 1)
519- require.NoError(t, err)487+ require.Equal(t, want[:], first.Row(1)[8:12])
520- cancel()488+ for range 5 {
521- _, err = s.Capture(context.Background(), target)489+ began := time.Now()
522- require.NoError(t, err, "a context cancelled between calls must not end the source")490+ f, err := s.Capture(context.Background(), target)
523- _, err = s.Outputs(ctx)491+ require.NoError(t, err)
524- require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it")492+ require.Less(t, time.Since(began), time.Second, "a pending capture does not block the caller")
525- _, err = s.Capture(context.Background(), target)493+ 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 }494 }
568- s.opMu.Lock()495+ require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
569- released := s.buf.data == nil && s.watchStop == nil496+ st := srv.Stats()
570- s.opMu.Unlock()497+ 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")498+ srv.Release() // the screen changed
572- _, err := s.Outputs(context.Background())499+ var f ports.Frame
573- require.ErrorIs(t, err, ports.ErrClosed)500+ require.Eventually(t, func() bool {
501+ f, err = s.Capture(context.Background(), target)
502+ require.NoError(t, err)
503+ return f.Row(1)[10] == 2
504+ }, 5*time.Second, time.Millisecond, "the pending capture completes into the other buffer")
574 }505 }
575 506
576-func TestContextSwitchRace(t *testing.T) {507+// A timed recording over a static screen keeps its schedule.
577- s, _ := newSource(t, compositorConfig{})508+func TestTimedRecordingOverStaticScreen(t *testing.T) {
578- out := firstOutput(t, s)509+ s, _ := newSource(t, testserver.Config{HoldAfterFirst: true})
579- target := ports.Target{OutputID: out.ID}510+ target := ports.Target{OutputID: firstOutput(t, s).ID}
580- for i := 0; i < 200; i++ {511+ _, err := s.Capture(context.Background(), target)
581- ctx, cancel := context.WithCancel(context.Background())512+ require.NoError(t, err)
582- _, err := s.Capture(ctx, target)513+ began := time.Now()
583- require.NoError(t, err)514+ 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)515 _, err = s.Capture(context.Background(), target)
586 require.NoError(t, err)516 require.NoError(t, err)
587- cancel()
588 }517 }
518+ require.Less(t, time.Since(began), 500*time.Millisecond)
589 }519 }
590 520
591-func TestNoShmOffer(t *testing.T) {521+// NeferWL sends no new constraint batch after failed(buffer_constraints): the
592- s, comp := newSource(t, compositorConfig{noShmOffer: true})522+// frame is retried with the current constraints after a roundtrip.
593- out := firstOutput(t, s)523+func TestFailedBufferConstraintsRetriesWithoutNewBatch(t *testing.T) {
594- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})524+ s, srv := newSource(t, testserver.Config{})
595- require.ErrorIs(t, err, ports.ErrUnsupportedFormat)525+ target := ports.Target{OutputID: firstOutput(t, s).ID}
596- require.ErrorContains(t, err, "no wl_shm buffer")526+ srv.Fail(1, 1)
597- require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }))527+ _, err := s.Capture(context.Background(), target)
598- _, err = s.Outputs(context.Background())528+ require.NoError(t, err)
529+ srv.Fail(maxFrameAttempts, 1)
530+ _, err = s.Capture(context.Background(), target)
531+ require.ErrorIs(t, err, errCompositorFailedCapture, "the retries are bounded")
532+ srv.Fail(0, 0)
533+ _, err = s.Capture(context.Background(), target)
599 require.NoError(t, err)534 require.NoError(t, err)
600 }535 }
601 536
602-func TestMalformedOutputModeAndScale(t *testing.T) {537+func TestNonNormalTransformIsRejected(t *testing.T) {
603- s, _ := newSource(t, compositorConfig{outputs: []outputSpec{538+ 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},539+ s, _ := newSource(t, testserver.Config{Transform: 3})
605- {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4},540+ _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, Region: region})
606- }})541+ require.ErrorIs(t, err, ports.ErrUnsupportedTransform)
607- outs, err := s.Outputs(context.Background())542+ require.ErrorContains(t, err, "output transform 3 is not supported")
608- require.NoError(t, err)543+ }
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 }544 }
614 545
615-func TestDuplicateGlobalIgnored(t *testing.T) {546+// A workspace source stops when its workspace goes away.
616- s, _ := newSource(t, compositorConfig{duplicateGlobal: true})547+func TestWorkspaceRemovedIsUnavailable(t *testing.T) {
617- outs, err := s.Outputs(context.Background())548+ cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
549+ cfg.NeferwlSource = true
550+ s, srv := newSource(t, cfg)
551+ target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1}
552+ _, err := s.Capture(context.Background(), target)
618 require.NoError(t, err)553 require.NoError(t, err)
619- require.Len(t, outs, 1)554+ srv.RemoveWorkspace("b")
620- require.Equal(t, 1, s.bound)555+ _, err = s.Capture(context.Background(), target)
556+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
621 }557 }
622 558
623-func TestReadyWithoutCopyIsTerminal(t *testing.T) {559+// A workspace session that stops because its workspace was removed is a plain
624- s, _ := newSource(t, compositorConfig{readyEarly: true})560+// stop, not a refusal: the allowlist hint is for a live workspace.
625- out := firstOutput(t, s)561+func TestWorkspaceRemovedBeforeFirstFrameIsNotRefusal(t *testing.T) {
626- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})562+ cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
627- require.Error(t, err)563+ cfg.NeferwlSource = true
628- _, err = s.Outputs(context.Background())564+ s, srv := newSource(t, cfg)
629- require.ErrorIs(t, err, ports.ErrClosed)565+ target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1}
566+ srv.RemoveWorkspace("b") // not read yet: the session is created, then stopped
567+ _, err := s.Capture(context.Background(), target)
568+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
569+ require.ErrorIs(t, err, ports.ErrCaptureStopped)
570+ require.NotContains(t, err.Error(), "capture-allow")
571+ require.NotContains(t, err.Error(), "refused")
572+}
573+
574+// A live workspace whose session is refused keeps the allowlist hint.
575+func TestWorkspaceSessionRefusedKeepsAllowlistHint(t *testing.T) {
576+ cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
577+ cfg.NeferwlSource, cfg.StopAtCreate = true, true
578+ s, _ := newSource(t, cfg)
579+ _, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1})
580+ require.ErrorIs(t, err, ports.ErrCaptureStopped)
581+ require.ErrorContains(t, err, "/etc/neferwl/capture-allow")
630 }582 }
631 583
632-func TestCompositorFailureIsRecoverable(t *testing.T) {584+// A cancellable context costs no more than a background one.
633- t.Run("before copy", func(t *testing.T) {585+func TestCaptureAllocationsCancellable(t *testing.T) {
634- s, comp := newSource(t, compositorConfig{failCapture: true})586+ if raceEnabled {
635- out := firstOutput(t, s)587+ t.Skip("race instrumentation changes allocation counts")
636- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})588+ }
637- require.Error(t, err)589+ s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}})
638- comp.mu.Lock()590+ target := ports.Target{OutputID: firstOutput(t, s).ID}
639- comp.cfg.failCapture = false591+ ctx, cancel := context.WithCancel(context.Background())
640- comp.mu.Unlock()592+ defer cancel()
641- _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})593+ for range 3 {
594+ _, err := s.Capture(ctx, target)
642 require.NoError(t, err)595 require.NoError(t, err)
596+ }
597+ runtime.GC()
598+ avg := testing.AllocsPerRun(200, func() {
599+ if _, err := s.Capture(ctx, target); err != nil {
600+ panic(err)
601+ }
643 })602 })
644- t.Run("after copy", func(t *testing.T) {603+ t.Logf("allocations per capture with a cancellable context: %g", avg)
645- s, _ := newSource(t, compositorConfig{failCopies: 1})604+ require.LessOrEqual(t, avg, float64(allocBudget))
646- out := firstOutput(t, s)605+}
647- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})606+
648- require.Error(t, err)607+// A constraint batch that repeats the same layout (a compositor may re-send
649- require.NotErrorIs(t, err, ports.ErrClosed)608+// 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})609+// static screen the next Capture would block for damage that never comes.
610+func TestSameGeometryConstraintBatchKeepsPendingFrame(t *testing.T) {
611+ s, srv := newSource(t, testserver.Config{HoldAfterFirst: true})
612+ target := ports.Target{OutputID: firstOutput(t, s).ID}
613+ // Every call is bounded, so a regression fails instead of hanging.
614+ capture := func() (ports.Frame, error) {
615+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
616+ defer cancel()
617+ return s.Capture(ctx, target)
618+ }
619+ for range 3 {
620+ _, err := capture()
651 require.NoError(t, err)621 require.NoError(t, err)
652- require.NoError(t, f.Validate())622+ }
653- })623+ srv.Resize("TEST-1", 8, 4) // same size: a new batch, same layout
624+ f, err := capture()
625+ require.NoError(t, err, "the last frame is repeated, not waited for")
626+ require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height})
627+ require.Equal(t, 2, srv.Stats().Frames, "the pending capture was kept, not replaced")
654 }628 }
A internal/adapters/wayland/workspaces.go +381 -0
@@ -0,0 +1,381 @@
1+package wayland
2+
3+import (
4+ "cmp"
5+ "context"
6+ "errors"
7+ "slices"
8+ "strconv"
9+ "unicode"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/wlturbo"
13+
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// ext-workspace-v1 client. Only the state needed to list workspaces and to
18+// name one as a capture source is kept; nothing is ever requested from the
19+// compositor (no activate, no commit).
20+
21+const (
22+ workspaceIface = "ext_workspace_manager_v1"
23+ workspaceVersion = 1
24+ maxWorkspaces = 256 // bound of the inventory
25+ maxNameLen = 256 // bytes, for workspace names
26+ maxGroupOutputs = 64
27+
28+ wsStateActive = 1
29+
30+ reqGroupDestroy = 1
31+ reqHandleDestroy = 0
32+
33+ maxInterned = 4 * maxWorkspaces // id strings remembered across removals
34+)
35+
36+// workspaceState is the ext-workspace inventory of a Source.
37+type workspaceState struct {
38+ mgr *workspaceManager // nil when the compositor has no ext-workspace
39+ handles map[uint32]*workspace // by proxy ID
40+ byID map[uint64]*workspace // by Source-local identity
41+ groups map[uint32]*workspaceGroup
42+ next uint64 // last identity handed out; identities start at 1
43+ interned map[string]uint64 // id string to identity, bounded by maxInterned
44+ overflow bool // the compositor announced more objects than maxWorkspaces
45+ shown uint64 // workspace last verified as displayed, without the extension
46+}
47+
48+// workspaceManager receives the creation events. It is bound once.
49+type workspaceManager struct {
50+ wlturbo.BaseProxy
51+ s *Source
52+}
53+
54+func (*workspaceManager) EventSignature(op uint16) (string, bool) {
55+ switch op {
56+ case 0, 1:
57+ return "new_id,", true // workspace_group, workspace
58+ case 2, 3:
59+ return "", true // done, finished
60+ }
61+ return "", false
62+}
63+
64+func (m *workspaceManager) Dispatch(e *wlturbo.Event) {
65+ s := m.s
66+ switch e.Opcode {
67+ case 0:
68+ id := e.Uint32()
69+ g := &workspaceGroup{s: s, outputs: make(map[uint32]bool)}
70+ g.SetContext(s.wl)
71+ g.SetID(id)
72+ g.SetVersion(m.Version())
73+ s.wl.Register(g)
74+ if len(s.ws.groups) >= maxWorkspaces {
75+ s.ws.overflow = true
76+ return
77+ }
78+ s.ws.groups[id] = g
79+ case 1:
80+ id := e.Uint32()
81+ w := &workspace{s: s}
82+ w.SetContext(s.wl)
83+ w.SetID(id)
84+ w.SetVersion(m.Version())
85+ s.wl.Register(w)
86+ if len(s.ws.handles) >= maxWorkspaces {
87+ s.ws.overflow = true
88+ return
89+ }
90+ s.ws.handles[id] = w
91+ case 2:
92+ s.ws.assignIDs()
93+ }
94+}
95+
96+type workspaceGroup struct {
97+ wlturbo.BaseProxy
98+ s *Source
99+ outputs map[uint32]bool // wl_output proxy IDs
100+}
101+
102+func (*workspaceGroup) EventSignature(op uint16) (string, bool) {
103+ switch op {
104+ case 0:
105+ return "uint,", true // capabilities
106+ case 1, 2, 3, 4:
107+ return "object,", true // output_enter, output_leave, workspace_enter, workspace_leave
108+ case 5:
109+ return "", true // removed
110+ }
111+ return "", false
112+}
113+
114+func (g *workspaceGroup) Dispatch(e *wlturbo.Event) {
115+ s := g.s
116+ switch e.Opcode {
117+ case 1:
118+ if id := e.Uint32(); len(g.outputs) < maxGroupOutputs {
119+ g.outputs[id] = true
120+ }
121+ case 2:
122+ delete(g.outputs, e.Uint32())
123+ case 3:
124+ if w := s.ws.handles[e.Uint32()]; w != nil {
125+ w.group = g
126+ }
127+ case 4:
128+ if w := s.ws.handles[e.Uint32()]; w != nil && w.group == g {
129+ w.group = nil
130+ }
131+ case 5:
132+ delete(s.ws.groups, g.ID())
133+ for _, w := range s.ws.handles {
134+ if w.group == g {
135+ w.group = nil
136+ }
137+ }
138+ _ = s.wl.Request(wlturbo.Request{Proxy: g, Opcode: reqGroupDestroy, Name: "ext_workspace_group_handle_v1.destroy", Destructor: true})
139+ }
140+}
141+
142+// workspace is one ext_workspace_handle_v1. It is a wlturbo object so that it
143+// can be the argument of create_workspace_source.
144+type workspace struct {
145+ wlturbo.BaseProxy
146+ s *Source
147+ idStr string // the id event: empty for a temporary workspace
148+ name string
149+ state uint32
150+ group *workspaceGroup
151+ id uint64 // Source-local identity, assigned at the first done
152+ removed bool
153+ frame *workspaceFrame // NeferWL's extension only; nil until asked for
154+}
155+
156+func (*workspace) EventSignature(op uint16) (string, bool) {
157+ switch op {
158+ case 0, 1:
159+ return "string,", true // id, name
160+ case 2:
161+ return "array,", true // coordinates
162+ case 3, 4:
163+ return "uint,", true // state, capabilities
164+ case 5:
165+ return "", true // removed
166+ }
167+ return "", false
168+}
169+
170+func (w *workspace) Dispatch(e *wlturbo.Event) {
171+ switch e.Opcode {
172+ case 0:
173+ if w.idStr == "" {
174+ w.idStr = e.String()
175+ }
176+ case 1:
177+ if n := e.String(); validName(n) {
178+ w.name = n
179+ }
180+ // opcode 2 (coordinates) is not used: nothing here depends on the grid.
181+ case 3:
182+ w.state = e.Uint32()
183+ case 5:
184+ w.removed = true
185+ s := w.s
186+ s.releaseWorkspaceFrame(w)
187+ delete(s.ws.handles, w.ID())
188+ if w.id != 0 && s.ws.byID[w.id] == w {
189+ delete(s.ws.byID, w.id)
190+ }
191+ _ = s.wl.Request(wlturbo.Request{Proxy: w, Opcode: reqHandleDestroy, Name: "ext_workspace_handle_v1.destroy", Destructor: true})
192+ }
193+}
194+
195+// assignIDs gives every workspace announced since the last done its
196+// identity. The id event is an opaque string: it is interned into a number
197+// local to this Source, from one counter, so identities cannot collide and the
198+// string is never parsed. The same string keeps its identity while the Source
199+// lives (up to maxInterned strings). A workspace without an id, or whose id
200+// is already held by a live workspace, is temporary: it gets a fresh number
201+// that is not remembered.
202+func (ws *workspaceState) assignIDs() {
203+ fresh := make([]*workspace, 0, len(ws.handles))
204+ for _, w := range ws.handles {
205+ if w.id == 0 && !w.removed {
206+ fresh = append(fresh, w)
207+ }
208+ }
209+ // Map order is random: numbers follow the announcement order.
210+ slices.SortFunc(fresh, func(a, b *workspace) int { return cmp.Compare(a.ID(), b.ID()) })
211+ for _, w := range fresh {
212+ w.id = ws.intern(w.idStr)
213+ ws.byID[w.id] = w
214+ }
215+}
216+
217+func (ws *workspaceState) intern(idStr string) uint64 {
218+ if idStr != "" {
219+ if id, ok := ws.interned[idStr]; ok {
220+ if ws.byID[id] == nil {
221+ return id
222+ }
223+ } else if len(ws.interned) < maxInterned {
224+ ws.next++
225+ ws.interned[idStr] = ws.next
226+ return ws.next
227+ }
228+ }
229+ ws.next++
230+ return ws.next
231+}
232+
233+// validName accepts a bounded, valid UTF-8, single-line name without control
234+// or bidirectional formatting characters.
235+func validName(n string) bool {
236+ if len(n) > maxNameLen || !utf8.ValidString(n) {
237+ return false
238+ }
239+ for _, r := range n {
240+ switch {
241+ case unicode.IsControl(r), r == 0x2028, r == 0x2029, r == 0x061C, r == 0x200E, r == 0x200F,
242+ r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069:
243+ return false
244+ }
245+ }
246+ return true
247+}
248+
249+func outputName(o *output) string {
250+ if o.name == "" { // wl_output older than version 4 has no name
251+ return "output-" + strconv.FormatUint(uint64(o.id), 10)
252+ }
253+ return o.name
254+}
255+
256+// forgetOutput drops a removed wl_output proxy from every group.
257+func (ws *workspaceState) forgetOutput(proxyID uint32) {
258+ for _, g := range ws.groups {
259+ delete(g.outputs, proxyID)
260+ }
261+}
262+
263+// bindWorkspaces binds ext-workspace-v1 when the compositor offers it.
264+func (s *Source) bindWorkspaces() error {
265+ s.ws.handles = make(map[uint32]*workspace)
266+ s.ws.byID = make(map[uint64]*workspace)
267+ s.ws.groups = make(map[uint32]*workspaceGroup)
268+ s.ws.interned = make(map[string]uint64)
269+ m := &workspaceManager{s: s}
270+ m.SetContext(s.wl)
271+ _, err := s.display.Registry().BindNegotiated(workspaceIface, workspaceVersion, m)
272+ switch {
273+ case err == nil:
274+ s.ws.mgr = m
275+ return nil
276+ case errors.Is(err, wlturbo.ErrGlobalNotFound):
277+ return nil
278+ }
279+ return err
280+}
281+
282+// Workspaces lists the workspaces ext-workspace-v1 advertises, one entry per
283+// output of the workspace's group, ordered by identity. It returns nil when
284+// the compositor has no ext-workspace.
285+func (s *Source) Workspaces(ctx context.Context) ([]ports.Workspace, error) {
286+ s.opMu.Lock()
287+ defer s.opMu.Unlock()
288+ if err := s.begin(ctx); err != nil {
289+ return nil, err
290+ }
291+ err := s.settle()
292+ if err == nil {
293+ // The frame of a workspace comes from NeferWL's extension: one
294+ // object per workspace, answered at creation.
295+ var asked bool
296+ if asked, err = s.requestFrames(); asked && err == nil {
297+ err = s.roundtrip()
298+ }
299+ }
300+ var list []ports.Workspace
301+ if err == nil {
302+ list = s.workspaceList()
303+ }
304+ if err = s.finish(ctx, err); err != nil {
305+ return nil, err
306+ }
307+ return list, nil
308+}
309+
310+func (s *Source) workspaceList() []ports.Workspace {
311+ if s.ws.mgr == nil || s.ws.overflow {
312+ return nil
313+ }
314+ var list []ports.Workspace
315+ for _, w := range s.ws.byID {
316+ if w.removed || w.group == nil {
317+ continue
318+ }
319+ for proxyID := range w.group.outputs {
320+ o := s.byProxy[proxyID]
321+ if o == nil {
322+ continue
323+ }
324+ list = append(list, ports.Workspace{
325+ ID: w.id, OutputID: o.id, Name: w.name,
326+ Region: workspaceRegion(w, o),
327+ Active: w.state&wsStateActive != 0,
328+ })
329+ }
330+ }
331+ slices.SortFunc(list, func(a, b ports.Workspace) int {
332+ return cmp.Or(cmp.Compare(a.ID, b.ID), cmp.Compare(a.OutputID, b.OutputID))
333+ })
334+ return list
335+}
336+
337+// workspaceRegion is where the workspace is on o, in output-local logical
338+// pixels: its frame from NeferWL's extension, clipped to the output, else the
339+// whole output (ext-workspace carries no geometry).
340+func workspaceRegion(w *workspace, o *output) ports.Region {
341+ lw, lh := o.logicalSize()
342+ whole := ports.Region{Width: lw, Height: lh}
343+ r, ok := w.frame.region()
344+ if !ok {
345+ return whole
346+ }
347+ x0, y0 := max(r.X, 0), max(r.Y, 0)
348+ x1, y1 := min(r.X+r.Width, lw), min(r.Y+r.Height, lh)
349+ if x1 <= x0 || y1 <= y0 {
350+ return whole
351+ }
352+ return ports.Region{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0}
353+}
354+
355+// workspaceKey resolves a workspace target. With NeferWL's extension the
356+// session follows the workspace itself, shown or not. Without it the
357+// workspace is captured as its output, and only while it is displayed: that is
358+// checked when the workspace is first targeted, so the recording then follows
359+// the output whatever it shows, and never starts on hidden content.
360+func (s *Source) workspaceKey(t ports.Target, out *output) (sessionKey, ports.Region, error) {
361+ if t.Region != (ports.Region{}) {
362+ return sessionKey{}, ports.Region{}, ports.ErrInvalidRegion
363+ }
364+ w := s.ws.byID[t.WorkspaceID]
365+ if w == nil || w.removed {
366+ return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
367+ }
368+ if s.ext.source != nil {
369+ return sessionKey{output: out.id, workspace: w.id}, ports.Region{}, nil
370+ }
371+ if s.ws.shown != w.id {
372+ if err := s.roundtrip(); err != nil {
373+ return sessionKey{}, ports.Region{}, err
374+ }
375+ if w.removed || w.state&wsStateActive == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] {
376+ return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
377+ }
378+ s.ws.shown = w.id
379+ }
380+ return sessionKey{output: out.id}, ports.Region{}, nil
381+}
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 +66 -0
@@ -0,0 +1,66 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/wlturbo"
5+
6+ "github.com/bnema/nefercap/internal/ports"
7+)
8+
9+// zxdg_output_v1 gives the logical size of an output, which maps a logical
10+// region onto buffer pixels when a region has to be cropped from the output
11+// frame. It is optional: the current mode over the integer scale is the
12+// fallback.
13+
14+const (
15+ reqGetXdgOutput = 1 // zxdg_output_manager_v1.get_xdg_output
16+ reqXdgOutputDestroy = 0 // zxdg_output_v1.destroy
17+)
18+
19+type xdgOutput struct {
20+ wlturbo.BaseProxy
21+ o *output
22+}
23+
24+func (*xdgOutput) EventSignature(op uint16) (string, bool) {
25+ switch op {
26+ case 0, 1:
27+ return "int,int,", true // logical_position, logical_size
28+ case 2:
29+ return "", true // done
30+ case 3, 4:
31+ return "string,", true // name, description
32+ }
33+ return "", false
34+}
35+
36+func (x *xdgOutput) Dispatch(e *wlturbo.Event) {
37+ if e.Opcode != 1 {
38+ return
39+ }
40+ w, h := e.Int32(), e.Int32()
41+ if w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension {
42+ x.o.lw, x.o.lh = w, h
43+ }
44+}
45+
46+// watchLogicalSize subscribes to the logical size of o.
47+func (s *Source) watchLogicalSize(o *output) {
48+ if s.xdgOutputs == nil {
49+ return
50+ }
51+ x := &xdgOutput{o: o}
52+ x.SetContext(s.wl)
53+ if err := s.wl.Request(wlturbo.Request{Proxy: s.xdgOutputs, Opcode: reqGetXdgOutput, Name: "zxdg_output_manager_v1.get_xdg_output", Child: x}, x, o.proxy); err != nil {
54+ s.log.Warn().Err(err).Msg("xdg-output request failed")
55+ return
56+ }
57+ o.xdg = x
58+}
59+
60+// releaseXdg destroys the zxdg_output_v1 of an output that went away.
61+func (s *Source) releaseXdg(o *output) {
62+ if x := o.xdg; x != nil {
63+ o.xdg = nil
64+ _ = s.wl.Request(wlturbo.Request{Proxy: x, Opcode: reqXdgOutputDestroy, Name: "zxdg_output_v1.destroy", Destructor: true})
65+ }
66+}
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