detached 6aca404

Merge pull request 'feat(screencast): share screens and take screenshots through xdg-desktop-portal-gnome' (#54) from feat/screencast-gnome into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/54
commit
6aca404f84cb31edec330959e118d961600452d8
author
bnema · · 2026-10-15 03:37 UTC
merge of
d8bb92c + 081cb4a · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
77 files · +9463 -285
M .goreleaser.yaml +4 -2
@@ -71,7 +71,8 @@ nfpms:
71 - foot71 - foot
72 - fuzzel72 - fuzzel
73 - xdg-desktop-portal-gtk73 - xdg-desktop-portal-gtk
74- - xdg-desktop-portal-wlr74+ - xdg-desktop-portal-gnome
75+ - pipewire
75 scripts:76 scripts:
76 postinstall: packaging/deb/postinst77 postinstall: packaging/deb/postinst
77 contents:78 contents:
@@ -208,7 +209,8 @@ aurs:
208 - "foot: default terminal"209 - "foot: default terminal"
209 - "fuzzel: launcher"210 - "fuzzel: launcher"
210 - "xdg-desktop-portal-gtk: file pickers and settings for apps"211 - "xdg-desktop-portal-gtk: file pickers and settings for apps"
211- - "xdg-desktop-portal-wlr: screen sharing and screenshots through the portal"212+ - "xdg-desktop-portal-gnome: screen sharing and screenshots through the portal"
213+ - "pipewire: screen sharing streams"
212 - "nefercap: screenshots and screen recording"214 - "nefercap: screenshots and screen recording"
213 package: |-215 package: |-
214 install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl"216 install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl"
M .hexcheck.yaml +1 -1
@@ -25,7 +25,7 @@ allow:
25 ruleSettings:25 ruleSettings:
26 missing-generated-mock-for-port:26 missing-generated-mock-for-port:
27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
28- excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go]28+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go]
29 suspicious-business-logic-in-adapter:29 suspicious-business-logic-in-adapter:
30 # app is the composition root; starting core and wiring channels is its job.30 # app is the composition root; starting core and wiring channels is its job.
31 excludePaths: [internal/app/app.go, internal/app/session.go]31 excludePaths: [internal/app/app.go, internal/app/session.go]
M .mockery.yml +38 -0
@@ -14,6 +14,7 @@ packages:
14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
15 interfaces:15 interfaces:
16 CaptureFrame:16 CaptureFrame:
17+ CastSink:
17 Clock:18 Clock:
18 ContentReturn:19 ContentReturn:
19 Notifier:20 Notifier:
@@ -110,3 +111,40 @@ packages:
110 dir: internal/adapters/statefile111 dir: internal/adapters/statefile
111 pkgname: statefile112 pkgname: statefile
112 filename: proc_tree_mock_test.go113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/screencast:
115+ interfaces:
116+ bus:
117+ config:
118+ dir: internal/adapters/screencast
119+ pkgname: screencast
120+ filename: bus_mock_test.go
121+ videoSource:
122+ config:
123+ dir: internal/adapters/screencast
124+ pkgname: screencast
125+ filename: video_source_mock_test.go
126+ videoCore:
127+ config:
128+ dir: internal/adapters/screencast
129+ pkgname: screencast
130+ filename: video_core_mock_test.go
131+ peerResolver:
132+ config:
133+ dir: internal/adapters/screencast
134+ pkgname: screencast
135+ filename: peer_resolver_mock_test.go
136+ runner:
137+ config:
138+ dir: internal/adapters/screencast
139+ pkgname: screencast
140+ filename: runner_mock_test.go
141+ dialer:
142+ config:
143+ dir: internal/adapters/screencast
144+ pkgname: screencast
145+ filename: dialer_mock_test.go
146+ pipewireOpener:
147+ config:
148+ dir: internal/adapters/screencast
149+ pkgname: screencast
150+ filename: pipewire_opener_mock_test.go
M Makefile +2 -2
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M README.md +1 -1
@@ -113,7 +113,7 @@ sudo apt install ./neferwl_<version>_amd64.deb
113 Optional tools:113 Optional tools:
114 114
115 ```sh115 ```sh
116-sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-wlr116+sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-gnome
117 ```117 ```
118 118
119 X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested.119 X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested.
M docs/capture.md +15 -7
@@ -28,10 +28,12 @@ By default, these executables may capture:
28 ```text28 ```text
29 /usr/bin/grim29 /usr/bin/grim
30 /usr/bin/nefercap30 /usr/bin/nefercap
31-/usr/lib/xdg-desktop-portal-wlr31+/usr/lib/xdg-desktop-portal-gnome
32-/usr/libexec/xdg-desktop-portal-wlr32+/usr/libexec/xdg-desktop-portal-gnome
33 ```33 ```
34 34
35+The list also gates the D-Bus callers of the screen-sharing service (see Portals): a caller whose executable is not listed gets `AccessDenied`.
36+
35 To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need:37 To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need:
36 38
37 ```sh39 ```sh
@@ -43,7 +45,7 @@ sudoedit /etc/neferwl/capture-allow
43 # one absolute executable path per line45 # one absolute executable path per line
44 /usr/bin/grim46 /usr/bin/grim
45 /usr/bin/nefercap47 /usr/bin/nefercap
46-/usr/lib/xdg-desktop-portal-wlr48+/usr/lib/xdg-desktop-portal-gnome
47 /opt/tools/bin/recorder49 /opt/tools/bin/recorder
48 ```50 ```
49 51
@@ -61,9 +63,15 @@ The allowlist stops unknown programs, not a program running as you that drives a
61 63
62 ## Portals64 ## Portals
63 65
64-- **Screen sharing** in browsers and apps: install `xdg-desktop-portal-wlr`.66+NeferWL serves the GNOME ScreenCast, DisplayConfig, Introspect and Screenshot D-Bus interfaces, so `xdg-desktop-portal-gnome` provides screen sharing and screenshots.
65-- **Screenshots and recordings:** install [nefercap](https://github.com/bnema/nefercap).67+
68+- **Screen sharing** in browsers and apps: install `xdg-desktop-portal-gnome` and run PipeWire. The portal dialog lists monitors and windows with their titles; the stream is a PipeWire video node. Without PipeWire, the ScreenCast interface is not offered.
69+- **Screenshots** through the portal, interactive or not: install [nefercap](https://github.com/bnema/nefercap), which writes the PNG.
70+- A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, the client stops it or disconnects, or NeferWL loses the D-Bus connection. When the receiving program stops reading, the stream pauses (no frames are rendered) and the cast stays open for the next reader, as in GNOME.
71+- Unsupported: area and virtual casts, and the "Select area" and "Window" choices of the screenshot dialog.
72+
73+The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the gnome portal.
66 74
67-The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the wlr portal. NeferWL has no window picker: set `chooser_type` and `chooser_cmd` in the wlr portal config to use slurp, wofi or bemenu (see `man xdg-desktop-portal-wlr`).75+The D-Bus interfaces are served on the session bus when NeferWL runs on a hardware session. With `--backend=headless`, pass `-dbus` to serve them. Names already owned (by a GNOME session) are skipped.
68 76
69-The wlr portal takes screenshots without asking, for any program running as you. The indicator still flashes.77+Screenshots through the portal are taken without asking, for any program running as you whose executable is on the allowlist. The indicator still flashes.
M docs/desktop.md +1 -1
@@ -64,7 +64,7 @@ Use an external locker such as swaylock or hyprlock. See [Session locking](sessi
64 64
65 ## Screen capture65 ## Screen capture
66 66
67-nefercap, `grim` and screen sharing through `xdg-desktop-portal-wlr` work out of the box. Other tools must be added to the allowlist. See [Screen capture](capture.md).67+nefercap, `grim` and screen sharing through `xdg-desktop-portal-gnome` work out of the box. Other tools must be added to the allowlist. See [Screen capture](capture.md).
68 68
69 ## Idle and screen off69 ## Idle and screen off
70 70
M examples/capture-allow +4 -4
@@ -5,8 +5,8 @@
5 #5 #
6 # /usr/bin/grim6 # /usr/bin/grim
7 # /usr/bin/nefercap7 # /usr/bin/nefercap
8-# /usr/lib/xdg-desktop-portal-wlr8+# /usr/lib/xdg-desktop-portal-gnome
9-# /usr/libexec/xdg-desktop-portal-wlr9+# /usr/libexec/xdg-desktop-portal-gnome
10 #10 #
11 # To allow a tool installed elsewhere, copy this file to11 # To allow a tool installed elsewhere, copy this file to
12 # /etc/neferwl/capture-allow as root and add its path. The file REPLACES the12 # /etc/neferwl/capture-allow as root and add its path. The file REPLACES the
@@ -27,5 +27,5 @@
27 # would then be allowed to capture.27 # would then be allowed to capture.
28 /usr/bin/grim28 /usr/bin/grim
29 /usr/bin/nefercap29 /usr/bin/nefercap
30-/usr/lib/xdg-desktop-portal-wlr30+/usr/lib/xdg-desktop-portal-gnome
31-/usr/libexec/xdg-desktop-portal-wlr31+/usr/libexec/xdg-desktop-portal-gnome
M go.mod +2 -0
@@ -7,8 +7,10 @@ require (
7 github.com/bnema/kvconf v0.1.27 github.com/bnema/kvconf v0.1.2
8 github.com/bnema/purego v0.13.0-bnema.18 github.com/bnema/purego v0.13.0-bnema.1
9 github.com/bnema/purego-libwayland v0.11.09 github.com/bnema/purego-libwayland v0.11.0
10+ github.com/bnema/purego-pipewire v0.2.0
10 github.com/bnema/purego-vulkan v0.6.011 github.com/bnema/purego-vulkan v0.6.0
11 github.com/bnema/wlturbo v0.5.012 github.com/bnema/wlturbo v0.5.0
13+ github.com/bnema/zerobus v0.2.0
12 github.com/bnema/zerowrap v1.4.114 github.com/bnema/zerowrap v1.4.1
13 github.com/godbus/dbus/v5 v5.2.215 github.com/godbus/dbus/v5 v5.2.2
14 github.com/rs/zerolog v1.35.116 github.com/rs/zerolog v1.35.1
M go.sum +4 -0 generated file: collapsed
M internal/adapters/captureallow/policy.go +2 -2
@@ -45,8 +45,8 @@ func Builtin() *Policy {
45 builtin := [...]string{45 builtin := [...]string{
46 "/usr/bin/grim",46 "/usr/bin/grim",
47 "/usr/bin/nefercap",47 "/usr/bin/nefercap",
48- "/usr/lib/xdg-desktop-portal-wlr",48+ "/usr/lib/xdg-desktop-portal-gnome",
49- "/usr/libexec/xdg-desktop-portal-wlr",49+ "/usr/libexec/xdg-desktop-portal-gnome",
50 }50 }
51 p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))}51 p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))}
52 for _, e := range builtin {52 for _, e := range builtin {
M internal/adapters/captureallow/policy_test.go +3 -3
@@ -16,8 +16,8 @@ func TestParse(t *testing.T) {
16 errLine int16 errLine int
17 wantFail bool17 wantFail bool
18 }{18 }{
19- {name: "paths comments blanks", in: "# header\n\n/usr/bin/grim\n /usr/lib/xdg-desktop-portal-wlr \n\t# indented comment\n/opt/a#b\n",19+ {name: "paths comments blanks", in: "# header\n\n/usr/bin/grim\n /usr/lib/xdg-desktop-portal-gnome \n\t# indented comment\n/opt/a#b\n",
20- mode: ModeList, allowed: []string{"/usr/bin/grim", "/usr/lib/xdg-desktop-portal-wlr", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}},20+ mode: ModeList, allowed: []string{"/usr/bin/grim", "/usr/lib/xdg-desktop-portal-gnome", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}},
21 {name: "cleaned", in: "/usr//bin/../bin/grim\n", mode: ModeList, allowed: []string{"/usr/bin/grim"}},21 {name: "cleaned", in: "/usr//bin/../bin/grim\n", mode: ModeList, allowed: []string{"/usr/bin/grim"}},
22 {name: "no trailing newline", in: "/a/b", mode: ModeList, allowed: []string{"/a/b"}},22 {name: "no trailing newline", in: "/a/b", mode: ModeList, allowed: []string{"/a/b"}},
23 {name: "empty file allows nothing", in: "", mode: ModeList, refused: []string{"/usr/bin/grim"}},23 {name: "empty file allows nothing", in: "", mode: ModeList, refused: []string{"/usr/bin/grim"}},
@@ -75,7 +75,7 @@ func TestParseTooLarge(t *testing.T) {
75 75
76 func TestBuiltin(t *testing.T) {76 func TestBuiltin(t *testing.T) {
77 p := Builtin()77 p := Builtin()
78- want := []string{"/usr/bin/grim", "/usr/bin/nefercap", "/usr/lib/xdg-desktop-portal-wlr", "/usr/libexec/xdg-desktop-portal-wlr"}78+ want := []string{"/usr/bin/grim", "/usr/bin/nefercap", "/usr/lib/xdg-desktop-portal-gnome", "/usr/libexec/xdg-desktop-portal-gnome"}
79 if p.Mode() != ModeList || p.Len() != len(want) {79 if p.Mode() != ModeList || p.Len() != len(want) {
80 t.Fatalf("mode %v, len %d", p.Mode(), p.Len())80 t.Fatalf("mode %v, len %d", p.Mode(), p.Len())
81 }81 }
M internal/adapters/logging/logging.go +1 -1
@@ -22,7 +22,7 @@ import (
22 var components = map[string]bool{22 var components = map[string]bool{
23 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,23 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
24 "render": true, "sync": true, "config": true, "app": true,24 "render": true, "sync": true, "config": true, "app": true,
25- "screensaver": true, "powerkey": true, "sched": true,25+ "screensaver": true, "powerkey": true, "sched": true, "screencast": true,
26 }26 }
27 27
28 // categories are debug switches narrower than a component, only on when28 // categories are debug switches narrower than a component, only on when
M internal/adapters/outputkit/capture/capture.go +43 -12
@@ -68,24 +68,22 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
68 if p := recover(); p != nil {68 if p := recover(); p != nil {
69 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())69 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())
70 }70 }
71+ // data is only ever a mapping made here, never req.Mapped.
71 if data != nil {72 if data != nil {
72 err = errors.Join(err, unix.Munmap(data))73 err = errors.Join(err, unix.Munmap(data))
73 }74 }
74 replyAt(ctx, req, err, at, replies)75 replyAt(ctx, req, err, at, replies)
75 }()76 }()
76- if req.Dst.File == nil || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {77+ size, err := destSize(req)
77- err = fmt.Errorf("invalid capture destination")78+ if err != nil {
78- return
79- }
80- rowBytes := int64(req.Width) * 4
81- // Validate the bound before multiplication: padding must not wrap size.
82- if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
83- err = fmt.Errorf("capture too large")
84 return79 return
85 }80 }
86- size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes81+ if req.Mapped != nil {
87- if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {82+ var dst []byte
88- err = fmt.Errorf("capture too large")83+ if dst, err = mappedDst(req, size); err != nil {
84+ return
85+ }
86+ err = frame.Read(req.Region, dst, req.Stride)
89 return87 return
90 }88 }
91 var st unix.Stat_t89 var st unix.Stat_t
@@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
94 return92 return
95 }93 }
96 if st.Size < size {94 if st.Size < size {
97- err = fmt.Errorf("capture buffer truncated")95+ err = errTruncated
98 return96 return
99 }97 }
100 // Map only for the duration of this capture; the renderer copies BGRA rows98 // Map only for the duration of this capture; the renderer copies BGRA rows
@@ -105,3 +103,36 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
105 }103 }
106 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)104 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)
107 }105 }
106+
107+// destSize validates the destination of req, a file or a borrowed mapping, and
108+// returns the bytes it must hold from offset 0 through the last pixel.
109+func destSize(req ports.CaptureRequest) (int64, error) {
110+ if (req.Dst.File == nil) == (req.Mapped == nil) || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {
111+ return 0, errInvalidDest
112+ }
113+ rowBytes := int64(req.Width) * 4
114+ // Validate the bound before multiplication: padding must not wrap size.
115+ if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
116+ return 0, errTooLarge
117+ }
118+ size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes
119+ if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {
120+ return 0, errTooLarge
121+ }
122+ return size, nil
123+}
124+
125+// mappedDst returns the borrowed mapping of req from the destination offset.
126+// The output never maps, unmaps or closes it.
127+func mappedDst(req ports.CaptureRequest, size int64) ([]byte, error) {
128+ if int64(len(req.Mapped)) < size {
129+ return nil, errTruncated
130+ }
131+ return req.Mapped[req.Dst.Offset:], nil
132+}
133+
134+var (
135+ errInvalidDest = errors.New("invalid capture destination")
136+ errTooLarge = errors.New("capture too large")
137+ errTruncated = errors.New("capture buffer truncated")
138+)
M internal/adapters/outputkit/capture/excluded.go +1 -1
@@ -193,4 +193,4 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
193 193
194 // Handed reports a zeroed request: one Split failed, or one already given to194 // Handed reports a zeroed request: one Split failed, or one already given to
195 // the worker. The owner never answers it again.195 // the worker. The owner never answers it again.
196-func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }196+func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil && q.Mapped == nil }
A internal/adapters/outputkit/capture/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package capture
4+
5+const raceEnabled = false
A internal/adapters/outputkit/capture/race_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build race
2+
3+package capture
4+
5+const raceEnabled = true
M internal/adapters/outputkit/capture/write_test.go +119 -0
@@ -12,6 +12,7 @@ import (
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15+ "golang.org/x/sys/unix"
15 )16 )
16 17
17 // writeRequest is a valid 2x2 request over a 16-byte file.18 // writeRequest is a valid 2x2 request over a 16-byte file.
@@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) {
177 frame.Calls = nil178 frame.Calls = nil
178 ok.Calls = nil179 ok.Calls = nil
179 }180 }
181+
182+// mappedRequest is a valid 2x2 request that writes into a borrowed mapping of
183+// size bytes.
184+func mappedRequest(size int) ports.CaptureRequest {
185+ return ports.CaptureRequest{ID: 9, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Mapped: make([]byte, size)}
186+}
187+
188+func TestWriteFrameMappedWritesAtOffset(t *testing.T) {
189+ req := mappedRequest(32)
190+ req.Dst.Offset = 8
191+ frame := portsmocks.NewMockCaptureFrame(t)
192+ frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
193+ if len(dst) != 24 {
194+ t.Errorf("dst holds %d bytes, want 24 (mapping from offset 8)", len(dst))
195+ return nil
196+ }
197+ copy(dst, []byte{1, 2, 3, 255})
198+ return nil
199+ }).Once()
200+ done := runWriteFrame(t, req, frame)
201+ if done.Err != nil {
202+ t.Fatal(done.Err)
203+ }
204+ if got := req.Mapped[8:12]; got[0] != 1 || got[3] != 255 {
205+ t.Fatalf("mapping not written at the offset: %v", req.Mapped[:16])
206+ }
207+ frame.Calls = nil
208+}
209+
210+func TestWriteFrameMappedExactFit(t *testing.T) {
211+ req := mappedRequest(8 + 2*4) // (h-1)*stride + w*4
212+ frame := portsmocks.NewMockCaptureFrame(t)
213+ frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(nil).Once()
214+ if done := runWriteFrame(t, req, frame); done.Err != nil {
215+ t.Fatal(done.Err)
216+ }
217+ frame.Calls = nil
218+}
219+
220+func TestWriteFrameMappedRejectsShortOrAmbiguous(t *testing.T) {
221+ for _, tc := range []struct {
222+ name string
223+ mutate func(q *ports.CaptureRequest)
224+ }{
225+ {"one byte short", func(q *ports.CaptureRequest) { q.Mapped = q.Mapped[:15] }},
226+ {"offset past mapping", func(q *ports.CaptureRequest) { q.Dst.Offset = 4 }},
227+ {"stride bound", func(q *ports.CaptureRequest) { q.Stride = math.MaxInt }},
228+ {"negative offset", func(q *ports.CaptureRequest) { q.Dst.Offset = -4 }},
229+ {"invalid format", func(q *ports.CaptureRequest) { q.Format = 7 }},
230+ {"file and mapping", func(q *ports.CaptureRequest) { q.Dst.File = os.Stdin }},
231+ } {
232+ t.Run(tc.name, func(t *testing.T) {
233+ req := mappedRequest(16)
234+ tc.mutate(&req)
235+ frame := portsmocks.NewMockCaptureFrame(t) // any Read call fails
236+ if done := runWriteFrame(t, req, frame); done.Err == nil {
237+ t.Fatal("invalid request succeeded")
238+ }
239+ })
240+ }
241+}
242+
243+// A borrowed mapping is never closed or unmapped by the output: it stays
244+// writable after the reply.
245+func TestWriteFrameMappedStaysMapped(t *testing.T) {
246+ m, err := unix.Mmap(-1, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_ANON|unix.MAP_PRIVATE)
247+ if err != nil {
248+ t.Fatal(err)
249+ }
250+ defer func() { _ = unix.Munmap(m) }()
251+ req := mappedRequest(0)
252+ req.Mapped = m
253+ frame := portsmocks.NewMockCaptureFrame(t)
254+ frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(nil).Once()
255+ if done := runWriteFrame(t, req, frame); done.Err != nil {
256+ t.Fatal(done.Err)
257+ }
258+ m[0], m[4095] = 1, 2 // would fault if unmapped
259+ frame.Calls = nil
260+}
261+
262+func TestHandedKeepsMappedRequests(t *testing.T) {
263+ if Handed(ports.CaptureRequest{Mapped: make([]byte, 4)}) {
264+ t.Fatal("a mapped request with ID 0 is not handed")
265+ }
266+ if !Handed(ports.CaptureRequest{}) {
267+ t.Fatal("a zero request is handed")
268+ }
269+}
270+
271+// The measured closure holds the production steps of a mapped write without
272+// the renderer's Read (a testify mock would record each call): destination
273+// validation, the borrowed slice and the reply.
274+func TestWriteMappedAllocations(t *testing.T) {
275+ if raceEnabled {
276+ t.Skip("the race detector allocates")
277+ }
278+ req := mappedRequest(16)
279+ replies := make(chan ports.CaptureDone, 1)
280+ ctx := context.Background()
281+ at := time.Unix(1, 2)
282+ var sink []byte
283+ allocs := testing.AllocsPerRun(200, func() {
284+ size, err := destSize(req)
285+ if err != nil {
286+ panic(err)
287+ }
288+ sink, err = mappedDst(req, size)
289+ if err != nil {
290+ panic(err)
291+ }
292+ replyAt(ctx, req, nil, at, replies)
293+ <-replies
294+ })
295+ if allocs != 0 || len(sink) != 16 {
296+ t.Fatalf("%v allocs per mapped write step, dst %d bytes", allocs, len(sink))
297+ }
298+}
A internal/adapters/peerexe/peerexe.go +110 -0
@@ -0,0 +1,110 @@
1+// Package peerexe names the executable behind a process through a pidfd.
2+//
3+// A pidfd pins its process: the pid cannot be recycled while the descriptor
4+// is open. ExeOfPidfd reads the executable through that pin and proves the
5+// process still existed after the read, so the answer is never the exe of a
6+// process that took over a recycled pid. There is no fallback to a bare pid,
7+// which has no such proof.
8+package peerexe
9+
10+import (
11+ "bufio"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "strconv"
16+ "strings"
17+
18+ "golang.org/x/sys/unix"
19+)
20+
21+var (
22+ // ErrExeDeleted: the executable of the process was removed.
23+ ErrExeDeleted = errors.New("executable deleted")
24+ // ErrNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the
25+ // peer cannot be identified safely.
26+ ErrNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
27+)
28+
29+// Pidfd returns a pidfd of the peer of the connected unix socket sockFD
30+// (SO_PEERPIDFD). The caller closes it. ErrNoPidfd means the kernel cannot
31+// give one.
32+func Pidfd(sockFD int) (*os.File, error) {
33+ pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
34+ switch {
35+ case err == nil:
36+ return os.NewFile(uintptr(pidfd), "pidfd"), nil
37+ case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
38+ return nil, ErrNoPidfd
39+ default:
40+ return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
41+ }
42+}
43+
44+// Open returns a pidfd of pid (pidfd_open). The caller closes it. A pid
45+// learned from a source that can be outrun by recycling (a D-Bus
46+// GetConnectionUnixProcessID reply) must be confirmed against the source
47+// again after Open, so the pidfd names the same process.
48+func Open(pid int) (*os.File, error) {
49+ fd, err := unix.PidfdOpen(pid, 0)
50+ if err != nil {
51+ return nil, fmt.Errorf("pidfd_open(%d): %w", pid, err)
52+ }
53+ return os.NewFile(uintptr(fd), "pidfd"), nil
54+}
55+
56+// ExeOfPidfd returns the executable and the pid of the process behind pidfd:
57+//
58+// 1. The pid is read from /proc/self/fdinfo/<pidfd>.
59+// 2. /proc/<pid>/exe is read.
60+// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
61+// had exited before the read, the pid could have named someone else, and
62+// the answer is refused.
63+//
64+// A deleted executable is ErrExeDeleted. The caller keeps pidfd open (and
65+// alive, runtime.KeepAlive if it holds an *os.File) during the call.
66+func ExeOfPidfd(pidfd int) (string, int, error) {
67+ pid, err := pidOfPidfd(pidfd)
68+ if err != nil {
69+ return "", 0, err
70+ }
71+ exe, err := readExe(pid)
72+ // After the read: a process that is gone may have let its pid be reused.
73+ if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
74+ return "", pid, fmt.Errorf("peer exited: %w", alive)
75+ }
76+ return exe, pid, err
77+}
78+
79+// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
80+// process has exited, 0 when it is outside this pid namespace.
81+func pidOfPidfd(pidfd int) (int, error) {
82+ f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
83+ if err != nil {
84+ return 0, err
85+ }
86+ defer f.Close()
87+ sc := bufio.NewScanner(f)
88+ for sc.Scan() {
89+ if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
90+ pid, err := strconv.Atoi(strings.TrimSpace(v))
91+ if err != nil || pid <= 0 {
92+ return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
93+ }
94+ return pid, nil
95+ }
96+ }
97+ return 0, errors.New("pidfd fdinfo has no Pid")
98+}
99+
100+// readExe reads /proc/<pid>/exe and refuses a deleted executable.
101+func readExe(pid int) (string, error) {
102+ exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
103+ if err != nil {
104+ return "", err
105+ }
106+ if strings.HasSuffix(exe, " (deleted)") {
107+ return "", ErrExeDeleted
108+ }
109+ return exe, nil
110+}
A internal/adapters/peerexe/peerexe_test.go +115 -0
@@ -0,0 +1,115 @@
1+package peerexe
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// The real lookup, against a socket whose peer is this process.
15+func TestPidfdSelf(t *testing.T) {
16+ self, err := os.Executable()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer unix.Close(fds[0])
25+ defer unix.Close(fds[1])
26+ pidfd, err := Pidfd(fds[0])
27+ if errors.Is(err, ErrNoPidfd) {
28+ t.Skip("kernel without SO_PEERPIDFD")
29+ }
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer pidfd.Close()
34+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
35+ runtime.KeepAlive(pidfd)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if exe != self || pid != os.Getpid() {
40+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
41+ }
42+}
43+
44+// Open of the own pid names the own executable.
45+func TestOpenSelf(t *testing.T) {
46+ self, err := os.Executable()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ pidfd, err := Open(os.Getpid())
51+ if err != nil {
52+ t.Skip("pidfd_open: ", err)
53+ }
54+ defer pidfd.Close()
55+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
56+ runtime.KeepAlive(pidfd)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if exe != self || pid != os.Getpid() {
61+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
62+ }
63+}
64+
65+func TestOpenMissingProcess(t *testing.T) {
66+ // Pids above the kernel's pid_max do not exist.
67+ if f, err := Open(1 << 30); err == nil {
68+ f.Close()
69+ t.Fatal("Open of a missing pid succeeded")
70+ }
71+}
72+
73+// A process whose binary was removed is refused, and a peer that already
74+// exited is never resolved from its (possibly recycled) pid.
75+func TestExeOfPidfdDeletedAndExited(t *testing.T) {
76+ dir := t.TempDir()
77+ path := filepath.Join(dir, "child")
78+ src, err := os.ReadFile("/bin/sleep")
79+ if err != nil {
80+ t.Skip("no /bin/sleep")
81+ }
82+ if err := os.WriteFile(path, src, 0o755); err != nil {
83+ t.Fatal(err)
84+ }
85+ proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
86+ if err != nil {
87+ t.Skip("cannot run a copy of sleep: ", err)
88+ }
89+ defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
90+ _ = os.Remove(path)
91+ pidfd, err := unix.PidfdOpen(proc.Pid, 0)
92+ if err != nil {
93+ t.Skip("pidfd_open: ", err)
94+ }
95+ defer unix.Close(pidfd)
96+ // Wait until exec has completed so /proc/<pid>/exe is the copy.
97+ deadline := time.Now().Add(2 * time.Second)
98+ for {
99+ if _, _, err := ExeOfPidfd(pidfd); errors.Is(err, ErrExeDeleted) {
100+ break
101+ }
102+ if time.Now().After(deadline) {
103+ t.Fatal("deleted executable not refused")
104+ }
105+ time.Sleep(5 * time.Millisecond)
106+ }
107+ if _, err := readExe(proc.Pid); !errors.Is(err, ErrExeDeleted) {
108+ t.Fatalf("readExe: %v", err)
109+ }
110+ _ = proc.Kill()
111+ _, _ = proc.Wait()
112+ if exe, _, err := ExeOfPidfd(pidfd); err == nil {
113+ t.Fatalf("exited peer resolved to %q", exe)
114+ }
115+}
A internal/adapters/screencast/bus.go +429 -0
@@ -0,0 +1,429 @@
1+package screencast
2+
3+import (
4+ "errors"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/zerobus"
9+)
10+
11+// Object paths, interfaces and errors of the served contract.
12+const (
13+ pathScreenCast = "/org/gnome/Mutter/ScreenCast"
14+ prefixSession = pathScreenCast + "/Session/u"
15+ prefixStream = pathScreenCast + "/Stream/u"
16+ pathDisplayConfig = "/org/gnome/Mutter/DisplayConfig"
17+ pathIntrospect = "/org/gnome/Shell/Introspect"
18+ pathScreenshot = "/org/gnome/Shell/Screenshot"
19+
20+ ifaceScreenCast = "org.gnome.Mutter.ScreenCast"
21+ ifaceSession = ifaceScreenCast + ".Session"
22+ ifaceStream = ifaceScreenCast + ".Stream"
23+ ifaceDisplayConfig = "org.gnome.Mutter.DisplayConfig"
24+ ifaceIntrospect = "org.gnome.Shell.Introspect"
25+ ifaceScreenshot = "org.gnome.Shell.Screenshot"
26+ ifaceProps = "org.freedesktop.DBus.Properties"
27+ ifacePeer = "org.freedesktop.DBus.Peer"
28+ ifaceIntrospectable = "org.freedesktop.DBus.Introspectable"
29+
30+ errAccessDenied = "org.freedesktop.DBus.Error.AccessDenied"
31+ errLimits = "org.freedesktop.DBus.Error.LimitsExceeded"
32+ errNotSupported = "org.freedesktop.DBus.Error.NotSupported"
33+ errInvalidArgs = "org.freedesktop.DBus.Error.InvalidArgs"
34+ errUnknownObject = "org.freedesktop.DBus.Error.UnknownObject"
35+ errUnknownProperty = "org.freedesktop.DBus.Error.UnknownProperty"
36+ errUnknownInterface = "org.freedesktop.DBus.Error.UnknownInterface"
37+ errPropertyReadOnly = "org.freedesktop.DBus.Error.PropertyReadOnly"
38+ errFailed = "org.freedesktop.DBus.Error.Failed"
39+)
40+
41+// objKind is the object a call is addressed to.
42+type objKind uint8
43+
44+const (
45+ objNone objKind = iota
46+ objScreenCast
47+ objSession
48+ objStream
49+ objDisplayConfig
50+ objIntrospect
51+ objScreenshot
52+)
53+
54+// method is a call the service understands, after its (path, interface,
55+// member) were resolved.
56+type method uint8
57+
58+const (
59+ mNotSupported method = iota
60+ mCreateSession
61+ mStart
62+ mStop
63+ mRecordMonitor
64+ mRecordWindow
65+ mGet
66+ mGetAll
67+ mSet
68+ mPing
69+ mIntrospect
70+ mGetState
71+ mGetWindows
72+ mShot
73+ mShotArea
74+ mShotInteractive
75+ mUnknownObject
76+)
77+
78+// request is a decoded method call. Every value is copied out of the receive
79+// buffer with a length cap, so the request can wait for the caller check and
80+// the reply can be built after other messages were read.
81+type request struct {
82+ sender string
83+ serial uint32
84+ noReply bool
85+ obj objKind
86+ id uint32 // session or stream number from the path
87+ method method
88+ bad bool // the body does not match the method
89+
90+ text, text2 string // connector; Properties interface and name
91+ window uint64
92+ cursor uint32
93+ remote bool // CreateSession: remote-desktop-session-id given
94+ x, y, w, h int32
95+ flag bool
96+}
97+
98+// privileged reports the calls that read the caller's executable again.
99+func (r *request) privileged() bool {
100+ switch r.method {
101+ case mCreateSession, mStart, mShot, mShotArea, mShotInteractive:
102+ return true
103+ }
104+ return false
105+}
106+
107+// capStr copies s, cut to maxArg bytes.
108+func capStr(s string) string {
109+ if len(s) > maxArg {
110+ s = s[:maxArg]
111+ }
112+ return strings.Clone(s)
113+}
114+
115+// objectOf finds the object of a path. A served object needs its bus name.
116+func (s *Service) objectOf(path string) (objKind, uint32) {
117+ switch path {
118+ case pathScreenCast:
119+ return objScreenCast, 0
120+ case pathDisplayConfig:
121+ return objDisplayConfig, 0
122+ case pathIntrospect:
123+ return objIntrospect, 0
124+ case pathScreenshot:
125+ return objScreenshot, 0
126+ }
127+ if n, ok := strings.CutPrefix(path, prefixSession); ok {
128+ id, err := strconv.ParseUint(n, 10, 32)
129+ return objSession, uint32(id) | boolBit(err != nil)
130+ }
131+ if n, ok := strings.CutPrefix(path, prefixStream); ok {
132+ id, err := strconv.ParseUint(n, 10, 32)
133+ return objStream, uint32(id) | boolBit(err != nil)
134+ }
135+ return objNone, 0
136+}
137+
138+// boolBit marks an unparsable number with an id no object has.
139+func boolBit(bad bool) uint32 {
140+ if bad {
141+ return 0xffffffff
142+ }
143+ return 0
144+}
145+
146+// served reports whether the bus name of the object is ours.
147+func (s *Service) served(k objKind) bool {
148+ switch k {
149+ case objScreenCast, objSession, objStream:
150+ return s.owns[0]
151+ case objDisplayConfig:
152+ return s.owns[1]
153+ case objIntrospect:
154+ return s.owns[2]
155+ case objScreenshot:
156+ return s.owns[3]
157+ }
158+ return false
159+}
160+
161+// decode reads a call completely: nothing of m is used after it returns.
162+func (s *Service) decode(m *zerobus.Message) request {
163+ req := request{sender: strings.Clone(m.Sender), serial: m.Serial, noReply: m.Flags&zerobus.FlagNoReplyExpected != 0}
164+ req.obj, req.id = s.objectOf(m.Path)
165+ if !s.served(req.obj) {
166+ req.method = mUnknownObject
167+ return req
168+ }
169+ r := m.Body()
170+ sig := m.Signature
171+ want := func(w string) bool {
172+ if sig != w {
173+ req.bad = true
174+ return false
175+ }
176+ return true
177+ }
178+ switch m.Interface {
179+ case ifaceProps:
180+ switch m.Member {
181+ case "Get":
182+ req.method = mGet
183+ if want("ss") {
184+ req.text, req.text2 = capStr(r.Str()), capStr(r.Str())
185+ }
186+ case "GetAll":
187+ req.method = mGetAll
188+ if want("s") {
189+ req.text = capStr(r.Str())
190+ }
191+ case "Set":
192+ req.method = mSet
193+ }
194+ case ifacePeer:
195+ if m.Member == "Ping" {
196+ req.method = mPing
197+ want("")
198+ }
199+ case ifaceIntrospectable:
200+ if m.Member == "Introspect" {
201+ req.method = mIntrospect
202+ want("")
203+ }
204+ case ifaceScreenCast:
205+ if req.obj == objScreenCast && m.Member == "CreateSession" {
206+ req.method = mCreateSession
207+ if want("a{sv}") {
208+ s.readProps(r, &req)
209+ }
210+ }
211+ case ifaceSession:
212+ if req.obj == objSession {
213+ switch m.Member {
214+ case "Start":
215+ req.method = mStart
216+ want("")
217+ case "Stop":
218+ req.method = mStop
219+ want("")
220+ case "RecordMonitor":
221+ req.method = mRecordMonitor
222+ if want("sa{sv}") {
223+ req.text = capStr(r.Str())
224+ s.readProps(r, &req)
225+ }
226+ case "RecordWindow":
227+ req.method = mRecordWindow
228+ if want("a{sv}") {
229+ s.readProps(r, &req)
230+ }
231+ }
232+ }
233+ case ifaceDisplayConfig:
234+ if req.obj == objDisplayConfig && m.Member == "GetCurrentState" {
235+ req.method = mGetState
236+ want("")
237+ }
238+ case ifaceIntrospect:
239+ if req.obj == objIntrospect && m.Member == "GetWindows" {
240+ req.method = mGetWindows
241+ want("")
242+ }
243+ case ifaceScreenshot:
244+ if req.obj == objScreenshot {
245+ switch m.Member {
246+ case "Screenshot":
247+ req.method = mShot
248+ if want("bbs") {
249+ _ = r.Bool()
250+ req.flag = r.Bool()
251+ _ = r.Str()
252+ }
253+ case "ScreenshotArea":
254+ req.method = mShotArea
255+ if want("iiiibs") {
256+ req.x, req.y, req.w, req.h = r.Int32(), r.Int32(), r.Int32(), r.Int32()
257+ _ = r.Bool()
258+ _ = r.Str()
259+ }
260+ case "InteractiveScreenshot":
261+ req.method = mShotInteractive
262+ want("")
263+ }
264+ }
265+ }
266+ if r.Err() != nil {
267+ req.bad = true
268+ }
269+ return req
270+}
271+
272+// readProps reads an a{sv} and keeps the keys the service knows.
273+func (s *Service) readProps(r *zerobus.Reader, req *request) {
274+ end := r.Array('{')
275+ for r.More(end) {
276+ r.Struct()
277+ key := r.Str()
278+ sig := r.Variant()
279+ switch {
280+ case key == "cursor-mode" && sig == "u":
281+ req.cursor = r.Uint32()
282+ case key == "window-id" && sig == "t":
283+ req.window = r.Uint64()
284+ case key == "remote-desktop-session-id":
285+ req.remote = true
286+ r.Skip(sig)
287+ default:
288+ r.Skip(sig)
289+ }
290+ }
291+}
292+
293+// onMessage handles one message from the bus.
294+func (s *Service) onMessage(m *zerobus.Message) {
295+ switch m.Type {
296+ case zerobus.TypeMethodCall:
297+ if m.Sender != "" {
298+ req := s.decode(m)
299+ s.admit(&req)
300+ }
301+ case zerobus.TypeMethodReturn, zerobus.TypeError:
302+ if m.Sender == busDaemon {
303+ s.onBusReply(m)
304+ }
305+ case zerobus.TypeSignal:
306+ if m.Sender == busDaemon && m.Interface == busDaemon && m.Member == "NameOwnerChanged" {
307+ r := m.Body()
308+ name := r.Str()
309+ _ = r.Str()
310+ owner := r.Str()
311+ if r.Err() == nil && owner == "" && strings.HasPrefix(name, ":") {
312+ s.onGone(name)
313+ }
314+ }
315+ }
316+}
317+
318+// send sends the message that was built. A broken connection ends the
319+// attempt; any other failure drops that one message.
320+func (s *Service) send() {
321+ if _, err := s.conn.Send(); err != nil {
322+ if errors.Is(err, zerobus.ErrClosed) {
323+ s.fatal = err
324+ return
325+ }
326+ s.log.Warn().Err(err).Msg("message not sent")
327+ }
328+}
329+
330+// replyTo builds the reply of req, or nil when the caller wants none.
331+func (s *Service) replyTo(req *request, sig string) *zerobus.Encoder {
332+ if req.noReply {
333+ return nil
334+ }
335+ return s.conn.NewReply(&zerobus.Message{Sender: req.sender, Serial: req.serial}, sig)
336+}
337+
338+// reply sends an empty reply.
339+func (s *Service) reply(req *request) {
340+ if s.replyTo(req, "") != nil {
341+ s.send()
342+ }
343+}
344+
345+// replyErr sends an error reply.
346+func (s *Service) replyErr(req *request, name, text string) {
347+ if req.noReply {
348+ return
349+ }
350+ s.conn.NewError(&zerobus.Message{Sender: req.sender, Serial: req.serial}, name, "s").Str(text)
351+ s.send()
352+}
353+
354+// emit sends a signal to one peer.
355+func (s *Service) emitTo(dest, path, iface, member string) *zerobus.Encoder {
356+ return s.conn.NewSignalTo(dest, path, iface, member, "")
357+}
358+
359+// dispatch runs a call of a checked caller.
360+func (s *Service) dispatch(c *caller, req *request) {
361+ if !s.allowed(c.exe) {
362+ s.deny(req)
363+ return
364+ }
365+ if req.privileged() && !s.recheck(c) {
366+ s.deny(req)
367+ return
368+ }
369+ switch {
370+ case req.method == mUnknownObject:
371+ s.replyErr(req, errUnknownObject, "no such object")
372+ return
373+ case req.bad:
374+ s.replyErr(req, errInvalidArgs, "unexpected arguments")
375+ return
376+ }
377+ var sess *session
378+ var st *stream
379+ switch req.obj {
380+ case objSession:
381+ if sess = s.sessionByID(req.id); sess == nil {
382+ s.replyErr(req, errUnknownObject, "no such session")
383+ return
384+ }
385+ case objStream:
386+ if st = s.streamByID(req.id); st == nil {
387+ s.replyErr(req, errUnknownObject, "no such stream")
388+ return
389+ }
390+ sess = st.sess
391+ }
392+ if sess != nil && sess.owner != req.sender {
393+ s.deny(req)
394+ return
395+ }
396+ switch req.method {
397+ case mPing:
398+ s.reply(req)
399+ case mIntrospect:
400+ if e := s.replyTo(req, "s"); e != nil {
401+ e.Str("<node/>")
402+ s.send()
403+ }
404+ case mGet, mGetAll, mSet:
405+ s.properties(req, sess, st)
406+ case mCreateSession:
407+ s.createSession(req)
408+ case mStart:
409+ s.start(req, sess)
410+ case mStop:
411+ s.reply(req)
412+ s.closeSession(sess, true)
413+ case mRecordMonitor, mRecordWindow:
414+ s.record(req, sess)
415+ case mGetState:
416+ s.replyState(req)
417+ case mGetWindows:
418+ s.replyWindows(req)
419+ case mShot, mShotArea, mShotInteractive:
420+ s.screenshot(req)
421+ default:
422+ s.replyErr(req, errNotSupported, "not supported")
423+ }
424+}
425+
426+// deny refuses a call.
427+func (s *Service) deny(req *request) {
428+ s.replyErr(req, errAccessDenied, "caller not allowed to capture the screen")
429+}
A internal/adapters/screencast/bus_mock_test.go +774 -0
@@ -0,0 +1,774 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ "syscall"
9+
10+ "github.com/bnema/zerobus"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockbus creates a new instance of mockbus. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockbus(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockbus {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockbus{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockbus is an autogenerated mock type for the bus type
38+type mockbus struct {
39+ mock.Mock
40+}
41+
42+type mockbus_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockbus) EXPECT() *mockbus_Expecter {
47+ return &mockbus_Expecter{mock: &_m.Mock}
48+}
49+
50+// AddMatch provides a mock function for the type mockbus
51+func (_mock *mockbus) AddMatch(rule string) error {
52+ ret := _mock.Called(rule)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for AddMatch")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
60+ r0 = returnFunc(rule)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// mockbus_AddMatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddMatch'
68+type mockbus_AddMatch_Call struct {
69+ *mock.Call
70+}
71+
72+// AddMatch is a helper method to define mock.On call
73+// - rule string
74+func (_e *mockbus_Expecter) AddMatch(rule any) *mockbus_AddMatch_Call {
75+ return &mockbus_AddMatch_Call{Call: _e.mock.On("AddMatch", rule)}
76+}
77+
78+func (_c *mockbus_AddMatch_Call) Run(run func(rule string)) *mockbus_AddMatch_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 string
81+ if args[0] != nil {
82+ arg0 = args[0].(string)
83+ }
84+ run(
85+ arg0,
86+ )
87+ })
88+ return _c
89+}
90+
91+func (_c *mockbus_AddMatch_Call) Return(err error) *mockbus_AddMatch_Call {
92+ _c.Call.Return(err)
93+ return _c
94+}
95+
96+func (_c *mockbus_AddMatch_Call) RunAndReturn(run func(rule string) error) *mockbus_AddMatch_Call {
97+ _c.Call.Return(run)
98+ return _c
99+}
100+
101+// Buffered provides a mock function for the type mockbus
102+func (_mock *mockbus) Buffered() bool {
103+ ret := _mock.Called()
104+
105+ if len(ret) == 0 {
106+ panic("no return value specified for Buffered")
107+ }
108+
109+ var r0 bool
110+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
111+ r0 = returnFunc()
112+ } else {
113+ r0 = ret.Get(0).(bool)
114+ }
115+ return r0
116+}
117+
118+// mockbus_Buffered_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Buffered'
119+type mockbus_Buffered_Call struct {
120+ *mock.Call
121+}
122+
123+// Buffered is a helper method to define mock.On call
124+func (_e *mockbus_Expecter) Buffered() *mockbus_Buffered_Call {
125+ return &mockbus_Buffered_Call{Call: _e.mock.On("Buffered")}
126+}
127+
128+func (_c *mockbus_Buffered_Call) Run(run func()) *mockbus_Buffered_Call {
129+ _c.Call.Run(func(args mock.Arguments) {
130+ run()
131+ })
132+ return _c
133+}
134+
135+func (_c *mockbus_Buffered_Call) Return(b bool) *mockbus_Buffered_Call {
136+ _c.Call.Return(b)
137+ return _c
138+}
139+
140+func (_c *mockbus_Buffered_Call) RunAndReturn(run func() bool) *mockbus_Buffered_Call {
141+ _c.Call.Return(run)
142+ return _c
143+}
144+
145+// Close provides a mock function for the type mockbus
146+func (_mock *mockbus) Close() error {
147+ ret := _mock.Called()
148+
149+ if len(ret) == 0 {
150+ panic("no return value specified for Close")
151+ }
152+
153+ var r0 error
154+ if returnFunc, ok := ret.Get(0).(func() error); ok {
155+ r0 = returnFunc()
156+ } else {
157+ r0 = ret.Error(0)
158+ }
159+ return r0
160+}
161+
162+// mockbus_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
163+type mockbus_Close_Call struct {
164+ *mock.Call
165+}
166+
167+// Close is a helper method to define mock.On call
168+func (_e *mockbus_Expecter) Close() *mockbus_Close_Call {
169+ return &mockbus_Close_Call{Call: _e.mock.On("Close")}
170+}
171+
172+func (_c *mockbus_Close_Call) Run(run func()) *mockbus_Close_Call {
173+ _c.Call.Run(func(args mock.Arguments) {
174+ run()
175+ })
176+ return _c
177+}
178+
179+func (_c *mockbus_Close_Call) Return(err error) *mockbus_Close_Call {
180+ _c.Call.Return(err)
181+ return _c
182+}
183+
184+func (_c *mockbus_Close_Call) RunAndReturn(run func() error) *mockbus_Close_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// NewError provides a mock function for the type mockbus
190+func (_mock *mockbus) NewError(m *zerobus.Message, name string, sig string) *zerobus.Encoder {
191+ ret := _mock.Called(m, name, sig)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for NewError")
195+ }
196+
197+ var r0 *zerobus.Encoder
198+ if returnFunc, ok := ret.Get(0).(func(*zerobus.Message, string, string) *zerobus.Encoder); ok {
199+ r0 = returnFunc(m, name, sig)
200+ } else {
201+ if ret.Get(0) != nil {
202+ r0 = ret.Get(0).(*zerobus.Encoder)
203+ }
204+ }
205+ return r0
206+}
207+
208+// mockbus_NewError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewError'
209+type mockbus_NewError_Call struct {
210+ *mock.Call
211+}
212+
213+// NewError is a helper method to define mock.On call
214+// - m *zerobus.Message
215+// - name string
216+// - sig string
217+func (_e *mockbus_Expecter) NewError(m any, name any, sig any) *mockbus_NewError_Call {
218+ return &mockbus_NewError_Call{Call: _e.mock.On("NewError", m, name, sig)}
219+}
220+
221+func (_c *mockbus_NewError_Call) Run(run func(m *zerobus.Message, name string, sig string)) *mockbus_NewError_Call {
222+ _c.Call.Run(func(args mock.Arguments) {
223+ var arg0 *zerobus.Message
224+ if args[0] != nil {
225+ arg0 = args[0].(*zerobus.Message)
226+ }
227+ var arg1 string
228+ if args[1] != nil {
229+ arg1 = args[1].(string)
230+ }
231+ var arg2 string
232+ if args[2] != nil {
233+ arg2 = args[2].(string)
234+ }
235+ run(
236+ arg0,
237+ arg1,
238+ arg2,
239+ )
240+ })
241+ return _c
242+}
243+
244+func (_c *mockbus_NewError_Call) Return(encoder *zerobus.Encoder) *mockbus_NewError_Call {
245+ _c.Call.Return(encoder)
246+ return _c
247+}
248+
249+func (_c *mockbus_NewError_Call) RunAndReturn(run func(m *zerobus.Message, name string, sig string) *zerobus.Encoder) *mockbus_NewError_Call {
250+ _c.Call.Return(run)
251+ return _c
252+}
253+
254+// NewMethodCall provides a mock function for the type mockbus
255+func (_mock *mockbus) NewMethodCall(dest string, path string, iface string, member string, sig string) (*zerobus.Encoder, uint32) {
256+ ret := _mock.Called(dest, path, iface, member, sig)
257+
258+ if len(ret) == 0 {
259+ panic("no return value specified for NewMethodCall")
260+ }
261+
262+ var r0 *zerobus.Encoder
263+ var r1 uint32
264+ if returnFunc, ok := ret.Get(0).(func(string, string, string, string, string) (*zerobus.Encoder, uint32)); ok {
265+ return returnFunc(dest, path, iface, member, sig)
266+ }
267+ if returnFunc, ok := ret.Get(0).(func(string, string, string, string, string) *zerobus.Encoder); ok {
268+ r0 = returnFunc(dest, path, iface, member, sig)
269+ } else {
270+ if ret.Get(0) != nil {
271+ r0 = ret.Get(0).(*zerobus.Encoder)
272+ }
273+ }
274+ if returnFunc, ok := ret.Get(1).(func(string, string, string, string, string) uint32); ok {
275+ r1 = returnFunc(dest, path, iface, member, sig)
276+ } else {
277+ r1 = ret.Get(1).(uint32)
278+ }
279+ return r0, r1
280+}
281+
282+// mockbus_NewMethodCall_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMethodCall'
283+type mockbus_NewMethodCall_Call struct {
284+ *mock.Call
285+}
286+
287+// NewMethodCall is a helper method to define mock.On call
288+// - dest string
289+// - path string
290+// - iface string
291+// - member string
292+// - sig string
293+func (_e *mockbus_Expecter) NewMethodCall(dest any, path any, iface any, member any, sig any) *mockbus_NewMethodCall_Call {
294+ return &mockbus_NewMethodCall_Call{Call: _e.mock.On("NewMethodCall", dest, path, iface, member, sig)}
295+}
296+
297+func (_c *mockbus_NewMethodCall_Call) Run(run func(dest string, path string, iface string, member string, sig string)) *mockbus_NewMethodCall_Call {
298+ _c.Call.Run(func(args mock.Arguments) {
299+ var arg0 string
300+ if args[0] != nil {
301+ arg0 = args[0].(string)
302+ }
303+ var arg1 string
304+ if args[1] != nil {
305+ arg1 = args[1].(string)
306+ }
307+ var arg2 string
308+ if args[2] != nil {
309+ arg2 = args[2].(string)
310+ }
311+ var arg3 string
312+ if args[3] != nil {
313+ arg3 = args[3].(string)
314+ }
315+ var arg4 string
316+ if args[4] != nil {
317+ arg4 = args[4].(string)
318+ }
319+ run(
320+ arg0,
321+ arg1,
322+ arg2,
323+ arg3,
324+ arg4,
325+ )
326+ })
327+ return _c
328+}
329+
330+func (_c *mockbus_NewMethodCall_Call) Return(encoder *zerobus.Encoder, v uint32) *mockbus_NewMethodCall_Call {
331+ _c.Call.Return(encoder, v)
332+ return _c
333+}
334+
335+func (_c *mockbus_NewMethodCall_Call) RunAndReturn(run func(dest string, path string, iface string, member string, sig string) (*zerobus.Encoder, uint32)) *mockbus_NewMethodCall_Call {
336+ _c.Call.Return(run)
337+ return _c
338+}
339+
340+// NewReply provides a mock function for the type mockbus
341+func (_mock *mockbus) NewReply(m *zerobus.Message, sig string) *zerobus.Encoder {
342+ ret := _mock.Called(m, sig)
343+
344+ if len(ret) == 0 {
345+ panic("no return value specified for NewReply")
346+ }
347+
348+ var r0 *zerobus.Encoder
349+ if returnFunc, ok := ret.Get(0).(func(*zerobus.Message, string) *zerobus.Encoder); ok {
350+ r0 = returnFunc(m, sig)
351+ } else {
352+ if ret.Get(0) != nil {
353+ r0 = ret.Get(0).(*zerobus.Encoder)
354+ }
355+ }
356+ return r0
357+}
358+
359+// mockbus_NewReply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewReply'
360+type mockbus_NewReply_Call struct {
361+ *mock.Call
362+}
363+
364+// NewReply is a helper method to define mock.On call
365+// - m *zerobus.Message
366+// - sig string
367+func (_e *mockbus_Expecter) NewReply(m any, sig any) *mockbus_NewReply_Call {
368+ return &mockbus_NewReply_Call{Call: _e.mock.On("NewReply", m, sig)}
369+}
370+
371+func (_c *mockbus_NewReply_Call) Run(run func(m *zerobus.Message, sig string)) *mockbus_NewReply_Call {
372+ _c.Call.Run(func(args mock.Arguments) {
373+ var arg0 *zerobus.Message
374+ if args[0] != nil {
375+ arg0 = args[0].(*zerobus.Message)
376+ }
377+ var arg1 string
378+ if args[1] != nil {
379+ arg1 = args[1].(string)
380+ }
381+ run(
382+ arg0,
383+ arg1,
384+ )
385+ })
386+ return _c
387+}
388+
389+func (_c *mockbus_NewReply_Call) Return(encoder *zerobus.Encoder) *mockbus_NewReply_Call {
390+ _c.Call.Return(encoder)
391+ return _c
392+}
393+
394+func (_c *mockbus_NewReply_Call) RunAndReturn(run func(m *zerobus.Message, sig string) *zerobus.Encoder) *mockbus_NewReply_Call {
395+ _c.Call.Return(run)
396+ return _c
397+}
398+
399+// NewSignal provides a mock function for the type mockbus
400+func (_mock *mockbus) NewSignal(path string, iface string, member string, sig string) *zerobus.Encoder {
401+ ret := _mock.Called(path, iface, member, sig)
402+
403+ if len(ret) == 0 {
404+ panic("no return value specified for NewSignal")
405+ }
406+
407+ var r0 *zerobus.Encoder
408+ if returnFunc, ok := ret.Get(0).(func(string, string, string, string) *zerobus.Encoder); ok {
409+ r0 = returnFunc(path, iface, member, sig)
410+ } else {
411+ if ret.Get(0) != nil {
412+ r0 = ret.Get(0).(*zerobus.Encoder)
413+ }
414+ }
415+ return r0
416+}
417+
418+// mockbus_NewSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewSignal'
419+type mockbus_NewSignal_Call struct {
420+ *mock.Call
421+}
422+
423+// NewSignal is a helper method to define mock.On call
424+// - path string
425+// - iface string
426+// - member string
427+// - sig string
428+func (_e *mockbus_Expecter) NewSignal(path any, iface any, member any, sig any) *mockbus_NewSignal_Call {
429+ return &mockbus_NewSignal_Call{Call: _e.mock.On("NewSignal", path, iface, member, sig)}
430+}
431+
432+func (_c *mockbus_NewSignal_Call) Run(run func(path string, iface string, member string, sig string)) *mockbus_NewSignal_Call {
433+ _c.Call.Run(func(args mock.Arguments) {
434+ var arg0 string
435+ if args[0] != nil {
436+ arg0 = args[0].(string)
437+ }
438+ var arg1 string
439+ if args[1] != nil {
440+ arg1 = args[1].(string)
441+ }
442+ var arg2 string
443+ if args[2] != nil {
444+ arg2 = args[2].(string)
445+ }
446+ var arg3 string
447+ if args[3] != nil {
448+ arg3 = args[3].(string)
449+ }
450+ run(
451+ arg0,
452+ arg1,
453+ arg2,
454+ arg3,
455+ )
456+ })
457+ return _c
458+}
459+
460+func (_c *mockbus_NewSignal_Call) Return(encoder *zerobus.Encoder) *mockbus_NewSignal_Call {
461+ _c.Call.Return(encoder)
462+ return _c
463+}
464+
465+func (_c *mockbus_NewSignal_Call) RunAndReturn(run func(path string, iface string, member string, sig string) *zerobus.Encoder) *mockbus_NewSignal_Call {
466+ _c.Call.Return(run)
467+ return _c
468+}
469+
470+// NewSignalTo provides a mock function for the type mockbus
471+func (_mock *mockbus) NewSignalTo(dest string, path string, iface string, member string, sig string) *zerobus.Encoder {
472+ ret := _mock.Called(dest, path, iface, member, sig)
473+
474+ if len(ret) == 0 {
475+ panic("no return value specified for NewSignalTo")
476+ }
477+
478+ var r0 *zerobus.Encoder
479+ if returnFunc, ok := ret.Get(0).(func(string, string, string, string, string) *zerobus.Encoder); ok {
480+ r0 = returnFunc(dest, path, iface, member, sig)
481+ } else {
482+ if ret.Get(0) != nil {
483+ r0 = ret.Get(0).(*zerobus.Encoder)
484+ }
485+ }
486+ return r0
487+}
488+
489+// mockbus_NewSignalTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewSignalTo'
490+type mockbus_NewSignalTo_Call struct {
491+ *mock.Call
492+}
493+
494+// NewSignalTo is a helper method to define mock.On call
495+// - dest string
496+// - path string
497+// - iface string
498+// - member string
499+// - sig string
500+func (_e *mockbus_Expecter) NewSignalTo(dest any, path any, iface any, member any, sig any) *mockbus_NewSignalTo_Call {
501+ return &mockbus_NewSignalTo_Call{Call: _e.mock.On("NewSignalTo", dest, path, iface, member, sig)}
502+}
503+
504+func (_c *mockbus_NewSignalTo_Call) Run(run func(dest string, path string, iface string, member string, sig string)) *mockbus_NewSignalTo_Call {
505+ _c.Call.Run(func(args mock.Arguments) {
506+ var arg0 string
507+ if args[0] != nil {
508+ arg0 = args[0].(string)
509+ }
510+ var arg1 string
511+ if args[1] != nil {
512+ arg1 = args[1].(string)
513+ }
514+ var arg2 string
515+ if args[2] != nil {
516+ arg2 = args[2].(string)
517+ }
518+ var arg3 string
519+ if args[3] != nil {
520+ arg3 = args[3].(string)
521+ }
522+ var arg4 string
523+ if args[4] != nil {
524+ arg4 = args[4].(string)
525+ }
526+ run(
527+ arg0,
528+ arg1,
529+ arg2,
530+ arg3,
531+ arg4,
532+ )
533+ })
534+ return _c
535+}
536+
537+func (_c *mockbus_NewSignalTo_Call) Return(encoder *zerobus.Encoder) *mockbus_NewSignalTo_Call {
538+ _c.Call.Return(encoder)
539+ return _c
540+}
541+
542+func (_c *mockbus_NewSignalTo_Call) RunAndReturn(run func(dest string, path string, iface string, member string, sig string) *zerobus.Encoder) *mockbus_NewSignalTo_Call {
543+ _c.Call.Return(run)
544+ return _c
545+}
546+
547+// ReadMessage provides a mock function for the type mockbus
548+func (_mock *mockbus) ReadMessage() (*zerobus.Message, error) {
549+ ret := _mock.Called()
550+
551+ if len(ret) == 0 {
552+ panic("no return value specified for ReadMessage")
553+ }
554+
555+ var r0 *zerobus.Message
556+ var r1 error
557+ if returnFunc, ok := ret.Get(0).(func() (*zerobus.Message, error)); ok {
558+ return returnFunc()
559+ }
560+ if returnFunc, ok := ret.Get(0).(func() *zerobus.Message); ok {
561+ r0 = returnFunc()
562+ } else {
563+ if ret.Get(0) != nil {
564+ r0 = ret.Get(0).(*zerobus.Message)
565+ }
566+ }
567+ if returnFunc, ok := ret.Get(1).(func() error); ok {
568+ r1 = returnFunc()
569+ } else {
570+ r1 = ret.Error(1)
571+ }
572+ return r0, r1
573+}
574+
575+// mockbus_ReadMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadMessage'
576+type mockbus_ReadMessage_Call struct {
577+ *mock.Call
578+}
579+
580+// ReadMessage is a helper method to define mock.On call
581+func (_e *mockbus_Expecter) ReadMessage() *mockbus_ReadMessage_Call {
582+ return &mockbus_ReadMessage_Call{Call: _e.mock.On("ReadMessage")}
583+}
584+
585+func (_c *mockbus_ReadMessage_Call) Run(run func()) *mockbus_ReadMessage_Call {
586+ _c.Call.Run(func(args mock.Arguments) {
587+ run()
588+ })
589+ return _c
590+}
591+
592+func (_c *mockbus_ReadMessage_Call) Return(message *zerobus.Message, err error) *mockbus_ReadMessage_Call {
593+ _c.Call.Return(message, err)
594+ return _c
595+}
596+
597+func (_c *mockbus_ReadMessage_Call) RunAndReturn(run func() (*zerobus.Message, error)) *mockbus_ReadMessage_Call {
598+ _c.Call.Return(run)
599+ return _c
600+}
601+
602+// RequestName provides a mock function for the type mockbus
603+func (_mock *mockbus) RequestName(name string, flags uint32) (uint32, error) {
604+ ret := _mock.Called(name, flags)
605+
606+ if len(ret) == 0 {
607+ panic("no return value specified for RequestName")
608+ }
609+
610+ var r0 uint32
611+ var r1 error
612+ if returnFunc, ok := ret.Get(0).(func(string, uint32) (uint32, error)); ok {
613+ return returnFunc(name, flags)
614+ }
615+ if returnFunc, ok := ret.Get(0).(func(string, uint32) uint32); ok {
616+ r0 = returnFunc(name, flags)
617+ } else {
618+ r0 = ret.Get(0).(uint32)
619+ }
620+ if returnFunc, ok := ret.Get(1).(func(string, uint32) error); ok {
621+ r1 = returnFunc(name, flags)
622+ } else {
623+ r1 = ret.Error(1)
624+ }
625+ return r0, r1
626+}
627+
628+// mockbus_RequestName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestName'
629+type mockbus_RequestName_Call struct {
630+ *mock.Call
631+}
632+
633+// RequestName is a helper method to define mock.On call
634+// - name string
635+// - flags uint32
636+func (_e *mockbus_Expecter) RequestName(name any, flags any) *mockbus_RequestName_Call {
637+ return &mockbus_RequestName_Call{Call: _e.mock.On("RequestName", name, flags)}
638+}
639+
640+func (_c *mockbus_RequestName_Call) Run(run func(name string, flags uint32)) *mockbus_RequestName_Call {
641+ _c.Call.Run(func(args mock.Arguments) {
642+ var arg0 string
643+ if args[0] != nil {
644+ arg0 = args[0].(string)
645+ }
646+ var arg1 uint32
647+ if args[1] != nil {
648+ arg1 = args[1].(uint32)
649+ }
650+ run(
651+ arg0,
652+ arg1,
653+ )
654+ })
655+ return _c
656+}
657+
658+func (_c *mockbus_RequestName_Call) Return(v uint32, err error) *mockbus_RequestName_Call {
659+ _c.Call.Return(v, err)
660+ return _c
661+}
662+
663+func (_c *mockbus_RequestName_Call) RunAndReturn(run func(name string, flags uint32) (uint32, error)) *mockbus_RequestName_Call {
664+ _c.Call.Return(run)
665+ return _c
666+}
667+
668+// Send provides a mock function for the type mockbus
669+func (_mock *mockbus) Send() (uint32, error) {
670+ ret := _mock.Called()
671+
672+ if len(ret) == 0 {
673+ panic("no return value specified for Send")
674+ }
675+
676+ var r0 uint32
677+ var r1 error
678+ if returnFunc, ok := ret.Get(0).(func() (uint32, error)); ok {
679+ return returnFunc()
680+ }
681+ if returnFunc, ok := ret.Get(0).(func() uint32); ok {
682+ r0 = returnFunc()
683+ } else {
684+ r0 = ret.Get(0).(uint32)
685+ }
686+ if returnFunc, ok := ret.Get(1).(func() error); ok {
687+ r1 = returnFunc()
688+ } else {
689+ r1 = ret.Error(1)
690+ }
691+ return r0, r1
692+}
693+
694+// mockbus_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send'
695+type mockbus_Send_Call struct {
696+ *mock.Call
697+}
698+
699+// Send is a helper method to define mock.On call
700+func (_e *mockbus_Expecter) Send() *mockbus_Send_Call {
701+ return &mockbus_Send_Call{Call: _e.mock.On("Send")}
702+}
703+
704+func (_c *mockbus_Send_Call) Run(run func()) *mockbus_Send_Call {
705+ _c.Call.Run(func(args mock.Arguments) {
706+ run()
707+ })
708+ return _c
709+}
710+
711+func (_c *mockbus_Send_Call) Return(v uint32, err error) *mockbus_Send_Call {
712+ _c.Call.Return(v, err)
713+ return _c
714+}
715+
716+func (_c *mockbus_Send_Call) RunAndReturn(run func() (uint32, error)) *mockbus_Send_Call {
717+ _c.Call.Return(run)
718+ return _c
719+}
720+
721+// SyscallConn provides a mock function for the type mockbus
722+func (_mock *mockbus) SyscallConn() (syscall.RawConn, error) {
723+ ret := _mock.Called()
724+
725+ if len(ret) == 0 {
726+ panic("no return value specified for SyscallConn")
727+ }
728+
729+ var r0 syscall.RawConn
730+ var r1 error
731+ if returnFunc, ok := ret.Get(0).(func() (syscall.RawConn, error)); ok {
732+ return returnFunc()
733+ }
734+ if returnFunc, ok := ret.Get(0).(func() syscall.RawConn); ok {
735+ r0 = returnFunc()
736+ } else {
737+ if ret.Get(0) != nil {
738+ r0 = ret.Get(0).(syscall.RawConn)
739+ }
740+ }
741+ if returnFunc, ok := ret.Get(1).(func() error); ok {
742+ r1 = returnFunc()
743+ } else {
744+ r1 = ret.Error(1)
745+ }
746+ return r0, r1
747+}
748+
749+// mockbus_SyscallConn_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyscallConn'
750+type mockbus_SyscallConn_Call struct {
751+ *mock.Call
752+}
753+
754+// SyscallConn is a helper method to define mock.On call
755+func (_e *mockbus_Expecter) SyscallConn() *mockbus_SyscallConn_Call {
756+ return &mockbus_SyscallConn_Call{Call: _e.mock.On("SyscallConn")}
757+}
758+
759+func (_c *mockbus_SyscallConn_Call) Run(run func()) *mockbus_SyscallConn_Call {
760+ _c.Call.Run(func(args mock.Arguments) {
761+ run()
762+ })
763+ return _c
764+}
765+
766+func (_c *mockbus_SyscallConn_Call) Return(rawConn syscall.RawConn, err error) *mockbus_SyscallConn_Call {
767+ _c.Call.Return(rawConn, err)
768+ return _c
769+}
770+
771+func (_c *mockbus_SyscallConn_Call) RunAndReturn(run func() (syscall.RawConn, error)) *mockbus_SyscallConn_Call {
772+ _c.Call.Return(run)
773+ return _c
774+}
A internal/adapters/screencast/bus_test.go +762 -0
@@ -0,0 +1,762 @@
1+package screencast
2+
3+import (
4+ "errors"
5+ "os"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/adapters/captureallow"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerobus"
14+)
15+
16+func testInventory() ports.CastInventory {
17+ return ports.CastInventory{
18+ Focused: "HEADLESS-1",
19+ Outputs: []ports.CastOutput{
20+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1},
21+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1},
22+ },
23+ Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}},
24+ }
25+}
26+
27+// ready starts the service, waits for its names and publishes the inventory.
28+func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) {
29+ t.Helper()
30+ h := newSvcHarness(t, o)
31+ c := h.client()
32+ for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} {
33+ h.waitName(c, n)
34+ }
35+ if !o.noPW {
36+ h.waitName(c, nameScreenCast)
37+ }
38+ h.svc.Inventory(testInventory())
39+ // The inventory is applied by the owner before it serves the next call
40+ // that needs it: a round trip through the service orders them.
41+ deadline := time.Now().Add(5 * time.Second)
42+ for time.Now().Before(deadline) {
43+ _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
44+ if m != nil && m.Signature == "a{ta{sv}}" {
45+ r := m.Body()
46+ if end := r.Array('{'); r.More(end) {
47+ return h, c
48+ }
49+ }
50+ time.Sleep(5 * time.Millisecond)
51+ }
52+ t.Fatal("inventory not applied")
53+ return h, c
54+}
55+
56+func createSession(t *testing.T, c *zerobus.Conn) string {
57+ t.Helper()
58+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
59+ a := e.BeginArray('{')
60+ e.EndArray(a)
61+ })
62+ if name != "" {
63+ t.Fatalf("CreateSession: %s", name)
64+ }
65+ return strings.Clone(m.Body().ObjectPath())
66+}
67+
68+func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) {
69+ name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
70+ e.Str(connector)
71+ a := e.BeginArray('{')
72+ e.Struct()
73+ e.Str("cursor-mode")
74+ e.Variant("u")
75+ e.Uint32(1)
76+ e.EndArray(a)
77+ })
78+ if name != "" {
79+ return name, ""
80+ }
81+ return "", strings.Clone(m.Body().ObjectPath())
82+}
83+
84+// Every method of the contract answers a caller on the allowlist.
85+func TestMethodMatrix(t *testing.T) {
86+ h, c := ready(t, harnessOpts{})
87+ _ = h
88+ sess := createSession(t, c)
89+ if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" {
90+ t.Fatalf("session path %q", sess)
91+ }
92+
93+ // Properties of the root object.
94+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") })
95+ if name != "" || m.Signature != "v" {
96+ t.Fatalf("Get Version: %s %v", name, m)
97+ }
98+ r := m.Body()
99+ if sig := r.Variant(); sig != "i" || r.Int32() != 4 {
100+ t.Fatal("Version is not 4")
101+ }
102+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty {
103+ t.Fatalf("unknown property: %q", name)
104+ }
105+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) {
106+ e.Str(ifaceScreenCast)
107+ e.Str("Version")
108+ e.Variant("i")
109+ e.Int32(1)
110+ }); name != errPropertyReadOnly {
111+ t.Fatalf("Set: %q", name)
112+ }
113+ name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) })
114+ if name != "" || m.Signature != "a{sv}" {
115+ t.Fatalf("GetAll: %s", name)
116+ }
117+
118+ // Streams: the monitor, the window, and what is not supported.
119+ stream, got := "", ""
120+ if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" {
121+ t.Fatalf("RecordMonitor: %s", name)
122+ }
123+ if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" {
124+ t.Fatalf("stream path %q", stream)
125+ }
126+ if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs {
127+ t.Fatalf("RecordMonitor of a missing monitor: %q", name)
128+ }
129+ name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
130+ a := e.BeginArray('{')
131+ e.Struct()
132+ e.Str("window-id")
133+ e.Variant("t")
134+ e.Uint64(7)
135+ e.EndArray(a)
136+ })
137+ if name != "" {
138+ t.Fatalf("RecordWindow: %s", name)
139+ }
140+ got = strings.Clone(m.Body().ObjectPath())
141+ if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" {
142+ t.Fatalf("window stream path %q", got)
143+ }
144+ name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
145+ a := e.BeginArray('{')
146+ e.Struct()
147+ e.Str("window-id")
148+ e.Variant("t")
149+ e.Uint64(99)
150+ e.EndArray(a)
151+ })
152+ if name != errInvalidArgs {
153+ t.Fatalf("RecordWindow of a closed window: %q", name)
154+ }
155+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) {
156+ e.Int32(0)
157+ e.Int32(0)
158+ e.Int32(1)
159+ e.Int32(1)
160+ e.EndArray(e.BeginArray('{'))
161+ }); name == "" {
162+ t.Fatal("RecordArea is not supported")
163+ }
164+
165+ // The stream answers GetAll with its parameters before Start.
166+ name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) })
167+ if name != "" {
168+ t.Fatalf("stream GetAll: %s", name)
169+ }
170+ r = m.Body()
171+ end := r.Array('{')
172+ r.Struct()
173+ if r.Str() != "Parameters" || r.Variant() != "a{sv}" {
174+ t.Fatal("no Parameters")
175+ }
176+ pend := r.Array('{')
177+ seen := map[string][2]int32{}
178+ for r.More(pend) {
179+ r.Struct()
180+ key := r.Str()
181+ if sig := r.Variant(); sig != "(ii)" {
182+ t.Fatalf("%s is %s", key, sig)
183+ }
184+ r.Struct()
185+ seen[key] = [2]int32{r.Int32(), r.Int32()}
186+ }
187+ _ = end
188+ if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} {
189+ t.Fatalf("parameters %v", seen)
190+ }
191+
192+ // Introspect and DisplayConfig properties.
193+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") })
194+ if name != "" {
195+ t.Fatalf("version: %s", name)
196+ }
197+ r = m.Body()
198+ if r.Variant() != "u" || r.Uint32() != 1 {
199+ t.Fatal("Introspect version is not 1")
200+ }
201+ if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" {
202+ t.Fatal("ApplyMonitorsConfig is not supported")
203+ }
204+ if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject {
205+ t.Fatalf("unknown session: %q", name)
206+ }
207+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
208+ t.Fatalf("Stop: %q", name)
209+ }
210+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject {
211+ t.Fatalf("Stop twice: %q", name)
212+ }
213+}
214+
215+// A signal from the session reaches only its owner: Stop is answered, then
216+// the owner hears Closed.
217+func TestStopSignalsClosedToOwner(t *testing.T) {
218+ h, c := ready(t, harnessOpts{})
219+ other := h.client()
220+ rule := "type='signal',interface='" + ifaceSession + "'"
221+ for _, x := range []*zerobus.Conn{c, other} {
222+ if err := x.AddMatch(rule); err != nil {
223+ t.Fatal(err)
224+ }
225+ }
226+ sess := createSession(t, c)
227+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
228+ t.Fatal(name)
229+ }
230+ m := readUntil(t, c, "Closed")
231+ if m.Path != sess || m.Type != zerobus.TypeSignal {
232+ t.Fatalf("closed %+v", m)
233+ }
234+ // The other client would have heard it before the answer of this call.
235+ other.Handle = func(m *zerobus.Message) {
236+ if m.Member == "Closed" {
237+ t.Error("Closed reached a client that is not the owner")
238+ }
239+ }
240+ other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
241+ if _, err := other.Call(); err != nil {
242+ t.Fatal(err)
243+ }
244+}
245+
246+func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message {
247+ t.Helper()
248+ for {
249+ m, err := c.ReadMessage()
250+ if err != nil {
251+ t.Fatal(err)
252+ }
253+ if m.Member == member {
254+ return m
255+ }
256+ }
257+}
258+
259+// A caller whose executable is not on the allowlist is refused by every
260+// object and every method, and nothing is created.
261+func TestDisallowedCallerGetsAccessDenied(t *testing.T) {
262+ h, c := ready(t, harnessOpts{})
263+ h.peers.exe.Store("/usr/bin/evil")
264+ for _, tc := range []struct {
265+ dest, path, iface, member, sig string
266+ body func(*zerobus.Encoder)
267+ }{
268+ {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }},
269+ {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }},
270+ {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }},
271+ {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil},
272+ {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil},
273+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil},
274+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }},
275+ {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil},
276+ } {
277+ if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied {
278+ t.Errorf("%s.%s: %q", tc.iface, tc.member, name)
279+ }
280+ }
281+ select {
282+ case cmd := <-h.cmds:
283+ t.Fatalf("command sent for a refused caller: %+v", cmd)
284+ default:
285+ }
286+}
287+
288+// A policy that closes is applied at once: the cache keeps the executable,
289+// not the verdict.
290+func TestPolicyChangeApplies(t *testing.T) {
291+ h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()})
292+ c := h.client()
293+ h.waitName(c, nameIntrospect)
294+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
295+ t.Fatalf("closed policy: %q", name)
296+ }
297+}
298+
299+// The executable is read again where it matters: an exec after the first
300+// check is caught at CreateSession.
301+func TestExecAfterFirstCheckIsCaught(t *testing.T) {
302+ h, c := ready(t, harnessOpts{})
303+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
304+ t.Fatalf("first call: %q", name)
305+ }
306+ h.peers.exe.Store("/usr/bin/evil")
307+ // Not a privileged entry point: the cached executable still serves it.
308+ if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" {
309+ t.Fatalf("cached call: %q", name)
310+ }
311+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
312+ if name != errAccessDenied {
313+ t.Fatalf("CreateSession after exec: %q", name)
314+ }
315+}
316+
317+// A pidfd that names another process than the one the bus named is refused.
318+func TestPidMismatchIsRefused(t *testing.T) {
319+ h := newSvcHarness(t, harnessOpts{})
320+ h.peers.exePid = func(open int) int { return open + 1 }
321+ c := h.client()
322+ h.waitName(c, nameIntrospect)
323+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
324+ t.Fatalf("pid mismatch: %q", name)
325+ }
326+}
327+
328+func TestPidfdOpenFailureIsRefused(t *testing.T) {
329+ h := newSvcHarness(t, harnessOpts{})
330+ h.peers.openErr = errors.New("no such process")
331+ c := h.client()
332+ h.waitName(c, nameIntrospect)
333+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
334+ t.Fatalf("pidfd_open failure: %q", name)
335+ }
336+}
337+
338+// The session and its streams answer only the unique name that created it.
339+func TestNonOwnerIsDenied(t *testing.T) {
340+ h, c := ready(t, harnessOpts{})
341+ other := h.client()
342+ sess := createSession(t, c)
343+ _, stream := recordMonitor(c, sess, "HEADLESS-1")
344+ for _, tc := range []struct{ path, iface, member, sig string }{
345+ {sess, ifaceSession, "Stop", ""},
346+ {sess, ifaceSession, "Start", ""},
347+ {sess, ifaceProps, "GetAll", "s"},
348+ {stream, ifaceProps, "GetAll", "s"},
349+ {stream, ifaceProps, "Get", "ss"},
350+ } {
351+ name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) {
352+ switch tc.sig {
353+ case "s":
354+ e.Str(ifaceSession)
355+ case "ss":
356+ e.Str(ifaceStream)
357+ e.Str("Parameters")
358+ }
359+ })
360+ if name != errAccessDenied {
361+ t.Errorf("%s %s: %q", tc.path, tc.member, name)
362+ }
363+ }
364+ if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied {
365+ t.Errorf("RecordMonitor by a non-owner: %q", name)
366+ }
367+ // The owner is unaffected.
368+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
369+ t.Fatalf("owner Stop: %q", name)
370+ }
371+}
372+
373+// Requests wait in a fixed table while their sender is checked: when it is
374+// full, the next one gets LimitsExceeded, and the parked ones are answered
375+// once the check ends.
376+func TestPendingTableFull(t *testing.T) {
377+ h := newSvcHarness(t, harnessOpts{holdOp: true})
378+ c := h.client()
379+ h.waitName(c, nameIntrospect)
380+ serials := make([]uint32, 0, maxPending+1)
381+ for range maxPending + 1 {
382+ c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "")
383+ serial, err := c.Send()
384+ if err != nil {
385+ t.Fatal(err)
386+ }
387+ serials = append(serials, serial)
388+ }
389+ m, err := c.ReadMessage()
390+ if err != nil {
391+ t.Fatal(err)
392+ }
393+ if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits {
394+ t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial)
395+ }
396+ close(h.peers.openGo)
397+ for range maxPending {
398+ m, err := c.ReadMessage()
399+ if err != nil {
400+ t.Fatal(err)
401+ }
402+ if m.Type != zerobus.TypeMethodReturn {
403+ t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName)
404+ }
405+ }
406+}
407+
408+// Closing the owner's connection stops its session: its slot is freed, so
409+// a new session starts again from the table.
410+func TestOwnerVanishFreesSessions(t *testing.T) {
411+ h, c := ready(t, harnessOpts{})
412+ owner := h.client()
413+ for range maxSessions {
414+ createSession(t, owner)
415+ }
416+ if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits {
417+ t.Fatalf("over the limit: %q", name)
418+ }
419+ _ = owner.Close()
420+ deadline := time.Now().Add(5 * time.Second)
421+ for {
422+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
423+ if name == "" {
424+ return
425+ }
426+ if time.Now().After(deadline) {
427+ t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String())
428+ }
429+ time.Sleep(10 * time.Millisecond)
430+ }
431+}
432+
433+// The remote desktop key is refused, and a service without PipeWire does not
434+// take the ScreenCast name.
435+func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) {
436+ _, c := ready(t, harnessOpts{})
437+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
438+ a := e.BeginArray('{')
439+ e.Struct()
440+ e.Str("remote-desktop-session-id")
441+ e.Variant("s")
442+ e.Str("x")
443+ e.EndArray(a)
444+ })
445+ if name != errNotSupported {
446+ t.Fatalf("remote desktop: %q", name)
447+ }
448+
449+ h := newSvcHarness(t, harnessOpts{noPW: true})
450+ c2 := h.client()
451+ h.waitName(c2, nameIntrospect)
452+ e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
453+ e.Str(nameScreenCast)
454+ m, err := c2.Call()
455+ if err != nil || m.Body().Bool() {
456+ t.Fatalf("ScreenCast name taken without PipeWire (%v)", err)
457+ }
458+}
459+
460+// The encoding of GetCurrentState and GetWindows from an inventory.
461+func TestDisplayConfigAndWindows(t *testing.T) {
462+ _, c := ready(t, harnessOpts{})
463+ name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil)
464+ if name != "" {
465+ t.Fatal(name)
466+ }
467+ r := m.Body()
468+ if r.Uint32() != 0 {
469+ t.Fatal("serial")
470+ }
471+ type mon struct {
472+ connector string
473+ current int
474+ modes int
475+ display string
476+ builtin bool
477+ }
478+ var mons []mon
479+ end := r.Array('(')
480+ for r.More(end) {
481+ r.Struct()
482+ r.Struct()
483+ x := mon{connector: r.Str()}
484+ r.Str()
485+ r.Str()
486+ r.Str()
487+ mend := r.Array('(')
488+ for r.More(mend) {
489+ r.Struct()
490+ r.Str()
491+ r.Int32()
492+ r.Int32()
493+ r.Float64()
494+ r.Float64()
495+ send := r.Array('d')
496+ for r.More(send) {
497+ r.Float64()
498+ }
499+ pend := r.Array('{')
500+ x.modes++
501+ for r.More(pend) {
502+ r.Struct()
503+ key := r.Str()
504+ sig := r.Variant()
505+ if key == "is-current" && r.Bool() {
506+ x.current++
507+ } else if sig == "b" {
508+ r.Bool()
509+ }
510+ }
511+ }
512+ pend := r.Array('{')
513+ for r.More(pend) {
514+ r.Struct()
515+ key := r.Str()
516+ switch r.Variant() {
517+ case "s":
518+ if v := r.Str(); key == "display-name" {
519+ x.display = v
520+ }
521+ case "b":
522+ x.builtin = r.Bool()
523+ }
524+ }
525+ mons = append(mons, x)
526+ }
527+ logicals := 0
528+ lend := r.Array('(')
529+ for r.More(lend) {
530+ r.Struct()
531+ r.Int32()
532+ r.Int32()
533+ r.Float64()
534+ r.Uint32()
535+ r.Bool()
536+ mend := r.Array('(')
537+ for r.More(mend) {
538+ r.Struct()
539+ r.Str()
540+ r.Str()
541+ r.Str()
542+ r.Str()
543+ }
544+ pend := r.Array('{')
545+ r.SkipTo(pend)
546+ logicals++
547+ }
548+ pend := r.Array('{')
549+ r.Struct()
550+ if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 {
551+ t.Fatal("layout-mode")
552+ }
553+ _ = pend
554+ if !r.Done() {
555+ t.Fatalf("trailing data: %v", r.Err())
556+ }
557+ if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" {
558+ t.Fatalf("monitors %+v logical %d", mons, logicals)
559+ }
560+ for _, x := range mons {
561+ if x.current != 1 {
562+ t.Errorf("%s has %d current modes", x.connector, x.current)
563+ }
564+ }
565+ if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" {
566+ t.Fatalf("monitors %+v", mons)
567+ }
568+
569+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
570+ if name != "" {
571+ t.Fatal(name)
572+ }
573+ r = m.Body()
574+ got := map[uint64][2]string{}
575+ wend := r.Array('{')
576+ for r.More(wend) {
577+ r.Struct()
578+ id := r.Uint64()
579+ var v [2]string
580+ pend := r.Array('{')
581+ for r.More(pend) {
582+ r.Struct()
583+ key := r.Str()
584+ r.Variant()
585+ val := r.Str()
586+ switch key {
587+ case "app-id":
588+ v[0] = val
589+ case "title":
590+ v[1] = val
591+ }
592+ }
593+ got[id] = v
594+ }
595+ if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} {
596+ t.Fatalf("windows %v", got)
597+ }
598+}
599+
600+// MonitorsChanged and WindowsChanged are directed: a peer that was refused
601+// gets nothing, even with a match rule for them; an allowed one gets them,
602+// and a peer that left the bus is forgotten.
603+func TestInventorySignalsGoToAllowedPeersOnly(t *testing.T) {
604+ h := newSvcHarness(t, harnessOpts{})
605+ h.peers.exeFile = func(n int) string {
606+ if n == 0 || n == 2 {
607+ return "/usr/bin/grim"
608+ }
609+ return "/usr/bin/evil"
610+ }
611+ allowed, denied, gone := h.client(), h.client(), h.client()
612+ for _, n := range []string{nameDisplayConfig, nameIntrospect} {
613+ h.waitName(allowed, n)
614+ }
615+ rule := "type='signal',path='" + pathIntrospect + "'"
616+ rule2 := "type='signal',path='" + pathDisplayConfig + "'"
617+ for _, x := range []*zerobus.Conn{allowed, denied, gone} {
618+ for _, r := range []string{rule, rule2} {
619+ if err := x.AddMatch(r); err != nil {
620+ t.Fatal(err)
621+ }
622+ }
623+ }
624+ // Check in order: allowed (pidfd 0), denied (1), gone (2).
625+ for i, x := range []*zerobus.Conn{allowed, denied, gone} {
626+ want := ""
627+ if i == 1 {
628+ want = errAccessDenied
629+ }
630+ if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != want {
631+ t.Fatalf("peer %d: %q, want %q", i, name, want)
632+ }
633+ }
634+ _ = gone.Close()
635+ // The first inventory is not a change. Wait until the owner applied it
636+ // (a published inventory replaces an unread one), then publish a change.
637+ h.svc.Inventory(testInventory())
638+ deadline := time.Now().Add(5 * time.Second)
639+ for {
640+ _, m := callErr(allowed, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
641+ if m != nil && m.Signature == "a{ta{sv}}" {
642+ r := m.Body()
643+ if end := r.Array('{'); r.More(end) {
644+ break
645+ }
646+ }
647+ if time.Now().After(deadline) {
648+ t.Fatal("inventory not applied")
649+ }
650+ time.Sleep(5 * time.Millisecond)
651+ }
652+ changed := testInventory()
653+ changed.Windows = append(changed.Windows, ports.CastWindow{ID: 9, AppID: "x", Title: "new"})
654+ changed.Outputs[0].X = 100
655+ h.svc.Inventory(changed)
656+ got := make(chan string, 8)
657+ go func() {
658+ for {
659+ m, err := allowed.ReadMessage()
660+ if err != nil {
661+ return
662+ }
663+ if m.Type == zerobus.TypeSignal && (m.Member == "WindowsChanged" || m.Member == "MonitorsChanged") {
664+ got <- m.Member
665+ }
666+ }
667+ }()
668+ seen := map[string]bool{}
669+ for len(seen) < 2 {
670+ select {
671+ case m := <-got:
672+ seen[m] = true
673+ case <-time.After(5 * time.Second):
674+ t.Fatalf("allowed peer heard %v", seen)
675+ }
676+ }
677+ // The service's owner goroutine answers in order: a call that it
678+ // answers (AccessDenied) comes after any signal it had wrongly directed
679+ // to this connection. The handler is active during the call.
680+ denied.Handle = func(m *zerobus.Message) {
681+ if m.Member == "WindowsChanged" || m.Member == "MonitorsChanged" {
682+ t.Errorf("refused peer got %s", m.Member)
683+ }
684+ }
685+ if name, _ := callErr(denied, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
686+ t.Fatalf("refused peer: %q", name)
687+ }
688+}
689+
690+// pidfdClosed reports whether the file was closed.
691+func pidfdClosed(f *os.File) bool {
692+ _, err := f.Stat()
693+ return errors.Is(err, os.ErrClosed)
694+}
695+
696+// Refused callers are not cached: more of them than the cache holds still
697+// leave room for the allowed peer, and their pidfds are closed at once.
698+func TestDeniedCallersDoNotExhaustTheCache(t *testing.T) {
699+ const denied = maxCallers + 8
700+ // One pidfd per check: the first peer is checked twice.
701+ const deniedChecks = denied + 1
702+ h := newSvcHarness(t, harnessOpts{})
703+ h.peers.exeFile = func(n int) string {
704+ if n < deniedChecks {
705+ return "/usr/bin/evil"
706+ }
707+ return "/usr/bin/grim"
708+ }
709+ c0 := h.client()
710+ h.waitName(c0, nameIntrospect)
711+ for i := range denied {
712+ x := h.client()
713+ if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
714+ t.Fatalf("denied peer %d: %q", i, name)
715+ }
716+ // The same peer asking again is checked again, never LimitsExceeded.
717+ if i == 0 {
718+ if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
719+ t.Fatalf("denied peer again: %q", name)
720+ }
721+ }
722+ }
723+ good := h.client()
724+ if name, _ := callErr(good, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
725+ t.Fatalf("allowed peer after %d denied: %q", denied, name)
726+ }
727+ h.peers.mu.Lock()
728+ opened := slices.Clone(h.peers.opened)
729+ h.peers.mu.Unlock()
730+ for i, f := range opened[:deniedChecks] {
731+ if !pidfdClosed(f) {
732+ t.Fatalf("pidfd of denied peer %d still open", i)
733+ }
734+ }
735+ if pidfdClosed(opened[len(opened)-1]) {
736+ t.Fatal("pidfd of the allowed peer closed while it is cached")
737+ }
738+}
739+
740+// The pidfds of the cached callers are closed when the bus is lost.
741+func TestBusLossClosesCallerPidfds(t *testing.T) {
742+ h := newSvcHarness(t, harnessOpts{})
743+ c := h.client()
744+ h.waitName(c, nameIntrospect)
745+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
746+ t.Fatal(name)
747+ }
748+ h.peers.mu.Lock()
749+ opened := slices.Clone(h.peers.opened)
750+ h.peers.mu.Unlock()
751+ if len(opened) != 1 || pidfdClosed(opened[0]) {
752+ t.Fatalf("%d pidfds, want one open", len(opened))
753+ }
754+ h.killBus()
755+ deadline := time.Now().Add(5 * time.Second)
756+ for !pidfdClosed(opened[0]) {
757+ if time.Now().After(deadline) {
758+ t.Fatal("pidfd of a cached caller survives the bus loss")
759+ }
760+ time.Sleep(5 * time.Millisecond)
761+ }
762+}
A internal/adapters/screencast/caller.go +259 -0
@@ -0,0 +1,259 @@
1+package screencast
2+
3+import (
4+ "os"
5+
6+ "github.com/bnema/zerobus"
7+)
8+
9+// The caller check proves which executable a D-Bus peer runs, with the pidfd
10+// proof the wayland adapter uses for its clients (package peerexe):
11+//
12+// 1. GetConnectionUnixProcessID(name) names a pid;
13+// 2. pidfd_open(pid) pins that process;
14+// 3. a second GetConnectionUnixProcessID must name the same pid: the name
15+// was connected throughout, so the pidfd names the peer and not a process
16+// that recycled the pid;
17+// 4. the exe is read through the pidfd.
18+//
19+// The bus calls are sent with Send and their replies come back through
20+// ReadMessage, matched by serial: nothing here waits. A request that arrives
21+// while its sender is being checked waits in the pending table.
22+
23+// callerState is how far the proof of a D-Bus peer's executable got.
24+type callerState uint8
25+
26+const (
27+ callerFree callerState = iota
28+ // callerPid: waiting for the bus to name the peer's pid.
29+ callerPid
30+ // callerConfirm: a pidfd was opened; waiting for the bus to name the same
31+ // pid again.
32+ callerConfirm
33+ // callerKnown: exe is the executable of the peer, and the allowlist
34+ // admitted it when it was proven. Only such peers are cached.
35+ callerKnown
36+)
37+
38+// caller is one checked unique bus name. The pidfd stays open for as long
39+// as the name is cached, so the executable can be read again at the
40+// privileged entry points.
41+type caller struct {
42+ state callerState
43+ name string // unique name, ":1.42"
44+ serial uint32 // the outstanding GetConnectionUnixProcessID request
45+ pid int
46+ pidfd *os.File
47+ exe string
48+}
49+
50+func (c *caller) release() {
51+ if c.pidfd != nil {
52+ _ = c.pidfd.Close()
53+ }
54+ *c = caller{}
55+}
56+
57+// pending is a request parked until its sender's check ends.
58+type pending struct {
59+ used bool
60+ req request
61+}
62+
63+// callerOf returns the cache entry of a unique name.
64+func (s *Service) callerOf(name string) *caller {
65+ for i := range s.callers {
66+ if s.callers[i].state != callerFree && s.callers[i].name == name {
67+ return &s.callers[i]
68+ }
69+ }
70+ return nil
71+}
72+
73+// admit takes a decoded call: a checked sender is served at once, any other
74+// waits for its check.
75+func (s *Service) admit(req *request) {
76+ c := s.callerOf(req.sender)
77+ if c != nil && c.state == callerKnown {
78+ if !s.allowed(c.exe) {
79+ // The policy closed on a cached peer: refuse and forget it.
80+ s.deny(req)
81+ c.release()
82+ return
83+ }
84+ s.dispatch(c, req)
85+ return
86+ }
87+ slot := -1
88+ for i := range s.pending {
89+ if !s.pending[i].used {
90+ slot = i
91+ break
92+ }
93+ }
94+ if slot < 0 {
95+ s.replyErr(req, errLimits, "too many requests wait for a caller check")
96+ return
97+ }
98+ if c == nil {
99+ for i := range s.callers {
100+ if s.callers[i].state == callerFree {
101+ c = &s.callers[i]
102+ break
103+ }
104+ }
105+ if c == nil {
106+ s.replyErr(req, errLimits, "too many callers")
107+ return
108+ }
109+ *c = caller{state: callerPid, name: req.sender}
110+ if !s.askPid(c) {
111+ c.release()
112+ s.deny(req)
113+ return
114+ }
115+ }
116+ s.pending[slot] = pending{used: true, req: *req}
117+}
118+
119+// askPid sends GetConnectionUnixProcessID for c and keeps its serial.
120+func (s *Service) askPid(c *caller) bool {
121+ e, serial := s.conn.NewMethodCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s")
122+ e.Str(c.name)
123+ if _, err := s.conn.Send(); err != nil {
124+ s.fatal = err
125+ return false
126+ }
127+ c.serial = serial
128+ return true
129+}
130+
131+// onBusReply advances the check of the caller the reply answers; any other
132+// reply of the bus is not ours.
133+func (s *Service) onBusReply(m *zerobus.Message) {
134+ var c *caller
135+ for i := range s.callers {
136+ cc := &s.callers[i]
137+ if (cc.state == callerPid || cc.state == callerConfirm) && cc.serial == m.ReplySerial {
138+ c = cc
139+ break
140+ }
141+ }
142+ if c == nil {
143+ return
144+ }
145+ pid := -1
146+ if m.Type == zerobus.TypeMethodReturn && m.Signature == "u" {
147+ r := m.Body()
148+ v := r.Uint32()
149+ if r.Err() == nil && v > 0 && v < 1<<31 {
150+ pid = int(v)
151+ }
152+ }
153+ switch {
154+ case pid < 0:
155+ s.failCaller(c, "pid lookup failed")
156+ case c.state == callerPid:
157+ pidfd, err := s.peers.Open(pid)
158+ if err != nil {
159+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: no pidfd")
160+ s.failCaller(c, "")
161+ return
162+ }
163+ c.pid, c.pidfd, c.state = pid, pidfd, callerConfirm
164+ if !s.askPid(c) {
165+ s.failCaller(c, "")
166+ }
167+ default:
168+ if pid != c.pid {
169+ s.log.Info().Str("sender", c.name).Msg("caller refused: pid changed during the check")
170+ s.failCaller(c, "")
171+ return
172+ }
173+ exe, got, err := s.peers.Exe(c.pidfd)
174+ if err != nil || got != c.pid {
175+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
176+ s.failCaller(c, "")
177+ return
178+ }
179+ if !s.allowed(exe) {
180+ // A refused peer is not cached: it would hold one of the
181+ // maxCallers entries until it leaves the bus, and enough of
182+ // them would lock the real portal out. It is checked again,
183+ // from a free entry, if it calls again.
184+ s.log.Info().Str("sender", c.name).Str("exe", exe).Msg("caller refused: executable not allowed")
185+ s.failCaller(c, "")
186+ return
187+ }
188+ c.exe, c.state = exe, callerKnown
189+ s.flush(c)
190+ }
191+}
192+
193+// failCaller refuses every request that waits for c and forgets it.
194+func (s *Service) failCaller(c *caller, why string) {
195+ if why != "" {
196+ s.log.Info().Str("sender", c.name).Msg("caller refused: " + why)
197+ }
198+ name := c.name
199+ c.release()
200+ for i := range s.pending {
201+ if p := &s.pending[i]; p.used && p.req.sender == name {
202+ req := p.req
203+ *p = pending{}
204+ s.deny(&req)
205+ }
206+ }
207+}
208+
209+// flush serves the requests that waited for c, in arrival order.
210+func (s *Service) flush(c *caller) {
211+ for i := range s.pending {
212+ p := &s.pending[i]
213+ if !p.used || p.req.sender != c.name {
214+ continue
215+ }
216+ req := p.req
217+ *p = pending{}
218+ s.dispatch(c, &req)
219+ }
220+}
221+
222+// onGone forgets a peer that left the bus: its check, its parked requests
223+// and its sessions.
224+func (s *Service) onGone(name string) {
225+ if c := s.callerOf(name); c != nil {
226+ c.release()
227+ }
228+ for i := range s.pending {
229+ if s.pending[i].used && s.pending[i].req.sender == name {
230+ s.pending[i] = pending{}
231+ }
232+ }
233+ for i := range s.sessions {
234+ if sess := &s.sessions[i]; sess.used && sess.owner == name {
235+ s.closeSession(sess, false)
236+ }
237+ }
238+}
239+
240+// allowed reports whether the allowlist in force admits exe.
241+func (s *Service) allowed(exe string) bool {
242+ p := s.policy()
243+ return p != nil && p.Allows(exe)
244+}
245+
246+// recheck reads the executable behind the pidfd again, so an exec after the
247+// first check is caught, and applies the allowlist to it.
248+func (s *Service) recheck(c *caller) bool {
249+ exe, pid, err := s.peers.Exe(c.pidfd)
250+ if err != nil || pid != c.pid {
251+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
252+ return false
253+ }
254+ if exe != c.exe {
255+ s.log.Info().Str("sender", c.name).Str("was", c.exe).Str("now", exe).Msg("caller executable changed")
256+ c.exe = exe
257+ }
258+ return s.allowed(exe)
259+}
A internal/adapters/screencast/dialer_mock_test.go +107 -0
@@ -0,0 +1,107 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockdialer creates a new instance of mockdialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockdialer(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockdialer {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockdialer{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockdialer is an autogenerated mock type for the dialer type
35+type mockdialer struct {
36+ mock.Mock
37+}
38+
39+type mockdialer_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockdialer) EXPECT() *mockdialer_Expecter {
44+ return &mockdialer_Expecter{mock: &_m.Mock}
45+}
46+
47+// Dial provides a mock function for the type mockdialer
48+func (_mock *mockdialer) Dial(address string) (bus, error) {
49+ ret := _mock.Called(address)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Dial")
53+ }
54+
55+ var r0 bus
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func(string) (bus, error)); ok {
58+ return returnFunc(address)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func(string) bus); ok {
61+ r0 = returnFunc(address)
62+ } else {
63+ if ret.Get(0) != nil {
64+ r0 = ret.Get(0).(bus)
65+ }
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
68+ r1 = returnFunc(address)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// mockdialer_Dial_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dial'
76+type mockdialer_Dial_Call struct {
77+ *mock.Call
78+}
79+
80+// Dial is a helper method to define mock.On call
81+// - address string
82+func (_e *mockdialer_Expecter) Dial(address any) *mockdialer_Dial_Call {
83+ return &mockdialer_Dial_Call{Call: _e.mock.On("Dial", address)}
84+}
85+
86+func (_c *mockdialer_Dial_Call) Run(run func(address string)) *mockdialer_Dial_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 string
89+ if args[0] != nil {
90+ arg0 = args[0].(string)
91+ }
92+ run(
93+ arg0,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *mockdialer_Dial_Call) Return(busMoqParam bus, err error) *mockdialer_Dial_Call {
100+ _c.Call.Return(busMoqParam, err)
101+ return _c
102+}
103+
104+func (_c *mockdialer_Dial_Call) RunAndReturn(run func(address string) (bus, error)) *mockdialer_Dial_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
A internal/adapters/screencast/harness_test.go +267 -0
@@ -0,0 +1,267 @@
1+package screencast
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/zerobus"
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/mock"
20+)
21+
22+// startBus starts a dbus-daemon of its own, so tests never touch the
23+// desktop's session bus, and returns its address and a function killing it,
24+// which is how a test loses the bus. CI must run these tests: there, a
25+// missing daemon fails.
26+func startBus(t *testing.T) (string, func()) {
27+ t.Helper()
28+ bin, err := exec.LookPath("dbus-daemon")
29+ if err != nil {
30+ if os.Getenv("CI") != "" {
31+ t.Fatal("dbus-daemon not installed")
32+ }
33+ t.Skip("dbus-daemon not installed")
34+ }
35+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1")
36+ out, err := cmd.StdoutPipe()
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if err := cmd.Start(); err != nil {
41+ t.Fatal(err)
42+ }
43+ kill := func() { _ = cmd.Process.Kill() }
44+ t.Cleanup(func() { kill(); _ = cmd.Wait() })
45+ line, err := bufio.NewReader(out).ReadString('\n')
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ return strings.TrimSpace(line), kill
50+}
51+
52+// logBuf is a locked bytes.Buffer, a real io.Writer.
53+type logBuf struct {
54+ mu sync.Mutex
55+ b bytes.Buffer
56+}
57+
58+func (l *logBuf) Write(p []byte) (int, error) {
59+ l.mu.Lock()
60+ defer l.mu.Unlock()
61+ return l.b.Write(p)
62+}
63+
64+func (l *logBuf) String() string {
65+ l.mu.Lock()
66+ defer l.mu.Unlock()
67+ return l.b.String()
68+}
69+
70+func testLog(out *logBuf) zerowrap.Logger {
71+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "screencast")
72+}
73+
74+// peers is what the mocked peerResolver answers: the executable of every
75+// peer, and a hook to hold Open.
76+type peers struct {
77+ exe atomic.Value // string
78+ exeNow func() string
79+ openGo chan struct{} // when set, Open waits for it
80+ pid atomic.Int64
81+ openErr error
82+ exePid func(open int) int // pid Exe reports; the opened one when nil
83+
84+ // exeFile, when set, tells the executable of one pidfd: the pidfds are
85+ // numbered in the order Open handed them out (0 is the first peer).
86+ exeFile func(n int) string
87+ mu sync.Mutex
88+ opened []*os.File
89+}
90+
91+func (p *peers) number(f *os.File) int {
92+ p.mu.Lock()
93+ defer p.mu.Unlock()
94+ for i, o := range p.opened {
95+ if o == f {
96+ return i
97+ }
98+ }
99+ return -1
100+}
101+
102+// svcHarness runs a Service on a private bus with mocked executables and
103+// PipeWire.
104+type svcHarness struct {
105+ t *testing.T
106+ addr string
107+ svc *Service
108+ cmds chan ports.CastCommand
109+ peers *peers
110+ core *mockvideoCore
111+ run *mockrunner
112+ log *logBuf
113+ done chan struct{}
114+
115+ killBus func() // loses the bus: the daemon dies
116+ cancel context.CancelFunc // stops the service, as the process exit does
117+ closed chan struct{} // a PipeWire connection was closed
118+}
119+
120+type harnessOpts struct {
121+ allow *captureallow.Policy
122+ noPW bool
123+ holdOp bool
124+ // drain overrides how long the stopping service waits for frames.
125+ drain time.Duration
126+ // iterate runs inside every PipeWire loop iteration, on the owner.
127+ iterate func()
128+}
129+
130+func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness {
131+ t.Helper()
132+ h := &svcHarness{t: t, cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{}), closed: make(chan struct{}, 8)}
133+ h.addr, h.killBus = startBus(t)
134+ h.peers.exe.Store("/usr/bin/grim")
135+ if o.holdOp {
136+ h.peers.openGo = make(chan struct{})
137+ }
138+ allow := o.allow
139+ if allow == nil {
140+ allow = captureallow.Builtin()
141+ }
142+ h.svc = New(func() *captureallow.Policy { return allow }, h.cmds, testLog(h.log))
143+ h.svc.address = h.addr
144+ if o.drain > 0 {
145+ h.svc.drain = o.drain
146+ }
147+
148+ res := newMockpeerResolver(t)
149+ res.EXPECT().Open(mock.Anything).RunAndReturn(func(pid int) (*os.File, error) {
150+ if h.peers.openGo != nil {
151+ <-h.peers.openGo
152+ }
153+ if h.peers.openErr != nil {
154+ return nil, h.peers.openErr
155+ }
156+ h.peers.pid.Store(int64(pid))
157+ f, err := os.Open(os.DevNull)
158+ if err == nil {
159+ h.peers.mu.Lock()
160+ h.peers.opened = append(h.peers.opened, f)
161+ h.peers.mu.Unlock()
162+ }
163+ return f, err
164+ }).Maybe()
165+ res.EXPECT().Exe(mock.Anything).RunAndReturn(func(f *os.File) (string, int, error) {
166+ open := int(h.peers.pid.Load())
167+ pid := open
168+ if h.peers.exePid != nil {
169+ pid = h.peers.exePid(open)
170+ }
171+ if h.peers.exeFile != nil {
172+ return h.peers.exeFile(h.peers.number(f)), pid, nil
173+ }
174+ if h.peers.exeNow != nil {
175+ return h.peers.exeNow(), pid, nil
176+ }
177+ return h.peers.exe.Load().(string), pid, nil
178+ }).Maybe()
179+ h.svc.peers = res
180+ h.run = newMockrunner(t)
181+ h.svc.runner = h.run
182+
183+ pwOpen := newMockpipewireOpener(t)
184+ if o.noPW {
185+ pwOpen.EXPECT().Open().Return(nil, os.ErrNotExist)
186+ } else {
187+ r, w, err := os.Pipe()
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ t.Cleanup(func() { r.Close(); w.Close() })
192+ h.core = newMockvideoCore(t)
193+ h.core.EXPECT().Fd().Return(int(r.Fd()))
194+ h.core.EXPECT().Iterate().Run(func() {
195+ if o.iterate != nil {
196+ o.iterate()
197+ }
198+ }).Maybe()
199+ h.core.EXPECT().Close().Run(func() {
200+ select {
201+ case h.closed <- struct{}{}:
202+ default:
203+ }
204+ }).Maybe()
205+ pwOpen.EXPECT().Open().Return(h.core, nil)
206+ }
207+ h.svc.pw = pwOpen
208+
209+ ctx, cancel := context.WithCancel(context.Background())
210+ h.cancel = cancel
211+ go func() { defer close(h.done); h.svc.Run(ctx, 10*time.Millisecond) }()
212+ t.Cleanup(func() {
213+ cancel()
214+ select {
215+ case <-h.done:
216+ case <-time.After(5 * time.Second):
217+ t.Error("Run did not stop")
218+ }
219+ })
220+ return h
221+}
222+
223+// client is a bus connection of the test.
224+func (h *svcHarness) client() *zerobus.Conn {
225+ h.t.Helper()
226+ c, err := zerobus.Dial(h.addr)
227+ if err != nil {
228+ h.t.Fatal(err)
229+ }
230+ h.t.Cleanup(func() { _ = c.Close() })
231+ return c
232+}
233+
234+// waitName waits until the service owns name.
235+func (h *svcHarness) waitName(c *zerobus.Conn, name string) {
236+ h.t.Helper()
237+ deadline := time.Now().Add(5 * time.Second)
238+ for time.Now().Before(deadline) {
239+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
240+ e.Str(name)
241+ m, err := c.Call()
242+ if err != nil {
243+ h.t.Fatal(err)
244+ }
245+ if m.Body().Bool() {
246+ return
247+ }
248+ time.Sleep(5 * time.Millisecond)
249+ }
250+ h.t.Fatalf("%s not owned", name)
251+}
252+
253+// callErr calls and returns the D-Bus error name, "" on success.
254+func callErr(c *zerobus.Conn, dest, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
255+ e := c.NewCall(dest, path, iface, member, sig)
256+ if body != nil {
257+ body(e)
258+ }
259+ m, err := c.Call()
260+ if err == nil {
261+ return "", m
262+ }
263+ if de, ok := err.(*zerobus.Error); ok {
264+ return de.Name, m
265+ }
266+ return err.Error(), m
267+}
A internal/adapters/screencast/inventory.go +203 -0
@@ -0,0 +1,203 @@
1+package screencast
2+
3+import (
4+ "reflect"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerobus"
10+)
11+
12+// applyInventory takes the outputs and windows wayland published. The
13+// receiver owns the slices. Departures of outputs and windows are signalled
14+// to the clients that watch them (the first inventory is not a change).
15+func (s *Service) applyInventory(inv ports.CastInventory) {
16+ slices.SortFunc(inv.Outputs, func(a, b ports.CastOutput) int { return strings.Compare(a.Info.Name, b.Info.Name) })
17+ slices.SortFunc(inv.Windows, func(a, b ports.CastWindow) int {
18+ switch {
19+ case a.ID < b.ID:
20+ return -1
21+ case a.ID > b.ID:
22+ return 1
23+ }
24+ return 0
25+ })
26+ old, had := s.inv, s.haveInv
27+ s.inv, s.haveInv = inv, true
28+ if !had {
29+ return
30+ }
31+ if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) {
32+ s.notifyAllowed(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged")
33+ }
34+ if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) {
35+ s.notifyAllowed(pathIntrospect, ifaceIntrospect, "WindowsChanged")
36+ }
37+}
38+
39+// notifyAllowed sends a body-less signal to each peer that passed the caller
40+// check and whose executable the allowlist admits now. The signals are
41+// directed: the inventory (output names, window titles) is never broadcast,
42+// so a peer that was refused, or has not been checked, learns nothing. At
43+// most maxCallers peers are served, from the cache, without allocation. The
44+// executable is read again through the pidfd, as at the privileged entry
45+// points. Peers that left the bus are gone from the cache (onGone).
46+func (s *Service) notifyAllowed(path, iface, member string) {
47+ for i := range s.callers {
48+ c := &s.callers[i]
49+ if c.state != callerKnown || !s.allowed(c.exe) || !s.recheck(c) {
50+ continue
51+ }
52+ s.conn.NewSignalTo(c.name, path, iface, member, "")
53+ s.send()
54+ }
55+}
56+
57+// isBuiltin follows Mutter: the connector of a laptop panel.
58+func isBuiltin(connector string) bool {
59+ for _, p := range [...]string{"eDP", "LVDS", "DSI"} {
60+ if strings.HasPrefix(connector, p) {
61+ return true
62+ }
63+ }
64+ return false
65+}
66+
67+// refreshHz is a refresh rate in Hz from millihertz.
68+func refreshHz(milli int) float64 { return float64(milli) / 1000 }
69+
70+// replyState answers DisplayConfig.GetCurrentState:
71+// (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}).
72+// layout-mode 1 is physical: logical sizes are scaled sizes of the modes.
73+// Exactly one mode of each monitor is current, as x-d-p-g requires.
74+func (s *Service) replyState(req *request) {
75+ e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}")
76+ if e == nil {
77+ return
78+ }
79+ e.Uint32(0)
80+ monitors := e.BeginArray('(')
81+ for i := range s.inv.Outputs {
82+ o := &s.inv.Outputs[i]
83+ e.Struct()
84+ s.encodeNames(e, o)
85+ modes := e.BeginArray('(')
86+ cur := o.Current
87+ if cur < 0 || cur >= len(o.Modes) {
88+ cur = 0
89+ }
90+ for j, m := range o.Modes {
91+ e.Struct()
92+ e.Str(modeID(m))
93+ e.Int32(int32(m.Width))
94+ e.Int32(int32(m.Height))
95+ e.Float64(refreshHz(m.RefreshMilli))
96+ e.Float64(1)
97+ sc := e.BeginArray('d')
98+ e.Float64(1)
99+ e.EndArray(sc)
100+ props := e.BeginArray('{')
101+ e.Struct()
102+ e.Str("is-preferred")
103+ e.Variant("b")
104+ e.Bool(m.Preferred)
105+ if j == cur {
106+ e.Struct()
107+ e.Str("is-current")
108+ e.Variant("b")
109+ e.Bool(true)
110+ }
111+ e.EndArray(props)
112+ }
113+ e.EndArray(modes)
114+ props := e.BeginArray('{')
115+ display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model)
116+ if display == "" {
117+ display = o.Info.Name
118+ }
119+ e.Struct()
120+ e.Str("display-name")
121+ e.Variant("s")
122+ e.Str(display)
123+ e.Struct()
124+ e.Str("is-builtin")
125+ e.Variant("b")
126+ e.Bool(isBuiltin(o.Info.Name))
127+ e.EndArray(props)
128+ }
129+ e.EndArray(monitors)
130+ logical := e.BeginArray('(')
131+ for i := range s.inv.Outputs {
132+ o := &s.inv.Outputs[i]
133+ e.Struct()
134+ e.Int32(int32(o.X))
135+ e.Int32(int32(o.Y))
136+ scale := o.Scale
137+ if scale <= 0 {
138+ scale = 1
139+ }
140+ e.Float64(scale)
141+ e.Uint32(uint32(o.Transform))
142+ e.Bool(i == 0)
143+ mons := e.BeginArray('(')
144+ e.Struct()
145+ s.encodeNames(e, o)
146+ e.EndArray(mons)
147+ props := e.BeginArray('{')
148+ e.EndArray(props)
149+ }
150+ e.EndArray(logical)
151+ props := e.BeginArray('{')
152+ e.Struct()
153+ e.Str("layout-mode")
154+ e.Variant("u")
155+ e.Uint32(1)
156+ e.EndArray(props)
157+ s.send()
158+}
159+
160+// encodeNames writes the monitor spec (connector, vendor, product, serial).
161+// The serial falls back to the connector, as session restore needs one.
162+func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) {
163+ e.Struct()
164+ serial := o.Info.Serial
165+ if serial == "" {
166+ serial = o.Info.Name
167+ }
168+ e.Str(o.Info.Name)
169+ e.Str(o.Info.Make)
170+ e.Str(o.Info.Model)
171+ e.Str(serial)
172+}
173+
174+func modeID(m ports.OutputMode) string {
175+ return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli))
176+}
177+
178+// replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title.
179+// The app id is the raw Wayland app id.
180+func (s *Service) replyWindows(req *request) {
181+ e := s.replyTo(req, "a{ta{sv}}")
182+ if e == nil {
183+ return
184+ }
185+ a := e.BeginArray('{')
186+ for i := range s.inv.Windows {
187+ w := &s.inv.Windows[i]
188+ e.Struct()
189+ e.Uint64(uint64(w.ID))
190+ props := e.BeginArray('{')
191+ e.Struct()
192+ e.Str("app-id")
193+ e.Variant("s")
194+ e.Str(w.AppID)
195+ e.Struct()
196+ e.Str("title")
197+ e.Variant("s")
198+ e.Str(w.Title)
199+ e.EndArray(props)
200+ }
201+ e.EndArray(a)
202+ s.send()
203+}
A internal/adapters/screencast/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package screencast
4+
5+const raceEnabled = false
A internal/adapters/screencast/peer_resolver_mock_test.go +175 -0
@@ -0,0 +1,175 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ "os"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockpeerResolver creates a new instance of mockpeerResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockpeerResolver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockpeerResolver {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockpeerResolver{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockpeerResolver is an autogenerated mock type for the peerResolver type
37+type mockpeerResolver struct {
38+ mock.Mock
39+}
40+
41+type mockpeerResolver_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockpeerResolver) EXPECT() *mockpeerResolver_Expecter {
46+ return &mockpeerResolver_Expecter{mock: &_m.Mock}
47+}
48+
49+// Exe provides a mock function for the type mockpeerResolver
50+func (_mock *mockpeerResolver) Exe(pidfd *os.File) (string, int, error) {
51+ ret := _mock.Called(pidfd)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Exe")
55+ }
56+
57+ var r0 string
58+ var r1 int
59+ var r2 error
60+ if returnFunc, ok := ret.Get(0).(func(*os.File) (string, int, error)); ok {
61+ return returnFunc(pidfd)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(*os.File) string); ok {
64+ r0 = returnFunc(pidfd)
65+ } else {
66+ r0 = ret.Get(0).(string)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(*os.File) int); ok {
69+ r1 = returnFunc(pidfd)
70+ } else {
71+ r1 = ret.Get(1).(int)
72+ }
73+ if returnFunc, ok := ret.Get(2).(func(*os.File) error); ok {
74+ r2 = returnFunc(pidfd)
75+ } else {
76+ r2 = ret.Error(2)
77+ }
78+ return r0, r1, r2
79+}
80+
81+// mockpeerResolver_Exe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Exe'
82+type mockpeerResolver_Exe_Call struct {
83+ *mock.Call
84+}
85+
86+// Exe is a helper method to define mock.On call
87+// - pidfd *os.File
88+func (_e *mockpeerResolver_Expecter) Exe(pidfd any) *mockpeerResolver_Exe_Call {
89+ return &mockpeerResolver_Exe_Call{Call: _e.mock.On("Exe", pidfd)}
90+}
91+
92+func (_c *mockpeerResolver_Exe_Call) Run(run func(pidfd *os.File)) *mockpeerResolver_Exe_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 *os.File
95+ if args[0] != nil {
96+ arg0 = args[0].(*os.File)
97+ }
98+ run(
99+ arg0,
100+ )
101+ })
102+ return _c
103+}
104+
105+func (_c *mockpeerResolver_Exe_Call) Return(exe string, pid int, err error) *mockpeerResolver_Exe_Call {
106+ _c.Call.Return(exe, pid, err)
107+ return _c
108+}
109+
110+func (_c *mockpeerResolver_Exe_Call) RunAndReturn(run func(pidfd *os.File) (string, int, error)) *mockpeerResolver_Exe_Call {
111+ _c.Call.Return(run)
112+ return _c
113+}
114+
115+// Open provides a mock function for the type mockpeerResolver
116+func (_mock *mockpeerResolver) Open(pid int) (*os.File, error) {
117+ ret := _mock.Called(pid)
118+
119+ if len(ret) == 0 {
120+ panic("no return value specified for Open")
121+ }
122+
123+ var r0 *os.File
124+ var r1 error
125+ if returnFunc, ok := ret.Get(0).(func(int) (*os.File, error)); ok {
126+ return returnFunc(pid)
127+ }
128+ if returnFunc, ok := ret.Get(0).(func(int) *os.File); ok {
129+ r0 = returnFunc(pid)
130+ } else {
131+ if ret.Get(0) != nil {
132+ r0 = ret.Get(0).(*os.File)
133+ }
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
136+ r1 = returnFunc(pid)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// mockpeerResolver_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
144+type mockpeerResolver_Open_Call struct {
145+ *mock.Call
146+}
147+
148+// Open is a helper method to define mock.On call
149+// - pid int
150+func (_e *mockpeerResolver_Expecter) Open(pid any) *mockpeerResolver_Open_Call {
151+ return &mockpeerResolver_Open_Call{Call: _e.mock.On("Open", pid)}
152+}
153+
154+func (_c *mockpeerResolver_Open_Call) Run(run func(pid int)) *mockpeerResolver_Open_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 int
157+ if args[0] != nil {
158+ arg0 = args[0].(int)
159+ }
160+ run(
161+ arg0,
162+ )
163+ })
164+ return _c
165+}
166+
167+func (_c *mockpeerResolver_Open_Call) Return(file *os.File, err error) *mockpeerResolver_Open_Call {
168+ _c.Call.Return(file, err)
169+ return _c
170+}
171+
172+func (_c *mockpeerResolver_Open_Call) RunAndReturn(run func(pid int) (*os.File, error)) *mockpeerResolver_Open_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
A internal/adapters/screencast/pipewire_opener_mock_test.go +100 -0
@@ -0,0 +1,100 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockpipewireOpener creates a new instance of mockpipewireOpener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockpipewireOpener(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockpipewireOpener {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockpipewireOpener{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockpipewireOpener is an autogenerated mock type for the pipewireOpener type
35+type mockpipewireOpener struct {
36+ mock.Mock
37+}
38+
39+type mockpipewireOpener_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockpipewireOpener) EXPECT() *mockpipewireOpener_Expecter {
44+ return &mockpipewireOpener_Expecter{mock: &_m.Mock}
45+}
46+
47+// Open provides a mock function for the type mockpipewireOpener
48+func (_mock *mockpipewireOpener) Open() (videoCore, error) {
49+ ret := _mock.Called()
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Open")
53+ }
54+
55+ var r0 videoCore
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func() (videoCore, error)); ok {
58+ return returnFunc()
59+ }
60+ if returnFunc, ok := ret.Get(0).(func() videoCore); ok {
61+ r0 = returnFunc()
62+ } else {
63+ if ret.Get(0) != nil {
64+ r0 = ret.Get(0).(videoCore)
65+ }
66+ }
67+ if returnFunc, ok := ret.Get(1).(func() error); ok {
68+ r1 = returnFunc()
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// mockpipewireOpener_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
76+type mockpipewireOpener_Open_Call struct {
77+ *mock.Call
78+}
79+
80+// Open is a helper method to define mock.On call
81+func (_e *mockpipewireOpener_Expecter) Open() *mockpipewireOpener_Open_Call {
82+ return &mockpipewireOpener_Open_Call{Call: _e.mock.On("Open")}
83+}
84+
85+func (_c *mockpipewireOpener_Open_Call) Run(run func()) *mockpipewireOpener_Open_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ run()
88+ })
89+ return _c
90+}
91+
92+func (_c *mockpipewireOpener_Open_Call) Return(videoCoreMoqParam videoCore, err error) *mockpipewireOpener_Open_Call {
93+ _c.Call.Return(videoCoreMoqParam, err)
94+ return _c
95+}
96+
97+func (_c *mockpipewireOpener_Open_Call) RunAndReturn(run func() (videoCore, error)) *mockpipewireOpener_Open_Call {
98+ _c.Call.Return(run)
99+ return _c
100+}
A internal/adapters/screencast/producer.go +370 -0
@@ -0,0 +1,370 @@
1+package screencast
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+)
9+
10+// The producer turns wayland's damage into PipeWire frames, one stream at a
11+// time and without allocating in steady state:
12+//
13+// CastDamage -> dirty
14+// pump: STREAMING, dirty or first frame, no frame in flight, interval
15+// elapsed, a buffer free -> Dequeue, CastFrame{Data: buffer}
16+// CastFrameDone ok -> Queue, then Trigger (the order PipeWire needs)
17+// CastFrameDone with an error -> Release, retry paced
18+//
19+// The buffer's mapping is lent to the wayland server until the CastFrameDone
20+// of that buffer, so the source is never resized or destroyed while a frame
21+// is in flight.
22+//
23+// A cast ends (the session signals Closed) on Stop, when the owner leaves
24+// the bus (NameOwnerChanged), when wayland stops the cast (output or window
25+// gone, lock), when the PipeWire stream goes to ERROR or back to
26+// UNCONNECTED, and when the service shuts down. It does not end when the
27+// consumer leaves: as in Mutter, the node then goes to PAUSED, no frame is
28+// requested while it is not STREAMING (a frame already in flight is
29+// released instead of queued), and the first frame after it is STREAMING
30+// again is forced. The D-Bus session stays up for the next consumer.
31+
32+// wl_shm formats of the supported video formats.
33+const (
34+ shmARGB8888 = 0
35+ shmXRGB8888 = 1
36+)
37+
38+// maxFrameErrors is how many frames in a row may fail before the stream
39+// stops.
40+const maxFrameErrors = 3
41+
42+// retryOpen is how long a command waits when the wayland channel is full.
43+const retryOpen = 10 * time.Millisecond
44+
45+func shmFormat(f pipewire.VideoFormat) uint32 {
46+ if f == pipewire.VideoFormatBGRA {
47+ return shmARGB8888
48+ }
49+ return shmXRGB8888
50+}
51+
52+// streamOfCast finds the stream of a cast ID.
53+func (s *Service) streamOfCast(id uint64) *stream {
54+ for i := range s.sessions {
55+ sess := &s.sessions[i]
56+ if !sess.used {
57+ continue
58+ }
59+ for j := range sess.streams {
60+ if st := &sess.streams[j]; st.used && st.cast == id {
61+ return st
62+ }
63+ }
64+ }
65+ return nil
66+}
67+
68+// onCastEvent applies one event of the wayland server.
69+func (s *Service) onCastEvent(ev *ports.CastEvent) {
70+ st := s.streamOfCast(ev.ID)
71+ if st == nil {
72+ return
73+ }
74+ switch ev.Kind {
75+ case ports.CastDamage:
76+ st.dirty = true
77+ case ports.CastSize:
78+ if st.closing {
79+ return
80+ }
81+ if st.haveSize && st.w == ev.Width && st.h == ev.Height {
82+ return
83+ }
84+ st.w, st.h, st.haveSize = ev.Width, ev.Height, true
85+ if st.src != nil {
86+ st.needResize = true
87+ }
88+ case ports.CastFrameDone:
89+ s.frameDone(st, ev)
90+ case ports.CastStopped:
91+ st.stopped, st.needClose = true, false
92+ if st.inflight {
93+ // Wayland orders CastStopped after the frame's CastFrameDone.
94+ s.log.Warn().Str("stream", st.path).Msg("cast stopped with a frame in flight")
95+ st.inflight = false
96+ s.giveBack(st)
97+ }
98+ if !st.closing {
99+ s.log.Info().Str("stream", st.path).Str("reason", string(ev.Reason)).Err(ev.Err).Msg("cast stopped")
100+ s.closeSession(st.sess, true)
101+ }
102+ }
103+}
104+
105+// giveBack releases the buffer a frame borrowed.
106+func (s *Service) giveBack(st *stream) {
107+ if st.buf != nil && st.src != nil {
108+ st.src.Release(st.buf)
109+ }
110+ st.buf = nil
111+}
112+
113+// frameDone settles the frame in flight: a complete image is queued and
114+// triggered, anything else gives the buffer back.
115+func (s *Service) frameDone(st *stream, ev *ports.CastEvent) {
116+ b := s.settleFrame(st, ev)
117+ if b == nil {
118+ return
119+ }
120+ if st.pwState != pipewire.StreamStateStreaming {
121+ // The consumer left while the frame was drawn: nobody wants it.
122+ // The picture is kept for the consumer that comes next.
123+ st.src.Release(b)
124+ st.dirty = true
125+ return
126+ }
127+ st.src.Queue(b, st.seq, st.pts, false)
128+ st.src.Trigger()
129+}
130+
131+// settleFrame updates the stream for the CastFrameDone of its frame and
132+// returns the buffer to queue, nil when there is none. It touches no
133+// PipeWire object but Release on the failure path.
134+func (s *Service) settleFrame(st *stream, ev *ports.CastEvent) *pipewire.VideoBuffer {
135+ if !st.inflight || st.buf == nil || st.buf.Index != ev.Buf {
136+ s.log.Warn().Str("stream", st.path).Int("buf", ev.Buf).Msg("frame done for no frame in flight")
137+ return nil
138+ }
139+ st.inflight = false
140+ if ev.Err != nil || st.closing {
141+ s.giveBack(st)
142+ if ev.Err == nil {
143+ return nil
144+ }
145+ st.errs++
146+ st.dirty = true
147+ if !st.closing {
148+ s.log.Info().Str("stream", st.path).Err(ev.Err).Int("errors", st.errs).Msg("cast frame failed")
149+ if st.errs >= maxFrameErrors {
150+ s.closeSession(st.sess, true)
151+ }
152+ }
153+ return nil
154+ }
155+ st.errs = 0
156+ st.seq++
157+ st.pts = 0
158+ if !ev.Time.IsZero() {
159+ st.pts = time.Duration(ev.Time.UnixNano())
160+ }
161+ b := st.buf
162+ st.buf = nil
163+ return b
164+}
165+
166+// pumpAll runs every stream: commands, negotiation and frames.
167+func (s *Service) pumpAll(now time.Time) {
168+ s.waitFor = 0
169+ for i := range s.sessions {
170+ sess := &s.sessions[i]
171+ if !sess.used {
172+ continue
173+ }
174+ for j := range sess.streams {
175+ if st := &sess.streams[j]; st.used {
176+ s.pump(st, now)
177+ }
178+ }
179+ }
180+}
181+
182+// wait asks for a poll timeout no longer than d.
183+func (s *Service) wait(d time.Duration) {
184+ if d <= 0 {
185+ d = time.Millisecond
186+ }
187+ if s.waitFor == 0 || d < s.waitFor {
188+ s.waitFor = d
189+ }
190+}
191+
192+// send puts a command on the wayland channel without blocking.
193+func (s *Service) sendCmd(cmd ports.CastCommand) bool {
194+ select {
195+ case s.cmds <- cmd:
196+ return true
197+ default:
198+ return false
199+ }
200+}
201+
202+func (s *Service) pump(st *stream, now time.Time) {
203+ if st.closing {
204+ s.pumpClosing(st)
205+ return
206+ }
207+ if st.needOpen {
208+ cmd := ports.CastCommand{Kind: ports.CastOpen, ID: st.cast, Output: st.output, Window: st.window, Cursor: st.cursor}
209+ if !s.sendCmd(cmd) {
210+ s.wait(retryOpen)
211+ return
212+ }
213+ st.needOpen, st.opened = false, true
214+ }
215+ if !st.haveSize {
216+ return
217+ }
218+ if st.src == nil {
219+ s.openSource(st)
220+ if st.src == nil {
221+ return
222+ }
223+ }
224+ if st.pwState == pipewire.StreamStateError || st.pwState == pipewire.StreamStateUnconnected && st.seenState {
225+ s.log.Info().Str("stream", st.path).Int32("state", st.pwState).Msg("PipeWire stream ended")
226+ s.closeSession(st.sess, true)
227+ return
228+ }
229+ if st.needResize && !st.inflight {
230+ st.needResize = false
231+ st.fw, st.fh = 0, 0
232+ if err := st.src.Resize(st.w, st.h); err != nil {
233+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire resize failed")
234+ s.closeSession(st.sess, true)
235+ return
236+ }
237+ }
238+ if !st.announced && st.pwState >= pipewire.StreamStatePaused && st.nodeID != 0 {
239+ st.announced = true
240+ s.conn.NewSignalTo(st.sess.owner, st.path, ifaceStream, "PipeWireStreamAdded", "u").Uint32(st.nodeID)
241+ s.send()
242+ }
243+ s.pumpFrame(st, now)
244+}
245+
246+// openSource creates the PipeWire node once the target size is known.
247+func (s *Service) openSource(st *stream) {
248+ if s.pwc == nil {
249+ s.closeSession(st.sess, true)
250+ return
251+ }
252+ cfg := pipewire.VideoConfig{Name: "neferwl-screencast", Width: st.w, Height: st.h, MaxFPS: st.maxFPS, Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA}}
253+ src, err := s.pwc.NewVideoSource(cfg, &st.h2)
254+ if err != nil {
255+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire stream not created")
256+ s.closeSession(st.sess, true)
257+ return
258+ }
259+ st.src = src
260+}
261+
262+// pumpFrame requests the next frame when one is due.
263+func (s *Service) pumpFrame(st *stream, now time.Time) {
264+ if !s.frameDue(st, now) {
265+ return
266+ }
267+ b := st.src.Dequeue()
268+ if b == nil {
269+ // Every buffer is with the consumer: look again shortly.
270+ s.wait(st.interval())
271+ return
272+ }
273+ if !s.issueFrame(st, b, now) {
274+ st.src.Release(b)
275+ }
276+}
277+
278+// interval is the shortest time between two frames.
279+func (st *stream) interval() time.Duration {
280+ return time.Second / time.Duration(max(st.maxFPS, 1))
281+}
282+
283+// frameDue reports whether a frame may be requested now: streaming, the
284+// negotiated size is the target's, something changed (or it is the first
285+// frame), none in flight and the interval has passed. When only the interval
286+// is missing, it asks for a poll timeout.
287+func (s *Service) frameDue(st *stream, now time.Time) bool {
288+ if st.inflight || st.pwState != pipewire.StreamStateStreaming || st.needResize ||
289+ st.fw != st.w || st.fh != st.h || st.fw == 0 {
290+ return false
291+ }
292+ if !st.dirty && !st.forced {
293+ return false
294+ }
295+ if since := now.Sub(st.lastReq); !st.lastReq.IsZero() && since < st.interval() {
296+ s.wait(st.interval() - since)
297+ return false
298+ }
299+ return true
300+}
301+
302+// issueFrame lends buffer b to the wayland server. False: the buffer is not
303+// taken and stays the caller's.
304+func (s *Service) issueFrame(st *stream, b *pipewire.VideoBuffer, now time.Time) bool {
305+ if b.Width != st.w || b.Height != st.h || len(b.Data) < b.Stride*b.Height {
306+ s.wait(st.interval())
307+ return false
308+ }
309+ cmd := ports.CastCommand{Kind: ports.CastFrame, ID: st.cast, Buf: b.Index, Data: b.Data, Stride: b.Stride, Width: b.Width, Height: b.Height, Format: shmFormat(b.Format)}
310+ if !s.sendCmd(cmd) {
311+ s.wait(st.interval())
312+ return false
313+ }
314+ st.inflight, st.buf = true, b
315+ st.lastReq, st.dirty, st.forced = now, false, false
316+ return true
317+}
318+
319+// pumpClosing finishes a stream of an ended session: CastClose, then the
320+// PipeWire node once no frame is in flight. Destroying the node does not
321+// wait for CastClose to be sent (only a frame in flight lends its buffer),
322+// but the slot is kept until it is.
323+func (s *Service) pumpClosing(st *stream) {
324+ if st.needClose {
325+ if s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) {
326+ st.needClose = false
327+ } else {
328+ s.wait(retryOpen)
329+ }
330+ }
331+ if st.inflight {
332+ return
333+ }
334+ if st.src != nil {
335+ s.giveBack(st)
336+ st.src.Destroy()
337+ st.src = nil
338+ }
339+ if !st.needClose {
340+ *st = stream{}
341+ }
342+}
343+
344+// reap frees the dying sessions once every stream released its resources.
345+func (s *Service) reap() {
346+ for i := range s.sessions {
347+ sess := &s.sessions[i]
348+ if !sess.used || !sess.dying {
349+ continue
350+ }
351+ live := false
352+ for j := range sess.streams {
353+ live = live || sess.streams[j].used
354+ }
355+ if !live {
356+ *sess = session{}
357+ }
358+ }
359+}
360+
361+// streamCount is the number of streams that still hold something: a
362+// PipeWire source, a lent buffer or an unsent CastClose.
363+func (s *Service) streamCount() int {
364+ n := 0
365+ s.forEachStream(func(*stream) { n++ })
366+ return n
367+}
368+
369+// streamsLive reports whether a stream still holds something to release.
370+func (s *Service) streamsLive() bool { return s.streamCount() > 0 }
A internal/adapters/screencast/producer_test.go +484 -0
@@ -0,0 +1,484 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/captureallow"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/purego-pipewire/pipewire"
13+ "github.com/bnema/zerobus"
14+ "github.com/stretchr/testify/mock"
15+)
16+
17+// producer is a service whose owner state is driven directly by the test, on
18+// the test goroutine (the owner): a generated mock bus, PipeWire core and
19+// video source stand where the real ones would be.
20+type producer struct {
21+ t *testing.T
22+ s *Service
23+ st *stream
24+ src *mockvideoSource
25+ core *mockvideoCore
26+ bus *mockbus
27+ now time.Time
28+ buf *pipewire.VideoBuffer
29+ cmds chan ports.CastCommand
30+}
31+
32+func newProducer(t *testing.T) *producer {
33+ t.Helper()
34+ cmds := make(chan ports.CastCommand, 16)
35+ p := &producer{t: t, now: time.Unix(1000, 0), cmds: cmds}
36+ p.s = New(func() *captureallow.Policy { return captureallow.Disabled() }, cmds, testLog(&logBuf{}))
37+ t.Cleanup(func() { _ = p.s.poller.Close() })
38+ p.bus = newMockbus(t)
39+ p.bus.EXPECT().NewSignalTo(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40+ func(string, string, string, string, string) *zerobus.Encoder { return &zerobus.Encoder{} }).Maybe()
41+ p.bus.EXPECT().Send().Return(1, nil).Maybe()
42+ p.s.conn = p.bus
43+ p.src = newMockvideoSource(t)
44+ p.core = newMockvideoCore(t)
45+ p.s.pwc = p.core
46+ p.buf = &pipewire.VideoBuffer{Index: 3, Data: make([]byte, 64*32*4), Stride: 64 * 4, Width: 64, Height: 32, Format: pipewire.VideoFormatBGRx}
47+
48+ sess := &p.s.sessions[0]
49+ *sess = session{used: true, id: 1, owner: ":1.7", path: prefixSession + "1", started: true}
50+ st := &sess.streams[0]
51+ *st = stream{used: true, sess: sess, id: 1, cast: 1, path: prefixStream + "1", output: "HEADLESS-1", maxFPS: 60, pwState: -2, needOpen: true}
52+ st.h2 = streamHandler{st: st}
53+ p.st = st
54+ return p
55+}
56+
57+func (p *producer) cmd() ports.CastCommand {
58+ p.t.Helper()
59+ select {
60+ case c := <-p.cmds:
61+ return c
62+ default:
63+ p.t.Fatal("no command")
64+ return ports.CastCommand{}
65+ }
66+}
67+
68+func (p *producer) none() {
69+ p.t.Helper()
70+ select {
71+ case c := <-p.cmds:
72+ p.t.Fatalf("unexpected command %+v", c)
73+ default:
74+ }
75+}
76+
77+// streaming opens the cast, sizes it, creates the source and brings it to
78+// STREAMING with the negotiated format.
79+func (p *producer) streaming() {
80+ p.t.Helper()
81+ p.s.pump(p.st, p.now)
82+ if c := p.cmd(); c.Kind != ports.CastOpen || c.ID != 1 || c.Output != "HEADLESS-1" {
83+ p.t.Fatalf("open %+v", c)
84+ }
85+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastSize, ID: 1, Width: 64, Height: 32})
86+ p.core.EXPECT().NewVideoSource(mock.Anything, mock.Anything).RunAndReturn(
87+ func(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error) {
88+ if cfg.Width != 64 || cfg.Height != 32 || cfg.MaxFPS != 60 {
89+ p.t.Errorf("config %+v", cfg)
90+ }
91+ return p.src, nil
92+ }).Once()
93+ p.s.pump(p.st, p.now)
94+ p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "")
95+ p.st.h2.FormatChanged(pipewire.VideoFormatBGRx, 64, 32, 256)
96+ p.st.h2.StateChanged(pipewire.StreamStateStreaming, 42, "")
97+}
98+
99+// The node id goes to the session owner alone, once, as the stream's signal.
100+func TestAnnouncesNodeToOwnerOnce(t *testing.T) {
101+ p := newProducer(t)
102+ var dest, path, member string
103+ p.bus.ExpectedCalls = nil
104+ p.bus.EXPECT().NewSignalTo(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
105+ func(d, pa, iface, m, sig string) *zerobus.Encoder {
106+ dest, path, member = d, pa, m
107+ if iface != ifaceStream || sig != "u" {
108+ t.Errorf("signal %s %s", iface, sig)
109+ }
110+ return &zerobus.Encoder{}
111+ }).Once()
112+ p.bus.EXPECT().Send().Return(1, nil).Once()
113+ p.streaming()
114+ p.src.EXPECT().Dequeue().Return(nil).Maybe()
115+ p.s.pump(p.st, p.now)
116+ p.s.pump(p.st, p.now)
117+ if dest != ":1.7" || path != prefixStream+"1" || member != "PipeWireStreamAdded" {
118+ t.Fatalf("signal to %q %q %q", dest, path, member)
119+ }
120+}
121+
122+// Nothing is requested without damage, except the first frame.
123+func TestPacingForcesFirstFrameThenWaitsForDamage(t *testing.T) {
124+ p := newProducer(t)
125+ p.streaming()
126+ p.src.EXPECT().Dequeue().Return(p.buf).Once()
127+ p.s.pump(p.st, p.now)
128+ c := p.cmd()
129+ if c.Kind != ports.CastFrame || c.Buf != 3 || c.Stride != 256 || c.Width != 64 || c.Height != 32 || c.Format != shmXRGB8888 || len(c.Data) != 64*32*4 || &c.Data[0] != &p.buf.Data[0] {
130+ t.Fatalf("frame %+v", c)
131+ }
132+ // Done: queued, then triggered, in that order.
133+ var order []string
134+ p.src.EXPECT().Queue(p.buf, uint64(1), mock.Anything, false).Run(func(*pipewire.VideoBuffer, uint64, time.Duration, bool) { order = append(order, "queue") }).Once()
135+ p.src.EXPECT().Trigger().Run(func() { order = append(order, "trigger") }).Once()
136+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Time: time.Unix(5, 0)})
137+ if len(order) != 2 || order[0] != "queue" || order[1] != "trigger" {
138+ t.Fatalf("order %v", order)
139+ }
140+ // No damage: no request however much time passes (Dequeue is not expected).
141+ p.now = p.now.Add(time.Second)
142+ p.s.pump(p.st, p.now)
143+ p.none()
144+}
145+
146+// At most one frame per interval, damage or not; the poll timeout is the
147+// time left.
148+func TestPacingOnePerInterval(t *testing.T) {
149+ p := newProducer(t)
150+ p.streaming()
151+ p.src.EXPECT().Dequeue().Return(p.buf).Twice()
152+ p.s.pump(p.st, p.now)
153+ p.cmd()
154+ p.src.EXPECT().Queue(mock.Anything, mock.Anything, mock.Anything, false).Return().Once()
155+ p.src.EXPECT().Trigger().Return().Once()
156+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3})
157+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastDamage, ID: 1})
158+ p.now = p.now.Add(5 * time.Millisecond)
159+ p.s.pumpAll(p.now)
160+ p.none()
161+ if got := p.s.timeout(); got < 11 || got > 12 {
162+ t.Fatalf("timeout %d ms, want the ~11.7 ms left of 16.7", got)
163+ }
164+ p.now = p.now.Add(12 * time.Millisecond)
165+ p.s.pumpAll(p.now)
166+ if c := p.cmd(); c.Kind != ports.CastFrame {
167+ t.Fatalf("%+v", c)
168+ }
169+ if got := p.s.timeout(); got != -1 {
170+ t.Fatalf("idle timeout %d", got)
171+ }
172+}
173+
174+// A new size waits for the frame in flight, then renegotiates.
175+func TestResizeWaitsForInflightFrame(t *testing.T) {
176+ p := newProducer(t)
177+ p.streaming()
178+ p.src.EXPECT().Dequeue().Return(p.buf).Once()
179+ p.s.pump(p.st, p.now)
180+ p.cmd()
181+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastSize, ID: 1, Width: 32, Height: 16})
182+ p.s.pump(p.st, p.now) // Resize is not expected while the frame is in flight
183+ p.src.EXPECT().Resize(32, 16).Return(nil).Once()
184+ p.src.EXPECT().Release(p.buf).Return().Once()
185+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Err: ports.ErrCaptureTransient})
186+ p.s.pump(p.st, p.now)
187+ // No frame until the new format is negotiated.
188+ p.now = p.now.Add(time.Second)
189+ p.s.pump(p.st, p.now)
190+ p.none()
191+}
192+
193+// A failed frame gives the buffer back; three in a row stop the stream.
194+func TestFrameErrorsReleaseAndStop(t *testing.T) {
195+ p := newProducer(t)
196+ p.streaming()
197+ p.src.EXPECT().Dequeue().Return(p.buf)
198+ p.src.EXPECT().Release(p.buf).Return()
199+ for i := range maxFrameErrors {
200+ p.s.pump(p.st, p.now)
201+ if c := p.cmd(); c.Kind != ports.CastFrame {
202+ t.Fatalf("frame %d: %+v", i, c)
203+ }
204+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Err: errTest})
205+ p.now = p.now.Add(time.Second)
206+ }
207+ if !p.st.closing || !p.s.sessions[0].dying {
208+ t.Fatal("stream not stopped after three errors")
209+ }
210+ p.src.EXPECT().Destroy().Return().Once()
211+ p.s.pump(p.st, p.now)
212+ if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 {
213+ t.Fatalf("%+v", c)
214+ }
215+ if p.st.used {
216+ t.Fatal("stream slot not freed")
217+ }
218+}
219+
220+// Ending the session with a frame in flight closes the cast at once but
221+// keeps the PipeWire node until the frame's CastFrameDone.
222+func TestTeardownWaitsForFrameDone(t *testing.T) {
223+ p := newProducer(t)
224+ p.streaming()
225+ p.src.EXPECT().Dequeue().Return(p.buf).Once()
226+ p.s.pump(p.st, p.now)
227+ p.cmd()
228+ p.s.closeSession(p.st.sess, true)
229+ p.s.pump(p.st, p.now)
230+ if c := p.cmd(); c.Kind != ports.CastClose {
231+ t.Fatalf("%+v", c)
232+ }
233+ p.s.pump(p.st, p.now)
234+ p.s.reap()
235+ if !p.st.used || !p.s.sessions[0].used {
236+ t.Fatal("released while a frame is in flight") // Destroy is not expected yet
237+ }
238+ p.src.EXPECT().Release(p.buf).Return().Once()
239+ p.src.EXPECT().Destroy().Return().Once()
240+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3})
241+ p.s.pump(p.st, p.now)
242+ p.s.reap()
243+ if p.st.used || p.s.sessions[0].used {
244+ t.Fatal("stream and session not freed after the frame")
245+ }
246+}
247+
248+// A cast that wayland ended closes the session and signals Closed.
249+func TestCastStoppedClosesSession(t *testing.T) {
250+ p := newProducer(t)
251+ p.streaming()
252+ p.bus.ExpectedCalls = nil
253+ var member string
254+ p.bus.EXPECT().NewSignalTo(":1.7", prefixSession+"1", ifaceSession, "Closed", "").RunAndReturn(
255+ func(_, _, _, m, _ string) *zerobus.Encoder {
256+ member = m
257+ return &zerobus.Encoder{}
258+ }).Once()
259+ p.bus.EXPECT().Send().Return(1, nil).Once()
260+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastStopped, ID: 1, Reason: ports.CaptureReasonWindowGone})
261+ if member != "Closed" {
262+ t.Fatal("Closed not signalled")
263+ }
264+ p.src.EXPECT().Destroy().Return().Once()
265+ p.s.pump(p.st, p.now)
266+ // Wayland ended it itself: no CastClose.
267+ p.none()
268+}
269+
270+// A PipeWire stream that went to ERROR ends the session.
271+func TestPipeWireErrorEndsSession(t *testing.T) {
272+ p := newProducer(t)
273+ p.streaming()
274+ p.st.h2.StateChanged(pipewire.StreamStateError, 42, "gone")
275+ p.s.pump(p.st, p.now)
276+ if !p.s.sessions[0].dying {
277+ t.Fatal("session still open")
278+ }
279+}
280+
281+var errTest = errors.New("frame failed")
282+
283+// The steady damage -> frame -> done cycle allocates nothing. Measured with
284+// real values: the sink, the pacing and the settle logic. The PipeWire calls
285+// of the cycle (Dequeue, Queue, Trigger) are the VideoSource's own,
286+// measured in purego-pipewire (TestVideoSourceFrameAllocations); a mock in
287+// the closure would record every call and grow.
288+func TestCastProducerAllocations(t *testing.T) {
289+ if raceEnabled {
290+ t.Skip("the race detector makes the wake callback allocate")
291+ }
292+ p := newProducer(t)
293+ p.cmds = make(chan ports.CastCommand, 4)
294+ p.s.cmds = p.cmds
295+ st := p.st
296+ st.pwState, st.w, st.h, st.fw, st.fh, st.haveSize = pipewire.StreamStateStreaming, 64, 32, 64, 32, true
297+ cmds := p.cmds
298+ b := p.buf
299+ damage := ports.CastEvent{Kind: ports.CastDamage, ID: 1}
300+ done := ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: b.Index, Time: time.Unix(5, 0)}
301+ now := p.now
302+ cycle := func() {
303+ p.s.Push(damage)
304+ evs, _, _, _ := p.s.take()
305+ for i := range evs {
306+ p.s.onCastEvent(&evs[i])
307+ }
308+ now = now.Add(time.Second)
309+ if !p.s.frameDue(st, now) || !p.s.issueFrame(st, b, now) {
310+ t.Fatal("no frame")
311+ }
312+ <-cmds
313+ p.s.Push(done)
314+ evs, _, _, _ = p.s.take()
315+ if p.s.settleFrame(st, &evs[0]) != b {
316+ t.Fatal("frame not settled")
317+ }
318+ st.buf = nil
319+ }
320+ for range 8 {
321+ cycle()
322+ }
323+ if n := testing.AllocsPerRun(200, cycle); n != 0 {
324+ t.Fatalf("%v allocations per frame, want 0", n)
325+ }
326+}
327+
328+// The consumer leaves: the node goes to PAUSED and no frame is requested,
329+// whatever damage and time bring, while the session stays open. A frame
330+// already in flight is released, not queued. When the node streams again the
331+// first frame is forced.
332+func TestPausedNodeGetsNoFramesAndSessionStays(t *testing.T) {
333+ p := newProducer(t)
334+ p.streaming()
335+ p.src.EXPECT().Dequeue().Return(p.buf).Twice()
336+ p.s.pump(p.st, p.now)
337+ if c := p.cmd(); c.Kind != ports.CastFrame {
338+ t.Fatalf("%+v", c)
339+ }
340+ // Paused with the frame in flight: it is given back, not queued (no
341+ // Queue or Trigger expectation).
342+ p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "")
343+ p.src.EXPECT().Release(p.buf).Return().Once()
344+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3, Time: time.Unix(5, 0)})
345+ for range 5 {
346+ p.s.onCastEvent(&ports.CastEvent{Kind: ports.CastDamage, ID: 1})
347+ p.now = p.now.Add(time.Second)
348+ p.s.pumpAll(p.now) // Dequeue is not expected a second time while paused
349+ p.none()
350+ }
351+ if p.s.sessions[0].dying || p.st.closing {
352+ t.Fatal("the session ended with the consumer")
353+ }
354+ if got := p.s.timeout(); got != -1 {
355+ t.Fatalf("paused stream asks for a timeout of %d ms", got)
356+ }
357+ // A consumer is back: the picture goes out at once, damage or not.
358+ p.st.h2.StateChanged(pipewire.StreamStateStreaming, 42, "")
359+ p.s.pumpAll(p.now)
360+ if c := p.cmd(); c.Kind != ports.CastFrame {
361+ t.Fatalf("no frame after the node streams again: %+v", c)
362+ }
363+}
364+
365+// Shutdown pumps every stream: CastClose goes out, the node is destroyed and
366+// PipeWire closed, with no frame in flight.
367+func TestShutdownClosesStreams(t *testing.T) {
368+ p := newProducer(t)
369+ p.streaming()
370+ var order []string
371+ p.src.EXPECT().Destroy().Run(func() { order = append(order, "destroy") }).Once()
372+ p.core.EXPECT().Close().Run(func() { order = append(order, "close") }).Once()
373+ p.s.shutdown(context.Background())
374+ if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 {
375+ t.Fatalf("%+v", c)
376+ }
377+ if len(order) != 2 || order[0] != "destroy" || order[1] != "close" || p.s.pwc != nil {
378+ t.Fatalf("order %v", order)
379+ }
380+ if p.st.used || p.s.sessions[0].used {
381+ t.Fatal("slots not freed")
382+ }
383+}
384+
385+// A stream opened on the wayland side that has no node yet is closed too.
386+func TestShutdownClosesStreamWithoutSource(t *testing.T) {
387+ p := newProducer(t)
388+ p.s.pump(p.st, p.now)
389+ if c := p.cmd(); c.Kind != ports.CastOpen {
390+ t.Fatalf("%+v", c)
391+ }
392+ p.core.EXPECT().Close().Return().Once()
393+ p.s.shutdown(context.Background())
394+ if c := p.cmd(); c.Kind != ports.CastClose || c.ID != 1 {
395+ t.Fatalf("%+v", c)
396+ }
397+ if p.st.used {
398+ t.Fatal("stream not freed")
399+ }
400+}
401+
402+// A frame in flight at shutdown is waited for: nothing is released or
403+// destroyed before its CastFrameDone, then the buffer is released, the node
404+// destroyed and PipeWire closed, in that order. The wait has no deadline
405+// while the service runs on (drain is far shorter than the wait here).
406+func TestShutdownWaitsForFrameInFlight(t *testing.T) {
407+ p := newProducer(t)
408+ p.s.drain = time.Nanosecond
409+ p.streaming()
410+ p.src.EXPECT().Dequeue().Return(p.buf).Once()
411+ p.s.pump(p.st, p.now)
412+ p.cmd()
413+ var delivered atomic.Bool
414+ var order []string
415+ step := func(name string) {
416+ if !delivered.Load() {
417+ t.Errorf("%s before the CastFrameDone", name)
418+ }
419+ order = append(order, name)
420+ }
421+ p.src.EXPECT().Release(p.buf).Run(func(*pipewire.VideoBuffer) { step("release") }).Once()
422+ p.src.EXPECT().Destroy().Run(func() { step("destroy") }).Once()
423+ p.core.EXPECT().Close().Run(func() { step("close") }).Once()
424+ done := make(chan struct{})
425+ go func() { defer close(done); p.s.shutdown(context.Background()) }()
426+ select {
427+ case c := <-p.cmds:
428+ if c.Kind != ports.CastClose {
429+ t.Errorf("%+v", c)
430+ }
431+ case <-time.After(5 * time.Second):
432+ t.Fatal("no CastClose")
433+ }
434+ delivered.Store(true)
435+ p.s.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 3})
436+ select {
437+ case <-done:
438+ case <-time.After(5 * time.Second):
439+ t.Fatal("shutdown did not return")
440+ }
441+ if len(order) != 3 || order[0] != "release" || order[1] != "destroy" || order[2] != "close" {
442+ t.Fatalf("order %v", order)
443+ }
444+}
445+
446+// When the service itself is stopping and the frame never comes back, nothing is destroyed or closed (the
447+// source's handler and its buffer are still in use), the storage the
448+// handler points into is left untouched and abandoned, and the service has
449+// a fresh table: repeated reconnects do not exhaust it.
450+func TestShutdownTimeoutKeepsHandlerStateAndFreesTable(t *testing.T) {
451+ p := newProducer(t)
452+ p.s.drain = 20 * time.Millisecond
453+ p.streaming()
454+ p.src.EXPECT().Dequeue().Return(p.buf).Once()
455+ p.s.pump(p.st, p.now)
456+ p.cmd()
457+ old := p.s.sessions
458+ // The service itself is stopping: the wait is bounded.
459+ stopped, stop := context.WithCancel(context.Background())
460+ stop()
461+ p.s.shutdown(stopped) // no Destroy, Release or Close is expected
462+ if c := p.cmd(); c.Kind != ports.CastClose {
463+ t.Fatalf("%+v", c)
464+ }
465+ if p.s.sessions == old {
466+ t.Fatal("the table the leaked handler points into is still in use")
467+ }
468+ if !p.st.used || p.st.src == nil || !p.st.inflight || p.st.h2.st != p.st {
469+ t.Fatal("the leaked stream's state was reset")
470+ }
471+ if p.s.streamsLive() || p.s.pwc != nil {
472+ t.Fatal("new table not empty or PipeWire kept")
473+ }
474+ // The handler of the leaked source still works on its own storage.
475+ p.st.h2.StateChanged(pipewire.StreamStatePaused, 42, "")
476+ if p.st.pwState != pipewire.StreamStatePaused {
477+ t.Fatal("handler lost its stream")
478+ }
479+ // A new attempt starts clean and can fill its table again.
480+ p.s.reset(p.s.ctx, p.bus)
481+ for i := range maxSessions {
482+ p.s.sessions[i] = session{used: true, id: uint32(i + 1)}
483+ }
484+}
A internal/adapters/screencast/race_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build race
2+
3+package screencast
4+
5+const raceEnabled = true
A internal/adapters/screencast/runner_mock_test.go +104 -0
@@ -0,0 +1,104 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockrunner creates a new instance of mockrunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockrunner(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockrunner {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockrunner{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockrunner is an autogenerated mock type for the runner type
37+type mockrunner struct {
38+ mock.Mock
39+}
40+
41+type mockrunner_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockrunner) EXPECT() *mockrunner_Expecter {
46+ return &mockrunner_Expecter{mock: &_m.Mock}
47+}
48+
49+// Run provides a mock function for the type mockrunner
50+func (_mock *mockrunner) Run(ctx context.Context, args []string) error {
51+ ret := _mock.Called(ctx, args)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Run")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, []string) error); ok {
59+ r0 = returnFunc(ctx, args)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockrunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
67+type mockrunner_Run_Call struct {
68+ *mock.Call
69+}
70+
71+// Run is a helper method to define mock.On call
72+// - ctx context.Context
73+// - args []string
74+func (_e *mockrunner_Expecter) Run(ctx any, args any) *mockrunner_Run_Call {
75+ return &mockrunner_Run_Call{Call: _e.mock.On("Run", ctx, args)}
76+}
77+
78+func (_c *mockrunner_Run_Call) Run(run func(ctx context.Context, args []string)) *mockrunner_Run_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ var arg1 []string
85+ if args[1] != nil {
86+ arg1 = args[1].([]string)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *mockrunner_Run_Call) Return(err error) *mockrunner_Run_Call {
97+ _c.Call.Return(err)
98+ return _c
99+}
100+
101+func (_c *mockrunner_Run_Call) RunAndReturn(run func(ctx context.Context, args []string) error) *mockrunner_Run_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
A internal/adapters/screencast/screencast.go +410 -0
@@ -0,0 +1,410 @@
1+// Package screencast serves the GNOME screen cast and screenshot D-Bus
2+// interfaces (org.gnome.Mutter.ScreenCast, org.gnome.Mutter.DisplayConfig,
3+// org.gnome.Shell.Introspect, org.gnome.Shell.Screenshot) so that
4+// xdg-desktop-portal-gnome can offer monitor and window sharing and
5+// screenshots. A cast is a PipeWire Video/Source node whose frames the
6+// wayland server renders into the node's buffers (ports.CastCommand), and a
7+// screenshot is taken by running nefercap.
8+//
9+// One owner goroutine, locked to its OS thread, owns all state: the D-Bus
10+// connection, the PipeWire loop, the sessions and the streams. It waits for
11+// the bus, the PipeWire loop and wake-ups from other goroutines (the wayland
12+// sink and screenshot jobs) in a single poll. Callers are checked against
13+// the capture allowlist through a pidfd before anything is dispatched.
14+package screencast
15+
16+import (
17+ "context"
18+ "errors"
19+ "runtime"
20+ "sync"
21+ "time"
22+
23+ "github.com/bnema/neferwl/internal/adapters/busretry"
24+ "github.com/bnema/neferwl/internal/adapters/captureallow"
25+ "github.com/bnema/neferwl/internal/adapters/clock"
26+ "github.com/bnema/neferwl/internal/adapters/fdwait"
27+ "github.com/bnema/neferwl/internal/ports"
28+ "github.com/bnema/zerobus"
29+ "github.com/bnema/zerowrap"
30+ "golang.org/x/sys/unix"
31+)
32+
33+// The bus names served, and the bounds of the fixed tables.
34+const (
35+ nameScreenCast = "org.gnome.Mutter.ScreenCast"
36+ nameDisplayConfig = "org.gnome.Mutter.DisplayConfig"
37+ nameIntrospect = "org.gnome.Shell.Introspect"
38+ nameScreenshot = "org.gnome.Shell.Screenshot"
39+ busDaemon = "org.freedesktop.DBus"
40+ busDaemonPath = "/org/freedesktop/DBus"
41+
42+ maxSessions = 8 // live sessions
43+ maxStreams = 4 // streams per session
44+ maxPending = 16 // requests waiting for a caller check
45+ maxCallers = 32 // checked D-Bus peers
46+ maxArg = 128
47+
48+ // sinkCap bounds the wayland events waiting for the owner. Damage past
49+ // it is merged into a flag; nothing else is dropped.
50+ sinkCap = 256
51+
52+ // shotTimeout bounds a nefercap run.
53+ shotTimeout = 60 * time.Second
54+
55+ // shutdownWait bounds the wait for frames still in flight at shutdown,
56+ // once the service itself is stopping.
57+ shutdownWait = 2 * time.Second
58+
59+ // shutdownPoll is how often shutdown looks again for the events that
60+ // settle a frame in flight.
61+ shutdownPoll = 5 * time.Millisecond
62+)
63+
64+var _ ports.CastSink = (*Service)(nil)
65+
66+// Service is the screencast adapter. Create it with New and run it once
67+// with Run; Push and Inventory are the only methods other goroutines call.
68+type Service struct {
69+ policy func() *captureallow.Policy
70+ cmds chan<- ports.CastCommand
71+ log zerowrap.Logger
72+
73+ poller *fdwait.Poller
74+ initErr error
75+
76+ // Seams; tests replace them before Run.
77+ clock ports.Clock
78+ dial dialer
79+ pw pipewireOpener
80+ peers peerResolver
81+ runner runner
82+ address string // session bus when empty
83+ env []string
84+
85+ // Transport: filled by other goroutines, drained by the owner.
86+ mu sync.Mutex
87+ queue []ports.CastEvent
88+ spare []ports.CastEvent
89+ lost bool // damage was merged away
90+ woken bool
91+ invNew *ports.CastInventory
92+ shotRes shotResult
93+
94+ // Owner state.
95+ ctx context.Context // the attempt's: it ends with the attempt
96+ conn bus
97+ pwc videoCore
98+ owns [4]bool // nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot
99+ inv ports.CastInventory
100+ haveInv bool
101+ callers [maxCallers]caller
102+ pending [maxPending]pending
103+ // sessions is a pointer so that a shutdown at process exit that had to
104+ // leave a PipeWire source behind can abandon the table: the source's
105+ // handler still points into it, so it is never reset or reused.
106+ sessions *[maxSessions]session
107+ nextSess uint32
108+ nextStrm uint32
109+ nextCast uint64
110+ waitFor time.Duration // time to the next paced frame, 0 when none
111+ shot shotState
112+ shotSeq uint64 // ID of the last screenshot job; never reset
113+ fatal error
114+ jobs sync.WaitGroup
115+ drain time.Duration // how long shutdown waits for frames once the service stops
116+}
117+
118+// New creates the service. policy returns the capture allowlist in force;
119+// cmds carries the commands for the wayland server.
120+func New(policy func() *captureallow.Policy, cmds chan<- ports.CastCommand, log zerowrap.Logger) *Service {
121+ s := &Service{
122+ policy: policy,
123+ cmds: cmds,
124+ log: log,
125+ clock: clock.System{},
126+ dial: zerobusDialer{},
127+ pw: realPipeWire{},
128+ peers: pidfdPeers{},
129+ queue: make([]ports.CastEvent, 0, sinkCap),
130+ spare: make([]ports.CastEvent, 0, sinkCap),
131+
132+ sessions: new([maxSessions]session),
133+ drain: shutdownWait,
134+ }
135+ s.poller, s.initErr = fdwait.New()
136+ return s
137+}
138+
139+// SetEnv sets the environment nefercap runs in (WAYLAND_DISPLAY names this
140+// compositor). Call it before Run.
141+func (s *Service) SetEnv(env []string) { s.env = env }
142+
143+// Push queues an event of the wayland server for the owner. It never blocks.
144+func (s *Service) Push(ev ports.CastEvent) {
145+ s.mu.Lock()
146+ if ev.Kind == ports.CastDamage && len(s.queue) >= sinkCap {
147+ s.lost = true
148+ } else {
149+ s.queue = append(s.queue, ev)
150+ }
151+ wake := !s.woken
152+ s.woken = true
153+ s.mu.Unlock()
154+ if wake {
155+ s.wake()
156+ }
157+}
158+
159+// Inventory takes the new outputs and windows; the owner publishes them.
160+func (s *Service) Inventory(inv ports.CastInventory) {
161+ s.mu.Lock()
162+ s.invNew = &inv
163+ wake := !s.woken
164+ s.woken = true
165+ s.mu.Unlock()
166+ if wake {
167+ s.wake()
168+ }
169+}
170+
171+func (s *Service) wake() {
172+ if s.poller != nil {
173+ s.poller.Wake()
174+ }
175+}
176+
177+// take swaps out what other goroutines queued. The events stay valid until
178+// the next take.
179+func (s *Service) take() (evs []ports.CastEvent, lost bool, inv *ports.CastInventory, shot *shotResult) {
180+ s.mu.Lock()
181+ s.spare, s.queue = s.queue, s.spare[:0]
182+ evs, lost, inv = s.spare, s.lost, s.invNew
183+ s.lost, s.invNew, s.woken = false, nil, false
184+ if s.shotRes.ready {
185+ r := s.shotRes
186+ s.shotRes = shotResult{}
187+ shot = &r
188+ }
189+ s.mu.Unlock()
190+ return evs, lost, inv, shot
191+}
192+
193+// Run serves until ctx ends. A lost bus or a name that others own is tried
194+// again after retry, growing to a minute. It returns once every job and
195+// stream is released.
196+func (s *Service) Run(ctx context.Context, retry time.Duration) {
197+ if s.initErr != nil {
198+ s.log.Warn().Err(s.initErr).Msg("screencast disabled")
199+ return
200+ }
201+ defer s.poller.Close()
202+ s.poller.StopOn(ctx)
203+ if s.runner == nil {
204+ s.runner = execRunner{env: s.env}
205+ }
206+ busretry.Run(ctx, retry, "D-Bus screencast", s.log, s.attempt)
207+ s.jobs.Wait()
208+}
209+
210+// attempt is one connection of the service to the buses.
211+func (s *Service) attempt(parent context.Context) (err error) {
212+ // The PipeWire loop belongs to this OS thread for its whole life.
213+ runtime.LockOSThread()
214+ defer runtime.UnlockOSThread()
215+ conn, err := s.dial.Dial(s.address)
216+ if err != nil {
217+ return err
218+ }
219+ defer func() { _ = conn.Close() }()
220+ // The screenshot job of this attempt ends with it.
221+ ctx, cancel := context.WithCancel(parent)
222+ defer cancel()
223+ s.reset(ctx, conn)
224+ if s.pwc, err = s.pw.Open(); err != nil {
225+ s.pwc = nil
226+ s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered")
227+ }
228+ defer s.shutdown(parent)
229+ if err = s.acquire(); err != nil {
230+ return err
231+ }
232+ return s.loop(ctx)
233+}
234+
235+// reset clears the owner state of a previous attempt.
236+func (s *Service) reset(ctx context.Context, conn bus) {
237+ s.ctx, s.conn, s.fatal = ctx, conn, nil
238+ s.owns = [4]bool{}
239+ s.haveInv = false
240+ s.callers = [maxCallers]caller{}
241+ s.pending = [maxPending]pending{}
242+ *s.sessions = [maxSessions]session{}
243+ s.shot = shotState{}
244+}
245+
246+// acquire watches departures and takes the names.
247+func (s *Service) acquire() error {
248+ rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''"
249+ if err := s.conn.AddMatch(rule); err != nil {
250+ return err
251+ }
252+ names := [4]string{nameScreenCast, nameDisplayConfig, nameIntrospect, nameScreenshot}
253+ got := 0
254+ for i, name := range names {
255+ if i == 0 && s.pwc == nil {
256+ continue
257+ }
258+ code, err := s.conn.RequestName(name, zerobus.NameFlagDoNotQueue)
259+ if err != nil {
260+ return err
261+ }
262+ if code != zerobus.NamePrimaryOwner && code != zerobus.NameAlreadyOwner {
263+ s.log.Warn().Str("name", name).Msg("name owned by another program; not served")
264+ continue
265+ }
266+ s.owns[i] = true
267+ got++
268+ s.log.Info().Str("name", name).Msg("serving")
269+ }
270+ if got == 0 {
271+ return errors.New("no D-Bus name acquired")
272+ }
273+ return nil
274+}
275+
276+// loop waits for the bus, PipeWire and other goroutines and serves them.
277+func (s *Service) loop(ctx context.Context) error {
278+ rc, err := s.conn.SyscallConn()
279+ if err != nil {
280+ return err
281+ }
282+ busFD := int32(-1)
283+ if err := rc.Control(func(fd uintptr) { busFD = int32(fd) }); err != nil {
284+ return err
285+ }
286+ var fds [2]unix.PollFd
287+ for ctx.Err() == nil {
288+ for s.fatal == nil && s.conn.Buffered() {
289+ s.readOne()
290+ }
291+ if s.pwc != nil {
292+ s.pwc.Iterate()
293+ }
294+ s.service()
295+ if s.fatal != nil {
296+ return s.fatal
297+ }
298+ fds[0] = unix.PollFd{Fd: busFD, Events: unix.POLLIN}
299+ n := 1
300+ if s.pwc != nil {
301+ fds[1] = unix.PollFd{Fd: int32(s.pwc.Fd()), Events: unix.POLLIN}
302+ n = 2
303+ }
304+ if _, err := s.poller.Poll(fds[:n], s.timeout()); err != nil {
305+ return err
306+ }
307+ if fds[0].Revents != 0 {
308+ s.readOne()
309+ }
310+ }
311+ return nil
312+}
313+
314+// readOne reads and handles one message; the read may wait for the rest of
315+// a message that arrived in part.
316+func (s *Service) readOne() {
317+ m, err := s.conn.ReadMessage()
318+ if err != nil {
319+ s.fatal = err
320+ return
321+ }
322+ s.onMessage(m)
323+}
324+
325+// service applies everything the other goroutines queued, then produces the
326+// frames that are due.
327+func (s *Service) service() {
328+ evs, lost, inv, shot := s.take()
329+ if inv != nil {
330+ s.applyInventory(*inv)
331+ }
332+ if lost {
333+ s.forEachStream(func(st *stream) { st.dirty = true })
334+ }
335+ for i := range evs {
336+ s.onCastEvent(&evs[i])
337+ }
338+ if shot != nil {
339+ s.onShotDone(shot)
340+ }
341+ s.pumpAll(s.clock.Now())
342+ s.reap()
343+}
344+
345+// shutdown ends the attempt: the screenshot job is stopped and joined, every
346+// session is closed, and every stream is pumped until it released its
347+// resources: CastClose goes to the wayland server, a frame still in flight
348+// is waited for (its buffer is lent to the wayland server), then the source
349+// is destroyed and its buffers released. Only then is PipeWire closed.
350+// The pidfds of the cached callers are closed first. parent is the context
351+// the service runs under; the attempt's own context
352+// may have ended for another reason.
353+//
354+// While parent is alive the attempt ended by itself (the bus was lost) and a
355+// new one follows. The wayland server is alive and answers every CastFrame
356+// with a CastFrameDone, so the wait for the frames in flight has no
357+// deadline: a source left behind would keep its slot in the process-wide
358+// table of PipeWire video sources for good, and repeated reconnects would
359+// exhaust it. If parent ends meanwhile, the wait is bounded as below.
360+//
361+// Once parent has ended, the process is going away and the wait for the
362+// frames in flight is bounded by s.drain. When a frame does not come back in
363+// time, the source cannot be destroyed safely. It is left as it is, with its
364+// loop, to the process exit. The sessions table that its handler points into
365+// is abandoned (never reset or reused) and a new one takes its place.
366+func (s *Service) shutdown(parent context.Context) {
367+ s.stopShot()
368+ // The cached pidfds belong to this attempt's bus connection.
369+ for i := range s.callers {
370+ s.callers[i].release()
371+ }
372+ s.forEachSession(func(sess *session) { s.closeSession(sess, true) })
373+ var deadline time.Time // set once parent ended
374+ for {
375+ evs, _, _, _ := s.take()
376+ for i := range evs {
377+ s.onCastEvent(&evs[i])
378+ }
379+ s.pumpAll(s.clock.Now())
380+ s.reap()
381+ if !s.streamsLive() {
382+ break
383+ }
384+ if deadline.IsZero() && parent.Err() != nil {
385+ deadline = time.Now().Add(s.drain)
386+ }
387+ if !deadline.IsZero() && !time.Now().Before(deadline) {
388+ s.log.Warn().Int("streams", s.streamCount()).Msg("frames still in flight at shutdown; PipeWire resources are left to the process exit")
389+ s.sessions = new([maxSessions]session)
390+ s.pwc = nil
391+ return
392+ }
393+ time.Sleep(shutdownPoll)
394+ }
395+ if s.pwc != nil {
396+ s.pwc.Close()
397+ s.pwc = nil
398+ }
399+}
400+
401+// timeout is the poll timeout in milliseconds: the time to the next frame
402+// that only waits for its interval, or -1.
403+func (s *Service) timeout() int {
404+ wait := s.waitFor
405+ s.waitFor = 0
406+ if wait <= 0 {
407+ return -1
408+ }
409+ return int((wait + time.Millisecond - 1) / time.Millisecond)
410+}
A internal/adapters/screencast/screenshot.go +204 -0
@@ -0,0 +1,204 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "time"
9+)
10+
11+// Screenshots are taken by nefercap, run as a child process:
12+//
13+// Screenshot nefercap screenshot -output FOCUSED -file F
14+// ScreenshotArea nefercap screenshot -output O -region X,Y,WxH -file F
15+// InteractiveScreenshot nefercap shot -file F
16+//
17+// F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most
18+// is in flight. The run is a goroutine; its result, tagged with the ID of
19+// the job, goes back through the service's transport and the owner sends
20+// the reply, so the bus is still used by the owner only. A result whose ID
21+// is not the job in flight is dropped. A job belongs to one bus attempt:
22+// when the attempt ends, the job is cancelled and joined before the state is
23+// reset (stopShot). A run that exits cleanly is a success only if it left a
24+// non-empty regular file at F; otherwise the reply is (false, ""), which is
25+// also how a cancelled interactive screenshot is answered. SelectArea and ScreenshotWindow (the "Select
26+// area" and "Window" choices of the GNOME dialog) are not supported;
27+// include_cursor and flash are ignored.
28+
29+// shotState is the screenshot in flight; one at most.
30+type shotState struct {
31+ busy bool
32+ id uint64 // names the job; results of other jobs are stale
33+ req request
34+ uri bool // reply with a file:// URI
35+ path string
36+ cancel context.CancelFunc // stops the run
37+ done chan struct{} // closed when the job goroutine is past its result
38+}
39+
40+// shotResult is what a finished nefercap run leaves for the owner.
41+type shotResult struct {
42+ ready bool
43+ id uint64 // the job it belongs to
44+ err error // nefercap failed
45+ made bool // the output file exists: a regular, non-empty file
46+}
47+
48+// screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot.
49+func (s *Service) screenshot(req *request) {
50+ if s.shot.busy {
51+ s.replyErr(req, errLimits, "a screenshot is already being taken")
52+ return
53+ }
54+ var args []string
55+ dir := shotDir()
56+ if dir == "" {
57+ s.replyErr(req, errFailed, "no runtime directory")
58+ return
59+ }
60+ path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png")
61+ switch req.method {
62+ case mShot:
63+ out := s.inv.Focused
64+ if out == "" && len(s.inv.Outputs) > 0 {
65+ out = s.inv.Outputs[0].Info.Name
66+ }
67+ if out == "" {
68+ s.replyErr(req, errFailed, "no output")
69+ return
70+ }
71+ args = []string{"screenshot", "-output", out, "-file", path}
72+ case mShotArea:
73+ out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h)
74+ if !ok {
75+ s.replyErr(req, errInvalidArgs, "area outside every output")
76+ return
77+ }
78+ args = []string{"screenshot", "-output", out, "-region", region, "-file", path}
79+ default:
80+ args = []string{"shot", "-file", path}
81+ }
82+ if err := os.MkdirAll(dir, 0o700); err != nil {
83+ s.log.Warn().Err(err).Msg("screenshot directory not created")
84+ s.replyErr(req, errFailed, "screenshot directory not created")
85+ return
86+ }
87+ s.shotSeq++
88+ s.shot = shotState{busy: true, id: s.shotSeq, req: *req, uri: req.method == mShotInteractive, path: path}
89+ s.runShot(args)
90+}
91+
92+// shotDir is $XDG_RUNTIME_DIR/neferwl.
93+func shotDir() string {
94+ rt := os.Getenv("XDG_RUNTIME_DIR")
95+ if rt == "" {
96+ return ""
97+ }
98+ return filepath.Join(rt, "neferwl")
99+}
100+
101+// areaTarget finds the output that contains the corner (x, y) of an area in
102+// the logical space and the output-local region, clipped to the output.
103+func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) {
104+ if w <= 0 || h <= 0 {
105+ return "", "", false
106+ }
107+ for i := range s.inv.Outputs {
108+ o := &s.inv.Outputs[i]
109+ lx, ly := int(x)-o.X, int(y)-o.Y
110+ if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height {
111+ continue
112+ }
113+ cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly)
114+ return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true
115+ }
116+ return "", "", false
117+}
118+
119+// runShot runs nefercap in a goroutine for the job in s.shot and leaves the
120+// tagged result for the owner. The run ends with the attempt's context.
121+func (s *Service) runShot(args []string) {
122+ ctx, cancel := context.WithTimeout(s.ctx, shotTimeout)
123+ done := make(chan struct{})
124+ s.shot.cancel, s.shot.done = cancel, done
125+ id, path := s.shot.id, s.shot.path
126+ run := s.runner
127+ s.jobs.Add(1)
128+ go func() {
129+ defer s.jobs.Done()
130+ defer close(done)
131+ defer cancel()
132+ err := run.Run(ctx, args)
133+ res := shotResult{ready: true, id: id, err: err}
134+ if err == nil {
135+ res.made = isOutputFile(path)
136+ }
137+ s.mu.Lock()
138+ s.shotRes = res
139+ s.woken = true
140+ s.mu.Unlock()
141+ s.wake()
142+ }()
143+}
144+
145+// isOutputFile reports whether path is a regular, non-empty file. A symlink
146+// is not followed: it is not a regular file.
147+func isOutputFile(path string) bool {
148+ fi, err := os.Lstat(path)
149+ return err == nil && fi.Mode().IsRegular() && fi.Size() > 0
150+}
151+
152+// stopShot cancels the screenshot in flight, waits for its goroutine and
153+// forgets its result, so no job outlives the attempt that started it. The
154+// requester is told it failed when the bus still carries the answer.
155+func (s *Service) stopShot() {
156+ st := s.shot
157+ if !st.busy {
158+ return
159+ }
160+ st.cancel()
161+ <-st.done
162+ s.mu.Lock()
163+ s.shotRes = shotResult{}
164+ s.mu.Unlock()
165+ s.shot = shotState{}
166+ _ = os.Remove(st.path)
167+ s.replyErr(&st.req, errFailed, "screenshot cancelled")
168+}
169+
170+// onShotDone sends the reply of the screenshot that finished. A result of
171+// another job than the one in flight is stale and completes nothing.
172+func (s *Service) onShotDone(r *shotResult) {
173+ st := s.shot
174+ if !st.busy || r.id != st.id {
175+ return
176+ }
177+ s.shot = shotState{}
178+ switch {
179+ case r.err != nil:
180+ s.log.Warn().Err(r.err).Msg("screenshot failed")
181+ _ = os.Remove(st.path)
182+ s.replyErr(&st.req, errFailed, "screenshot failed")
183+ case !r.made:
184+ // nefercap exited cleanly and left nothing usable (an interactive
185+ // screenshot that was cancelled, for one).
186+ s.log.Info().Msg("screenshot produced no file")
187+ _ = os.Remove(st.path)
188+ if e := s.replyTo(&st.req, "bs"); e != nil {
189+ e.Bool(false)
190+ e.Str("")
191+ s.send()
192+ }
193+ default:
194+ out := st.path
195+ if st.uri {
196+ out = "file://" + out
197+ }
198+ if e := s.replyTo(&st.req, "bs"); e != nil {
199+ e.Bool(true)
200+ e.Str(out)
201+ s.send()
202+ }
203+ }
204+}
A internal/adapters/screencast/screenshot_test.go +259 -0
@@ -0,0 +1,259 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+// writeShot is what nefercap does: it writes the PNG named by -file.
16+func writeShot(t *testing.T, args []string) {
17+ t.Helper()
18+ i := slices.Index(args, "-file")
19+ if i < 0 || i+1 >= len(args) {
20+ t.Errorf("no -file in %v", args)
21+ return
22+ }
23+ if err := os.WriteFile(args[i+1], []byte("\x89PNG"), 0o600); err != nil {
24+ t.Error(err)
25+ }
26+}
27+
28+// Screenshot: nefercap on the focused output; the reply carries the file.
29+func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) {
30+ rt := t.TempDir()
31+ t.Setenv("XDG_RUNTIME_DIR", rt)
32+ h, c := ready(t, harnessOpts{})
33+ got := make(chan []string, 1)
34+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
35+ got <- slices.Clone(args)
36+ writeShot(t, args)
37+ return nil
38+ }).Once()
39+ name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) {
40+ e.Bool(true)
41+ e.Bool(true)
42+ e.Str("ignored")
43+ })
44+ if name != "" {
45+ t.Fatal(name)
46+ }
47+ r := m.Body()
48+ ok, file := r.Bool(), strings.Clone(r.Str())
49+ args := <-got
50+ if !ok || !strings.HasPrefix(file, filepath.Join(rt, "neferwl", "screenshot-")) || !strings.HasSuffix(file, ".png") {
51+ t.Fatalf("reply %v %q", ok, file)
52+ }
53+ want := []string{"screenshot", "-output", "HEADLESS-1", "-file", file}
54+ if !slices.Equal(args, want) {
55+ t.Fatalf("args %v, want %v", args, want)
56+ }
57+ if fi, err := os.Stat(filepath.Join(rt, "neferwl")); err != nil || fi.Mode().Perm() != 0o700 {
58+ t.Fatalf("directory %v %v", fi, err)
59+ }
60+}
61+
62+// ScreenshotArea: the output containing the corner, with a clipped,
63+// output-local region; outside every output is InvalidArgs.
64+func TestScreenshotAreaMapsToRegion(t *testing.T) {
65+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
66+ h, c := ready(t, harnessOpts{})
67+ got := make(chan []string, 1)
68+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
69+ got <- slices.Clone(args)
70+ writeShot(t, args)
71+ return nil
72+ }).Once()
73+ area := func(x, y, w, hh int32) func(*zerobus.Encoder) {
74+ return func(e *zerobus.Encoder) {
75+ e.Int32(x)
76+ e.Int32(y)
77+ e.Int32(w)
78+ e.Int32(hh)
79+ e.Bool(false)
80+ e.Str("f")
81+ }
82+ }
83+ // HEADLESS-2 sits at x=64, 32x32: the area starts at (70,4), wider than the rest of the output.
84+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(70, 4, 100, 10)); name != "" {
85+ t.Fatal(name)
86+ }
87+ args := <-got
88+ if args[1] != "-output" || args[2] != "HEADLESS-2" || args[3] != "-region" || args[4] != "6,4,26x10" {
89+ t.Fatalf("args %v", args)
90+ }
91+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(500, 4, 10, 10)); name != errInvalidArgs {
92+ t.Fatalf("outside: %q", name)
93+ }
94+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(0, 0, 0, 10)); name != errInvalidArgs {
95+ t.Fatalf("empty: %q", name)
96+ }
97+}
98+
99+// InteractiveScreenshot: nefercap shot, answered with a file:// uri. A second
100+// shot while one runs is LimitsExceeded; a failure is Failed and the busy
101+// flag clears.
102+func TestInteractiveScreenshotBusyAndFailure(t *testing.T) {
103+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
104+ h, c := ready(t, harnessOpts{})
105+ release := make(chan error)
106+ got := make(chan []string, 2)
107+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
108+ got <- slices.Clone(args)
109+ err := <-release
110+ if err == nil {
111+ writeShot(t, args)
112+ }
113+ return err
114+ }).Twice()
115+
116+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
117+ first, err := c.Send()
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ args := <-got
122+ if len(args) != 3 || args[0] != "shot" || args[1] != "-file" {
123+ t.Fatalf("args %v", args)
124+ }
125+ other := h.client()
126+ if name, _ := callErr(other, nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil); name != errLimits {
127+ t.Fatalf("second shot: %q", name)
128+ }
129+ release <- nil
130+ m, err := c.ReadMessage()
131+ if err != nil || m.ReplySerial != first {
132+ t.Fatalf("reply %v %v", m, err)
133+ }
134+ r := m.Body()
135+ if ok, uri := r.Bool(), r.Str(); !ok || uri != "file://"+args[2] {
136+ t.Fatalf("reply %v %q", ok, uri)
137+ }
138+
139+ // nefercap fails (or is missing): Failed, and the next shot is allowed.
140+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
141+ if _, err := c.Send(); err != nil {
142+ t.Fatal(err)
143+ }
144+ <-got
145+ release <- os.ErrNotExist
146+ m, err = c.ReadMessage()
147+ if err != nil || m.ErrorName != errFailed {
148+ t.Fatalf("failure reply %+v %v", m, err)
149+ }
150+}
151+
152+// What the dialog offers beyond the supported calls is NotSupported.
153+func TestScreenshotUnsupportedMethods(t *testing.T) {
154+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
155+ _, c := ready(t, harnessOpts{})
156+ for _, member := range []string{"SelectArea", "ScreenshotWindow", "PickColor", "FlashArea"} {
157+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, "", nil); name != errNotSupported && name != errInvalidArgs {
158+ t.Errorf("%s: %q", member, name)
159+ }
160+ }
161+}
162+
163+// nefercap that exits cleanly without leaving a regular, non-empty file is
164+// not a success: the file is missing, empty, or a symlink.
165+func TestScreenshotWithoutUsableFileIsNotSuccess(t *testing.T) {
166+ rt := t.TempDir()
167+ t.Setenv("XDG_RUNTIME_DIR", rt)
168+ target := filepath.Join(rt, "real.png")
169+ if err := os.WriteFile(target, []byte("png"), 0o600); err != nil {
170+ t.Fatal(err)
171+ }
172+ for name, mk := range map[string]func(path string) error{
173+ "missing": func(string) error { return nil },
174+ "empty": func(path string) error { return os.WriteFile(path, nil, 0o600) },
175+ "symlink": func(path string) error { return os.Symlink(target, path) },
176+ "dir": func(path string) error { return os.Mkdir(path, 0o700) },
177+ } {
178+ t.Run(name, func(t *testing.T) {
179+ h, c := ready(t, harnessOpts{})
180+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
181+ if err := mk(args[slices.Index(args, "-file")+1]); err != nil {
182+ t.Error(err)
183+ }
184+ return nil
185+ }).Once()
186+ for _, member := range []string{"Screenshot", "InteractiveScreenshot"} {
187+ if member == "InteractiveScreenshot" {
188+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
189+ if err := mk(args[slices.Index(args, "-file")+1]); err != nil {
190+ t.Error(err)
191+ }
192+ return nil
193+ }).Once()
194+ }
195+ sig := ""
196+ body := func(*zerobus.Encoder) {}
197+ if member == "Screenshot" {
198+ sig = "bbs"
199+ body = func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }
200+ }
201+ errName, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, sig, body)
202+ if errName != "" {
203+ t.Fatalf("%s: %s", member, errName)
204+ }
205+ r := m.Body()
206+ if ok, file := r.Bool(), r.Str(); ok || file != "" {
207+ t.Fatalf("%s: success %v %q without a file", member, ok, file)
208+ }
209+ }
210+ })
211+ }
212+}
213+
214+// A result that belongs to another job completes nothing, and a job that
215+// the attempt's end cancels is joined before its state is reset.
216+func TestStaleScreenshotResultIsDropped(t *testing.T) {
217+ p := newProducer(t)
218+ run := newMockrunner(t)
219+ p.s.runner = run
220+ ctx, cancel := context.WithCancel(context.Background())
221+ defer cancel()
222+ p.s.ctx = ctx
223+ started := make(chan struct{})
224+ var seen error
225+ run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(c context.Context, _ []string) error {
226+ close(started)
227+ <-c.Done()
228+ seen = c.Err()
229+ return c.Err()
230+ }).Once()
231+ p.bus.EXPECT().NewError(mock.Anything, errFailed, "s").RunAndReturn(
232+ func(*zerobus.Message, string, string) *zerobus.Encoder { return &zerobus.Encoder{} }).Once()
233+
234+ p.s.shotSeq++
235+ p.s.shot = shotState{busy: true, id: p.s.shotSeq, path: filepath.Join(t.TempDir(), "x.png")}
236+ p.s.runShot([]string{"shot"})
237+ <-started
238+ // A result of some earlier job is in the transport: it is not this job's.
239+ p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true})
240+ if !p.s.shot.busy {
241+ t.Fatal("a stale result completed the job in flight")
242+ }
243+ // The attempt ends: the job is cancelled and joined before the reset.
244+ p.s.stopShot()
245+ if seen == nil {
246+ t.Fatal("stopShot returned before the job saw its cancellation")
247+ }
248+ if p.s.shot.busy || p.s.shotRes.ready {
249+ t.Fatal("job state survives the attempt")
250+ }
251+ // Its own late result (not possible after the join, but harmless) and a
252+ // new job with a new ID: the first job's ID completes nothing.
253+ p.s.shotSeq++
254+ p.s.shot = shotState{busy: true, id: p.s.shotSeq}
255+ p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true})
256+ if !p.s.shot.busy {
257+ t.Fatal("a result of the cancelled job completed the next request")
258+ }
259+}
A internal/adapters/screencast/seams.go +192 -0
@@ -0,0 +1,192 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "runtime"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/neferwl/internal/adapters/peerexe"
14+ "github.com/bnema/purego-pipewire/pipewire"
15+ "github.com/bnema/zerobus"
16+)
17+
18+// bus is the part of a zerobus connection the service uses. Only Send and
19+// ReadMessage are used after startup: a connection has one encoder and one
20+// receive buffer, so Call and Handle are off limits.
21+type bus interface {
22+ NewReply(m *zerobus.Message, sig string) *zerobus.Encoder
23+ NewError(m *zerobus.Message, name, sig string) *zerobus.Encoder
24+ NewSignal(path, iface, member, sig string) *zerobus.Encoder
25+ NewSignalTo(dest, path, iface, member, sig string) *zerobus.Encoder
26+ NewMethodCall(dest, path, iface, member, sig string) (*zerobus.Encoder, uint32)
27+ Send() (uint32, error)
28+ ReadMessage() (*zerobus.Message, error)
29+ Buffered() bool
30+ SyscallConn() (syscall.RawConn, error)
31+ AddMatch(rule string) error
32+ RequestName(name string, flags uint32) (uint32, error)
33+ Close() error
34+}
35+
36+// videoSource is the part of a pipewire.VideoSource the producer uses. All
37+// calls run on the loop thread.
38+type videoSource interface {
39+ NodeID() uint32
40+ State() int32
41+ Dequeue() *pipewire.VideoBuffer
42+ Queue(b *pipewire.VideoBuffer, seq uint64, pts time.Duration, corrupted bool)
43+ Release(b *pipewire.VideoBuffer)
44+ Trigger()
45+ Resize(w, h int) error
46+ Destroy()
47+}
48+
49+// videoCore is a PipeWire connection driven by the service's own poll loop.
50+type videoCore interface {
51+ // Fd is the pollable descriptor of the loop.
52+ Fd() int
53+ // Iterate dispatches pending events; handler callbacks run inside it.
54+ Iterate()
55+ NewVideoSource(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error)
56+ Close()
57+}
58+
59+// peerResolver proves which executable a D-Bus peer runs: Open pins the
60+// process behind a pid, Exe reads the executable through that pin.
61+type peerResolver interface {
62+ Open(pid int) (*os.File, error)
63+ Exe(pidfd *os.File) (exe string, pid int, err error)
64+}
65+
66+// runner runs nefercap with args.
67+type runner interface {
68+ Run(ctx context.Context, args []string) error
69+}
70+
71+// dialer connects the session bus (address empty) or the bus at address.
72+type dialer interface {
73+ Dial(address string) (bus, error)
74+}
75+
76+type zerobusDialer struct{}
77+
78+func (zerobusDialer) Dial(address string) (bus, error) {
79+ if address == "" {
80+ return zerobus.SessionBus()
81+ }
82+ return zerobus.Dial(address)
83+}
84+
85+// pipewireOpener connects to the PipeWire daemon. Open runs on the goroutine
86+// that stays locked to its OS thread: the loop belongs to that thread.
87+type pipewireOpener interface {
88+ Open() (videoCore, error)
89+}
90+
91+type realPipeWire struct{}
92+
93+func (realPipeWire) Open() (videoCore, error) { return openPipeWire() }
94+
95+// pidfdPeers is peerResolver over the pidfd proof of package peerexe.
96+type pidfdPeers struct{}
97+
98+func (pidfdPeers) Open(pid int) (*os.File, error) { return peerexe.Open(pid) }
99+
100+func (pidfdPeers) Exe(pidfd *os.File) (string, int, error) {
101+ exe, pid, err := peerexe.ExeOfPidfd(int(pidfd.Fd()))
102+ runtime.KeepAlive(pidfd)
103+ return exe, pid, err
104+}
105+
106+// nefercap is the program that takes the screenshots.
107+const nefercap = "nefercap"
108+
109+// execRunner runs nefercap in the session's environment (WAYLAND_DISPLAY
110+// names this compositor).
111+type execRunner struct{ env []string }
112+
113+func (r execRunner) Run(ctx context.Context, args []string) error {
114+ bin, err := exec.LookPath(nefercap)
115+ if err != nil {
116+ return err
117+ }
118+ cmd := exec.CommandContext(ctx, bin, args...)
119+ // A cancelled run is joined by the owner: never wait long for pipes
120+ // that a grandchild keeps open.
121+ cmd.WaitDelay = 2 * time.Second
122+ if len(r.env) > 0 {
123+ cmd.Env = r.env
124+ }
125+ out, err := cmd.CombinedOutput()
126+ if err != nil {
127+ if len(out) > 256 {
128+ out = out[len(out)-256:]
129+ }
130+ return fmt.Errorf("%s: %w: %s", nefercap, err, out)
131+ }
132+ return nil
133+}
134+
135+// pwLoop is videoCore over a real PipeWire loop, context and core.
136+type pwLoop struct {
137+ rt pipewire.Runtime
138+ loop pipewire.Loop
139+ ctx pipewire.Context
140+ core pipewire.Core
141+}
142+
143+// openPipeWire connects to the PipeWire daemon.
144+func openPipeWire() (videoCore, error) {
145+ rt, err := pipewire.Init()
146+ if err != nil {
147+ return nil, err
148+ }
149+ p := &pwLoop{rt: rt}
150+ if p.loop, err = rt.NewLoop(); err != nil {
151+ p.Close()
152+ return nil, err
153+ }
154+ if p.ctx, err = rt.NewContext(p.loop); err != nil {
155+ p.Close()
156+ return nil, err
157+ }
158+ if p.core, err = p.ctx.Connect(); err != nil {
159+ p.Close()
160+ return nil, err
161+ }
162+ return p, nil
163+}
164+
165+func (p *pwLoop) Fd() int { return p.loop.Fd() }
166+
167+func (p *pwLoop) Iterate() { p.loop.Iterate() }
168+
169+func (p *pwLoop) NewVideoSource(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error) {
170+ vs, err := p.core.NewVideoSource(cfg, h)
171+ if err != nil {
172+ return nil, err
173+ }
174+ if vs == nil {
175+ return nil, errors.New("pipewire returned no video source")
176+ }
177+ return vs, nil
178+}
179+
180+// Close releases the connection, innermost first. Destroy the sources first.
181+func (p *pwLoop) Close() {
182+ if p.core != nil {
183+ p.core.Disconnect()
184+ }
185+ if p.ctx != nil {
186+ p.ctx.Destroy()
187+ }
188+ if p.loop != nil {
189+ p.loop.Destroy()
190+ }
191+ p.rt.Deinit()
192+}
A internal/adapters/screencast/session.go +298 -0
@@ -0,0 +1,298 @@
1+package screencast
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// session is one org.gnome.Mutter.ScreenCast.Session. The sessions live in a
10+// fixed array of Service, so pointers into it (the streams' back references,
11+// the PipeWire handlers) stay valid.
12+type session struct {
13+ used bool
14+ id uint32
15+ path string
16+ owner string // unique name that called CreateSession
17+ // started: Start was called, the streams were opened.
18+ started bool
19+ // dying: the session is closed to the bus; the slot is freed once every
20+ // stream released its PipeWire resources.
21+ dying bool
22+ streams [maxStreams]stream
23+}
24+
25+func (s *Service) sessionByID(id uint32) *session {
26+ for i := range s.sessions {
27+ if sess := &s.sessions[i]; sess.used && !sess.dying && sess.id == id {
28+ return sess
29+ }
30+ }
31+ return nil
32+}
33+
34+func (s *Service) streamByID(id uint32) *stream {
35+ for i := range s.sessions {
36+ sess := &s.sessions[i]
37+ if !sess.used || sess.dying {
38+ continue
39+ }
40+ for j := range sess.streams {
41+ if st := &sess.streams[j]; st.used && st.id == id {
42+ return st
43+ }
44+ }
45+ }
46+ return nil
47+}
48+
49+func (s *Service) forEachSession(f func(*session)) {
50+ for i := range s.sessions {
51+ if s.sessions[i].used {
52+ f(&s.sessions[i])
53+ }
54+ }
55+}
56+
57+func (s *Service) forEachStream(f func(*stream)) {
58+ for i := range s.sessions {
59+ sess := &s.sessions[i]
60+ if !sess.used {
61+ continue
62+ }
63+ for j := range sess.streams {
64+ if sess.streams[j].used {
65+ f(&sess.streams[j])
66+ }
67+ }
68+ }
69+}
70+
71+// createSession answers CreateSession.
72+func (s *Service) createSession(req *request) {
73+ if req.remote {
74+ s.replyErr(req, errNotSupported, "remote desktop sessions are not supported")
75+ return
76+ }
77+ var sess *session
78+ for i := range s.sessions {
79+ if !s.sessions[i].used {
80+ sess = &s.sessions[i]
81+ break
82+ }
83+ }
84+ if sess == nil {
85+ s.replyErr(req, errLimits, "too many screen cast sessions")
86+ return
87+ }
88+ s.nextSess++
89+ *sess = session{used: true, id: s.nextSess, owner: req.sender}
90+ sess.path = prefixSession + strconv.FormatUint(uint64(sess.id), 10)
91+ s.log.Info().Str("sender", req.sender).Str("session", sess.path).Msg("screen cast session created")
92+ if e := s.replyTo(req, "o"); e != nil {
93+ e.ObjectPath(sess.path)
94+ s.send()
95+ }
96+}
97+
98+// record answers RecordMonitor and RecordWindow: it adds a stream.
99+func (s *Service) record(req *request, sess *session) {
100+ if sess.started {
101+ s.replyErr(req, errFailed, "session already started")
102+ return
103+ }
104+ var st *stream
105+ for i := range sess.streams {
106+ if !sess.streams[i].used {
107+ st = &sess.streams[i]
108+ break
109+ }
110+ }
111+ if st == nil {
112+ s.replyErr(req, errLimits, "too many streams in the session")
113+ return
114+ }
115+ *st = stream{used: true, sess: sess, cursor: req.cursor != 0, maxFPS: defaultFPS, pwState: -2}
116+ if req.method == mRecordWindow {
117+ if !s.haveWindow(ports.WindowID(req.window)) {
118+ *st = stream{}
119+ s.replyErr(req, errInvalidArgs, "no such window")
120+ return
121+ }
122+ st.window = ports.WindowID(req.window)
123+ // A window moves and resizes: the stream carries no position.
124+ st.px, st.py, st.pw, st.ph = 0, 0, 1, 1
125+ } else {
126+ o := s.outputByName(req.text)
127+ if o == nil {
128+ *st = stream{}
129+ s.replyErr(req, errInvalidArgs, "no such monitor")
130+ return
131+ }
132+ st.output = o.Info.Name
133+ st.px, st.py, st.pw, st.ph = int32(o.X), int32(o.Y), int32(o.Width), int32(o.Height)
134+ if o.Current >= 0 && o.Current < len(o.Modes) {
135+ if hz := o.Modes[o.Current].RefreshMilli / 1000; hz > 0 {
136+ st.maxFPS = min(hz, 240)
137+ }
138+ }
139+ }
140+ s.nextStrm++
141+ s.nextCast++
142+ st.id, st.cast = s.nextStrm, s.nextCast
143+ st.path = prefixStream + strconv.FormatUint(uint64(st.id), 10)
144+ st.h2 = streamHandler{st: st}
145+ if e := s.replyTo(req, "o"); e != nil {
146+ e.ObjectPath(st.path)
147+ s.send()
148+ }
149+}
150+
151+// start answers Start: the streams are opened on the wayland side.
152+func (s *Service) start(req *request, sess *session) {
153+ if sess.started {
154+ s.replyErr(req, errFailed, "session already started")
155+ return
156+ }
157+ n := 0
158+ for i := range sess.streams {
159+ if sess.streams[i].used {
160+ sess.streams[i].needOpen = true
161+ n++
162+ }
163+ }
164+ if n == 0 {
165+ s.replyErr(req, errFailed, "session has no stream")
166+ return
167+ }
168+ sess.started = true
169+ s.reply(req)
170+}
171+
172+// closeSession ends a session: its streams are closed on the wayland side
173+// and, with signal set, the owner is told. The slot is freed by reap.
174+func (s *Service) closeSession(sess *session, signal bool) {
175+ if sess.dying {
176+ return
177+ }
178+ sess.dying = true
179+ for i := range sess.streams {
180+ if st := &sess.streams[i]; st.used {
181+ st.close()
182+ if !st.opened && st.src == nil {
183+ *st = stream{}
184+ }
185+ }
186+ }
187+ if signal {
188+ s.emitTo(sess.owner, sess.path, ifaceSession, "Closed")
189+ s.send()
190+ }
191+ s.log.Info().Str("session", sess.path).Msg("screen cast session closed")
192+}
193+
194+// outputByName finds an output of the inventory by connector.
195+func (s *Service) outputByName(name string) *ports.CastOutput {
196+ for i := range s.inv.Outputs {
197+ if s.inv.Outputs[i].Info.Name == name {
198+ return &s.inv.Outputs[i]
199+ }
200+ }
201+ return nil
202+}
203+
204+func (s *Service) haveWindow(id ports.WindowID) bool {
205+ for i := range s.inv.Windows {
206+ if s.inv.Windows[i].ID == id {
207+ return true
208+ }
209+ }
210+ return false
211+}
212+
213+// properties answers Properties.Get, GetAll and Set.
214+func (s *Service) properties(req *request, sess *session, st *stream) {
215+ if req.method == mSet {
216+ s.replyErr(req, errPropertyReadOnly, "no writable property")
217+ return
218+ }
219+ iface := ""
220+ switch req.obj {
221+ case objScreenCast:
222+ iface = ifaceScreenCast
223+ case objSession:
224+ iface = ifaceSession
225+ case objStream:
226+ iface = ifaceStream
227+ case objDisplayConfig:
228+ iface = ifaceDisplayConfig
229+ case objIntrospect:
230+ iface = ifaceIntrospect
231+ case objScreenshot:
232+ iface = ifaceScreenshot
233+ }
234+ if req.text != iface && !(req.method == mGetAll && req.text == "") {
235+ s.replyErr(req, errUnknownInterface, "no such interface")
236+ return
237+ }
238+ if req.method == mGetAll {
239+ e := s.replyTo(req, "a{sv}")
240+ if e == nil {
241+ return
242+ }
243+ a := e.BeginArray('{')
244+ switch req.obj {
245+ case objScreenCast:
246+ e.Struct()
247+ e.Str("Version")
248+ e.Variant("i")
249+ e.Int32(screenCastVersion)
250+ case objStream:
251+ e.Struct()
252+ e.Str("Parameters")
253+ e.Variant("a{sv}")
254+ encodeParameters(e, st)
255+ case objIntrospect:
256+ e.Struct()
257+ e.Str("version")
258+ e.Variant("u")
259+ e.Uint32(introspectVersion)
260+ }
261+ e.EndArray(a)
262+ s.send()
263+ return
264+ }
265+ switch {
266+ case req.obj == objScreenCast && req.text2 == "Version":
267+ if e := s.replyTo(req, "v"); e != nil {
268+ e.Variant("i")
269+ e.Int32(screenCastVersion)
270+ s.send()
271+ }
272+ case req.obj == objStream && req.text2 == "Parameters":
273+ if e := s.replyTo(req, "v"); e != nil {
274+ e.Variant("a{sv}")
275+ encodeParameters(e, st)
276+ s.send()
277+ }
278+ case req.obj == objIntrospect && req.text2 == "version":
279+ if e := s.replyTo(req, "v"); e != nil {
280+ e.Variant("u")
281+ e.Uint32(introspectVersion)
282+ s.send()
283+ }
284+ default:
285+ s.replyErr(req, errUnknownProperty, "no such property")
286+ }
287+}
288+
289+// Versions served. Introspect version 2 would make x-d-p-g read
290+// AnimationsEnabled and ScreenSize, which are not served.
291+const (
292+ screenCastVersion = 4
293+ introspectVersion = 1
294+ defaultFPS = 60
295+)
296+
297+func itoa(n int) string { return strconv.Itoa(n) }
298+func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 3, 64) }
A internal/adapters/screencast/shutdown_test.go +164 -0
@@ -0,0 +1,164 @@
1+package screencast
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-pipewire/pipewire"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// liveCast is a cast on a private bus, brought to STREAMING with a mocked
14+// PipeWire source, so a test can lose the bus under it.
15+type liveCast struct {
16+ h *svcHarness
17+ src *mockvideoSource
18+ buf *pipewire.VideoBuffer
19+ // onLoop runs functions inside the PipeWire loop iteration, where the
20+ // real handler callbacks run.
21+ onLoop chan func()
22+ hdl chan pipewire.VideoHandler
23+}
24+
25+func (l *liveCast) loop(f func()) {
26+ l.onLoop <- f
27+ l.h.svc.Push(ports.CastEvent{Kind: ports.CastDamage, ID: 1 << 40}) // wakes the owner
28+}
29+
30+func startLiveCast(t *testing.T, withFrame bool, drain time.Duration) *liveCast {
31+ t.Helper()
32+ l := &liveCast{onLoop: make(chan func(), 8), hdl: make(chan pipewire.VideoHandler, 1)}
33+ l.h, _ = ready(t, harnessOpts{drain: drain, iterate: func() {
34+ select {
35+ case f := <-l.onLoop:
36+ f()
37+ default:
38+ }
39+ }})
40+ h := l.h
41+ c := h.client()
42+ l.src = newMockvideoSource(t)
43+ l.buf = &pipewire.VideoBuffer{Index: 2, Data: make([]byte, 64*32*4), Stride: 256, Width: 64, Height: 32, Format: pipewire.VideoFormatBGRx}
44+ h.core.EXPECT().NewVideoSource(mock.Anything, mock.Anything).RunAndReturn(
45+ func(_ pipewire.VideoConfig, hd pipewire.VideoHandler) (videoSource, error) {
46+ l.hdl <- hd
47+ return l.src, nil
48+ }).Once()
49+ if withFrame {
50+ l.src.EXPECT().Dequeue().Return(l.buf).Once()
51+ } else {
52+ l.src.EXPECT().Dequeue().Return(nil).Maybe()
53+ }
54+
55+ sess := createSession(t, c)
56+ if name, _ := recordMonitor(c, sess, "HEADLESS-1"); name != "" {
57+ t.Fatal(name)
58+ }
59+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Start", "", nil); name != "" {
60+ t.Fatal(name)
61+ }
62+ if cmd := recvCmd(t, h); cmd.Kind != ports.CastOpen {
63+ t.Fatalf("%+v", cmd)
64+ }
65+ h.svc.Push(ports.CastEvent{Kind: ports.CastSize, ID: 1, Width: 64, Height: 32})
66+ var hd pipewire.VideoHandler
67+ select {
68+ case hd = <-l.hdl:
69+ case <-time.After(5 * time.Second):
70+ t.Fatal("no PipeWire source")
71+ }
72+ l.loop(func() {
73+ hd.StateChanged(pipewire.StreamStatePaused, 42, "")
74+ hd.FormatChanged(pipewire.VideoFormatBGRx, 64, 32, 256)
75+ hd.StateChanged(pipewire.StreamStateStreaming, 42, "")
76+ })
77+ if withFrame {
78+ if cmd := recvCmd(t, h); cmd.Kind != ports.CastFrame || cmd.Buf != 2 {
79+ t.Fatalf("%+v", cmd)
80+ }
81+ }
82+ return l
83+}
84+
85+func recvCmd(t *testing.T, h *svcHarness) ports.CastCommand {
86+ t.Helper()
87+ select {
88+ case c := <-h.cmds:
89+ return c
90+ case <-time.After(5 * time.Second):
91+ t.Fatal("no command\n" + h.log.String())
92+ return ports.CastCommand{}
93+ }
94+}
95+
96+func waitClosed(t *testing.T, h *svcHarness) {
97+ t.Helper()
98+ select {
99+ case <-h.closed:
100+ case <-time.After(5 * time.Second):
101+ t.Fatal("PipeWire not closed\n" + h.log.String())
102+ }
103+}
104+
105+// Losing the bus with a live stream and no frame in flight: the cast is
106+// closed on the wayland side, the source destroyed, PipeWire closed.
107+func TestBusLossTearsDownStream(t *testing.T) {
108+ l := startLiveCast(t, false, 0)
109+ l.src.EXPECT().Destroy().Return().Once()
110+ l.h.killBus()
111+ if cmd := recvCmd(t, l.h); cmd.Kind != ports.CastClose || cmd.ID != 1 {
112+ t.Fatalf("%+v", cmd)
113+ }
114+ waitClosed(t, l.h)
115+}
116+
117+// With a frame in flight: CastClose goes out at once, but the source is
118+// destroyed only after the frame's CastFrameDone, even when that comes later
119+// than drain: the service runs on, so nothing is left behind. The order is
120+// synchronized by channels: the buffer must be released before the node is
121+// destroyed, and PipeWire closed after both.
122+func TestBusLossWaitsForFrameInFlight(t *testing.T) {
123+ l := startLiveCast(t, true, time.Nanosecond)
124+ l.h.killBus()
125+ if cmd := recvCmd(t, l.h); cmd.Kind != ports.CastClose || cmd.ID != 1 {
126+ t.Fatalf("%+v", cmd)
127+ }
128+ var delivered atomic.Bool
129+ steps := make(chan string, 3)
130+ step := func(name string) {
131+ if !delivered.Load() {
132+ t.Errorf("%s before the CastFrameDone", name)
133+ }
134+ steps <- name
135+ }
136+ l.src.EXPECT().Release(l.buf).Run(func(*pipewire.VideoBuffer) { step("release") }).Once()
137+ l.src.EXPECT().Destroy().Run(func() { step("destroy") }).Once()
138+ delivered.Store(true)
139+ l.h.svc.Push(ports.CastEvent{Kind: ports.CastFrameDone, ID: 1, Buf: 2})
140+ waitClosed(t, l.h)
141+ if len(steps) != 2 || <-steps != "release" || <-steps != "destroy" {
142+ t.Fatal("release and destroy did not run in order before PipeWire closed")
143+ }
144+}
145+
146+// The service itself stopping with a frame that is never answered returns
147+// within the bounded wait, and leaves the source and PipeWire alone.
148+func TestStopWithUnansweredFrameIsBounded(t *testing.T) {
149+ l := startLiveCast(t, true, 20*time.Millisecond)
150+ l.h.cancel()
151+ if cmd := recvCmd(t, l.h); cmd.Kind != ports.CastClose || cmd.ID != 1 {
152+ t.Fatalf("%+v", cmd)
153+ }
154+ select {
155+ case <-l.h.done:
156+ case <-time.After(5 * time.Second):
157+ t.Fatal("Run did not return")
158+ }
159+ select {
160+ case <-l.h.closed:
161+ t.Fatal("PipeWire closed under a source with a frame in flight")
162+ default:
163+ }
164+}
A internal/adapters/screencast/stream.go +114 -0
@@ -0,0 +1,114 @@
1+package screencast
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+ "github.com/bnema/zerobus"
9+)
10+
11+// stream is one cast of a session: a PipeWire node fed with frames that the
12+// wayland server renders. Everything here is touched by the owner goroutine
13+// only; the PipeWire handler runs on it too, inside Loop.Iterate.
14+type stream struct {
15+ used bool
16+ sess *session
17+ id uint32 // number in the object path
18+ cast uint64 // ID in the ports.CastCommands
19+ path string
20+
21+ // Target.
22+ output string
23+ window ports.WindowID
24+ cursor bool
25+ maxFPS int
26+ // Stream.Parameters: position and size in the compositor space.
27+ px, py, pw, ph int32
28+
29+ // Lifecycle.
30+ needOpen bool // CastOpen is yet to be sent
31+ opened bool // CastOpen was sent
32+ needClose bool // CastClose is yet to be sent
33+ closing bool // the session ended: free once no frame is in flight
34+ stopped bool // CastStopped came: the wayland side is done
35+
36+ // Negotiation.
37+ w, h int // size of the target, from CastSize
38+ haveSize bool
39+ needResize bool
40+ src videoSource
41+ h2 streamHandler
42+ pwState int32 // last state of the PipeWire stream; -2 before the first
43+ seenState bool // a state above UNCONNECTED was seen
44+ nodeID uint32
45+ announced bool // PipeWireStreamAdded was sent
46+ format pipewire.VideoFormat
47+ fw, fh int // size the format was negotiated at; 0 while renegotiating
48+
49+ // Frames.
50+ dirty bool
51+ forced bool // the first frame after STREAMING is not paced by damage
52+ inflight bool
53+ buf *pipewire.VideoBuffer
54+ lastReq time.Time
55+ seq uint64
56+ pts time.Duration
57+ errs int // consecutive failed frames
58+}
59+
60+// streamHandler is the pipewire.VideoHandler of a stream. Its callbacks run
61+// inside Loop.Iterate: they only record, the pump acts.
62+type streamHandler struct{ st *stream }
63+
64+func (h *streamHandler) StateChanged(state int32, nodeID uint32, _ string) {
65+ st := h.st
66+ st.pwState = state
67+ if nodeID != 0 {
68+ st.nodeID = nodeID
69+ }
70+ if state > pipewire.StreamStateUnconnected {
71+ st.seenState = true
72+ }
73+ if state == pipewire.StreamStateStreaming {
74+ st.forced = true
75+ }
76+}
77+
78+func (h *streamHandler) FormatChanged(f pipewire.VideoFormat, w, hh, _ int) {
79+ h.st.format, h.st.fw, h.st.fh = f, w, hh
80+ // A new format shows a new picture: the first frame is not paced by damage.
81+ h.st.forced = true
82+}
83+
84+// BuffersChanged: the next pump looks for a free buffer anyway.
85+func (h *streamHandler) BuffersChanged() {}
86+
87+// close ends the stream on the wayland side; the slot is freed by reap once
88+// no frame is in flight.
89+func (st *stream) close() {
90+ if st.closing {
91+ return
92+ }
93+ st.closing = true
94+ st.needOpen = false
95+ st.needClose = st.opened && !st.stopped
96+}
97+
98+// encodeParameters writes the a{sv} of Stream.Parameters.
99+func encodeParameters(e *zerobus.Encoder, st *stream) {
100+ a := e.BeginArray('{')
101+ e.Struct()
102+ e.Str("position")
103+ e.Variant("(ii)")
104+ e.Struct()
105+ e.Int32(st.px)
106+ e.Int32(st.py)
107+ e.Struct()
108+ e.Str("size")
109+ e.Variant("(ii)")
110+ e.Struct()
111+ e.Int32(st.pw)
112+ e.Int32(st.ph)
113+ e.EndArray(a)
114+}
A internal/adapters/screencast/video_core_mock_test.go +224 -0
@@ -0,0 +1,224 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ "github.com/bnema/purego-pipewire/pipewire"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockvideoCore creates a new instance of mockvideoCore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockvideoCore(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockvideoCore {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockvideoCore{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockvideoCore is an autogenerated mock type for the videoCore type
36+type mockvideoCore struct {
37+ mock.Mock
38+}
39+
40+type mockvideoCore_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockvideoCore) EXPECT() *mockvideoCore_Expecter {
45+ return &mockvideoCore_Expecter{mock: &_m.Mock}
46+}
47+
48+// Close provides a mock function for the type mockvideoCore
49+func (_mock *mockvideoCore) Close() {
50+ _mock.Called()
51+ return
52+}
53+
54+// mockvideoCore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
55+type mockvideoCore_Close_Call struct {
56+ *mock.Call
57+}
58+
59+// Close is a helper method to define mock.On call
60+func (_e *mockvideoCore_Expecter) Close() *mockvideoCore_Close_Call {
61+ return &mockvideoCore_Close_Call{Call: _e.mock.On("Close")}
62+}
63+
64+func (_c *mockvideoCore_Close_Call) Run(run func()) *mockvideoCore_Close_Call {
65+ _c.Call.Run(func(args mock.Arguments) {
66+ run()
67+ })
68+ return _c
69+}
70+
71+func (_c *mockvideoCore_Close_Call) Return() *mockvideoCore_Close_Call {
72+ _c.Call.Return()
73+ return _c
74+}
75+
76+func (_c *mockvideoCore_Close_Call) RunAndReturn(run func()) *mockvideoCore_Close_Call {
77+ _c.Run(run)
78+ return _c
79+}
80+
81+// Fd provides a mock function for the type mockvideoCore
82+func (_mock *mockvideoCore) Fd() int {
83+ ret := _mock.Called()
84+
85+ if len(ret) == 0 {
86+ panic("no return value specified for Fd")
87+ }
88+
89+ var r0 int
90+ if returnFunc, ok := ret.Get(0).(func() int); ok {
91+ r0 = returnFunc()
92+ } else {
93+ r0 = ret.Get(0).(int)
94+ }
95+ return r0
96+}
97+
98+// mockvideoCore_Fd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fd'
99+type mockvideoCore_Fd_Call struct {
100+ *mock.Call
101+}
102+
103+// Fd is a helper method to define mock.On call
104+func (_e *mockvideoCore_Expecter) Fd() *mockvideoCore_Fd_Call {
105+ return &mockvideoCore_Fd_Call{Call: _e.mock.On("Fd")}
106+}
107+
108+func (_c *mockvideoCore_Fd_Call) Run(run func()) *mockvideoCore_Fd_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ run()
111+ })
112+ return _c
113+}
114+
115+func (_c *mockvideoCore_Fd_Call) Return(n int) *mockvideoCore_Fd_Call {
116+ _c.Call.Return(n)
117+ return _c
118+}
119+
120+func (_c *mockvideoCore_Fd_Call) RunAndReturn(run func() int) *mockvideoCore_Fd_Call {
121+ _c.Call.Return(run)
122+ return _c
123+}
124+
125+// Iterate provides a mock function for the type mockvideoCore
126+func (_mock *mockvideoCore) Iterate() {
127+ _mock.Called()
128+ return
129+}
130+
131+// mockvideoCore_Iterate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Iterate'
132+type mockvideoCore_Iterate_Call struct {
133+ *mock.Call
134+}
135+
136+// Iterate is a helper method to define mock.On call
137+func (_e *mockvideoCore_Expecter) Iterate() *mockvideoCore_Iterate_Call {
138+ return &mockvideoCore_Iterate_Call{Call: _e.mock.On("Iterate")}
139+}
140+
141+func (_c *mockvideoCore_Iterate_Call) Run(run func()) *mockvideoCore_Iterate_Call {
142+ _c.Call.Run(func(args mock.Arguments) {
143+ run()
144+ })
145+ return _c
146+}
147+
148+func (_c *mockvideoCore_Iterate_Call) Return() *mockvideoCore_Iterate_Call {
149+ _c.Call.Return()
150+ return _c
151+}
152+
153+func (_c *mockvideoCore_Iterate_Call) RunAndReturn(run func()) *mockvideoCore_Iterate_Call {
154+ _c.Run(run)
155+ return _c
156+}
157+
158+// NewVideoSource provides a mock function for the type mockvideoCore
159+func (_mock *mockvideoCore) NewVideoSource(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error) {
160+ ret := _mock.Called(cfg, h)
161+
162+ if len(ret) == 0 {
163+ panic("no return value specified for NewVideoSource")
164+ }
165+
166+ var r0 videoSource
167+ var r1 error
168+ if returnFunc, ok := ret.Get(0).(func(pipewire.VideoConfig, pipewire.VideoHandler) (videoSource, error)); ok {
169+ return returnFunc(cfg, h)
170+ }
171+ if returnFunc, ok := ret.Get(0).(func(pipewire.VideoConfig, pipewire.VideoHandler) videoSource); ok {
172+ r0 = returnFunc(cfg, h)
173+ } else {
174+ if ret.Get(0) != nil {
175+ r0 = ret.Get(0).(videoSource)
176+ }
177+ }
178+ if returnFunc, ok := ret.Get(1).(func(pipewire.VideoConfig, pipewire.VideoHandler) error); ok {
179+ r1 = returnFunc(cfg, h)
180+ } else {
181+ r1 = ret.Error(1)
182+ }
183+ return r0, r1
184+}
185+
186+// mockvideoCore_NewVideoSource_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewVideoSource'
187+type mockvideoCore_NewVideoSource_Call struct {
188+ *mock.Call
189+}
190+
191+// NewVideoSource is a helper method to define mock.On call
192+// - cfg pipewire.VideoConfig
193+// - h pipewire.VideoHandler
194+func (_e *mockvideoCore_Expecter) NewVideoSource(cfg any, h any) *mockvideoCore_NewVideoSource_Call {
195+ return &mockvideoCore_NewVideoSource_Call{Call: _e.mock.On("NewVideoSource", cfg, h)}
196+}
197+
198+func (_c *mockvideoCore_NewVideoSource_Call) Run(run func(cfg pipewire.VideoConfig, h pipewire.VideoHandler)) *mockvideoCore_NewVideoSource_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 pipewire.VideoConfig
201+ if args[0] != nil {
202+ arg0 = args[0].(pipewire.VideoConfig)
203+ }
204+ var arg1 pipewire.VideoHandler
205+ if args[1] != nil {
206+ arg1 = args[1].(pipewire.VideoHandler)
207+ }
208+ run(
209+ arg0,
210+ arg1,
211+ )
212+ })
213+ return _c
214+}
215+
216+func (_c *mockvideoCore_NewVideoSource_Call) Return(videoSourceMoqParam videoSource, err error) *mockvideoCore_NewVideoSource_Call {
217+ _c.Call.Return(videoSourceMoqParam, err)
218+ return _c
219+}
220+
221+func (_c *mockvideoCore_NewVideoSource_Call) RunAndReturn(run func(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error)) *mockvideoCore_NewVideoSource_Call {
222+ _c.Call.Return(run)
223+ return _c
224+}
A internal/adapters/screencast/video_source_mock_test.go +403 -0
@@ -0,0 +1,403 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package screencast
6+
7+import (
8+ "time"
9+
10+ "github.com/bnema/purego-pipewire/pipewire"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockvideoSource creates a new instance of mockvideoSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockvideoSource(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockvideoSource {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockvideoSource{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockvideoSource is an autogenerated mock type for the videoSource type
38+type mockvideoSource struct {
39+ mock.Mock
40+}
41+
42+type mockvideoSource_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockvideoSource) EXPECT() *mockvideoSource_Expecter {
47+ return &mockvideoSource_Expecter{mock: &_m.Mock}
48+}
49+
50+// Dequeue provides a mock function for the type mockvideoSource
51+func (_mock *mockvideoSource) Dequeue() *pipewire.VideoBuffer {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Dequeue")
56+ }
57+
58+ var r0 *pipewire.VideoBuffer
59+ if returnFunc, ok := ret.Get(0).(func() *pipewire.VideoBuffer); ok {
60+ r0 = returnFunc()
61+ } else {
62+ if ret.Get(0) != nil {
63+ r0 = ret.Get(0).(*pipewire.VideoBuffer)
64+ }
65+ }
66+ return r0
67+}
68+
69+// mockvideoSource_Dequeue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dequeue'
70+type mockvideoSource_Dequeue_Call struct {
71+ *mock.Call
72+}
73+
74+// Dequeue is a helper method to define mock.On call
75+func (_e *mockvideoSource_Expecter) Dequeue() *mockvideoSource_Dequeue_Call {
76+ return &mockvideoSource_Dequeue_Call{Call: _e.mock.On("Dequeue")}
77+}
78+
79+func (_c *mockvideoSource_Dequeue_Call) Run(run func()) *mockvideoSource_Dequeue_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ run()
82+ })
83+ return _c
84+}
85+
86+func (_c *mockvideoSource_Dequeue_Call) Return(videoBuffer *pipewire.VideoBuffer) *mockvideoSource_Dequeue_Call {
87+ _c.Call.Return(videoBuffer)
88+ return _c
89+}
90+
91+func (_c *mockvideoSource_Dequeue_Call) RunAndReturn(run func() *pipewire.VideoBuffer) *mockvideoSource_Dequeue_Call {
92+ _c.Call.Return(run)
93+ return _c
94+}
95+
96+// Destroy provides a mock function for the type mockvideoSource
97+func (_mock *mockvideoSource) Destroy() {
98+ _mock.Called()
99+ return
100+}
101+
102+// mockvideoSource_Destroy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destroy'
103+type mockvideoSource_Destroy_Call struct {
104+ *mock.Call
105+}
106+
107+// Destroy is a helper method to define mock.On call
108+func (_e *mockvideoSource_Expecter) Destroy() *mockvideoSource_Destroy_Call {
109+ return &mockvideoSource_Destroy_Call{Call: _e.mock.On("Destroy")}
110+}
111+
112+func (_c *mockvideoSource_Destroy_Call) Run(run func()) *mockvideoSource_Destroy_Call {
113+ _c.Call.Run(func(args mock.Arguments) {
114+ run()
115+ })
116+ return _c
117+}
118+
119+func (_c *mockvideoSource_Destroy_Call) Return() *mockvideoSource_Destroy_Call {
120+ _c.Call.Return()
121+ return _c
122+}
123+
124+func (_c *mockvideoSource_Destroy_Call) RunAndReturn(run func()) *mockvideoSource_Destroy_Call {
125+ _c.Run(run)
126+ return _c
127+}
128+
129+// NodeID provides a mock function for the type mockvideoSource
130+func (_mock *mockvideoSource) NodeID() uint32 {
131+ ret := _mock.Called()
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for NodeID")
135+ }
136+
137+ var r0 uint32
138+ if returnFunc, ok := ret.Get(0).(func() uint32); ok {
139+ r0 = returnFunc()
140+ } else {
141+ r0 = ret.Get(0).(uint32)
142+ }
143+ return r0
144+}
145+
146+// mockvideoSource_NodeID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NodeID'
147+type mockvideoSource_NodeID_Call struct {
148+ *mock.Call
149+}
150+
151+// NodeID is a helper method to define mock.On call
152+func (_e *mockvideoSource_Expecter) NodeID() *mockvideoSource_NodeID_Call {
153+ return &mockvideoSource_NodeID_Call{Call: _e.mock.On("NodeID")}
154+}
155+
156+func (_c *mockvideoSource_NodeID_Call) Run(run func()) *mockvideoSource_NodeID_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ run()
159+ })
160+ return _c
161+}
162+
163+func (_c *mockvideoSource_NodeID_Call) Return(v uint32) *mockvideoSource_NodeID_Call {
164+ _c.Call.Return(v)
165+ return _c
166+}
167+
168+func (_c *mockvideoSource_NodeID_Call) RunAndReturn(run func() uint32) *mockvideoSource_NodeID_Call {
169+ _c.Call.Return(run)
170+ return _c
171+}
172+
173+// Queue provides a mock function for the type mockvideoSource
174+func (_mock *mockvideoSource) Queue(b *pipewire.VideoBuffer, seq uint64, pts time.Duration, corrupted bool) {
175+ _mock.Called(b, seq, pts, corrupted)
176+ return
177+}
178+
179+// mockvideoSource_Queue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Queue'
180+type mockvideoSource_Queue_Call struct {
181+ *mock.Call
182+}
183+
184+// Queue is a helper method to define mock.On call
185+// - b *pipewire.VideoBuffer
186+// - seq uint64
187+// - pts time.Duration
188+// - corrupted bool
189+func (_e *mockvideoSource_Expecter) Queue(b any, seq any, pts any, corrupted any) *mockvideoSource_Queue_Call {
190+ return &mockvideoSource_Queue_Call{Call: _e.mock.On("Queue", b, seq, pts, corrupted)}
191+}
192+
193+func (_c *mockvideoSource_Queue_Call) Run(run func(b *pipewire.VideoBuffer, seq uint64, pts time.Duration, corrupted bool)) *mockvideoSource_Queue_Call {
194+ _c.Call.Run(func(args mock.Arguments) {
195+ var arg0 *pipewire.VideoBuffer
196+ if args[0] != nil {
197+ arg0 = args[0].(*pipewire.VideoBuffer)
198+ }
199+ var arg1 uint64
200+ if args[1] != nil {
201+ arg1 = args[1].(uint64)
202+ }
203+ var arg2 time.Duration
204+ if args[2] != nil {
205+ arg2 = args[2].(time.Duration)
206+ }
207+ var arg3 bool
208+ if args[3] != nil {
209+ arg3 = args[3].(bool)
210+ }
211+ run(
212+ arg0,
213+ arg1,
214+ arg2,
215+ arg3,
216+ )
217+ })
218+ return _c
219+}
220+
221+func (_c *mockvideoSource_Queue_Call) Return() *mockvideoSource_Queue_Call {
222+ _c.Call.Return()
223+ return _c
224+}
225+
226+func (_c *mockvideoSource_Queue_Call) RunAndReturn(run func(b *pipewire.VideoBuffer, seq uint64, pts time.Duration, corrupted bool)) *mockvideoSource_Queue_Call {
227+ _c.Run(run)
228+ return _c
229+}
230+
231+// Release provides a mock function for the type mockvideoSource
232+func (_mock *mockvideoSource) Release(b *pipewire.VideoBuffer) {
233+ _mock.Called(b)
234+ return
235+}
236+
237+// mockvideoSource_Release_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Release'
238+type mockvideoSource_Release_Call struct {
239+ *mock.Call
240+}
241+
242+// Release is a helper method to define mock.On call
243+// - b *pipewire.VideoBuffer
244+func (_e *mockvideoSource_Expecter) Release(b any) *mockvideoSource_Release_Call {
245+ return &mockvideoSource_Release_Call{Call: _e.mock.On("Release", b)}
246+}
247+
248+func (_c *mockvideoSource_Release_Call) Run(run func(b *pipewire.VideoBuffer)) *mockvideoSource_Release_Call {
249+ _c.Call.Run(func(args mock.Arguments) {
250+ var arg0 *pipewire.VideoBuffer
251+ if args[0] != nil {
252+ arg0 = args[0].(*pipewire.VideoBuffer)
253+ }
254+ run(
255+ arg0,
256+ )
257+ })
258+ return _c
259+}
260+
261+func (_c *mockvideoSource_Release_Call) Return() *mockvideoSource_Release_Call {
262+ _c.Call.Return()
263+ return _c
264+}
265+
266+func (_c *mockvideoSource_Release_Call) RunAndReturn(run func(b *pipewire.VideoBuffer)) *mockvideoSource_Release_Call {
267+ _c.Run(run)
268+ return _c
269+}
270+
271+// Resize provides a mock function for the type mockvideoSource
272+func (_mock *mockvideoSource) Resize(w int, h int) error {
273+ ret := _mock.Called(w, h)
274+
275+ if len(ret) == 0 {
276+ panic("no return value specified for Resize")
277+ }
278+
279+ var r0 error
280+ if returnFunc, ok := ret.Get(0).(func(int, int) error); ok {
281+ r0 = returnFunc(w, h)
282+ } else {
283+ r0 = ret.Error(0)
284+ }
285+ return r0
286+}
287+
288+// mockvideoSource_Resize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resize'
289+type mockvideoSource_Resize_Call struct {
290+ *mock.Call
291+}
292+
293+// Resize is a helper method to define mock.On call
294+// - w int
295+// - h int
296+func (_e *mockvideoSource_Expecter) Resize(w any, h any) *mockvideoSource_Resize_Call {
297+ return &mockvideoSource_Resize_Call{Call: _e.mock.On("Resize", w, h)}
298+}
299+
300+func (_c *mockvideoSource_Resize_Call) Run(run func(w int, h int)) *mockvideoSource_Resize_Call {
301+ _c.Call.Run(func(args mock.Arguments) {
302+ var arg0 int
303+ if args[0] != nil {
304+ arg0 = args[0].(int)
305+ }
306+ var arg1 int
307+ if args[1] != nil {
308+ arg1 = args[1].(int)
309+ }
310+ run(
311+ arg0,
312+ arg1,
313+ )
314+ })
315+ return _c
316+}
317+
318+func (_c *mockvideoSource_Resize_Call) Return(err error) *mockvideoSource_Resize_Call {
319+ _c.Call.Return(err)
320+ return _c
321+}
322+
323+func (_c *mockvideoSource_Resize_Call) RunAndReturn(run func(w int, h int) error) *mockvideoSource_Resize_Call {
324+ _c.Call.Return(run)
325+ return _c
326+}
327+
328+// State provides a mock function for the type mockvideoSource
329+func (_mock *mockvideoSource) State() int32 {
330+ ret := _mock.Called()
331+
332+ if len(ret) == 0 {
333+ panic("no return value specified for State")
334+ }
335+
336+ var r0 int32
337+ if returnFunc, ok := ret.Get(0).(func() int32); ok {
338+ r0 = returnFunc()
339+ } else {
340+ r0 = ret.Get(0).(int32)
341+ }
342+ return r0
343+}
344+
345+// mockvideoSource_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State'
346+type mockvideoSource_State_Call struct {
347+ *mock.Call
348+}
349+
350+// State is a helper method to define mock.On call
351+func (_e *mockvideoSource_Expecter) State() *mockvideoSource_State_Call {
352+ return &mockvideoSource_State_Call{Call: _e.mock.On("State")}
353+}
354+
355+func (_c *mockvideoSource_State_Call) Run(run func()) *mockvideoSource_State_Call {
356+ _c.Call.Run(func(args mock.Arguments) {
357+ run()
358+ })
359+ return _c
360+}
361+
362+func (_c *mockvideoSource_State_Call) Return(n int32) *mockvideoSource_State_Call {
363+ _c.Call.Return(n)
364+ return _c
365+}
366+
367+func (_c *mockvideoSource_State_Call) RunAndReturn(run func() int32) *mockvideoSource_State_Call {
368+ _c.Call.Return(run)
369+ return _c
370+}
371+
372+// Trigger provides a mock function for the type mockvideoSource
373+func (_mock *mockvideoSource) Trigger() {
374+ _mock.Called()
375+ return
376+}
377+
378+// mockvideoSource_Trigger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trigger'
379+type mockvideoSource_Trigger_Call struct {
380+ *mock.Call
381+}
382+
383+// Trigger is a helper method to define mock.On call
384+func (_e *mockvideoSource_Expecter) Trigger() *mockvideoSource_Trigger_Call {
385+ return &mockvideoSource_Trigger_Call{Call: _e.mock.On("Trigger")}
386+}
387+
388+func (_c *mockvideoSource_Trigger_Call) Run(run func()) *mockvideoSource_Trigger_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ run()
391+ })
392+ return _c
393+}
394+
395+func (_c *mockvideoSource_Trigger_Call) Return() *mockvideoSource_Trigger_Call {
396+ _c.Call.Return()
397+ return _c
398+}
399+
400+func (_c *mockvideoSource_Trigger_Call) RunAndReturn(run func()) *mockvideoSource_Trigger_Call {
401+ _c.Run(run)
402+ return _c
403+}
M internal/adapters/vulkan/capture.go +97 -44
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13 vk "github.com/bnema/purego-vulkan/vulkan"13 vk "github.com/bnema/purego-vulkan/vulkan"
14+ "golang.org/x/sys/unix"
14 )15 )
15 16
16 //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv17 //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv
@@ -107,6 +108,27 @@ func encodeSRGB8(f float32) byte {
107 return byte(math.Round(v * 255))108 return byte(math.Round(v * 255))
108 }109 }
109 110
111+// captureScratch holds the structs a capture passes to Vulkan by pointer.
112+// The purego call makes a pointer to a local escape, so a local would be a
113+// heap allocation per capture; these live in the Renderer instead (the
114+// owner goroutine uses them, and Vulkan reads each only during its call).
115+// Every field is set in full before use. Sequential uses may share a field;
116+// the sync export uses Renderer.call.getFD and .fd.
117+type captureScratch struct {
118+ cmd vk.CommandBuffer
119+ fence vk.Fence
120+ begin vk.CommandBufferBeginInfo
121+ submit vk.SubmitInfo
122+ // The copy (recordCaptureCopy).
123+ region vk.BufferImageCopy
124+ hb vk.BufferMemoryBarrier
125+ b vk.ImageMemoryBarrier
126+ // The HDR conversion pass (recordCaptureHDR).
127+ hdrIn, hdrB vk.ImageMemoryBarrier
128+ attachment vk.RenderingAttachmentInfo
129+ info vk.RenderingInfo
130+}
131+
110 // captureSync is the fence and semaphore surface of a capture slot: the132 // captureSync is the fence and semaphore surface of a capture slot: the
111 // non-blocking poll, the sync file export and the semaphore replacement133 // non-blocking poll, the sync file export and the semaphore replacement
112 // (a seam for tests; the device implements it).134 // (a seam for tests; the device implements it).
@@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer }
128 func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {150 func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {
129 // A zero timeout only reports the fence state (the bindings lack151 // A zero timeout only reports the fence state (the bindings lack
130 // vkGetFenceStatus): Timeout while the copy runs.152 // vkGetFenceStatus): Timeout while the copy runs.
131- return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0)153+ d.r.capCall.fence = f
154+ return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0)
132 }155 }
133 156
134 func (d deviceSync) resetFence(f vk.Fence) vk.Result {157 func (d deviceSync) resetFence(f vk.Fence) vk.Result {
135- return d.r.dd.ResetFences(d.r.device, 1, &f)158+ d.r.capCall.fence = f
159+ return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence)
136 }160 }
137 161
138 func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) {162 func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) {
139- var fd int32163+ sc := &d.r.call
140- get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}164+ sc.fd = -1
141- res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd)165+ sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
142- return fd, res166+ res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd)
167+ return sc.fd, res
143 }168 }
144 169
145 func (d deviceSync) newSemaphore() (vk.Semaphore, error) {170 func (d deviceSync) newSemaphore() (vk.Semaphore, error) {
@@ -170,14 +195,43 @@ type captureSlot struct {
170 // handle kept after EndCapture is invalid by contract (its next195 // handle kept after EndCapture is invalid by contract (its next
171 // state belongs to another lease).196 // state belongs to another lease).
172 frame captureFrame197 frame captureFrame
198+ // file wraps syncFD, the slot's sync file descriptor, which the slot
199+ // owns from the first export to freeCaptureSlot. Every later export
200+ // is moved onto the same descriptor number (dup3), so a capture
201+ // makes no os.File and the descriptor is closed exactly once. Nil
202+ // until the first export.
203+ file *os.File
204+ syncFD int
205+}
206+
207+// adoptSync makes fd, a sync file the caller owns, the slot's. It always
208+// takes ownership of fd: fd is closed on failure and after the move. The
209+// slot's previous sync file is replaced, so a lease must have ended first.
210+func (s *captureSlot) adoptSync(fd int) error {
211+ if s.file == nil {
212+ f := os.NewFile(uintptr(fd), "neferwl-capture")
213+ if f == nil {
214+ return fmt.Errorf("invalid capture sync file %d", fd)
215+ }
216+ s.file, s.syncFD = f, fd
217+ return nil
218+ }
219+ err := unix.Dup3(fd, s.syncFD, unix.O_CLOEXEC)
220+ _ = unix.Close(fd)
221+ if err != nil {
222+ return fmt.Errorf("move capture sync file: %w", err)
223+ }
224+ return nil
173 }225 }
174 226
175 // captureFrame is the lease handed to the worker (ports.CaptureFrame).227 // captureFrame is the lease handed to the worker (ports.CaptureFrame).
176 // Its fields are written by the owner before the handoff and read by the228 // Its fields are written by the owner before the handoff and read by the
177 // worker; the owner touches them again only after EndCapture.229 // worker; the owner touches them again only after EndCapture.
178 type captureFrame struct {230 type captureFrame struct {
179- r *Renderer231+ r *Renderer
180- slot *captureSlot232+ slot *captureSlot
233+ // done is the slot's sync file (nil: nothing to wait for). The slot
234+ // owns it: valid until EndCapture, never closed by the reader.
181 done *os.File235 done *os.File
182 pixels []byte // the slot's mapping, width*height*4236 pixels []byte // the slot's mapping, width*height*4
183 w, h int237 w, h int
@@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync {
301 func (r *Renderer) freeCaptureSlot(s *captureSlot) {355 func (r *Renderer) freeCaptureSlot(s *captureSlot) {
302 d := r.dd356 d := r.dd
303 s.frame.valid = false357 s.frame.valid = false
304- if s.frame.done != nil {358+ s.frame.done = nil
305- s.frame.done.Close()359+ if s.file != nil {
306- s.frame.done = nil360+ _ = s.file.Close()
307 }361 }
308 if s.done != 0 {362 if s.done != 0 {
309 r.captureSync().destroySemaphore(s.done)363 r.captureSync().destroySemaphore(s.done)
@@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
425 if s == nil {479 if s == nil {
426 return nil, ports.ErrCaptureBusy480 return nil, ports.ErrCaptureBusy
427 }481 }
428- d := r.dd482+ d, sc := r.dd, &r.capCall
429- cmd := s.cmd483+ sc.cmd = s.cmd
484+ cmd := sc.cmd
430 if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {485 if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {
431 return nil, err486 return nil, err
432 }487 }
433- begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}488+ sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
434- if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil {489+ if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil {
435 return nil, err490 return nil, err
436 }491 }
437 if hdr {492 if hdr {
@@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
445 // After the frame on the same queue: the copy reads what it drew, and500 // After the frame on the same queue: the copy reads what it drew, and
446 // the next frame's first barrier (transfer source stage) orders its501 // the next frame's first barrier (transfer source stage) orders its
447 // writes after this read.502 // writes after this read.
448- submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}503+ sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
449 if !debug {504 if !debug {
450 // The export below takes the signal; a debug capture waits on505 // The export below takes the signal; a debug capture waits on
451 // the fence only, so the semaphore must not be signalled (a506 // the fence only, so the semaphore must not be signalled (a
452 // binary semaphore is signalled once, then waited or exported).507 // binary semaphore is signalled once, then waited or exported).
453- submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done508+ sc.submit.SignalSemaphoreCount, sc.submit.SignalSemaphores = 1, &s.done
454 }509 }
455- if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil {510+ if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &sc.submit, s.fence)); err != nil {
456 return nil, err511 return nil, err
457 }512 }
458 s.pending = true513 s.pending = true
@@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
469 }524 }
470 if fd >= 0 {525 if fd >= 0 {
471 // -1: already signalled at export, nothing to wait for.526 // -1: already signalled at export, nothing to wait for.
472- f.done = os.NewFile(uintptr(fd), "neferwl-capture")527+ if err := s.adoptSync(int(fd)); err != nil {
473- if f.done == nil {528+ return nil, err
474- return nil, fmt.Errorf("invalid capture sync file %d", fd)
475 }529 }
530+ f.done = s.file
476 }531 }
477 }532 }
478 s.leased, f.valid = true, true533 s.leased, f.valid = true, true
@@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) {
487 return542 return
488 }543 }
489 f.valid = false544 f.valid = false
490- if f.done != nil {545+ f.done = nil // the slot keeps the descriptor for its next lease
491- f.done.Close()
492- f.done = nil
493- }
494 f.slot.leased = false546 f.slot.leased = false
495 }547 }
496 548
@@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error {
566 // Exported targets are borrowed from the display without a layout618 // Exported targets are borrowed from the display without a layout
567 // change: the display may be reading them at the same time.619 // change: the display may be reading them at the same time.
568 func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {620 func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {
569- d := r.dd621+ d, sc := r.dd, &r.capCall
570 if t.exported {622 if t.exported {
571 // Acquire from the display. The frame released the image at623 // Acquire from the display. The frame released the image at
572 // its color attachment stage: this acquire waits for that.624 // its color attachment stage: this acquire waits for that.
573- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}625+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}
574- d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)626+ d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b)
575 }627 }
576- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}628+ sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
577- d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &region)629+ d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region)
578- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}630+ sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}
579- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)631+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil)
580 if t.exported {632 if t.exported {
581- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}633+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}
582- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)634+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b)
583 }635 }
584 }636 }
585 637
@@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.
587 // the sRGB8 capture image with the capture pass, then copies that. hdrOwn639 // the sRGB8 capture image with the capture pass, then copies that. hdrOwn
588 // is put back in TRANSFER_SRC, as the next frame expects.640 // is put back in TRANSFER_SRC, as the next frame expects.
589 func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {641 func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {
590- d, p, out := r.dd, &r.capturePass, &r.captureSDR642+ d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall
591- in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}643+ in, b := &sc.hdrIn, &sc.hdrB
592- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)644+ *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
645+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in)
593 // The previous capture read the image as a transfer source.646 // The previous capture read the image as a transfer source.
594- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange}647+ *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange}
595- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)648+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b)
596- attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}649+ sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
597- info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}650+ sc.info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment}
598- d.CmdBeginRendering(cmd, &info)651+ d.CmdBeginRendering(cmd, &sc.info)
599 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)652 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)
600 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)653 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)
601 d.CmdDraw(cmd, 3, 1, 0, 0)654 d.CmdDraw(cmd, 3, 1, 0, 0)
602 d.CmdEndRendering(cmd)655 d.CmdEndRendering(cmd)
603 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit656 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
604 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal657 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
605- d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)658+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in)
606 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit659 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit
607 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal660 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal
608- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)661+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b)
609 r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)662 r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)
610 }663 }
611 664
M internal/adapters/vulkan/capture_test.go +206 -16
@@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) {
291 }291 }
292 }292 }
293 293
294-// The lease object of a slot is reused: a steady state of captures294+// The lease object of a slot is reused, and a stale handle reports invalid.
295-// allocates only the sync file, and a stale handle reports invalid.
296 func TestCaptureLeaseReused(t *testing.T) {295 func TestCaptureLeaseReused(t *testing.T) {
297 r, err := New(8, 8)296 r, err := New(8, 8)
298 if err != nil {297 if err != nil {
@@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) {
309 t.Fatal("lease object not reused")308 t.Fatal("lease object not reused")
310 }309 }
311 r.EndCapture(second)310 r.EndCapture(second)
312- cycle := func() {311+ dst := make([]byte, 4)
313- cf, err := r.BeginCapture()312+ if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
314- if err != nil {313+ t.Fatal("stale lease read accepted")
315- t.Fatal(err)314+ }
315+}
316+
317+// pollSyncFile waits for a sync file without allocating (syncfile.Wait
318+// builds its poll set per call).
319+func pollSyncFile(f *os.File, fds []unix.PollFd) error {
320+ fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
321+ for {
322+ n, err := unix.Poll(fds, 1000)
323+ switch {
324+ case errors.Is(err, unix.EINTR):
325+ case err != nil:
326+ return err
327+ case n > 0 && fds[0].Revents&unix.POLLIN != 0:
328+ return nil
329+ case n > 0:
330+ return errors.New("sync file poll error")
331+ default:
332+ return errors.New("sync file poll timeout")
316 }333 }
317- if done := cf.Done(); done != nil {334+ }
318- if err := syncfile.Wait(context.Background(), done); err != nil {335+}
336+
337+// openFDs counts the process's open descriptors.
338+func openFDs(t *testing.T) int {
339+ t.Helper()
340+ ents, err := os.ReadDir("/proc/self/fd")
341+ if err != nil {
342+ t.Skipf("no /proc/self/fd: %v", err)
343+ }
344+ return len(ents)
345+}
346+
347+// The renderer capture cycle (lease, wait for the copy, read the rows,
348+// return the lease) allocates nothing once the slots exist, on the SDR and
349+// the HDR conversion path, and holds a constant number of descriptors: the
350+// slot keeps its sync file for every lease. No mock stands in the measured
351+// closure.
352+func TestCaptureCycleAllocations(t *testing.T) {
353+ for _, tc := range []struct {
354+ name string
355+ new func(*testing.T) *Renderer
356+ }{
357+ {"sdr", func(t *testing.T) *Renderer {
358+ r, err := New(16, 8)
359+ if err != nil {
360+ t.Skipf("Vulkan unavailable: %v", err)
361+ }
362+ t.Cleanup(r.Close)
363+ return r
364+ }},
365+ {"hdr", hdrTestRenderer},
366+ } {
367+ t.Run(tc.name, func(t *testing.T) {
368+ r := tc.new(t)
369+ requireCaptureSync(t, r)
370+ if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil {
319 t.Fatal(err)371 t.Fatal(err)
320 }372 }
373+ dst := make([]byte, 16*8*4)
374+ fds := make([]unix.PollFd, 1)
375+ region := image.Rect(0, 0, 16, 8)
376+ var cycleErr error
377+ cycle := func() {
378+ cf, err := r.BeginCapture()
379+ if err != nil {
380+ cycleErr = err
381+ return
382+ }
383+ if done := cf.Done(); done != nil {
384+ if err := pollSyncFile(done, fds); err != nil {
385+ cycleErr = err
386+ }
387+ }
388+ if err := cf.Read(region, dst, 16*4); err != nil {
389+ cycleErr = err
390+ }
391+ r.EndCapture(cf)
392+ }
393+ cycle() // creates the slots, the capture pass and the sync files
394+ cycle()
395+ if cycleErr != nil {
396+ t.Fatal(cycleErr)
397+ }
398+ fdsBefore := openFDs(t)
399+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
400+ t.Errorf("%.1f allocations per capture cycle, want 0", allocs)
401+ }
402+ if cycleErr != nil {
403+ t.Fatal(cycleErr)
404+ }
405+ if after := openFDs(t); after != fdsBefore {
406+ t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore)
407+ }
408+ if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 {
409+ t.Error("capture read back black")
410+ }
411+ })
412+ }
413+}
414+
415+// adoptSync takes ownership of the descriptor it is given: the first one is
416+// wrapped, a later one is moved onto the same descriptor number and its
417+// source is closed, so nothing leaks and nothing is closed twice.
418+func TestCaptureAdoptSync(t *testing.T) {
419+ pipe := func() (r, w int) {
420+ var p [2]int
421+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
422+ t.Fatal(err)
321 }423 }
322- r.EndCapture(cf)424+ return p[0], p[1]
323- }425+ }
324- cycle()426+ r1, w1 := pipe()
325- // No lease wrapper: the allocations left are the Vulkan structs the427+ r2, w2 := pipe()
326- // FFI boundary keeps on the heap (as in Render, budget 16) plus the428+ t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) })
327- // sync file (os.NewFile and its finalizer).429+ var s captureSlot
328- if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 {430+ if err := s.adoptSync(r1); err != nil {
329- t.Errorf("%.1f allocations per capture, want <= 16", allocs)431+ t.Fatal(err)
432+ }
433+ if s.syncFD != r1 || s.file == nil {
434+ t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1)
435+ }
436+ file := s.file
437+ if err := s.adoptSync(r2); err != nil {
438+ t.Fatal(err)
439+ }
440+ if s.file != file || s.syncFD != r1 {
441+ t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD)
442+ }
443+ if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF {
444+ t.Errorf("source descriptor still open after the move: %v", err)
445+ }
446+ if _, err := unix.Write(w2, []byte{7}); err != nil {
447+ t.Fatal(err)
448+ }
449+ var b [1]byte
450+ if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 {
451+ t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err)
452+ }
453+ // A failed move still closes the source.
454+ r3, w3 := pipe()
455+ t.Cleanup(func() { _ = unix.Close(w3) })
456+ bad := captureSlot{file: file, syncFD: -1}
457+ if err := bad.adoptSync(r3); err == nil {
458+ t.Error("move onto an invalid descriptor accepted")
459+ }
460+ if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF {
461+ t.Errorf("source descriptor leaked by a failed move: %v", err)
462+ }
463+ if err := file.Close(); err != nil {
464+ t.Errorf("slot file close: %v", err)
465+ }
466+}
467+
468+// A slot keeps one descriptor for its sync files: it is the same number and
469+// the same file for every lease, closed once by Close, and a lease the
470+// device finished at export (no sync file) hands out none.
471+func TestCaptureSyncFileOwnership(t *testing.T) {
472+ r, err := New(8, 8)
473+ if err != nil {
474+ t.Skipf("Vulkan unavailable: %v", err)
475+ }
476+ requireCaptureSync(t, r)
477+ closed := false
478+ defer func() {
479+ if !closed {
480+ r.Close()
481+ }
482+ }()
483+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
484+ t.Fatal(err)
485+ }
486+ before := openFDs(t)
487+ cf := captureWait(t, r).(*captureFrame)
488+ s := cf.slot
489+ file, fd := cf.Done(), s.syncFD
490+ if file == nil || s.file != file {
491+ t.Fatalf("first lease: done %v, slot file %v", file, s.file)
492+ }
493+ r.EndCapture(cf)
494+ if cf.Done() != nil {
495+ t.Error("ended lease still exposes the sync file")
496+ }
497+ if _, err := file.Stat(); err != nil {
498+ t.Errorf("EndCapture closed the slot's sync file: %v", err)
499+ }
500+ for range 4 {
501+ next := captureWait(t, r).(*captureFrame)
502+ if next.slot == s {
503+ if next.Done() != file || s.syncFD != fd {
504+ t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd)
505+ }
506+ }
507+ r.EndCapture(next)
508+ }
509+ // Two slots, each holding one sync file.
510+ if held := openFDs(t) - before; held > captureSlots {
511+ t.Errorf("%d descriptors held by %d slots", held, captureSlots)
512+ }
513+ r.Close()
514+ closed = true
515+ if _, err := file.Stat(); err == nil {
516+ t.Error("Close left the slot's sync file open")
517+ }
518+ if got := openFDs(t); got > before {
519+ t.Errorf("%d descriptors open after Close, %d before the captures", got, before)
330 }520 }
331 }521 }
332 522
M internal/adapters/vulkan/renderer.go +2 -0
@@ -112,6 +112,8 @@ type Renderer struct {
112 frameStages []vk.PipelineStageFlags112 frameStages []vk.PipelineStageFlags
113 // call holds the structs a per-frame Vulkan call is handed by pointer.113 // call holds the structs a per-frame Vulkan call is handed by pointer.
114 call callScratch114 call callScratch
115+ // capCall holds the structs a capture hands Vulkan by pointer.
116+ capCall captureScratch
115 }117 }
116 118
117 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.119 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
M internal/adapters/wayland/captureauth.go +5 -2
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 6
7 "github.com/bnema/neferwl/internal/adapters/captureallow"7 "github.com/bnema/neferwl/internal/adapters/captureallow"
8+ "github.com/bnema/neferwl/internal/adapters/peerexe"
8 "github.com/bnema/purego-libwayland/server"9 "github.com/bnema/purego-libwayland/server"
9 )10 )
10 11
@@ -13,7 +14,9 @@ import (
13 // capture sessions and frames, whatever their source (output, output region14 // capture sessions and frames, whatever their source (output, output region
14 // or workspace), and the attach of a layer surface to a capture exclusion. A15 // or workspace), and the attach of a layer surface to a capture exclusion. A
15 // refused client gets the failure its protocol defines (failed, or stopped16 // refused client gets the failure its protocol defines (failed, or stopped
16-// for a session) and nothing is captured or flashed. In order:17+// for a session) and nothing is captured or flashed. The same executable
18+// allowlist also gates the D-Bus screencast callers (adapters/screencast). In
19+// order:
17 //20 //
18 // 1. While the session is protected (ext-session-lock) no client may21 // 1. While the session is protected (ext-session-lock) no client may
19 // capture, so nothing of the locked session reaches a buffer.22 // capture, so nothing of the locked session reaches a buffer.
@@ -136,7 +139,7 @@ func (s *Server) peerIdentity(c server.Client) *peerID {
136 id.pidfd, err = s.peer.Pidfd(fd)139 id.pidfd, err = s.peer.Pidfd(fd)
137 if err != nil {140 if err != nil {
138 id.pidfd, id.err = nil, err141 id.pidfd, id.err = nil, err
139- if errors.Is(err, errNoPidfd) && !s.noPidfdWarned {142+ if errors.Is(err, peerexe.ErrNoPidfd) && !s.noPidfdWarned {
140 s.noPidfdWarned = true143 s.noPidfdWarned = true
141 s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")144 s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")
142 }145 }
M internal/adapters/wayland/captureauth_test.go +4 -47
@@ -13,6 +13,7 @@ import (
13 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"13 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
14 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"14 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
15 "github.com/bnema/neferwl/internal/adapters/captureallow"15 "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/adapters/peerexe"
16 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"17 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
17 "github.com/bnema/neferwl/internal/ports"18 "github.com/bnema/neferwl/internal/ports"
18 "github.com/bnema/zerowrap"19 "github.com/bnema/zerowrap"
@@ -275,7 +276,7 @@ func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) {
275 }276 }
276 277
277 func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {278 func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {
278- for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} {279+ for name, err := range map[string]error{"deleted": peerexe.ErrExeDeleted, "unknown": errors.New("no such process")} {
279 t.Run(name, func(t *testing.T) {280 t.Run(name, func(t *testing.T) {
280 peer := newMockpeerExe(t)281 peer := newMockpeerExe(t)
281 pidfd := devNull(t)282 pidfd := devNull(t)
@@ -350,7 +351,7 @@ func TestLinuxPeerExeSelf(t *testing.T) {
350 defer unix.Close(fds[1])351 defer unix.Close(fds[1])
351 var l linuxPeerExe352 var l linuxPeerExe
352 pidfd, err := l.Pidfd(fds[0])353 pidfd, err := l.Pidfd(fds[0])
353- if errors.Is(err, errNoPidfd) {354+ if errors.Is(err, peerexe.ErrNoPidfd) {
354 t.Skip("kernel without SO_PEERPIDFD")355 t.Skip("kernel without SO_PEERPIDFD")
355 }356 }
356 if err != nil {357 if err != nil {
@@ -364,55 +365,11 @@ func TestLinuxPeerExeSelf(t *testing.T) {
364 if exe != self || pid != os.Getpid() {365 if exe != self || pid != os.Getpid() {
365 t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())366 t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
366 }367 }
367- if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) {368+ if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, peerexe.ErrNoPidfd) {
368 t.Fatalf("noPidfd: %v", err)369 t.Fatalf("noPidfd: %v", err)
369 }370 }
370 }371 }
371 372
372-// A process whose binary was removed is refused, and a peer that already
373-// exited is never resolved from its (possibly recycled) pid.
374-func TestLinuxPeerExeDeletedAndExited(t *testing.T) {
375- dir := t.TempDir()
376- path := filepath.Join(dir, "child")
377- src, err := os.ReadFile("/bin/sleep")
378- if err != nil {
379- t.Skip("no /bin/sleep")
380- }
381- if err := os.WriteFile(path, src, 0o755); err != nil {
382- t.Fatal(err)
383- }
384- proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
385- if err != nil {
386- t.Skip("cannot run a copy of sleep: ", err)
387- }
388- defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
389- _ = os.Remove(path)
390- // Wait until exec has completed so /proc/<pid>/exe is the copy.
391- deadline := time.Now().Add(2 * time.Second)
392- for {
393- if _, err := readExe(proc.Pid); errors.Is(err, errExeDeleted) {
394- break
395- }
396- if time.Now().After(deadline) {
397- t.Fatal("deleted executable not refused")
398- }
399- time.Sleep(5 * time.Millisecond)
400- }
401- pidfd, err := unix.PidfdOpen(proc.Pid, 0)
402- if err != nil {
403- t.Skip("pidfd_open: ", err)
404- }
405- defer unix.Close(pidfd)
406- if _, _, err := exeOfPidfd(pidfd); !errors.Is(err, errExeDeleted) {
407- t.Fatalf("deleted: %v", err)
408- }
409- _ = proc.Kill()
410- _, _ = proc.Wait()
411- if exe, _, err := exeOfPidfd(pidfd); err == nil {
412- t.Fatalf("exited peer resolved to %q", exe)
413- }
414-}
415-
416 // The exclusion attach asks too: a HUD whose executable is not listed gets373 // The exclusion attach asks too: a HUD whose executable is not listed gets
417 // `unauthorized` even with the right token, while the recorder that owns the374 // `unauthorized` even with the right token, while the recorder that owns the
418 // exclusion (listed) is unaffected.375 // exclusion (listed) is unaffected.
M internal/adapters/wayland/captureexe.go +10 -83
@@ -1,15 +1,10 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4- "bufio"
5- "errors"
6- "fmt"
7 "os"4 "os"
8 "runtime"5 "runtime"
9- "strconv"
10- "strings"
11 6
12- "golang.org/x/sys/unix"7+ "github.com/bnema/neferwl/internal/adapters/peerexe"
13 )8 )
14 9
15 // peerExe names the executable behind a client connection, in two steps so a10 // peerExe names the executable behind a client connection, in two steps so a
@@ -18,7 +13,8 @@ import (
18 type peerExe interface {13 type peerExe interface {
19 // Pidfd returns a pidfd of the peer of the connected unix socket sockFD14 // Pidfd returns a pidfd of the peer of the connected unix socket sockFD
20 // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled15 // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled
21- // while the file is open. errNoPidfd means the kernel cannot give one.16+ // while the file is open. peerexe.ErrNoPidfd means the kernel cannot give
17+ // one.
22 Pidfd(sockFD int) (*os.File, error)18 Pidfd(sockFD int) (*os.File, error)
23 // Exe returns the path of the executable running as the process behind19 // Exe returns the path of the executable running as the process behind
24 // pidfd, and its pid. A deleted executable, or a process that has20 // pidfd, and its pid. A deleted executable, or a process that has
@@ -26,24 +22,10 @@ type peerExe interface {
26 Exe(pidfd *os.File) (exe string, pid int, err error)22 Exe(pidfd *os.File) (exe string, pid int, err error)
27 }23 }
28 24
29-var (25+// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+),
30- errExeDeleted = errors.New("executable deleted")26+// with the proof of peerexe.ExeOfPidfd. There is no fallback to the
31- // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the27+// SO_PEERCRED pid: it has no such proof, so a kernel without SO_PEERPIDFD
32- // peer cannot be identified safely.28+// identifies nobody.
33- errNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
34-)
35-
36-// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+):
37-//
38-// 1. The pidfd pins the peer process: its pid cannot be recycled while the
39-// descriptor is open. The pid is read from /proc/self/fdinfo/<pidfd>.
40-// 2. /proc/<pid>/exe is read.
41-// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
42-// had exited before the read, the pid could have named someone else, and
43-// the answer is refused.
44-//
45-// There is no fallback to the SO_PEERCRED pid: it has no such proof, so a
46-// kernel without SO_PEERPIDFD identifies nobody.
47 type linuxPeerExe struct {29 type linuxPeerExe struct {
48 // noPidfd simulates a kernel without SO_PEERPIDFD (tests).30 // noPidfd simulates a kernel without SO_PEERPIDFD (tests).
49 noPidfd bool31 noPidfd bool
@@ -51,67 +33,12 @@ type linuxPeerExe struct {
51 33
52 func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {34 func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {
53 if l.noPidfd {35 if l.noPidfd {
54- return nil, errNoPidfd36+ return nil, peerexe.ErrNoPidfd
55- }
56- pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
57- switch {
58- case err == nil:
59- return os.NewFile(uintptr(pidfd), "pidfd"), nil
60- case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
61- return nil, errNoPidfd
62- default:
63- return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
64 }37 }
38+ return peerexe.Pidfd(sockFD)
65 }39 }
66 40
67 func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {41 func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {
68 defer runtime.KeepAlive(pidfd)42 defer runtime.KeepAlive(pidfd)
69- return exeOfPidfd(int(pidfd.Fd()))43+ return peerexe.ExeOfPidfd(int(pidfd.Fd()))
70-}
71-
72-// exeOfPidfd runs the pidfd procedure above.
73-func exeOfPidfd(pidfd int) (string, int, error) {
74- pid, err := pidOfPidfd(pidfd)
75- if err != nil {
76- return "", 0, err
77- }
78- exe, err := readExe(pid)
79- // After the read: a process that is gone may have let its pid be reused.
80- if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
81- return "", pid, fmt.Errorf("peer exited: %w", alive)
82- }
83- return exe, pid, err
84-}
85-
86-// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
87-// process has exited, 0 when it is outside this pid namespace.
88-func pidOfPidfd(pidfd int) (int, error) {
89- f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
90- if err != nil {
91- return 0, err
92- }
93- defer f.Close()
94- sc := bufio.NewScanner(f)
95- for sc.Scan() {
96- if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
97- pid, err := strconv.Atoi(strings.TrimSpace(v))
98- if err != nil || pid <= 0 {
99- return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
100- }
101- return pid, nil
102- }
103- }
104- return 0, errors.New("pidfd fdinfo has no Pid")
105-}
106-
107-// readExe reads /proc/<pid>/exe and refuses a deleted executable.
108-func readExe(pid int) (string, error) {
109- exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
110- if err != nil {
111- return "", err
112- }
113- if strings.HasSuffix(exe, " (deleted)") {
114- return "", errExeDeleted
115- }
116- return exe, nil
117 }44 }
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 // ext-foreign-toplevel-list lists mapped toplevels, read-only: title, app_id12 // ext-foreign-toplevel-list lists mapped toplevels, read-only: title, app_id
13-// and a stable identifier. xdg-desktop-portal-wlr lists the windows it can13+// and a stable identifier. Screen-sharing portals list the windows they can
14 // share with it, and ext_foreign_toplevel_image_capture_source_manager_v114 // share with it, and ext_foreign_toplevel_image_capture_source_manager_v1
15 // takes its handles (window capture, imagecopy.go). It follows the same15 // takes its handles (window capture, imagecopy.go). It follows the same
16 // window state and session-protection rules as wlr-foreign-toplevel.16 // window state and session-protection rules as wlr-foreign-toplevel.
M internal/adapters/wayland/foreigntoplevel.go +1 -0
@@ -206,6 +206,7 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
206 l.closed(id)206 l.closed(id)
207 }207 }
208 s.windowCaptureGone(id)208 s.windowCaptureGone(id)
209+ s.castInventoryChanged()
209 }210 }
210 211
211 // refreshToplevels reconciles inventory after output changes or unlock:212 // refreshToplevels reconciles inventory after output changes or unlock:
M internal/adapters/wayland/frames.go +1 -0
@@ -155,6 +155,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
155 if p.Flip != nil {155 if p.Flip != nil {
156 s.flips[p.Output] = *p.Flip156 s.flips[p.Output] = *p.Flip
157 s.presentFlip(p.Output, p.Flip)157 s.presentFlip(p.Output, p.Flip)
158+ s.castFlip(p.Output, p.Flip)
158 }159 }
159 }160 }
160 s.dropFeedbacks(time.Now())161 s.dropFeedbacks(time.Now())
M internal/adapters/wayland/imagecopy.go +101 -32
@@ -113,6 +113,12 @@ func (s *Server) windowCaptureGone(id ports.WindowID) {
113 c.end(true, true)113 c.end(true, true)
114 }114 }
115 }115 }
116+ for _, c := range s.castSessions {
117+ if c.src.kind == srcWindow && c.src.window == id {
118+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("cast end")
119+ c.end(ports.CaptureReasonWindowGone, nil, true, true)
120+ }
121+ }
116 }122 }
117 123
118 type extraSources struct{ s *Server }124 type extraSources struct{ s *Server }
@@ -180,7 +186,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
180 if src != nil {186 if src != nil {
181 cs = s.captureSources[src.Resource]187 cs = s.captureSources[src.Resource]
182 }188 }
183- state := &captureSession{s: s, src: cs, cursor: options&1 != 0}189+ state := &captureSession{captureTarget: captureTarget{s: s, src: cs}, cursor: options&1 != 0}
184 res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)190 res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
185 if err != nil {191 if err != nil {
186 return192 return
@@ -243,19 +249,26 @@ func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCurso
243 }249 }
244 }250 }
245 251
252+// captureTarget is what every registered capture follows, whatever consumes
253+// its frames (an ext_image_copy_capture_session_v1 or a screencast cast): the
254+// source, the id core knows it by and core's last decision about it.
255+type captureTarget struct {
256+ s *Server
257+ src *captureSource
258+ id uint64 // registered with core; 0 when never
259+ // st is the last decision of core; haveState tells there is one.
260+ st ports.CaptureSessionState
261+ haveState bool
262+}
263+
246 // captureSession is one ext_image_copy_capture_session_v1.264 // captureSession is one ext_image_copy_capture_session_v1.
247 type captureSession struct {265 type captureSession struct {
248- s *Server266+ captureTarget
249 res *ext.ExtImageCopyCaptureSessionV1267 res *ext.ExtImageCopyCaptureSessionV1
250- src *captureSource
251- id uint64 // registered with core; 0 when never
252 cursor, stopped bool268 cursor, stopped bool
253 frame *captureExtFrame269 frame *captureExtFrame
254 sent image.Point // buffer size of the last constraints270 sent image.Point // buffer size of the last constraints
255- // st is the last decision of core; haveState tells there is one.271+ excl *exclusion // the live exclusion object of this session
256- st ports.CaptureSessionState
257- haveState bool
258- excl *exclusion // the live exclusion object of this session
259 // exclSeen: the last state of core knew an exclusion. Frames of an272 // exclSeen: the last state of core knew an exclusion. Frames of an
260 // excluded session leave the HUD out while it is (see tag).273 // excluded session leave the HUD out while it is (see tag).
261 exclSeen bool274 exclSeen bool
@@ -290,7 +303,7 @@ func (c *captureSession) end(send, notify bool) {
290 // image of its off-screen render, from the origin) and whether the workspace303 // image of its off-screen render, from the origin) and whether the workspace
291 // is off screen. Core's last state decides where a workspace is; before it,304 // is off screen. Core's last state decides where a workspace is; before it,
292 // the last workspace snapshot does.305 // the last workspace snapshot does.
293-func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok bool) {306+func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok bool) {
294 src := c.src307 src := c.src
295 if src == nil {308 if src == nil {
296 return nil, image.Rectangle{}, false, false309 return nil, image.Rectangle{}, false, false
@@ -377,24 +390,57 @@ func (c *captureSession) sendConstraints() {
377 c.res.SendDone()390 c.res.SendDone()
378 }391 }
379 392
380-// refresh follows the target: new constraints when the buffer size changed,393+// targetFate says what became of a followed target.
381-// the end when nothing is left of it.394+type targetFate uint8
382-func (c *captureSession) refresh() {395+
383- if c.stopped || c.id == 0 {396+const (
384- return397+ targetLive targetFate = iota
385- }398+ targetWorkspaceGone // the workspace left the snapshot
399+ targetEmpty // an output or region with nothing left
400+)
401+
402+// follow looks at the target now: its fate and, while it lives, its geometry
403+// (rect, ok as geometry gives them). A window or workspace that is not
404+// locatable for the moment stays live: core's next state decides.
405+func (c *captureTarget) follow() (fate targetFate, rect image.Rectangle, ok bool) {
386 if c.src.kind == srcWorkspace {406 if c.src.kind == srcWorkspace {
387 // The workspace left the snapshot: its handle is removed, stop at once407 // The workspace left the snapshot: its handle is removed, stop at once
388 // without waiting for core's state.408 // without waiting for core's state.
389 if _, _, found := c.s.workspaceInfo(c.src.workspace); !found {409 if _, _, found := c.s.workspaceInfo(c.src.workspace); !found {
390- c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end")410+ return targetWorkspaceGone, image.Rectangle{}, false
391- c.end(true, true)
392- return
393 }411 }
394 }412 }
395- _, rect, _, ok := c.geometry()413+ _, rect, _, ok = c.geometry()
414+ if !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow {
415+ return targetEmpty, rect, false
416+ }
417+ return targetLive, rect, ok
418+}
419+
420+// applyState takes what core decided; it reports a decision that ends the
421+// target (a terminal reason).
422+func (c *captureTarget) applyState(st ports.CaptureSessionState) (ended bool) {
423+ if st.Reason.Terminal() {
424+ c.s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end")
425+ return true
426+ }
427+ c.st, c.haveState = st, true
428+ c.s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state")
429+ return false
430+}
431+
432+// refresh follows the target: new constraints when the buffer size changed,
433+// the end when nothing is left of it.
434+func (c *captureSession) refresh() {
435+ if c.stopped || c.id == 0 {
436+ return
437+ }
438+ fate, rect, ok := c.follow()
396 switch {439 switch {
397- case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow:440+ case fate == targetWorkspaceGone:
441+ c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end")
442+ c.end(true, true)
443+ case fate == targetEmpty:
398 c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")444 c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")
399 c.end(true, true)445 c.end(true, true)
400 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:446 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:
@@ -407,6 +453,9 @@ func (s *Server) refreshSessions() {
407 for c := range s.captureSessions {453 for c := range s.captureSessions {
408 c.refresh()454 c.refresh()
409 }455 }
456+ for _, c := range s.castSessions {
457+ c.refresh()
458+ }
410 }459 }
411 460
412 // endCaptureSessions stops every session, and its exclusion with it, when461 // endCaptureSessions stops every session, and its exclusion with it, when
@@ -419,6 +468,11 @@ func (s *Server) endCaptureSessions() {
419 }468 }
420 c.end(true, true)469 c.end(true, true)
421 }470 }
471+ for _, c := range s.castSessions {
472+ s.log.Info().Uint64("cast", c.cast).Str("reason", "session-protected").Msg("cast end")
473+ c.end(ports.CaptureReasonNone, errCastProtected, true, true)
474+ }
475+ s.castInventoryChanged()
422 }476 }
423 477
424 // outputGone ends the sessions of an unplugged output without waiting for478 // outputGone ends the sessions of an unplugged output without waiting for
@@ -430,21 +484,29 @@ func (s *Server) outputGone(o *output) {
430 c.end(true, true)484 c.end(true, true)
431 }485 }
432 }486 }
487+ for _, c := range s.castSessions {
488+ if c.src.kind == srcOutput && c.src.output == o {
489+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("cast end")
490+ c.end(ports.CaptureReasonOutputGone, nil, true, true)
491+ }
492+ }
433 }493 }
434 494
435 // captureState applies what core decided about a session.495 // captureState applies what core decided about a session.
436 func (s *Server) captureState(st ports.CaptureSessionState) {496 func (s *Server) captureState(st ports.CaptureSessionState) {
497+ if c := s.castByCore[st.ID]; c != nil {
498+ c.state(st)
499+ return
500+ }
437 c := s.sessionsByID[st.ID]501 c := s.sessionsByID[st.ID]
438 if c == nil {502 if c == nil {
439 return503 return
440 }504 }
441- if st.Reason.Terminal() {505+ if c.applyState(st) {
442- s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end")
443 c.end(true, false)506 c.end(true, false)
444 return507 return
445 }508 }
446- c.st, c.haveState, c.exclSeen = st, true, st.Exclusion509+ c.exclSeen = st.Exclusion
447- s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state")
448 if ex := c.excl; ex != nil {510 if ex := c.excl; ex != nil {
449 ex.confirm(st.Layers)511 ex.confirm(st.Layers)
450 }512 }
@@ -457,6 +519,19 @@ func (s *Server) captureState(st ports.CaptureSessionState) {
457 // still in the last state, a frame is not served rather than shown with the519 // still in the last state, a frame is not served rather than shown with the
458 // HUD in it (ok false).520 // HUD in it (ok false).
459 func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {521 func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
522+ t = c.targetTag(hidden)
523+ if c.excl != nil && !c.exclSeen {
524+ return t, false
525+ }
526+ if c.exclSeen {
527+ t.exclude, t.revision = true, c.st.Revision
528+ }
529+ return t, true
530+}
531+
532+// targetTag is the part of a frame's tag the target decides: the session,
533+// what is captured and what core is told of it.
534+func (c *captureTarget) targetTag(hidden bool) (t captureTag) {
460 t = captureTag{session: c.id}535 t = captureTag{session: c.id}
461 switch c.src.kind {536 switch c.src.kind {
462 case srcWorkspace:537 case srcWorkspace:
@@ -475,13 +550,7 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
475 case srcWindow:550 case srcWindow:
476 t.taken = ports.CaptureFrameTaken{Window: c.src.window}551 t.taken = ports.CaptureFrameTaken{Window: c.src.window}
477 }552 }
478- if c.excl != nil && !c.exclSeen {553+ return t
479- return t, false
480- }
481- if c.exclSeen {
482- t.exclude, t.revision = true, c.st.Revision
483- }
484- return t, true
485 }554 }
486 555
487 func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {556 func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
@@ -518,7 +587,7 @@ type captureExtFrame struct {
518 // A frame whose target is briefly unavailable (not active yet, rendered off587 // A frame whose target is briefly unavailable (not active yet, rendered off
519 // screen by another session, its indicator or exclusion not on screen yet,588 // screen by another session, its indicator or exclusion not on screen yet,
520 // a renderer slot busy) is tried again rather than failed: failed(unknown)589 // a renderer slot busy) is tried again rather than failed: failed(unknown)
521-// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only590+// ends the whole screencast in screen-sharing clients. Only
522 // a stopped session, a refused client or a wrong buffer fail at once.591 // a stopped session, a refused client or a wrong buffer fail at once.
523 const (592 const (
524 captureRetryEvery = 16 * time.Millisecond593 captureRetryEvery = 16 * time.Millisecond
M internal/adapters/wayland/outputmanagement.go +1 -0
@@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) {
189 return189 return
190 }190 }
191 s.outputHeads = h191 s.outputHeads = h
192+ s.castInventoryChanged()
192 s.refreshOutputManagers()193 s.refreshOutputManagers()
193 }194 }
194 func (s *Server) refreshOutputManagers() {195 func (s *Server) refreshOutputManagers() {
M internal/adapters/wayland/outputs.go +4 -0
@@ -160,11 +160,15 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
160 // changed ones resent.160 // changed ones resent.
161 func (s *Server) setOutputs(c ports.SetOutputs) {161 func (s *Server) setOutputs(c ports.SetOutputs) {
162 changed := !slices.Equal(s.outputPlaces, c.Outputs)162 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163+ focusChanged := s.focusedOutput != c.Focused
163 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)164 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
164 defer func() {165 defer func() {
165 if changed {166 if changed {
166 s.refreshOutputManagers()167 s.refreshOutputManagers()
167 }168 }
169+ if changed || focusChanged {
170+ s.castInventoryChanged()
171+ }
168 }()172 }()
169 s.focusedOutput = c.Focused173 s.focusedOutput = c.Focused
170 want := map[string]ports.OutputPlacement{}174 want := map[string]ports.OutputPlacement{}
M internal/adapters/wayland/popup.go +5 -0
@@ -66,6 +66,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
66 }66 }
67 case buffer && !w.mapped && x.acked && !pp.done:67 case buffer && !w.mapped && x.acked && !pp.done:
68 w.mapped = true68 w.mapped = true
69+ s.castCommitted(w.id)
69 s.emit(ports.PopupMapped{ID: w.id})70 s.emit(ports.PopupMapped{ID: w.id})
70 s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped")71 s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped")
71 case !buffer && w.mapped:72 case !buffer && w.mapped:
@@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) {
146 }147 }
147 p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))148 p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))
148 s := p.w.xdg.server149 s := p.w.xdg.server
150+ if p.w.mapped {
151+ // A moved popup changes the toplevel's cast picture.
152+ s.castCommitted(p.w.id)
153+ }
149 s.serial++154 s.serial++
150 p.w.xdg.resource.SendConfigure(s.serial)155 p.w.xdg.resource.SendConfigure(s.serial)
151 p.w.xdg.serials = append(p.w.xdg.serials, s.serial)156 p.w.xdg.serials = append(p.w.xdg.serials, s.serial)
A internal/adapters/wayland/postring.go +98 -0
@@ -0,0 +1,98 @@
1+package wayland
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// postRing carries values from producer goroutines to the display goroutine
10+// without allocating. It is a transport queue, not window state: the mutex
11+// guards only the slices and the pending flag. Values are never merged or
12+// dropped, in order of arrival.
13+//
14+// It binds its drain once and posts it at most once per batch: Display.Post
15+// is unbounded (a Post per value would let a fast producer grow the queue),
16+// so one pending drain serves every value pushed before it runs.
17+type postRing[T any] struct {
18+ display *server.Display
19+ apply func(T)
20+ drainFn func()
21+ // space is signalled (never blocking) after each drain and after a push
22+ // that left room, to wake blocked pushers one after the other.
23+ space chan struct{}
24+
25+ mu sync.Mutex
26+ ring, spare []T
27+ limit int
28+ pending bool // a drain is posted and has not taken the ring yet
29+}
30+
31+// newPostRing returns a ring of limit values whose values apply runs on the
32+// display goroutine, in order. limit must be at least 1.
33+func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] {
34+ r := &postRing[T]{
35+ display: d,
36+ apply: apply,
37+ space: make(chan struct{}, 1),
38+ ring: make([]T, 0, limit),
39+ spare: make([]T, 0, limit),
40+ limit: limit,
41+ }
42+ r.drainFn = r.drain
43+ return r
44+}
45+
46+// Push queues v for the display goroutine and wakes it. A full ring blocks
47+// the caller, which must not be the display goroutine, until a drain made
48+// room; it reports false, with v dropped, once the display has stopped.
49+func (r *postRing[T]) Push(v T) bool {
50+ for {
51+ r.mu.Lock()
52+ if len(r.ring) < r.limit {
53+ r.ring = append(r.ring, v)
54+ post := !r.pending
55+ r.pending = true
56+ room := len(r.ring) < r.limit
57+ r.mu.Unlock()
58+ if post {
59+ r.display.Post(r.drainFn)
60+ }
61+ if room {
62+ r.signal() // chain the wake to another blocked pusher
63+ }
64+ return true
65+ }
66+ r.mu.Unlock()
67+ select {
68+ case <-r.space:
69+ case <-r.display.Stopped():
70+ return false
71+ }
72+ }
73+}
74+
75+func (r *postRing[T]) signal() {
76+ select {
77+ case r.space <- struct{}{}:
78+ default:
79+ }
80+}
81+
82+// drain runs on the display goroutine and applies the batch in order.
83+func (r *postRing[T]) drain() {
84+ r.mu.Lock()
85+ batch := r.ring
86+ r.ring, r.spare = r.spare[:0], nil
87+ r.pending = false
88+ r.mu.Unlock()
89+ r.signal()
90+ for _, v := range batch {
91+ r.apply(v)
92+ }
93+ // Do not retain what the values reference in the spare batch.
94+ clear(batch)
95+ r.mu.Lock()
96+ r.spare = batch[:0]
97+ r.mu.Unlock()
98+}
A internal/adapters/wayland/postring_test.go +160 -0
@@ -0,0 +1,160 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Pushing a capture reply and applying it on the display goroutine allocates
14+// nothing in steady state. The count includes the display goroutine:
15+// AllocsPerRun reads the global malloc counter.
16+func TestCapturedForwardAllocations(t *testing.T) {
17+ if raceEnabled {
18+ t.Skip("the race detector makes the purego wake callback allocate")
19+ }
20+ s, _, _, _, _, _ := presentServer(t)
21+ done := ports.CaptureDone{ID: 77, Output: "HEADLESS-1", Time: time.Unix(1, 2)}
22+ // Posts run in order: once the marker ran, the drain before it did. The
23+ // marker is bound once and the channel reused, so waiting allocates nothing.
24+ ran := make(chan struct{}, 1)
25+ marker := func() { ran <- struct{}{} }
26+ for range 4 {
27+ s.capturedRing.Push(done)
28+ s.display.Post(marker)
29+ <-ran
30+ }
31+ allocs := testing.AllocsPerRun(200, func() {
32+ s.capturedRing.Push(done)
33+ s.display.Post(marker)
34+ <-ran
35+ })
36+ if allocs != 0 {
37+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
38+ }
39+}
40+
41+// A full ring blocks the push until the display goroutine drains it, and no
42+// value is lost or reordered.
43+func TestPostRingFullBlocksWithoutLoss(t *testing.T) {
44+ s, _, _, _, _, _ := presentServer(t)
45+ const limit, total = 4, 3*4 + 1
46+ var got []int // display goroutine only
47+ r := newPostRing(s.display, limit, func(v int) { got = append(got, v) })
48+
49+ // Stall the display goroutine so nothing drains.
50+ release := make(chan struct{})
51+ stalled := make(chan struct{})
52+ s.display.Post(func() { close(stalled); <-release })
53+ <-stalled
54+
55+ pushed := make(chan int, total)
56+ var wg sync.WaitGroup
57+ wg.Add(1)
58+ go func() {
59+ defer wg.Done()
60+ for i := range total {
61+ if !r.Push(i) {
62+ t.Errorf("push %d refused", i)
63+ return
64+ }
65+ pushed <- i
66+ }
67+ }()
68+ for range limit {
69+ select {
70+ case <-pushed:
71+ case <-time.After(2 * time.Second):
72+ t.Fatal("pushes below the limit blocked")
73+ }
74+ }
75+ select {
76+ case i := <-pushed:
77+ t.Fatalf("push %d returned on a full ring", i)
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+ close(release)
81+ wg.Wait()
82+ // The marker runs after every drain posted before it.
83+ var final []int
84+ ran := make(chan struct{})
85+ for {
86+ s.display.Post(func() {
87+ if len(got) == total {
88+ final = append(final, got...)
89+ }
90+ ran <- struct{}{}
91+ })
92+ <-ran
93+ if final != nil {
94+ break
95+ }
96+ time.Sleep(time.Millisecond)
97+ }
98+ for i, v := range final {
99+ if v != i {
100+ t.Fatalf("applied %v, want 0..%d in order", final, total-1)
101+ }
102+ }
103+}
104+
105+// A push blocked on a full ring resumes once the display drains it.
106+func TestPostRingBlockedPushResumes(t *testing.T) {
107+ s, _, _, _, _, _ := presentServer(t)
108+ r := newPostRing(s.display, 1, func(int) {})
109+ release := make(chan struct{})
110+ stalled := make(chan struct{})
111+ s.display.Post(func() { close(stalled); <-release })
112+ <-stalled
113+ if !r.Push(1) {
114+ t.Fatal("first push refused")
115+ }
116+ result := make(chan bool, 1)
117+ go func() { result <- r.Push(2) }()
118+ select {
119+ case ok := <-result:
120+ t.Fatalf("push on a full ring returned %v", ok)
121+ case <-time.After(50 * time.Millisecond):
122+ }
123+ close(release)
124+ select {
125+ case ok := <-result:
126+ if !ok {
127+ t.Fatal("push refused while the display runs")
128+ }
129+ case <-time.After(2 * time.Second):
130+ t.Fatal("blocked push never resumed")
131+ }
132+}
133+
134+// A push blocked on a full ring returns false once the display has stopped,
135+// instead of waiting for a drain that never comes.
136+func TestPostRingPushStopsWithDisplay(t *testing.T) {
137+ s, err := New(Options{RuntimeDir: t.TempDir()}, Channels{}, logging.For(context.Background(), "wayland"))
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ ctx, cancel := context.WithCancel(context.Background())
142+ done := make(chan error, 1)
143+ go func() { done <- s.Run(ctx) }()
144+ cancel()
145+ <-done
146+ r := newPostRing(s.display, 1, func(int) {})
147+ if !r.Push(1) { // room: queued, its drain is dropped by the stopped display
148+ t.Fatal("first push refused")
149+ }
150+ result := make(chan bool, 1)
151+ go func() { result <- r.Push(2) }()
152+ select {
153+ case ok := <-result:
154+ if ok {
155+ t.Fatal("push on a stopped display reported success")
156+ }
157+ case <-time.After(2 * time.Second):
158+ t.Fatal("push blocked after the display stopped")
159+ }
160+}
A internal/adapters/wayland/screencast.go +500 -0
@@ -0,0 +1,500 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "image"
6+ "reflect"
7+ "slices"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// Screencast. The screencast adapter (a D-Bus service that feeds PipeWire
15+// streams) drives casts through ports.CastCommand on Channels.CastCommands
16+// and hears back through Options.Casts (ports.CastSink). A cast is a capture
17+// target registered with core like an ext-image-copy-capture session
18+// (ports.CaptureSessionOpen), so the capture indicator stays on while it
19+// lives, but its frames are written straight into the PipeWire buffer the
20+// adapter lends (ports.CaptureRequest.Mapped) and it has no wayland client:
21+// the adapter checked its caller, so casts bypass mayCapture, not protected().
22+//
23+// Everything here runs on the display goroutine, which owns the casts. The
24+// frame cycle (CastFrame, the capture reply, CastFrameDone) and the damage
25+// events allocate nothing; opening a cast and publishing the inventory may.
26+
27+// maxCasts bounds the live casts. Each has at most one frame in flight, and
28+// a frame takes one of the maxCaptureInflight admission credits like any
29+// capture, so capturedRingSize (MaxCaptureInflight + maxCasts) holds every
30+// reply.
31+const maxCasts = 8
32+
33+// castRingSize bounds the cast commands waiting for the display goroutine.
34+const castRingSize = 64
35+
36+var (
37+ errCastNoTarget = errors.New("cast: target missing")
38+ errCastLimit = errors.New("cast: too many casts")
39+ errCastProtected = errors.New("cast: session is protected")
40+ errCastStopped = errors.New("cast: stopped")
41+ errCastNotReady = errors.New("cast: target not ready")
42+ errCastSize = errors.New("cast: frame size differs from the target")
43+ errCastBusy = errors.New("cast: frame already in flight")
44+ errCastUnknown = errors.New("cast: unknown cast")
45+)
46+
47+// castSession is one cast.
48+type castSession struct {
49+ captureTarget
50+ cast uint64 // the adapter's ID
51+ cursor bool
52+ // first: CaptureFrameTaken was sent, which turns the indicator on.
53+ first bool
54+ size image.Point // the size the adapter was last told
55+ // dirty: the window committed since the last CastDamage (window casts).
56+ dirty bool
57+ stopped bool
58+ // frame is the frame the adapter waits for (waiting) and, once its
59+ // capture was accepted, whether the output owns the buffer (inflight).
60+ frame ports.CastCommand
61+ waiting bool
62+ inflight bool
63+ deadline time.Time
64+ retryTimer ports.Timer
65+ // doneFn, retryFn and timerFn are bound once, so a frame binds no
66+ // closure: the capture reply, the retry on the display goroutine and the
67+ // timer callback that posts it.
68+ doneFn func(ports.CaptureDone)
69+ retryFn func()
70+ timerFn func()
71+ // ending: the cast ended while a capture still wrote into the adapter's
72+ // buffer; CastStopped follows its reply.
73+ ending, announce bool
74+ endReason ports.CaptureReason
75+ endErr error
76+}
77+
78+// postRetry is the retry timer's callback, on the timer's goroutine: it
79+// posts the retry to the display goroutine.
80+func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) }
81+
82+// push hands an event to the adapter.
83+func (c *castSession) push(ev ports.CastEvent) {
84+ ev.ID = c.cast
85+ c.s.pushCast(ev)
86+}
87+
88+// pushCast sends ev to the sink; Push never blocks.
89+func (s *Server) pushCast(ev ports.CastEvent) {
90+ if s.casts != nil {
91+ s.casts.Push(ev)
92+ }
93+}
94+
95+// applyCast runs one command of the adapter on the display goroutine.
96+func (s *Server) applyCast(cmd ports.CastCommand) {
97+ switch cmd.Kind {
98+ case ports.CastOpen:
99+ s.openCast(cmd)
100+ case ports.CastFrame:
101+ c := s.castSessions[cmd.ID]
102+ switch {
103+ case c == nil:
104+ s.refuseCastFrame(cmd, errCastUnknown)
105+ case c.waiting:
106+ s.refuseCastFrame(cmd, errCastBusy)
107+ default:
108+ c.frame, c.waiting = cmd, true
109+ c.deadline = s.clock.Now().Add(captureRetryFor)
110+ c.attempt()
111+ }
112+ case ports.CastClose:
113+ if c := s.castSessions[cmd.ID]; c != nil {
114+ c.end(ports.CaptureReasonNone, nil, true, false)
115+ }
116+ }
117+}
118+
119+// refuseCastFrame answers a frame no cast takes, so the adapter gets its
120+// buffer back.
121+func (s *Server) refuseCastFrame(cmd ports.CastCommand, err error) {
122+ s.pushCast(ports.CastEvent{Kind: ports.CastFrameDone, ID: cmd.ID, Buf: cmd.Buf, Err: err})
123+}
124+
125+// refuseCast ends a cast that never began.
126+func (s *Server) refuseCast(id uint64, reason ports.CaptureReason, err error) {
127+ s.log.Info().Uint64("cast", id).Str("reason", string(reason)).Err(err).Msg("cast refused")
128+ s.pushCast(ports.CastEvent{Kind: ports.CastStopped, ID: id, Reason: reason, Err: err})
129+}
130+
131+// openCast registers a cast with core and tells the adapter its size.
132+func (s *Server) openCast(cmd ports.CastCommand) {
133+ switch {
134+ case s.castSessions[cmd.ID] != nil:
135+ s.log.Warn().Uint64("cast", cmd.ID).Msg("cast open: duplicate ID ignored")
136+ return
137+ case len(s.castSessions) >= maxCasts:
138+ s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastLimit)
139+ return
140+ case s.protected():
141+ s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastProtected)
142+ return
143+ }
144+ src := &captureSource{kind: srcDead}
145+ open := ports.CaptureSessionOpen{}
146+ gone := ports.CaptureReasonOutputGone
147+ if cmd.Window != 0 {
148+ gone = ports.CaptureReasonWindowGone
149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
150+ src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151+ open.Window = cmd.Window
152+ }
153+ } else if o := s.outputByNameExact(cmd.Output); o != nil {
154+ src = &captureSource{kind: srcOutput, output: o}
155+ open.Output = o.name()
156+ }
157+ c := &castSession{captureTarget: captureTarget{s: s, src: src}, cast: cmd.ID, cursor: cmd.Cursor}
158+ if src.kind == srcDead {
159+ s.refuseCast(cmd.ID, gone, errCastNoTarget)
160+ return
161+ }
162+ _, rect, _, ok := c.geometry()
163+ if !ok {
164+ s.refuseCast(cmd.ID, gone, errCastNoTarget)
165+ return
166+ }
167+ c.doneFn, c.retryFn, c.timerFn = c.onDone, c.retryNow, c.postRetry
168+ s.nextSession++
169+ c.id = s.nextSession
170+ open.ID = c.id
171+ c.size = image.Pt(rect.Dx(), rect.Dy())
172+ s.castSessions[cmd.ID], s.castByCore[c.id] = c, c
173+ s.emit(open)
174+ s.log.Info().Uint64("cast", cmd.ID).Uint64("session", c.id).Int("w", c.size.X).Int("h", c.size.Y).Msg("cast open")
175+ c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
176+}
177+
178+// state applies what core decided about the cast.
179+func (c *castSession) state(st ports.CaptureSessionState) {
180+ if c.applyState(st) {
181+ c.end(st.Reason, nil, false, true)
182+ return
183+ }
184+ c.refresh()
185+}
186+
187+// refresh follows the target: a new size goes to the adapter, which has to
188+// renegotiate its stream; nothing left of the target ends the cast.
189+func (c *castSession) refresh() {
190+ if c.stopped {
191+ return
192+ }
193+ fate, rect, ok := c.follow()
194+ switch {
195+ case fate == targetWorkspaceGone:
196+ c.end(ports.CaptureReasonWorkspaceGone, nil, true, true)
197+ case fate == targetEmpty:
198+ c.s.log.Info().Uint64("cast", c.cast).Msg("cast end: target empty")
199+ c.end(ports.CaptureReasonOutputGone, nil, true, true)
200+ case ok && image.Pt(rect.Dx(), rect.Dy()) != c.size:
201+ c.size = image.Pt(rect.Dx(), rect.Dy())
202+ c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
203+ }
204+}
205+
206+// end stops the cast once. notify tells core (which has to forget it unless
207+// it decided the end itself), announce tells the adapter (CastStopped; not
208+// for its own CastClose). A capture that still writes into the adapter's
209+// buffer finishes first: its CastFrameDone, then CastStopped, so CastStopped
210+// means the buffers are the adapter's again.
211+func (c *castSession) end(reason ports.CaptureReason, err error, notify, announce bool) {
212+ if c.stopped {
213+ return
214+ }
215+ s := c.s
216+ c.stopped = true
217+ if c.retryTimer != nil {
218+ c.retryTimer.Stop()
219+ }
220+ delete(s.castSessions, c.cast)
221+ delete(s.castByCore, c.id)
222+ if notify {
223+ s.emit(ports.CaptureSessionClose{ID: c.id})
224+ }
225+ c.announce, c.endReason, c.endErr = announce, reason, err
226+ if c.inflight {
227+ c.ending = true
228+ return
229+ }
230+ if c.waiting {
231+ c.frameDone(errCastStopped, time.Time{})
232+ }
233+ c.finish()
234+}
235+
236+// finish tells the adapter the cast is over.
237+func (c *castSession) finish() {
238+ if c.announce {
239+ c.push(ports.CastEvent{Kind: ports.CastStopped, Reason: c.endReason, Err: c.endErr})
240+ }
241+}
242+
243+// frameDone answers the frame the adapter waits for.
244+func (c *castSession) frameDone(err error, at time.Time) {
245+ c.waiting = false
246+ c.push(ports.CastEvent{Kind: ports.CastFrameDone, Buf: c.frame.Buf, Err: err, Time: at})
247+ c.frame = ports.CastCommand{}
248+}
249+
250+// retryNow is the posted retry timer: the frame is tried again.
251+func (c *castSession) retryNow() {
252+ if !c.stopped && c.waiting && !c.inflight {
253+ c.attempt()
254+ }
255+}
256+
257+// retry tries the frame again shortly, or fails it past its deadline, as a
258+// frame of an ext session does (captureExtFrame.retry).
259+func (c *castSession) retry(why error) {
260+ s := c.s
261+ if !s.clock.Now().Before(c.deadline) {
262+ s.log.Info().Uint64("cast", c.cast).Err(why).Msg("cast frame failed after retries")
263+ c.frameDone(why, time.Time{})
264+ return
265+ }
266+ if c.retryTimer == nil {
267+ c.retryTimer = s.clock.AfterFunc(captureRetryEvery, c.timerFn)
268+ return
269+ }
270+ c.retryTimer.Reset(captureRetryEvery)
271+}
272+
273+// attempt serves the waiting frame from the target as it is now.
274+func (c *castSession) attempt() {
275+ s := c.s
276+ if s.protected() {
277+ c.end(ports.CaptureReasonNone, errCastProtected, true, true)
278+ return
279+ }
280+ o, rect, hidden, ok := c.geometry()
281+ if !ok || c.haveState && !c.st.Active {
282+ c.retry(errCastNotReady)
283+ return
284+ }
285+ if rect.Dx() != c.frame.Width || rect.Dy() != c.frame.Height {
286+ // The target moved on: the adapter has to renegotiate before it can
287+ // ask again; tell it now if refresh has not yet.
288+ c.refresh()
289+ if !c.stopped {
290+ c.frameDone(errCastSize, time.Time{})
291+ }
292+ return
293+ }
294+ if !s.requestCast(c, o, rect, c.targetTag(hidden)) {
295+ c.retry(ports.ErrCaptureBusy)
296+ }
297+}
298+
299+// requestCast queues the capture of the waiting frame into the adapter's
300+// buffer. The reply is the cast's own prebound function, not a closure per
301+// frame. False: the in-flight bound or the renderer queue is full.
302+func (s *Server) requestCast(c *castSession, o *output, rect image.Rectangle, tag captureTag) bool {
303+ f := c.frame
304+ if o == nil || s.channels.Captures == nil || s.channels.Captured == nil || len(s.captureInflight) >= maxCaptureInflight {
305+ return false
306+ }
307+ s.nextCapture++
308+ id := s.nextCapture
309+ s.captureInflight[id] = struct{}{}
310+ s.captureReplies[id] = c.doneFn
311+ req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: c.cursor, Mapped: f.Data, Width: f.Width, Height: f.Height, Stride: f.Stride, Format: f.Format, Session: tag.session, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true}
312+ select {
313+ case s.channels.Captures <- req:
314+ default:
315+ delete(s.captureReplies, id)
316+ delete(s.captureInflight, id)
317+ return false
318+ }
319+ c.inflight = true
320+ if !c.first {
321+ // The indicator is on screen while the frame is rendered; the cast
322+ // keeps it on for as long as it lives.
323+ c.first = true
324+ taken := tag.taken
325+ taken.Session = tag.session
326+ s.emit(taken)
327+ }
328+ return true
329+}
330+
331+// onDone settles the capture of the waiting frame.
332+func (c *castSession) onDone(done ports.CaptureDone) {
333+ c.inflight = false
334+ if errors.Is(done.Err, ports.ErrCaptureTransient) && !c.stopped {
335+ c.retry(done.Err)
336+ return
337+ }
338+ err := done.Err
339+ if err != nil {
340+ c.s.log.Info().Uint64("cast", c.cast).Err(err).Msg("cast frame failed")
341+ }
342+ if c.ending {
343+ // The cast is over: the adapter must not queue this frame.
344+ err = errors.Join(errCastStopped, err)
345+ }
346+ if c.waiting {
347+ c.frameDone(err, done.Time)
348+ }
349+ if c.ending {
350+ c.ending = false
351+ c.finish()
352+ }
353+}
354+
355+// castFlip answers a flip of an output: the casts on it have something new
356+// to show. An output cast always has; a window cast only when its window
357+// committed since the last damage. Display goroutine; no allocation.
358+func (s *Server) castFlip(output string, f *ports.FlipInfo) {
359+ if len(s.castSessions) == 0 {
360+ return
361+ }
362+ var at time.Time
363+ for _, c := range s.castSessions {
364+ switch c.src.kind {
365+ case srcOutput:
366+ if c.src.output.name() != output {
367+ continue
368+ }
369+ case srcWindow:
370+ if !c.dirty {
371+ continue
372+ }
373+ if o, _, _, ok := c.geometry(); !ok || o.name() != output {
374+ continue
375+ }
376+ default:
377+ continue
378+ }
379+ if at.IsZero() {
380+ at = flipTime(f)
381+ }
382+ c.dirty = false
383+ c.push(ports.CastEvent{Kind: ports.CastDamage, Time: at})
384+ }
385+}
386+
387+// flipTime is when a flip happened, on CLOCK_MONOTONIC as capture times are.
388+func flipTime(f *ports.FlipInfo) time.Time {
389+ if f.When > 0 {
390+ return time.Unix(0, int64(f.When))
391+ }
392+ var ts unix.Timespec
393+ if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil {
394+ return time.Time{}
395+ }
396+ return time.Unix(ts.Sec, ts.Nsec)
397+}
398+
399+// castCommitted marks the casts of a window that committed content. A popup
400+// is drawn with its toplevel (captureWindowScene), so its commits, map,
401+// unmap and reposition mark the casts of the toplevel it hangs from, found
402+// through the same parent chain as toplevelRoot.
403+func (s *Server) castCommitted(id ports.WindowID) {
404+ if len(s.castSessions) == 0 {
405+ return
406+ }
407+ // A popup chain is as deep as menus nest; the bound only guards a cycle.
408+ for range 64 {
409+ w := s.windows[id]
410+ if w == nil || w.popup == nil || w.popup.parent == nil {
411+ break
412+ }
413+ id = w.popup.parent.id
414+ }
415+ for _, c := range s.castSessions {
416+ if c.src.kind == srcWindow && c.src.window == id {
417+ c.dirty = true
418+ }
419+ }
420+}
421+
422+// forwardCasts moves the adapter's commands to the display goroutine through
423+// castRing, in order; a full ring blocks this goroutine, never the display.
424+func (s *Server) forwardCasts(ctxDone <-chan struct{}) {
425+ for {
426+ select {
427+ case <-ctxDone:
428+ return
429+ case <-s.display.Stopped():
430+ return
431+ case cmd, ok := <-s.channels.CastCommands:
432+ if !ok {
433+ return
434+ }
435+ if !s.castRing.Push(cmd) {
436+ return
437+ }
438+ }
439+ }
440+}
441+
442+// castInventoryChanged schedules a publication of the inventory after the
443+// outputs, the titles, the app ids or the mapped windows may have changed.
444+// Changes made in one display loop turn share one publication.
445+func (s *Server) castInventoryChanged() {
446+ if s.casts == nil || s.castInvPosted {
447+ return
448+ }
449+ s.castInvPosted = true
450+ s.display.Post(s.castInvFn)
451+}
452+
453+// publishCastInventory sends the inventory when it differs from the last one.
454+func (s *Server) publishCastInventory() {
455+ s.castInvPosted = false
456+ inv := s.castInventory()
457+ if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) {
458+ return
459+ }
460+ // The receiver owns what it is given: keep a copy of our own to compare.
461+ s.castInvLast, s.castInvSent = inv, true
462+ s.casts.Inventory(s.castInventory())
463+}
464+
465+// castInventory is the outputs and, unless the session is protected, the
466+// mapped toplevels now.
467+func (s *Server) castInventory() ports.CastInventory {
468+ inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))}
469+ for _, o := range s.outputs {
470+ p := o.place
471+ out := ports.CastOutput{Info: p.Info, Current: -1, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Scale: p.Scale, Transform: p.Transform}
472+ for _, h := range s.outputHeads.Heads {
473+ if h.Info.Name == p.Info.Name {
474+ out.Modes = slices.Clone(h.Modes)
475+ if h.Current != nil {
476+ out.Current = slices.Index(out.Modes, *h.Current)
477+ }
478+ }
479+ }
480+ if out.Current < 0 {
481+ // No head inventory (headless) or the mode is not listed: the
482+ // mode the output runs.
483+ mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0}
484+ if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool {
485+ return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli
486+ }); out.Current < 0 {
487+ out.Modes, out.Current = append(out.Modes, mode), len(out.Modes)
488+ }
489+ }
490+ inv.Outputs = append(inv.Outputs, out)
491+ }
492+ if s.protected() {
493+ return inv
494+ }
495+ for _, id := range s.mappedToplevels() {
496+ w := s.windows[id]
497+ inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title})
498+ }
499+ return inv
500+}
A internal/adapters/wayland/screencast_test.go +697 -0
@@ -0,0 +1,697 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
11+ "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
16+)
17+
18+// castLayout: two small outputs, so frames are cheap and a flip of the other
19+// output can be told from a flip of the cast's.
20+var castLayout = ports.Layout{
21+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1},
22+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1},
23+}
24+
25+// castHarness runs a server with the cast channels wired: the commands go in
26+// through casts, the events and inventories the server publishes are recorded
27+// by the generated CastSink mock into got and invs, and the capture requests
28+// and replies are the test's.
29+type castHarness struct {
30+ t *testing.T
31+ s *Server
32+ dir string
33+ events chan ports.ClientEvent
34+ commands chan ports.ClientCommand
35+ requests chan ports.CaptureRequest
36+ replies chan ports.CaptureDone
37+ casts chan ports.CastCommand
38+ got chan ports.CastEvent
39+ invs chan ports.CastInventory
40+ changes chan ports.SecurityState
41+}
42+
43+// newCastHarness starts the server; withSink false leaves Options.Casts nil
44+// (the allocation test measures the server alone).
45+func newCastHarness(t *testing.T, withSink bool) *castHarness {
46+ t.Helper()
47+ h := &castHarness{
48+ t: t,
49+ dir: t.TempDir(),
50+ events: make(chan ports.ClientEvent, 256),
51+ commands: make(chan ports.ClientCommand, 16),
52+ requests: make(chan ports.CaptureRequest, maxCaptureInflight),
53+ replies: make(chan ports.CaptureDone, maxCaptureInflight+2),
54+ casts: make(chan ports.CastCommand, 16),
55+ got: make(chan ports.CastEvent, 4096),
56+ invs: make(chan ports.CastInventory, 64),
57+ changes: make(chan ports.SecurityState, 16),
58+ }
59+ opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}}
60+ if withSink {
61+ sink := portsmocks.NewMockCastSink(t)
62+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe()
63+ sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe()
64+ opts.Casts = sink
65+ }
66+ ch := Channels{
67+ Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts,
68+ SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16),
69+ }
70+ s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ h.s = s
75+ relayContents(t, s)
76+ ctx, cancel := context.WithCancel(context.Background())
77+ done := make(chan error, 1)
78+ go func() { done <- s.Run(ctx) }()
79+ t.Cleanup(func() { cancel(); <-done })
80+ return h
81+}
82+
83+// sync returns once the display goroutine ran everything posted before.
84+func (h *castHarness) sync() {
85+ h.t.Helper()
86+ ran := make(chan struct{})
87+ h.s.display.Post(func() { close(ran) })
88+ select {
89+ case <-ran:
90+ case <-time.After(3 * time.Second):
91+ h.t.Fatal("display goroutine stalled")
92+ }
93+}
94+
95+// next is the next event pushed to the sink, which must have that kind.
96+func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent {
97+ h.t.Helper()
98+ select {
99+ case ev := <-h.got:
100+ if ev.Kind != kind {
101+ h.t.Fatalf("event %+v, want kind %d", ev, kind)
102+ }
103+ return ev
104+ case <-time.After(3 * time.Second):
105+ h.t.Fatalf("no cast event of kind %d", kind)
106+ return ports.CastEvent{}
107+ }
108+}
109+
110+// none says that no event is pending once the display goroutine caught up.
111+func (h *castHarness) none() {
112+ h.t.Helper()
113+ h.sync()
114+ select {
115+ case ev := <-h.got:
116+ h.t.Fatalf("unexpected cast event %+v", ev)
117+ default:
118+ }
119+}
120+
121+func (h *castHarness) request() ports.CaptureRequest {
122+ h.t.Helper()
123+ select {
124+ case r := <-h.requests:
125+ return r
126+ case <-time.After(3 * time.Second):
127+ h.t.Fatal("no capture request")
128+ return ports.CaptureRequest{}
129+ }
130+}
131+
132+// noRequest says that no capture was requested.
133+func (h *castHarness) noRequest(wait time.Duration) {
134+ h.t.Helper()
135+ select {
136+ case r := <-h.requests:
137+ h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output)
138+ case <-time.After(wait):
139+ }
140+}
141+
142+// castClientEvent waits for the next ClientEvent of type T, skipping others.
143+func castClientEvent[T ports.ClientEvent](h *castHarness) T {
144+ h.t.Helper()
145+ for {
146+ select {
147+ case ev := <-h.events:
148+ if v, ok := ev.(T); ok {
149+ return v
150+ }
151+ case <-time.After(3 * time.Second):
152+ var zero T
153+ h.t.Fatalf("no %T", zero)
154+ return zero
155+ }
156+ }
157+}
158+
159+// complete answers a capture request the way an output does.
160+func (h *castHarness) complete(req ports.CaptureRequest, err error) {
161+ h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)}
162+}
163+
164+func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) {
165+ h.t.Helper()
166+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true}
167+ size := h.next(ports.CastSize)
168+ return size, castClientEvent[ports.CaptureSessionOpen](h).ID
169+}
170+
171+// state sends core's decision and waits until the display goroutine took it.
172+func (h *castHarness) state(core uint64, st ports.CaptureSessionState) {
173+ h.t.Helper()
174+ h.commands <- st
175+ for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
176+ var seen bool
177+ h.s.display.Do(func() { seen = h.s.castByCore[core].haveState })
178+ if seen {
179+ return
180+ }
181+ }
182+ h.t.Fatal("state not applied")
183+}
184+
185+func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand {
186+ return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1}
187+}
188+
189+func (h *castHarness) flip(output string) {
190+ h.t.Helper()
191+ h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}})
192+ h.sync()
193+}
194+
195+// An output cast: open registers it with core and reports its size; a frame is
196+// captured into the lent mapping, indicated once, and reported done.
197+func TestCastOpenSizeFrameDone(t *testing.T) {
198+ h := newCastHarness(t, true)
199+ size, core := h.open(7, "HEADLESS-1")
200+ if size.ID != 7 || size.Width != 64 || size.Height != 32 {
201+ t.Fatalf("size %+v", size)
202+ }
203+ if core == 0 {
204+ t.Fatal("cast not registered with core")
205+ }
206+ data := make([]byte, 64*32*4)
207+ h.casts <- castFrame(7, 3, data, 64, 32)
208+ req := h.request()
209+ if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil ||
210+ len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 {
211+ t.Fatalf("request %+v", req)
212+ }
213+ taken := castClientEvent[ports.CaptureFrameTaken](h)
214+ if taken.Session != core || taken.Output != "HEADLESS-1" {
215+ t.Fatalf("taken %+v", taken)
216+ }
217+ h.complete(req, nil)
218+ done := h.next(ports.CastFrameDone)
219+ if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) {
220+ t.Fatalf("done %+v", done)
221+ }
222+ // Later frames are not indicated again: the cast keeps the indicator on.
223+ h.casts <- castFrame(7, 4, data, 64, 32)
224+ h.complete(h.request(), nil)
225+ if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil {
226+ t.Fatalf("second done %+v", done)
227+ }
228+ h.sync()
229+ for len(h.events) > 0 {
230+ if ev := <-h.events; ev != nil {
231+ if _, again := ev.(ports.CaptureFrameTaken); again {
232+ t.Fatalf("second frame indicated again: %+v", ev)
233+ }
234+ }
235+ }
236+ // CastClose ends the cast without a reply and tells core.
237+ h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7}
238+ if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core {
239+ t.Fatalf("close %+v", c)
240+ }
241+ h.none()
242+ // A frame for a closed cast gives its buffer back.
243+ h.casts <- castFrame(7, 5, data, 64, 32)
244+ if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil {
245+ t.Fatalf("frame of a closed cast: %+v", done)
246+ }
247+}
248+
249+// A target that is missing refuses the cast.
250+func TestCastOpenRefused(t *testing.T) {
251+ h := newCastHarness(t, true)
252+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"}
253+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil {
254+ t.Fatalf("stopped %+v", ev)
255+ }
256+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99}
257+ if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone {
258+ t.Fatalf("stopped %+v", ev)
259+ }
260+ for id := uint64(10); id < 10+maxCasts+1; id++ {
261+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"}
262+ }
263+ for range maxCasts {
264+ h.next(ports.CastSize)
265+ }
266+ if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) {
267+ t.Fatalf("over the limit: %+v", ev)
268+ }
269+}
270+
271+// A window cast follows the window and stops when it closes.
272+func TestCastWindowCloseStops(t *testing.T) {
273+ h := newCastHarness(t, true)
274+ c := protocolClient(t, h.s, h.dir)
275+ w, _, xdg := surfaceMapper(t, c, h.events)()
276+ registerProtocol(t, c, xdg)
277+ top := xdg + 1
278+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
279+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID}
280+ if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 {
281+ t.Fatalf("size %+v", size)
282+ }
283+ core := castClientEvent[ports.CaptureSessionOpen](h)
284+ if core.Window != w.ID {
285+ t.Fatalf("open %+v", core)
286+ }
287+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
288+ roundtrip(t, c)
289+ ev := h.next(ports.CastStopped)
290+ if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone {
291+ t.Fatalf("stopped %+v", ev)
292+ }
293+ if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID {
294+ t.Fatalf("close %+v", cl)
295+ }
296+ h.none()
297+}
298+
299+// A session lock ends every cast and refuses new ones.
300+func TestCastSessionLockStops(t *testing.T) {
301+ h := newCastHarness(t, true)
302+ h.open(1, "HEADLESS-1")
303+ data := make([]byte, 64*32*4)
304+ h.casts <- castFrame(1, 0, data, 64, 32)
305+ req := h.request()
306+ c := protocolClient(t, h.s, h.dir)
307+ if _, err := bindLockManager(t, c).Lock(); err != nil {
308+ t.Fatal(err)
309+ }
310+ roundtrip(t, c)
311+ // The capture still writes into the lent buffer: its reply comes first.
312+ h.none()
313+ h.complete(req, nil)
314+ if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) {
315+ t.Fatalf("done %+v", done)
316+ }
317+ if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) {
318+ t.Fatalf("stopped %+v", ev)
319+ }
320+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"}
321+ if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) {
322+ t.Fatalf("open under lock: %+v", ev)
323+ }
324+}
325+
326+// An unplugged output ends the casts on it, after the capture in flight.
327+func TestCastOutputUnplugStops(t *testing.T) {
328+ h := newCastHarness(t, true)
329+ h.open(1, "HEADLESS-1")
330+ h.open(2, "HEADLESS-2")
331+ data := make([]byte, 64*32*4)
332+ h.casts <- castFrame(1, 9, data, 64, 32)
333+ req := h.request()
334+ h.commands <- ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"}
335+ h.none()
336+ h.complete(req, nil)
337+ if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil {
338+ t.Fatalf("a frame written for a dead cast must not be queued: %+v", done)
339+ }
340+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone {
341+ t.Fatalf("stopped %+v", ev)
342+ }
343+ // The cast on the other output lives.
344+ h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32)
345+ h.complete(h.request(), nil)
346+ if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil {
347+ t.Fatalf("done %+v", done)
348+ }
349+}
350+
351+// A new mode renegotiates (CastSize); a frame of the old size is refused.
352+func TestCastSizeChange(t *testing.T) {
353+ h := newCastHarness(t, true)
354+ h.open(1, "HEADLESS-1")
355+ bigger := ports.Layout{castLayout[0], castLayout[1]}
356+ bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64
357+ h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"}
358+ if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 {
359+ t.Fatalf("size %+v", size)
360+ }
361+ h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32)
362+ if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) {
363+ t.Fatalf("done %+v", done)
364+ }
365+ h.noRequest(20 * time.Millisecond)
366+ h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64)
367+ h.complete(h.request(), nil)
368+ if done := h.next(ports.CastFrameDone); done.Err != nil {
369+ t.Fatalf("done %+v", done)
370+ }
371+}
372+
373+// A transient failure is retried on the cadence of an ext frame; past the
374+// deadline the frame fails with the error.
375+func TestCastTransientRetry(t *testing.T) {
376+ h := newCastHarness(t, true)
377+ h.open(1, "HEADLESS-1")
378+ data := make([]byte, 64*32*4)
379+ h.casts <- castFrame(1, 0, data, 64, 32)
380+ first := h.request()
381+ h.complete(first, ports.ErrCaptureBusy)
382+ start := time.Now()
383+ retried := h.request()
384+ if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 {
385+ t.Fatalf("retry %+v after %v", retried, time.Since(start))
386+ }
387+ h.complete(retried, nil)
388+ if done := h.next(ports.CastFrameDone); done.Err != nil {
389+ t.Fatalf("done %+v", done)
390+ }
391+
392+ // Never ready: retried until the deadline, then reported.
393+ h.casts <- castFrame(1, 1, data, 64, 32)
394+ attempts := 0
395+ for {
396+ select {
397+ case req := <-h.requests:
398+ attempts++
399+ h.complete(req, ports.ErrCaptureBusy)
400+ continue
401+ case ev := <-h.got:
402+ if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) {
403+ t.Fatalf("event %+v", ev)
404+ }
405+ case <-time.After(5 * time.Second):
406+ t.Fatal("frame neither retried nor failed")
407+ }
408+ break
409+ }
410+ if attempts < 10 {
411+ t.Fatalf("%d attempts in %v", attempts, captureRetryFor)
412+ }
413+}
414+
415+// Until core says the cast is active the frame waits, then goes out.
416+func TestCastWaitsForActiveState(t *testing.T) {
417+ h := newCastHarness(t, true)
418+ _, core := h.open(1, "HEADLESS-1")
419+ h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"})
420+ h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32)
421+ h.noRequest(60 * time.Millisecond)
422+ h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true}
423+ h.complete(h.request(), nil)
424+ if done := h.next(ports.CastFrameDone); done.Err != nil {
425+ t.Fatalf("done %+v", done)
426+ }
427+ // A terminal decision of core ends the cast without telling core.
428+ h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone}
429+ if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone {
430+ t.Fatalf("stopped %+v", ev)
431+ }
432+ h.sync()
433+ for len(h.events) > 0 {
434+ if _, closed := (<-h.events).(ports.CaptureSessionClose); closed {
435+ t.Fatal("core told of an end it decided")
436+ }
437+ }
438+}
439+
440+// A flip of the cast's output damages an output cast, and only that flip.
441+func TestCastFlipDamagesOutputCast(t *testing.T) {
442+ h := newCastHarness(t, true)
443+ h.open(1, "HEADLESS-1")
444+ h.open(2, "HEADLESS-2")
445+ h.flip("HEADLESS-1")
446+ ev := h.next(ports.CastDamage)
447+ if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) {
448+ t.Fatalf("damage %+v", ev)
449+ }
450+ h.none()
451+ h.flip("HEADLESS-2")
452+ if ev = h.next(ports.CastDamage); ev.ID != 2 {
453+ t.Fatalf("damage %+v", ev)
454+ }
455+ // Every flip damages an output cast, committed or not.
456+ h.flip("HEADLESS-1")
457+ h.flip("HEADLESS-1")
458+ h.next(ports.CastDamage)
459+ h.next(ports.CastDamage)
460+ h.none()
461+}
462+
463+// A window cast is damaged by a flip of its output only after the window
464+// committed since the last damage.
465+func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
466+ h := newCastHarness(t, true)
467+ c := protocolClient(t, h.s, h.dir)
468+ w, _, _ := surfaceMapper(t, c, h.events)()
469+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
470+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
471+ h.next(ports.CastSize)
472+
473+ h.flip("HEADLESS-1")
474+ h.none() // nothing committed since the cast began
475+ commit := func() {
476+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) {
477+ t.Fatal("display stopped")
478+ }
479+ }
480+ commit()
481+ h.flip("HEADLESS-2")
482+ h.none() // not the window's output
483+ h.flip("HEADLESS-1")
484+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
485+ t.Fatalf("damage %+v", ev)
486+ }
487+ h.flip("HEADLESS-1")
488+ h.none() // the damage consumed the commit
489+ commit()
490+ commit()
491+ h.flip("HEADLESS-1")
492+ h.next(ports.CastDamage)
493+ h.none() // two commits, one damage
494+}
495+
496+// A popup is drawn with its toplevel: a static toplevel whose popup (or a
497+// popup of that popup) keeps committing, mapping, unmapping or moving still
498+// damages the window cast. The popups are windows of the server, as an
499+// xdg_popup makes them.
500+func TestCastWindowDamagedByPopupChanges(t *testing.T) {
501+ h := newCastHarness(t, true)
502+ c := protocolClient(t, h.s, h.dir)
503+ w, _, _ := surfaceMapper(t, c, h.events)()
504+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
505+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
506+ h.next(ports.CastSize)
507+
508+ var menu, sub ports.WindowID
509+ if !h.s.display.Do(func() {
510+ top := h.s.windows[w.ID]
511+ m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true}
512+ m.popup = &popup{w: m, parent: top}
513+ h.s.windows[m.id] = m
514+ s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true}
515+ s2.popup = &popup{w: s2, parent: m}
516+ h.s.windows[s2.id] = s2
517+ h.s.nextWindow += 2
518+ menu, sub = m.id, s2.id
519+ }) {
520+ t.Fatal("display stopped")
521+ }
522+ t.Cleanup(func() {
523+ h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) })
524+ })
525+ h.flip("HEADLESS-1")
526+ h.none() // nothing changed yet
527+ change := func(name string, f func()) {
528+ t.Helper()
529+ if !h.s.display.Do(f) {
530+ t.Fatal("display stopped")
531+ }
532+ h.flip("HEADLESS-1")
533+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
534+ t.Fatalf("%s: damage %+v", name, ev)
535+ }
536+ h.flip("HEADLESS-1")
537+ h.none() // the damage consumed the change
538+ }
539+ content := func(id ports.WindowID) func() {
540+ return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) }
541+ }
542+ change("popup commit", content(menu))
543+ change("popup commit again", content(menu))
544+ change("nested popup commit", content(sub))
545+ change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() })
546+ change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) })
547+ change("popup reposition", func() { h.s.castCommitted(menu) })
548+ // A window nobody casts leaves the cast alone.
549+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) {
550+ t.Fatal("display stopped")
551+ }
552+ h.flip("HEADLESS-1")
553+ h.none()
554+}
555+
556+// The inventory is published once per change, not once per frame or flip, and
557+// changes of one display loop turn share one publication.
558+func TestCastInventoryPublishedOncePerChange(t *testing.T) {
559+ h := newCastHarness(t, true)
560+ inv := func() ports.CastInventory {
561+ t.Helper()
562+ select {
563+ case v := <-h.invs:
564+ return v
565+ case <-time.After(3 * time.Second):
566+ t.Fatal("no inventory")
567+ return ports.CastInventory{}
568+ }
569+ }
570+ noInv := func() {
571+ t.Helper()
572+ h.sync()
573+ h.sync()
574+ select {
575+ case v := <-h.invs:
576+ t.Fatalf("unexpected inventory %+v", v)
577+ default:
578+ }
579+ }
580+ first := inv()
581+ if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 {
582+ t.Fatalf("first inventory %+v", first)
583+ }
584+ for _, o := range first.Outputs {
585+ if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width {
586+ t.Fatalf("current mode of %s: %+v", o.Info.Name, o)
587+ }
588+ }
589+ noInv()
590+
591+ c := protocolClient(t, h.s, h.dir)
592+ w, _, xdg := surfaceMapper(t, c, h.events)()
593+ registerProtocol(t, c, xdg)
594+ top := xdg + 1
595+ if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID {
596+ t.Fatalf("mapped inventory %+v", got)
597+ }
598+ noInv()
599+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
600+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
601+ roundtrip(t, c)
602+ var got ports.CastInventory
603+ for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() {
604+ }
605+ noInv()
606+ // The same title again changes nothing.
607+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
608+ roundtrip(t, c)
609+ noInv()
610+
611+ // Frames and flips do not publish.
612+ h.open(1, "HEADLESS-1")
613+ data := make([]byte, 64*32*4)
614+ for i := range 20 {
615+ h.flip("HEADLESS-1")
616+ h.casts <- castFrame(1, i%3, data, 64, 32)
617+ h.complete(h.request(), nil)
618+ }
619+ noInv()
620+
621+ // Several changes in one turn: one publication, of the last state. An
622+ // unchanged inventory is not published at all.
623+ h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() })
624+ noInv()
625+ h.s.display.Do(func() {
626+ for _, title := range []string{"a", "b", "c"} {
627+ h.s.windows[w.ID].title = title
628+ h.s.castInventoryChanged()
629+ }
630+ })
631+ if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" {
632+ t.Fatalf("inventory %+v", got)
633+ }
634+ noInv()
635+
636+ // An output moving is a change; the focused output too.
637+ moved := slices.Clone(castLayout)
638+ moved[1].X = 200
639+ h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"}
640+ if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" {
641+ t.Fatalf("inventory %+v", got)
642+ }
643+ noInv()
644+
645+ // Under a session lock the windows are not listed.
646+ lc := protocolClient(t, h.s, h.dir)
647+ if _, err := bindLockManager(t, lc).Lock(); err != nil {
648+ t.Fatal(err)
649+ }
650+ roundtrip(t, lc)
651+ for got = inv(); len(got.Windows) != 0; got = inv() {
652+ }
653+}
654+
655+// Pushing a cast frame, capturing it and answering the flip allocates nothing
656+// in steady state: the cast command rides the post ring, the request and the
657+// reply are plain values, the reply function is bound once. The count
658+// includes the display goroutine (AllocsPerRun reads the global malloc
659+// counter). Nothing in the closure is a testify mock; the sink is left out so
660+// the measured code is the server's.
661+func TestCastFrameAllocations(t *testing.T) {
662+ if raceEnabled {
663+ t.Skip("the race detector makes the purego wake callback allocate")
664+ }
665+ h := newCastHarness(t, false)
666+ s := h.s
667+ s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"})
668+ core := castClientEvent[ports.CaptureSessionOpen](h).ID
669+ data := make([]byte, 64*32*4)
670+ frame := castFrame(1, 0, data, 64, 32)
671+ flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}}
672+ ran := make(chan struct{}, 1)
673+ marker := func() { ran <- struct{}{} }
674+ at := time.Unix(1, 2)
675+ cycle := func() {
676+ s.castRing.Push(frame)
677+ req := <-h.requests
678+ s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at})
679+ s.Presentation().Push(flip)
680+ s.display.Post(marker)
681+ <-ran
682+ }
683+ for range 4 {
684+ cycle()
685+ }
686+ if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core {
687+ t.Fatalf("taken %+v", taken)
688+ }
689+ var live int
690+ s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) })
691+ if live != 1 {
692+ t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live)
693+ }
694+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
695+ t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs)
696+ }
697+}
M internal/adapters/wayland/screencopy.go +19 -8
@@ -239,6 +239,24 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
239 return 0, false239 return 0, false
240 }240 }
241 }241 }
242+
243+// capturedRingSize bounds the replies waiting for the display goroutine: one
244+// per admitted capture, and the cast frames in flight (8).
245+const capturedRingSize = ports.MaxCaptureInflight + 8
246+
247+// applyCaptured settles one capture reply on the display goroutine.
248+func (s *Server) applyCaptured(done ports.CaptureDone) {
249+ delete(s.captureInflight, done.ID)
250+ if fn := s.captureReplies[done.ID]; fn != nil {
251+ delete(s.captureReplies, done.ID)
252+ fn(done)
253+ }
254+ s.maybeLocked()
255+}
256+
257+// forwardCaptured moves backend replies to the display goroutine through
258+// capturedRing. Each reply releases an admission credit, so none is merged or
259+// dropped; a full ring blocks this goroutine, never the display.
242 func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {260 func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
243 for {261 for {
244 select {262 select {
@@ -250,14 +268,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
250 if !ok {268 if !ok {
251 return269 return
252 }270 }
253- if !s.display.Do(func() {271+ if !s.capturedRing.Push(done) {
254- delete(s.captureInflight, done.ID)
255- if fn := s.captureReplies[done.ID]; fn != nil {
256- delete(s.captureReplies, done.ID)
257- fn(done)
258- }
259- s.maybeLocked()
260- }) {
261 return272 return
262 }273 }
263 }274 }
M internal/adapters/wayland/server.go +34 -3
@@ -48,6 +48,9 @@ type Options struct {
48 // nil lets every client capture, unless the session is protected. The48 // nil lets every client capture, unless the session is protected. The
49 // caller keeps it current (captureallow.Store.Run).49 // caller keeps it current (captureallow.Store.Run).
50 CaptureAllow *captureallow.Store50 CaptureAllow *captureallow.Store
51+ // Casts receives the screencast events and inventory (screencast.go);
52+ // nil serves no casts (Channels.CastCommands is not read).
53+ Casts ports.CastSink
51 // LogDamage logs the damage of every fresh client commit at debug level54 // LogDamage logs the damage of every fresh client commit at debug level
52 // (--debug=wayland-damage).55 // (--debug=wayland-damage).
53 LogDamage bool56 LogDamage bool
@@ -78,6 +81,9 @@ type Channels struct {
78 // never block the backend; standalone tests may use smaller channels.81 // never block the backend; standalone tests may use smaller channels.
79 Captures chan<- ports.CaptureRequest82 Captures chan<- ports.CaptureRequest
80 Captured <-chan ports.CaptureDone83 Captured <-chan ports.CaptureDone
84+ // CastCommands carries the screencast adapter's commands (ports.CastCommand);
85+ // they are applied in order on the display goroutine. Nil serves no casts.
86+ CastCommands <-chan ports.CastCommand
81 // OutputFormats are the outputs' direct scanout formats, offered in87 // OutputFormats are the outputs' direct scanout formats, offered in
82 // dmabuf feedback to fullscreen surfaces.88 // dmabuf feedback to fullscreen surfaces.
83 OutputFormats <-chan ports.OutputFormats89 OutputFormats <-chan ports.OutputFormats
@@ -111,6 +117,9 @@ type Server struct {
111 nextLeaseID uint64117 nextLeaseID uint64
112 nextCapture uint64118 nextCapture uint64
113 captureReplies map[uint64]func(ports.CaptureDone)119 captureReplies map[uint64]func(ports.CaptureDone)
120+ // capturedRing hands backend replies to the display goroutine
121+ // (forwardCaptured); the display goroutine applies them in order.
122+ capturedRing *postRing[ports.CaptureDone]
114 // captureInflight counts accepted captures until the backend completes123 // captureInflight counts accepted captures until the backend completes
115 // them, independent of client resources; only the display loop touches it.124 // them, independent of client resources; only the display loop touches it.
116 captureInflight map[uint64]struct{}125 captureInflight map[uint64]struct{}
@@ -119,9 +128,20 @@ type Server struct {
119 // sessionsByID are the sessions registered with core (imagecopy.go);128 // sessionsByID are the sessions registered with core (imagecopy.go);
120 // excl is the one live exclusion (exclusion.go); ownUID the compositor's.129 // excl is the one live exclusion (exclusion.go); ownUID the compositor's.
121 sessionsByID map[uint64]*captureSession130 sessionsByID map[uint64]*captureSession
122- nextSession uint64131+ // Screencast (screencast.go): casts by the adapter's ID and by the ID
123- excl *exclusion132+ // core knows them by; castRing carries the adapter's commands to the
124- ownUID uint32133+ // display goroutine; the castInv fields coalesce inventory publication.
134+ casts ports.CastSink
135+ castSessions map[uint64]*castSession
136+ castByCore map[uint64]*castSession
137+ castRing *postRing[ports.CastCommand]
138+ castInvFn func()
139+ castInvPosted bool
140+ castInvSent bool
141+ castInvLast ports.CastInventory
142+ nextSession uint64
143+ excl *exclusion
144+ ownUID uint32
125 // captureAllow, peer and peers decide mayCapture (captureauth.go):145 // captureAllow, peer and peers decide mayCapture (captureauth.go):
126 // peers keeps each client's pidfd until it is destroyed.146 // peers keeps each client's pidfd until it is destroyed.
127 captureAllow *captureallow.Store147 captureAllow *captureallow.Store
@@ -358,6 +378,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
358 s.paceSignalFn = s.paceSignalled378 s.paceSignalFn = s.paceSignalled
359 s.flushLinksFn = s.flushLinks379 s.flushLinksFn = s.flushLinks
360 s.sink = newPresentSink(s)380 s.sink = newPresentSink(s)
381+ s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)
382+ s.casts = opts.Casts
383+ s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}
384+ s.castRing = newPostRing(d, castRingSize, s.applyCast)
385+ s.castInvFn = s.publishCastInventory
361 s.leaseDevices = map[string]*leaseDevice{}386 s.leaseDevices = map[string]*leaseDevice{}
362 s.pendingLeases = map[uint64]*leaseObject{}387 s.pendingLeases = map[uint64]*leaseObject{}
363 s.pendingLeaseCards = map[uint64]string{}388 s.pendingLeaseCards = map[uint64]string{}
@@ -549,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error {
549 wg.Add(1)574 wg.Add(1)
550 go func() { defer wg.Done(); s.syncWait.run(ctx) }()575 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
551 }576 }
577+ if s.casts != nil && s.channels.CastCommands != nil {
578+ wg.Add(1)
579+ go func() { defer wg.Done(); s.forwardCasts(ctx.Done()) }()
580+ }
581+ s.castInventoryChanged()
552 err := s.display.Run(ctx)582 err := s.display.Run(ctx)
553 s.stopSecurityContexts()583 s.stopSecurityContexts()
554 wg.Wait()584 wg.Wait()
@@ -706,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
706 l.add(c)736 l.add(c)
707 s.flushLink(l)737 s.flushLink(l)
708 }738 }
739+ s.castCommitted(c.ID)
709 }740 }
710 741
711 // maxCommandBatch bounds how many queued commands one display round trip applies.742 // maxCommandBatch bounds how many queued commands one display round trip applies.
M internal/adapters/wayland/session_lock.go +1 -0
@@ -91,6 +91,7 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) {
91 s.lockReadiness.End(l.state.Generation)91 s.lockReadiness.End(l.state.Generation)
92 s.sessionLock = nil92 s.sessionLock = nil
93 s.refreshToplevels()93 s.refreshToplevels()
94+ s.castInventoryChanged() // the windows are listed again
94 s.updateWorkspaceManagers(s.workspaceSnapshot)95 s.updateWorkspaceManagers(s.workspaceSnapshot)
95 for _, surface := range l.surfaces {96 for _, surface := range l.surfaces {
96 surface.unmap()97 surface.unmap()
M internal/adapters/wayland/xdg.go +5 -0
@@ -249,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
249 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})249 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})
250 x.server.syncInhibitors()250 x.server.syncInhibitors()
251 x.server.toplevelChanged(w)251 x.server.toplevelChanged(w)
252+ x.server.castInventoryChanged()
252 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")253 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
253 } else if !buffer && w.mapped {254 } else if !buffer && w.mapped {
254 w.unmap()255 w.unmap()
@@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
339 }340 }
340 t.w.title = title341 t.w.title = title
341 t.w.xdg.server.toplevelChanged(t.w)342 t.w.xdg.server.toplevelChanged(t.w)
343+ if t.w.mapped {
344+ t.w.xdg.server.castInventoryChanged()
345+ }
342 }346 }
343 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {347 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
344 if appID == t.w.appID {348 if appID == t.w.appID {
@@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
348 if t.w.mapped {352 if t.w.mapped {
349 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})353 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
350 t.w.xdg.server.toplevelChanged(t.w)354 t.w.xdg.server.toplevelChanged(t.w)
355+ t.w.xdg.server.castInventoryChanged()
351 }356 }
352 }357 }
353 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}358 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
M internal/app/app.go +5 -1
@@ -27,7 +27,11 @@ type Options struct {
27 HeadlessHDR bool27 HeadlessHDR bool
28 // Session exports the displays to D-Bus and systemd user services28 // Session exports the displays to D-Bus and systemd user services
29 // before any client starts, and withdraws them on exit.29 // before any client starts, and withdraws them on exit.
30- Session bool30+ Session bool
31+ // DBus serves the session D-Bus services (idle inhibition, screen
32+ // casting and screenshots) of a headless run, which otherwise leaves the
33+ // session bus alone. For tests under dbus-run-session.
34+ DBus bool
31 ScreenshotDir string35 ScreenshotDir string
32 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a36 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
33 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).37 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
A internal/app/screencast_test.go +442 -0
@@ -0,0 +1,442 @@
1+//go:build screencast
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "runtime"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "testing"
17+ "time"
18+
19+ "github.com/bnema/neferwl/internal/adapters/config"
20+ "github.com/bnema/neferwl/internal/ports"
21+ "github.com/bnema/zerobus"
22+)
23+
24+// The screencast tests need a session bus of their own (run them under
25+// dbus-run-session), the user's PipeWire and gst-launch-1.0 as the consumer.
26+
27+const (
28+ scName = "org.gnome.Mutter.ScreenCast"
29+ scPath = "/org/gnome/Mutter/ScreenCast"
30+ scSession = "org.gnome.Mutter.ScreenCast.Session"
31+ scStream = "org.gnome.Mutter.ScreenCast.Stream"
32+ scDenied = "org.freedesktop.DBus.Error.AccessDenied"
33+)
34+
35+// castApp is a headless NeferWL serving the D-Bus services.
36+type castApp struct {
37+ input chan<- ports.InputEvent
38+ stop func()
39+}
40+
41+func requireCastEnv(t *testing.T) {
42+ t.Helper()
43+ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
44+ t.Skip("no session bus: run under dbus-run-session")
45+ }
46+ if _, err := exec.LookPath("gst-launch-1.0"); err != nil {
47+ t.Skip("gst-launch-1.0 unavailable")
48+ }
49+ if err := exec.Command("pw-dump").Run(); err != nil {
50+ t.Skipf("PipeWire is not running: %v", err)
51+ }
52+}
53+
54+// startCastApp runs the compositor with the capture allowlist content. The
55+// PipeWire socket stays where it is: only the Wayland socket and the state
56+// move to temporary directories.
57+func startCastApp(t *testing.T, allow string) *castApp {
58+ t.Helper()
59+ t.Setenv("PIPEWIRE_RUNTIME_DIR", os.Getenv("XDG_RUNTIME_DIR"))
60+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
61+ t.Setenv("XDG_STATE_HOME", t.TempDir())
62+ t.Setenv("HOME", t.TempDir())
63+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
64+ if err := os.WriteFile(allowPath, []byte(allow), 0o644); err != nil {
65+ t.Fatal(err)
66+ }
67+ ctx, cancel := context.WithCancel(context.Background())
68+ inputReady := make(chan chan<- ports.InputEvent, 1)
69+ done := make(chan error, 1)
70+ go func() {
71+ done <- run(ctx, Options{Backend: "headless", DBus: true, NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{320, 240}}, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())},
72+ func(in chan<- ports.InputEvent) { inputReady <- in })
73+ }()
74+ a := &castApp{}
75+ select {
76+ case a.input = <-inputReady:
77+ case <-time.After(10 * time.Second):
78+ cancel()
79+ t.Fatal("app did not start")
80+ }
81+ a.stop = func() {
82+ cancel()
83+ select {
84+ case err := <-done:
85+ if err != nil && !strings.Contains(err.Error(), "context canceled") {
86+ t.Errorf("Run: %v", err)
87+ }
88+ case <-time.After(10 * time.Second):
89+ t.Error("Run did not stop")
90+ }
91+ }
92+ t.Cleanup(a.stop)
93+ return a
94+}
95+
96+// castClient is a client of the D-Bus services.
97+func castClient(t *testing.T) *zerobus.Conn {
98+ t.Helper()
99+ c, err := zerobus.SessionBus()
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ t.Cleanup(func() { _ = c.Close() })
104+ return c
105+}
106+
107+func waitOwner(t *testing.T, c *zerobus.Conn, name string) {
108+ t.Helper()
109+ for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
110+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
111+ e.Str(name)
112+ m, err := c.Call()
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ if m.Body().Bool() {
117+ return
118+ }
119+ }
120+ t.Fatalf("%s never appeared", name)
121+}
122+
123+func emptyProps(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }
124+
125+// call returns the error name, "" on success.
126+func call(c *zerobus.Conn, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
127+ e := c.NewCall(scName, path, iface, member, sig)
128+ if body != nil {
129+ body(e)
130+ }
131+ m, err := c.Call()
132+ if err == nil {
133+ return "", m
134+ }
135+ if de, ok := err.(*zerobus.Error); ok {
136+ return de.Name, m
137+ }
138+ return err.Error(), m
139+}
140+
141+func recordMonitor(c *zerobus.Conn, session, connector string) (string, string) {
142+ name, m := call(c, session, scSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
143+ e.Str(connector)
144+ a := e.BeginArray('{')
145+ e.Struct()
146+ e.Str("cursor-mode")
147+ e.Variant("u")
148+ e.Uint32(1)
149+ e.EndArray(a)
150+ })
151+ if name != "" {
152+ return name, ""
153+ }
154+ return "", strings.Clone(m.Body().ObjectPath())
155+}
156+
157+// A monitor cast streams live to a PipeWire consumer, allocates next to
158+// nothing per frame, refuses another client's calls and ends on Stop.
159+func TestScreencastStreamsMonitor(t *testing.T) {
160+ requireCastEnv(t)
161+ app := startCastApp(t, "*\n")
162+ c := castClient(t)
163+ waitOwner(t, c, scName)
164+ // The inventory reaches the service after the outputs appear.
165+ var session, stream string
166+ for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(50 * time.Millisecond) {
167+ name, m := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps)
168+ if name != "" {
169+ t.Fatalf("CreateSession: %s", name)
170+ }
171+ session = strings.Clone(m.Body().ObjectPath())
172+ var errName string
173+ if errName, stream = recordMonitor(c, session, "HEADLESS-1"); errName == "" {
174+ break
175+ }
176+ call(c, session, scSession, "Stop", "", nil)
177+ if time.Now().After(deadline) {
178+ t.Fatalf("RecordMonitor: %s", errName)
179+ }
180+ }
181+ rule := "type='signal',interface='" + scStream + "'"
182+ if err := c.AddMatch(rule); err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := c.AddMatch("type='signal',interface='" + scSession + "'"); err != nil {
186+ t.Fatal(err)
187+ }
188+ if name, _ := call(c, session, scSession, "Start", "", nil); name != "" {
189+ t.Fatalf("Start: %s", name)
190+ }
191+ node := uint32(0)
192+ for node == 0 {
193+ m, err := c.ReadMessage()
194+ if err != nil {
195+ t.Fatal(err)
196+ }
197+ if m.Member == "PipeWireStreamAdded" && m.Path == stream && m.Destination == c.UniqueName() {
198+ node = m.Body().Uint32()
199+ }
200+ }
201+
202+ // Pointer motion damages the output while the consumer runs. The events
203+ // are boxed once: sending them allocates nothing.
204+ stopMoving := make(chan struct{})
205+ var stopOnce sync.Once
206+ stopMotion := func() { stopOnce.Do(func() { close(stopMoving) }) }
207+ a := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 40, Y: 40}})
208+ b := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 200, Y: 160}})
209+ moved := make(chan struct{})
210+ go func() {
211+ defer close(moved)
212+ tick := time.NewTicker(16 * time.Millisecond)
213+ defer tick.Stop()
214+ for i := 0; ; i++ {
215+ ev := a
216+ if i%2 == 1 {
217+ ev = b
218+ }
219+ // Neither the send nor the wait may outlive the test: the app
220+ // may have stopped reading.
221+ select {
222+ case app.input <- ev:
223+ case <-stopMoving:
224+ return
225+ }
226+ select {
227+ case <-tick.C:
228+ case <-stopMoving:
229+ return
230+ }
231+ }
232+ }()
233+ defer func() { stopMotion(); <-moved }()
234+
235+ gst := exec.Command("gst-launch-1.0", "-v", "pipewiresrc", "path="+itoa(node), "stream-properties=props,media.type=Video", "num-buffers=300", "!", "fakesink", "silent=false")
236+ out, err := gst.StdoutPipe()
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ if err := gst.Start(); err != nil {
241+ t.Fatal(err)
242+ }
243+ var frames atomic.Int64
244+ go func() {
245+ // Counted on raw reads: a line per frame would allocate a string.
246+ buf := make([]byte, 64<<10)
247+ for {
248+ n, err := out.Read(buf)
249+ frames.Add(int64(bytes.Count(buf[:n], []byte("chain "))))
250+ if err != nil {
251+ return
252+ }
253+ }
254+ }()
255+ exited := make(chan error, 1)
256+ go func() { exited <- gst.Wait() }()
257+
258+ // Steady state: 5 seconds after the stream warmed up. Every allocation is
259+ // recorded (MemProfileRate 1) and attributed by its stack: the cast path
260+ // (screencast, wayland casts, purego-pipewire, zerobus) must allocate
261+ // less than once per frame, and so must the renderer capture path it
262+ // shares with ext-image-copy-capture (BeginCapture, EndCapture and the
263+ // pipeline worker). The headless output's own display frame (Render's
264+ // sync file, the flip report, injected input) allocates whether or not
265+ // a cast runs: it is reported in the whole-process figure, not asserted.
266+ time.Sleep(2 * time.Second)
267+ runtime.MemProfileRate = 1
268+ f0 := frames.Load()
269+ before := allocSnapshot()
270+ var m0, m1 runtime.MemStats
271+ runtime.ReadMemStats(&m0)
272+ time.Sleep(5 * time.Second)
273+ runtime.ReadMemStats(&m1)
274+ after := allocSnapshot()
275+ f1 := frames.Load()
276+ runtime.MemProfileRate = 512 * 1024
277+ n := f1 - f0
278+ if n < 60 {
279+ t.Errorf("only %d frames in 5 s", n)
280+ } else {
281+ cast, other := after.sub(before)
282+ per := float64(cast) / float64(n)
283+ t.Logf("%d frames: cast path %.3f mallocs per frame, whole process %.2f (renderer and runtime included; %d other profiled)",
284+ n, per, float64(m1.Mallocs-m0.Mallocs)/float64(n), other)
285+ if per >= 1 {
286+ t.Errorf("cast path: %.2f mallocs per frame over %d frames, want < 1", per, n)
287+ }
288+ }
289+
290+ // The consumer got its 300 buffers and left.
291+ select {
292+ case err := <-exited:
293+ if err != nil {
294+ t.Errorf("gst-launch-1.0: %v", err)
295+ }
296+ case <-time.After(60 * time.Second):
297+ _ = gst.Process.Kill()
298+ t.Fatal("gst-launch-1.0 did not finish")
299+ }
300+ stopMotion()
301+
302+ // A second client is another unique name: it is allowed (the list is
303+ // off) but the session is not its own.
304+ other := castClient(t)
305+ if name, _ := call(other, session, scSession, "Stop", "", nil); name != scDenied {
306+ t.Errorf("Stop by another client: %q", name)
307+ }
308+ if name, _ := call(other, stream, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str(scStream) }); name != scDenied {
309+ t.Errorf("stream GetAll by another client: %q", name)
310+ }
311+ // A window that is not mapped cannot be recorded.
312+ if name, _ := call(c, session, scSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
313+ a := e.BeginArray('{')
314+ e.Struct()
315+ e.Str("window-id")
316+ e.Variant("t")
317+ e.Uint64(99999)
318+ e.EndArray(a)
319+ }); name == "" {
320+ t.Error("RecordWindow of a closed window succeeded")
321+ }
322+ if name, _ := call(c, session, scSession, "Stop", "", nil); name != "" {
323+ t.Fatalf("Stop: %s", name)
324+ }
325+ for closed := false; !closed; {
326+ m, err := c.ReadMessage()
327+ if err != nil {
328+ t.Fatal(err)
329+ }
330+ closed = m.Member == "Closed" && m.Path == session
331+ }
332+}
333+
334+// A caller whose executable is not on the list gets AccessDenied from
335+// every object, and nothing is captured.
336+func TestScreencastAllowlistDenies(t *testing.T) {
337+ requireCastEnv(t)
338+ startCastApp(t, "/usr/bin/grim\n")
339+ c := castClient(t)
340+ waitOwner(t, c, scName)
341+ if name, _ := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps); name != scDenied {
342+ t.Errorf("CreateSession: %q", name)
343+ }
344+ if name, _ := call(c, scPath, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str("org.gnome.Mutter.ScreenCast") }); name != scDenied {
345+ t.Errorf("GetAll: %q", name)
346+ }
347+ for _, x := range []struct{ dest, path, iface, member string }{
348+ {"org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"},
349+ {"org.gnome.Shell.Introspect", "/org/gnome/Shell/Introspect", "org.gnome.Shell.Introspect", "GetWindows"},
350+ {"org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", "InteractiveScreenshot"},
351+ } {
352+ e := c.NewCall(x.dest, x.path, x.iface, x.member, "")
353+ _ = e
354+ if _, err := c.Call(); err == nil || !strings.Contains(err.Error(), scDenied) {
355+ t.Errorf("%s: %v", x.member, err)
356+ }
357+ }
358+}
359+
360+func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
361+
362+// castPath names the packages whose allocations the cast path owns.
363+var castPath = []string{
364+ "internal/adapters/screencast.",
365+ "purego-pipewire",
366+ "bnema/zerobus",
367+ "wayland.(*castSession)",
368+ "wayland.(*Server).requestCast",
369+ "wayland.(*Server).castFlip",
370+ "wayland.(*Server).forwardCasts",
371+ "wayland.(*Server).forwardCaptured",
372+ "wayland.postRing",
373+ "outputkit/capture.writeFrame",
374+ // The renderer capture path, shared with ext-image-copy-capture and
375+ // screencopy: lease, copy, read, return.
376+ "vulkan.(*Renderer).BeginCapture",
377+ "vulkan.(*Renderer).EndCapture",
378+ "outputkit/capture.(*Pipeline).SubmitScoped",
379+ "outputkit/capture.(*Pipeline).process",
380+ "outputkit/capture.(*Pipeline).Recycle",
381+}
382+
383+// allocs counts profiled allocation objects by stack, for the cast path and
384+// for the rest.
385+type allocs struct {
386+ cast, other map[string]int64
387+}
388+
389+func allocSnapshot() allocs {
390+ runtime.GC() // the memory profile is as of the last completed GC
391+ var recs []runtime.MemProfileRecord
392+ for {
393+ recs = make([]runtime.MemProfileRecord, 4096)
394+ n, ok := runtime.MemProfile(recs, true)
395+ if ok {
396+ recs = recs[:n]
397+ break
398+ }
399+ recs = make([]runtime.MemProfileRecord, n+256)
400+ if n, ok = runtime.MemProfile(recs, true); ok {
401+ recs = recs[:n]
402+ break
403+ }
404+ }
405+ a := allocs{cast: map[string]int64{}, other: map[string]int64{}}
406+ for i := range recs {
407+ pcs := recs[i].Stack()
408+ frames := runtime.CallersFrames(pcs)
409+ var key strings.Builder
410+ isCast := false
411+ for {
412+ f, more := frames.Next()
413+ key.WriteString(f.Function)
414+ key.WriteByte(';')
415+ for _, p := range castPath {
416+ if strings.Contains(f.Function, p) {
417+ isCast = true
418+ }
419+ }
420+ if !more {
421+ break
422+ }
423+ }
424+ if isCast {
425+ a.cast[key.String()] += recs[i].AllocObjects
426+ } else {
427+ a.other[key.String()] += recs[i].AllocObjects
428+ }
429+ }
430+ return a
431+}
432+
433+// sub returns the objects allocated between two snapshots.
434+func (a allocs) sub(b allocs) (cast, other int64) {
435+ for k, v := range a.cast {
436+ cast += v - b.cast[k]
437+ }
438+ for k, v := range a.other {
439+ other += v - b.other[k]
440+ }
441+ return cast, other
442+}
M internal/app/session.go +23 -3
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/neferwl/internal/adapters/logging"19 "github.com/bnema/neferwl/internal/adapters/logging"
20 "github.com/bnema/neferwl/internal/adapters/powerkey"20 "github.com/bnema/neferwl/internal/adapters/powerkey"
21 "github.com/bnema/neferwl/internal/adapters/sched"21 "github.com/bnema/neferwl/internal/adapters/sched"
22+ "github.com/bnema/neferwl/internal/adapters/screencast"
22 "github.com/bnema/neferwl/internal/adapters/screensaver"23 "github.com/bnema/neferwl/internal/adapters/screensaver"
23 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"24 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
24 "github.com/bnema/neferwl/internal/adapters/statefile"25 "github.com/bnema/neferwl/internal/adapters/statefile"
@@ -52,6 +53,7 @@ type session struct {
52 dmabuf ports.DMABufSupport53 dmabuf ports.DMABufSupport
53 wsIDs *workspaceid.IDs54 wsIDs *workspaceid.IDs
54 captureAllow *captureallow.Store55 captureAllow *captureallow.Store
56+ cast *screencast.Service // nil without the D-Bus services
55 server *wayland.Server57 server *wayland.Server
56 childEnv []string58 childEnv []string
57 statePath string59 statePath string
@@ -113,8 +115,16 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
113 } else {115 } else {
114 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)116 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
115 }117 }
118+ // Only a real session (or a test that asks) serves the session bus:
119+ // headless runs would take the names from the desktop they run in.
120+ var casts ports.CastSink
121+ if s.hw != nil || opts.DBus {
122+ s.cast = screencast.New(s.captureAllow.Policy, s.p.castCommands, logging.For(ctx, "screencast"))
123+ casts = s.cast
124+ }
116 if s.server, err = wayland.New(wayland.Options{125 if s.server, err = wayland.New(wayland.Options{
117 CaptureAllow: s.captureAllow,126 CaptureAllow: s.captureAllow,
127+ Casts: casts,
118 WorkspaceIDs: s.wsIDs,128 WorkspaceIDs: s.wsIDs,
119 Security: s.security,129 Security: s.security,
120 RuntimeDir: runtimeDir,130 RuntimeDir: runtimeDir,
@@ -374,14 +384,24 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
374 if hw != nil {384 if hw != nil {
375 workers.Add(1)385 workers.Add(1)
376 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()386 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
377- // Only a real session serves the session bus: headless runs would take387+ }
378- // the name from the desktop they run in.388+ // Only a real session serves the session bus: headless runs would take
379- workers.Add(1)389+ // the names from the desktop they run in, unless a test asks (DBus).
390+ if hw != nil || opts.DBus {
391+ workers.Add(2)
380 go func() {392 go func() {
381 defer workers.Done()393 defer workers.Done()
382 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}394 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
383 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))395 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
384 }()396 }()
397+ go func() {
398+ defer workers.Done()
399+ // nefercap finds this compositor through the session environment.
400+ s.cast.SetEnv(childEnv)
401+ s.cast.Run(ctx, busRetry)
402+ }()
403+ }
404+ if hw != nil {
385 // The same goes for the system bus: logind's locks keep the power405 // The same goes for the system bus: logind's locks keep the power
386 // keys from acting before a bind sees them.406 // keys from acting before a bind sees them.
387 workers.Add(1)407 workers.Add(1)
M internal/app/wiring.go +7 -0
@@ -63,6 +63,10 @@ type pipes struct {
63 // coalesces them.63 // coalesces them.
64 idleInhibited chan bool64 idleInhibited chan bool
65 idleActivity chan struct{}65 idleActivity chan struct{}
66+
67+ // Screencast commands to wayland (the screencast adapter's side is the
68+ // sender); the events come back through wayland.Options.Casts.
69+ castCommands chan ports.CastCommand
66 }70 }
67 71
68 func newPipes(realSession bool) *pipes {72 func newPipes(realSession bool) *pipes {
@@ -105,6 +109,8 @@ func newPipes(realSession bool) *pipes {
105 109
106 idleInhibited: make(chan bool, 1),110 idleInhibited: make(chan bool, 1),
107 idleActivity: make(chan struct{}, 1),111 idleActivity: make(chan struct{}, 1),
112+
113+ castCommands: make(chan ports.CastCommand, 64),
108 }114 }
109 if realSession {115 if realSession {
110 p.scales = make(chan ports.ScaleChanged, 8)116 p.scales = make(chan ports.ScaleChanged, 8)
@@ -152,6 +158,7 @@ func (p *pipes) wayland() wayland.Channels {
152 OutputApplied: p.appliedOutput,158 OutputApplied: p.appliedOutput,
153 IdleInhibited: p.idleInhibited,159 IdleInhibited: p.idleInhibited,
154 IdleActivity: p.idleActivity,160 IdleActivity: p.idleActivity,
161+ CastCommands: p.castCommands,
155 }162 }
156 }163 }
157 164
M internal/cli/run.go +7 -3
@@ -24,9 +24,9 @@ import (
24 24
25 // runFlags are the root command flags.25 // runFlags are the root command flags.
26 type runFlags struct {26 type runFlags struct {
27- backend, screenshot, size, input, debug, config, pprof string27+ backend, screenshot, size, input, debug, config, pprof string
28- noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool28+ noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool
29- timeout time.Duration29+ timeout time.Duration
30 }30 }
31 31
32 func (f *runFlags) register(fs *flag.FlagSet) {32 func (f *runFlags) register(fs *flag.FlagSet) {
@@ -42,6 +42,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
42 fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")42 fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45+ fs.BoolVar(&f.dbus, "dbus", false, "headless: serve the session D-Bus services; for tests under dbus-run-session")
45 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")46 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
46 }47 }
47 48
@@ -51,6 +52,8 @@ func (f *runFlags) validate(args []string) ([][2]int, error) {
51 switch {52 switch {
52 case f.backend != "drm" && !headless:53 case f.backend != "drm" && !headless:
53 return nil, usagef("invalid backend %q", f.backend)54 return nil, usagef("invalid backend %q", f.backend)
55+ case f.dbus && !headless:
56+ return nil, usagef("--dbus requires --backend=headless")
54 case f.headlessHDR && !headless:57 case f.headlessHDR && !headless:
55 return nil, usagef("--headless-hdr requires --backend=headless")58 return nil, usagef("--headless-hdr requires --backend=headless")
56 case f.screenshot != "" && !headless:59 case f.screenshot != "" && !headless:
@@ -154,6 +157,7 @@ func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
154 NoXwayland: f.noXwayland,157 NoXwayland: f.noXwayland,
155 HeadlessHDR: f.headlessHDR,158 HeadlessHDR: f.headlessHDR,
156 Session: f.session,159 Session: f.session,
160+ DBus: f.dbus,
157 ScreenshotDir: f.screenshot,161 ScreenshotDir: f.screenshot,
158 ScreenshotRaw: f.screenshotRaw,162 ScreenshotRaw: f.screenshotRaw,
159 Sizes: sizes,163 Sizes: sizes,
A internal/mocks/ports/screencast_mock.go +126 -0
@@ -0,0 +1,126 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockCastSink creates a new instance of MockCastSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockCastSink(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockCastSink {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &MockCastSink{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// MockCastSink is an autogenerated mock type for the CastSink type
36+type MockCastSink struct {
37+ mock.Mock
38+}
39+
40+type MockCastSink_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *MockCastSink) EXPECT() *MockCastSink_Expecter {
45+ return &MockCastSink_Expecter{mock: &_m.Mock}
46+}
47+
48+// Inventory provides a mock function for the type MockCastSink
49+func (_mock *MockCastSink) Inventory(castInventory ports.CastInventory) {
50+ _mock.Called(castInventory)
51+ return
52+}
53+
54+// MockCastSink_Inventory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inventory'
55+type MockCastSink_Inventory_Call struct {
56+ *mock.Call
57+}
58+
59+// Inventory is a helper method to define mock.On call
60+// - castInventory ports.CastInventory
61+func (_e *MockCastSink_Expecter) Inventory(castInventory any) *MockCastSink_Inventory_Call {
62+ return &MockCastSink_Inventory_Call{Call: _e.mock.On("Inventory", castInventory)}
63+}
64+
65+func (_c *MockCastSink_Inventory_Call) Run(run func(castInventory ports.CastInventory)) *MockCastSink_Inventory_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 ports.CastInventory
68+ if args[0] != nil {
69+ arg0 = args[0].(ports.CastInventory)
70+ }
71+ run(
72+ arg0,
73+ )
74+ })
75+ return _c
76+}
77+
78+func (_c *MockCastSink_Inventory_Call) Return() *MockCastSink_Inventory_Call {
79+ _c.Call.Return()
80+ return _c
81+}
82+
83+func (_c *MockCastSink_Inventory_Call) RunAndReturn(run func(castInventory ports.CastInventory)) *MockCastSink_Inventory_Call {
84+ _c.Run(run)
85+ return _c
86+}
87+
88+// Push provides a mock function for the type MockCastSink
89+func (_mock *MockCastSink) Push(castEvent ports.CastEvent) {
90+ _mock.Called(castEvent)
91+ return
92+}
93+
94+// MockCastSink_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
95+type MockCastSink_Push_Call struct {
96+ *mock.Call
97+}
98+
99+// Push is a helper method to define mock.On call
100+// - castEvent ports.CastEvent
101+func (_e *MockCastSink_Expecter) Push(castEvent any) *MockCastSink_Push_Call {
102+ return &MockCastSink_Push_Call{Call: _e.mock.On("Push", castEvent)}
103+}
104+
105+func (_c *MockCastSink_Push_Call) Run(run func(castEvent ports.CastEvent)) *MockCastSink_Push_Call {
106+ _c.Call.Run(func(args mock.Arguments) {
107+ var arg0 ports.CastEvent
108+ if args[0] != nil {
109+ arg0 = args[0].(ports.CastEvent)
110+ }
111+ run(
112+ arg0,
113+ )
114+ })
115+ return _c
116+}
117+
118+func (_c *MockCastSink_Push_Call) Return() *MockCastSink_Push_Call {
119+ _c.Call.Return()
120+ return _c
121+}
122+
123+func (_c *MockCastSink_Push_Call) RunAndReturn(run func(castEvent ports.CastEvent)) *MockCastSink_Push_Call {
124+ _c.Run(run)
125+ return _c
126+}
M internal/ports/capture_messages.go +5 -0
@@ -14,6 +14,11 @@ type CaptureRequest struct {
14 Dst SHMBuffer14 Dst SHMBuffer
15 Width, Height, Stride int15 Width, Height, Stride int
16 Format uint3216 Format uint32
17+ // Mapped, when set, is the destination instead of Dst.File (nil then):
18+ // the image is written at Mapped[Dst.Offset:] with Stride. The sender
19+ // keeps the mapping alive until CaptureDone; the output never maps,
20+ // unmaps or closes it.
21+ Mapped []byte
17 // Session is the registered capture session (CaptureSessionOpen) the22 // Session is the registered capture session (CaptureSessionOpen) the
18 // request belongs to, 0 for wlr-screencopy. It is informational, except23 // request belongs to, 0 for wlr-screencopy. It is informational, except
19 // with Exclude.24 // with Exclude.
M internal/ports/devices.go +2 -1
@@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient)
26 // goroutine waits on Done, then reads. EndCapture returns the lease.26 // goroutine waits on Done, then reads. EndCapture returns the lease.
27 type CaptureFrame interface {27 type CaptureFrame interface {
28 // Done is the sync file signalled when the copy finished; nil means it28 // Done is the sync file signalled when the copy finished; nil means it
29- // had finished when BeginCapture returned. The frame owns it.29+ // had finished when BeginCapture returned. The frame owns it: it stays
30+ // valid until EndCapture, and the reader must not close it.
30 Done() *os.File31 Done() *os.File
31 // Read copies region (output pixels) as opaque sRGB8 BGRA rows into32 // Read copies region (output pixels) as opaque sRGB8 BGRA rows into
32 // dst, stride bytes per row. Valid after Done signalled and before33 // dst, stride bytes per row. Valid after Done signalled and before
A internal/ports/screencast.go +11 -0
@@ -0,0 +1,11 @@
1+package ports
2+
3+// CastSink receives wayland → screencast messages. Push never blocks:
4+// the screencast adapter queues the event for its owner goroutine, and drops
5+// nothing that releases a buffer or ends a cast (CastFrameDone, CastStopped).
6+// Inventory publishes the current outputs and windows after each change; the
7+// value and its slices belong to the receiver afterwards.
8+type CastSink interface {
9+ Push(CastEvent)
10+ Inventory(CastInventory)
11+}
A internal/ports/screencast_messages.go +89 -0
@@ -0,0 +1,89 @@
1+package ports
2+
3+import "time"
4+
5+// The cast messages are concrete structs, not sealed interfaces, so a send
6+// on a channel never boxes them (zero allocation per frame).
7+
8+// CastCommandKind selects the CastCommand fields in use.
9+type CastCommandKind uint8
10+
11+const (
12+ // CastOpen starts cast ID on Output or Window.
13+ CastOpen CastCommandKind = iota + 1
14+ // CastFrame asks for one frame into buffer Buf.
15+ CastFrame
16+ // CastClose ends cast ID. Wayland sends no CastStopped for it; a frame
17+ // still in flight is answered with its CastFrameDone as usual.
18+ CastClose
19+)
20+
21+// CastCommand carries screencast → wayland: open or close a cast, or request
22+// one frame into a PipeWire buffer.
23+type CastCommand struct {
24+ Kind CastCommandKind
25+ ID uint64
26+ // CastOpen: the target. A window cast sets Window and leaves Output empty.
27+ Output string
28+ Window WindowID
29+ Cursor bool
30+ // CastFrame: Buf is the buffer slot. Data is the buffer's mapping,
31+ // borrowed until the CastFrameDone of Buf: the sender neither reuses nor
32+ // unmaps it before then.
33+ Buf int
34+ Data []byte
35+ Stride int
36+ Width, Height int
37+ Format uint32 // wl_shm format
38+}
39+
40+// CastEventKind selects the CastEvent fields in use.
41+type CastEventKind uint8
42+
43+const (
44+ // CastSize: the target is now Width x Height; the stream must renegotiate.
45+ CastSize CastEventKind = iota + 1
46+ // CastDamage: the target changed and a frame may be requested. Time is
47+ // the flip time.
48+ CastDamage
49+ // CastFrameDone: the frame into Buf finished. Err is nil when Data holds
50+ // a complete image; Time is the capture time.
51+ CastFrameDone
52+ // CastStopped: the cast ended (Reason, Err); no event follows.
53+ CastStopped
54+)
55+
56+// CastEvent carries wayland → screencast through CastSink.Push.
57+type CastEvent struct {
58+ Kind CastEventKind
59+ ID uint64
60+ Width, Height int
61+ Buf int
62+ Err error
63+ Time time.Time
64+ Reason CaptureReason
65+}
66+
67+// CastOutput is one output of the inventory.
68+type CastOutput struct {
69+ Info OutputInfo
70+ Modes []OutputMode
71+ Current int // index into Modes, -1 when none
72+ // X, Y, Width, Height are the logical placement.
73+ X, Y, Width, Height int
74+ Scale float64
75+ Transform BufferTransform
76+}
77+
78+// CastWindow is one window of the inventory.
79+type CastWindow struct {
80+ ID WindowID
81+ AppID, Title string
82+}
83+
84+// CastInventory is the set a cast target is picked from.
85+type CastInventory struct {
86+ Outputs []CastOutput
87+ Windows []CastWindow
88+ Focused string // name of the focused output
89+}
M packaging/arch/PKGBUILD +2 -1
@@ -14,7 +14,8 @@ optdepends=('xwayland-satellite>=0.7: X11 apps'
14 'foot: default terminal'14 'foot: default terminal'
15 'fuzzel: launcher'15 'fuzzel: launcher'
16 'xdg-desktop-portal-gtk: file pickers and settings for apps'16 'xdg-desktop-portal-gtk: file pickers and settings for apps'
17- 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal'17+ 'xdg-desktop-portal-gnome: screen sharing and screenshots through the portal'
18+ 'pipewire: screen sharing streams'
18 'nefercap: screenshots and screen recording')19 'nefercap: screenshots and screen recording')
19 install=neferwl.install20 install=neferwl.install
20 source=("$pkgname-$pkgver.tar.gz")21 source=("$pkgname-$pkgver.tar.gz")
M packaging/aur/neferwl-git/PKGBUILD +2 -1
@@ -16,7 +16,8 @@ optdepends=('xwayland-satellite>=0.7: X11 apps'
16 'foot: default terminal'16 'foot: default terminal'
17 'fuzzel: launcher'17 'fuzzel: launcher'
18 'xdg-desktop-portal-gtk: file pickers and settings for apps'18 'xdg-desktop-portal-gtk: file pickers and settings for apps'
19- 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal'19+ 'xdg-desktop-portal-gnome: screen sharing and screenshots through the portal'
20+ 'pipewire: screen sharing streams'
20 'nefercap: screenshots and screen recording')21 'nefercap: screenshots and screen recording')
21 provides=('neferwl')22 provides=('neferwl')
22 conflicts=('neferwl' 'neferwl-bin')23 conflicts=('neferwl' 'neferwl-bin')
M packaging/neferwl-portals.conf +2 -2
@@ -1,4 +1,4 @@
1 [preferred]1 [preferred]
2 default=gtk2 default=gtk
3-org.freedesktop.impl.portal.ScreenCast=wlr3+org.freedesktop.impl.portal.ScreenCast=gnome
4-org.freedesktop.impl.portal.Screenshot=wlr4+org.freedesktop.impl.portal.Screenshot=gnome

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 26

Tags 10

↑↓ moveenter openesc close