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
77 files · +9463 -285
M .goreleaser.yaml +4 -2
| @@ -71,7 +71,8 @@ nfpms: | |||
| 71 | 71 | - foot | |
| 72 | 72 | - fuzzel | |
| 73 | 73 | - xdg-desktop-portal-gtk | |
| 74 | - | - xdg-desktop-portal-wlr | |
| 74 | + | - xdg-desktop-portal-gnome | |
| 75 | + | - pipewire | |
| 75 | 76 | scripts: | |
| 76 | 77 | postinstall: packaging/deb/postinst | |
| 77 | 78 | contents: | |
| @@ -208,7 +209,8 @@ aurs: | |||
| 208 | 209 | - "foot: default terminal" | |
| 209 | 210 | - "fuzzel: launcher" | |
| 210 | 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 | 214 | - "nefercap: screenshots and screen recording" | |
| 213 | 215 | package: |- | |
| 214 | 216 | install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl" | |
M .hexcheck.yaml +1 -1
| @@ -25,7 +25,7 @@ allow: | |||
| 25 | 25 | ruleSettings: | |
| 26 | 26 | missing-generated-mock-for-port: | |
| 27 | 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 | 29 | suspicious-business-logic-in-adapter: | |
| 30 | 30 | # app is the composition root; starting core and wiring channels is its job. | |
| 31 | 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | |
M .mockery.yml +38 -0
| @@ -14,6 +14,7 @@ packages: | |||
| 14 | 14 | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | |
| 15 | 15 | interfaces: | |
| 16 | 16 | CaptureFrame: | |
| 17 | + | CastSink: | |
| 17 | 18 | Clock: | |
| 18 | 19 | ContentReturn: | |
| 19 | 20 | Notifier: | |
| @@ -110,3 +111,40 @@ packages: | |||
| 110 | 111 | dir: internal/adapters/statefile | |
| 111 | 112 | pkgname: statefile | |
| 112 | 113 | 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 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 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|xkb | |
| 64 | + | ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 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=1 | |
| 72 | + | 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 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # 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 | 113 | Optional tools: | |
| 114 | 114 | ||
| 115 | 115 | ```sh | |
| 116 | - | sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-wlr | |
| 116 | + | sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-gnome | |
| 117 | 117 | ``` | |
| 118 | 118 | ||
| 119 | 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 | 28 | ```text | |
| 29 | 29 | /usr/bin/grim | |
| 30 | 30 | /usr/bin/nefercap | |
| 31 | - | /usr/lib/xdg-desktop-portal-wlr | |
| 32 | - | /usr/libexec/xdg-desktop-portal-wlr | |
| 31 | + | /usr/lib/xdg-desktop-portal-gnome | |
| 32 | + | /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 | 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 | 39 | ```sh | |
| @@ -43,7 +45,7 @@ sudoedit /etc/neferwl/capture-allow | |||
| 43 | 45 | # one absolute executable path per line | |
| 44 | 46 | /usr/bin/grim | |
| 45 | 47 | /usr/bin/nefercap | |
| 46 | - | /usr/lib/xdg-desktop-portal-wlr | |
| 48 | + | /usr/lib/xdg-desktop-portal-gnome | |
| 47 | 49 | /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 | 64 | ## Portals | |
| 63 | 65 | ||
| 64 | - | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-wlr`. | |
| 65 | - | - **Screenshots and recordings:** install [nefercap](https://github.com/bnema/nefercap). | |
| 66 | + | NeferWL serves the GNOME ScreenCast, DisplayConfig, Introspect and Screenshot D-Bus interfaces, so `xdg-desktop-portal-gnome` provides screen sharing and screenshots. | |
| 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 | 65 | ## 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 | 69 | ## Idle and screen off | |
| 70 | 70 | ||
M examples/capture-allow +4 -4
| @@ -5,8 +5,8 @@ | |||
| 5 | 5 | # | |
| 6 | 6 | # /usr/bin/grim | |
| 7 | 7 | # /usr/bin/nefercap | |
| 8 | - | # /usr/lib/xdg-desktop-portal-wlr | |
| 9 | - | # /usr/libexec/xdg-desktop-portal-wlr | |
| 8 | + | # /usr/lib/xdg-desktop-portal-gnome | |
| 9 | + | # /usr/libexec/xdg-desktop-portal-gnome | |
| 10 | 10 | # | |
| 11 | 11 | # To allow a tool installed elsewhere, copy this file to | |
| 12 | 12 | # /etc/neferwl/capture-allow as root and add its path. The file REPLACES the | |
| @@ -27,5 +27,5 @@ | |||
| 27 | 27 | # would then be allowed to capture. | |
| 28 | 28 | /usr/bin/grim | |
| 29 | 29 | /usr/bin/nefercap | |
| 30 | - | /usr/lib/xdg-desktop-portal-wlr | |
| 31 | - | /usr/libexec/xdg-desktop-portal-wlr | |
| 30 | + | /usr/lib/xdg-desktop-portal-gnome | |
| 31 | + | /usr/libexec/xdg-desktop-portal-gnome | |
M go.mod +2 -0
| @@ -7,8 +7,10 @@ require ( | |||
| 7 | 7 | github.com/bnema/kvconf v0.1.2 | |
| 8 | 8 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 9 | 9 | github.com/bnema/purego-libwayland v0.11.0 | |
| 10 | + | github.com/bnema/purego-pipewire v0.2.0 | |
| 10 | 11 | github.com/bnema/purego-vulkan v0.6.0 | |
| 11 | 12 | github.com/bnema/wlturbo v0.5.0 | |
| 13 | + | github.com/bnema/zerobus v0.2.0 | |
| 12 | 14 | github.com/bnema/zerowrap v1.4.1 | |
| 13 | 15 | github.com/godbus/dbus/v5 v5.2.2 | |
| 14 | 16 | github.com/rs/zerolog v1.35.1 | |
M go.sum +4 -0
| @@ -6,10 +6,14 @@ github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2i | |||
| 6 | 6 | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 7 | 7 | github.com/bnema/purego-libwayland v0.11.0 h1:9DHAgg1M9+blG5rAC9EWlJt2GFRnA/pxuXE0S0S2Cl8= | |
| 8 | 8 | github.com/bnema/purego-libwayland v0.11.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA= | |
| 9 | + | github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro= | |
| 10 | + | github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ= | |
| 9 | 11 | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |
| 10 | 12 | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 11 | 13 | github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4= | |
| 12 | 14 | github.com/bnema/wlturbo v0.5.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 15 | + | github.com/bnema/zerobus v0.2.0 h1:BqQ3587nARwtsJdPwcB2WI5fV2ykoF0d/HEBUYZqWn8= | |
| 16 | + | github.com/bnema/zerobus v0.2.0/go.mod h1:QWo6NcbIyic3+gcqwB3IzZh8dIEdXFfw7WdhjNPITYM= | |
| 13 | 17 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| 14 | 18 | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | |
| 15 | 19 | github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= | |
M internal/adapters/captureallow/policy.go +2 -2
| @@ -45,8 +45,8 @@ func Builtin() *Policy { | |||
| 45 | 45 | builtin := [...]string{ | |
| 46 | 46 | "/usr/bin/grim", | |
| 47 | 47 | "/usr/bin/nefercap", | |
| 48 | - | "/usr/lib/xdg-desktop-portal-wlr", | |
| 49 | - | "/usr/libexec/xdg-desktop-portal-wlr", | |
| 48 | + | "/usr/lib/xdg-desktop-portal-gnome", | |
| 49 | + | "/usr/libexec/xdg-desktop-portal-gnome", | |
| 50 | 50 | } | |
| 51 | 51 | p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))} | |
| 52 | 52 | for _, e := range builtin { | |
M internal/adapters/captureallow/policy_test.go +3 -3
| @@ -16,8 +16,8 @@ func TestParse(t *testing.T) { | |||
| 16 | 16 | errLine int | |
| 17 | 17 | 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", | |
| 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", ""}}, | |
| 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-gnome", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}}, | |
| 21 | 21 | {name: "cleaned", in: "/usr//bin/../bin/grim\n", mode: ModeList, allowed: []string{"/usr/bin/grim"}}, | |
| 22 | 22 | {name: "no trailing newline", in: "/a/b", mode: ModeList, allowed: []string{"/a/b"}}, | |
| 23 | 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 | 76 | func TestBuiltin(t *testing.T) { | |
| 77 | 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 | 79 | if p.Mode() != ModeList || p.Len() != len(want) { | |
| 80 | 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 | 22 | var components = map[string]bool{ | |
| 23 | 23 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | |
| 24 | 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 | 28 | // 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 | 68 | if p := recover(); p != nil { | |
| 69 | 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 | 72 | if data != nil { | |
| 72 | 73 | err = errors.Join(err, unix.Munmap(data)) | |
| 73 | 74 | } | |
| 74 | 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 | - | err = fmt.Errorf("invalid capture destination") | |
| 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") | |
| 77 | + | size, err := destSize(req) | |
| 78 | + | if err != nil { | |
| 84 | 79 | return | |
| 85 | 80 | } | |
| 86 | - | size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes | |
| 87 | - | if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes { | |
| 88 | - | err = fmt.Errorf("capture too large") | |
| 81 | + | if req.Mapped != nil { | |
| 82 | + | var dst []byte | |
| 83 | + | if dst, err = mappedDst(req, size); err != nil { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | err = frame.Read(req.Region, dst, req.Stride) | |
| 89 | 87 | return | |
| 90 | 88 | } | |
| 91 | 89 | var st unix.Stat_t | |
| @@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu | |||
| 94 | 92 | return | |
| 95 | 93 | } | |
| 96 | 94 | if st.Size < size { | |
| 97 | - | err = fmt.Errorf("capture buffer truncated") | |
| 95 | + | err = errTruncated | |
| 98 | 96 | return | |
| 99 | 97 | } | |
| 100 | 98 | // 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 | 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 | 194 | // Handed reports a zeroed request: one Split failed, or one already given to | |
| 195 | 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 | 12 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | + | "golang.org/x/sys/unix" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // writeRequest is a valid 2x2 request over a 16-byte file. | |
| @@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) { | |||
| 177 | 178 | frame.Calls = nil | |
| 178 | 179 | 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 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 13 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | //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 | 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 | 132 | // captureSync is the fence and semaphore surface of a capture slot: the | |
| 111 | 133 | // non-blocking poll, the sync file export and the semaphore replacement | |
| 112 | 134 | // (a seam for tests; the device implements it). | |
| @@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer } | |||
| 128 | 150 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | |
| 129 | 151 | // A zero timeout only reports the fence state (the bindings lack | |
| 130 | 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 | 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 | 162 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | |
| 139 | - | var fd int32 | |
| 140 | - | get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 141 | - | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd) | |
| 142 | - | return fd, res | |
| 163 | + | sc := &d.r.call | |
| 164 | + | sc.fd = -1 | |
| 165 | + | sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 166 | + | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd) | |
| 167 | + | return sc.fd, res | |
| 143 | 168 | } | |
| 144 | 169 | ||
| 145 | 170 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | |
| @@ -170,14 +195,43 @@ type captureSlot struct { | |||
| 170 | 195 | // handle kept after EndCapture is invalid by contract (its next | |
| 171 | 196 | // state belongs to another lease). | |
| 172 | 197 | 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 | 227 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | |
| 176 | 228 | // Its fields are written by the owner before the handoff and read by the | |
| 177 | 229 | // worker; the owner touches them again only after EndCapture. | |
| 178 | 230 | type captureFrame struct { | |
| 179 | - | r *Renderer | |
| 180 | - | slot *captureSlot | |
| 231 | + | r *Renderer | |
| 232 | + | 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 | 235 | done *os.File | |
| 182 | 236 | pixels []byte // the slot's mapping, width*height*4 | |
| 183 | 237 | w, h int | |
| @@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync { | |||
| 301 | 355 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | |
| 302 | 356 | d := r.dd | |
| 303 | 357 | s.frame.valid = false | |
| 304 | - | if s.frame.done != nil { | |
| 305 | - | s.frame.done.Close() | |
| 306 | - | s.frame.done = nil | |
| 358 | + | s.frame.done = nil | |
| 359 | + | if s.file != nil { | |
| 360 | + | _ = s.file.Close() | |
| 307 | 361 | } | |
| 308 | 362 | if s.done != 0 { | |
| 309 | 363 | r.captureSync().destroySemaphore(s.done) | |
| @@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 425 | 479 | if s == nil { | |
| 426 | 480 | return nil, ports.ErrCaptureBusy | |
| 427 | 481 | } | |
| 428 | - | d := r.dd | |
| 429 | - | cmd := s.cmd | |
| 482 | + | d, sc := r.dd, &r.capCall | |
| 483 | + | sc.cmd = s.cmd | |
| 484 | + | cmd := sc.cmd | |
| 430 | 485 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | |
| 431 | 486 | return nil, err | |
| 432 | 487 | } | |
| 433 | - | begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 434 | - | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil { | |
| 488 | + | sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 489 | + | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil { | |
| 435 | 490 | return nil, err | |
| 436 | 491 | } | |
| 437 | 492 | if hdr { | |
| @@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 445 | 500 | // After the frame on the same queue: the copy reads what it drew, and | |
| 446 | 501 | // the next frame's first barrier (transfer source stage) orders its | |
| 447 | 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 | 504 | if !debug { | |
| 450 | 505 | // The export below takes the signal; a debug capture waits on | |
| 451 | 506 | // the fence only, so the semaphore must not be signalled (a | |
| 452 | 507 | // binary semaphore is signalled once, then waited or exported). | |
| 453 | - | submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done | |
| 508 | + | 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 | 511 | return nil, err | |
| 457 | 512 | } | |
| 458 | 513 | s.pending = true | |
| @@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 469 | 524 | } | |
| 470 | 525 | if fd >= 0 { | |
| 471 | 526 | // -1: already signalled at export, nothing to wait for. | |
| 472 | - | f.done = os.NewFile(uintptr(fd), "neferwl-capture") | |
| 473 | - | if f.done == nil { | |
| 474 | - | return nil, fmt.Errorf("invalid capture sync file %d", fd) | |
| 527 | + | if err := s.adoptSync(int(fd)); err != nil { | |
| 528 | + | return nil, err | |
| 475 | 529 | } | |
| 530 | + | f.done = s.file | |
| 476 | 531 | } | |
| 477 | 532 | } | |
| 478 | 533 | s.leased, f.valid = true, true | |
| @@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) { | |||
| 487 | 542 | return | |
| 488 | 543 | } | |
| 489 | 544 | f.valid = false | |
| 490 | - | if f.done != nil { | |
| 491 | - | f.done.Close() | |
| 492 | - | f.done = nil | |
| 493 | - | } | |
| 545 | + | f.done = nil // the slot keeps the descriptor for its next lease | |
| 494 | 546 | f.slot.leased = false | |
| 495 | 547 | } | |
| 496 | 548 | ||
| @@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |||
| 566 | 618 | // Exported targets are borrowed from the display without a layout | |
| 567 | 619 | // change: the display may be reading them at the same time. | |
| 568 | 620 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | |
| 569 | - | d := r.dd | |
| 621 | + | d, sc := r.dd, &r.capCall | |
| 570 | 622 | if t.exported { | |
| 571 | 623 | // Acquire from the display. The frame released the image at | |
| 572 | 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} | |
| 574 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 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} | |
| 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}} | |
| 577 | - | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, ®ion) | |
| 578 | - | 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) | |
| 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}} | |
| 629 | + | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region) | |
| 630 | + | sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} | |
| 631 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil) | |
| 580 | 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} | |
| 582 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 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} | |
| 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 | 639 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | |
| 588 | 640 | // is put back in TRANSFER_SRC, as the next frame expects. | |
| 589 | 641 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | |
| 590 | - | d, p, out := r.dd, &r.capturePass, &r.captureSDR | |
| 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} | |
| 592 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | |
| 642 | + | d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall | |
| 643 | + | in, b := &sc.hdrIn, &sc.hdrB | |
| 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 | 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} | |
| 595 | - | 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} | |
| 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} | |
| 598 | - | d.CmdBeginRendering(cmd, &info) | |
| 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} | |
| 648 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) | |
| 649 | + | sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 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} | |
| 651 | + | d.CmdBeginRendering(cmd, &sc.info) | |
| 599 | 652 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | |
| 600 | 653 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | |
| 601 | 654 | d.CmdDraw(cmd, 3, 1, 0, 0) | |
| 602 | 655 | d.CmdEndRendering(cmd) | |
| 603 | 656 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | |
| 604 | 657 | 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 | 659 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | |
| 607 | 660 | 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 | 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 captures | |
| 295 | - | // allocates only the sync file, and a stale handle reports invalid. | |
| 294 | + | // The lease object of a slot is reused, and a stale handle reports invalid. | |
| 296 | 295 | func TestCaptureLeaseReused(t *testing.T) { | |
| 297 | 296 | r, err := New(8, 8) | |
| 298 | 297 | if err != nil { | |
| @@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) { | |||
| 309 | 308 | t.Fatal("lease object not reused") | |
| 310 | 309 | } | |
| 311 | 310 | r.EndCapture(second) | |
| 312 | - | cycle := func() { | |
| 313 | - | cf, err := r.BeginCapture() | |
| 314 | - | if err != nil { | |
| 315 | - | t.Fatal(err) | |
| 311 | + | dst := make([]byte, 4) | |
| 312 | + | if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil { | |
| 313 | + | t.Fatal("stale lease read accepted") | |
| 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 { | |
| 318 | - | if err := syncfile.Wait(context.Background(), done); err != nil { | |
| 334 | + | } | |
| 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 | 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) | |
| 323 | - | } | |
| 324 | - | cycle() | |
| 325 | - | // No lease wrapper: the allocations left are the Vulkan structs the | |
| 326 | - | // FFI boundary keeps on the heap (as in Render, budget 16) plus the | |
| 327 | - | // sync file (os.NewFile and its finalizer). | |
| 328 | - | if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 { | |
| 329 | - | t.Errorf("%.1f allocations per capture, want <= 16", allocs) | |
| 424 | + | return p[0], p[1] | |
| 425 | + | } | |
| 426 | + | r1, w1 := pipe() | |
| 427 | + | r2, w2 := pipe() | |
| 428 | + | t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) }) | |
| 429 | + | var s captureSlot | |
| 430 | + | if err := s.adoptSync(r1); err != nil { | |
| 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 | 112 | frameStages []vk.PipelineStageFlags | |
| 113 | 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | |
| 114 | 114 | call callScratch | |
| 115 | + | // capCall holds the structs a capture hands Vulkan by pointer. | |
| 116 | + | capCall captureScratch | |
| 115 | 117 | } | |
| 116 | 118 | ||
| 117 | 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 | 5 | "os" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 8 | + | "github.com/bnema/neferwl/internal/adapters/peerexe" | |
| 8 | 9 | "github.com/bnema/purego-libwayland/server" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -13,7 +14,9 @@ import ( | |||
| 13 | 14 | // capture sessions and frames, whatever their source (output, output region | |
| 14 | 15 | // or workspace), and the attach of a layer surface to a capture exclusion. A | |
| 15 | 16 | // 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 | 21 | // 1. While the session is protected (ext-session-lock) no client may | |
| 19 | 22 | // capture, so nothing of the locked session reaches a buffer. | |
| @@ -136,7 +139,7 @@ func (s *Server) peerIdentity(c server.Client) *peerID { | |||
| 136 | 139 | id.pidfd, err = s.peer.Pidfd(fd) | |
| 137 | 140 | if err != nil { | |
| 138 | 141 | id.pidfd, id.err = nil, err | |
| 139 | - | if errors.Is(err, errNoPidfd) && !s.noPidfdWarned { | |
| 142 | + | if errors.Is(err, peerexe.ErrNoPidfd) && !s.noPidfdWarned { | |
| 140 | 143 | s.noPidfdWarned = true | |
| 141 | 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 | 13 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | |
| 14 | 14 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 16 | + | "github.com/bnema/neferwl/internal/adapters/peerexe" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/ports" | |
| 18 | 19 | "github.com/bnema/zerowrap" | |
| @@ -275,7 +276,7 @@ func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) { | |||
| 275 | 276 | } | |
| 276 | 277 | ||
| 277 | 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 | 280 | t.Run(name, func(t *testing.T) { | |
| 280 | 281 | peer := newMockpeerExe(t) | |
| 281 | 282 | pidfd := devNull(t) | |
| @@ -350,7 +351,7 @@ func TestLinuxPeerExeSelf(t *testing.T) { | |||
| 350 | 351 | defer unix.Close(fds[1]) | |
| 351 | 352 | var l linuxPeerExe | |
| 352 | 353 | pidfd, err := l.Pidfd(fds[0]) | |
| 353 | - | if errors.Is(err, errNoPidfd) { | |
| 354 | + | if errors.Is(err, peerexe.ErrNoPidfd) { | |
| 354 | 355 | t.Skip("kernel without SO_PEERPIDFD") | |
| 355 | 356 | } | |
| 356 | 357 | if err != nil { | |
| @@ -364,55 +365,11 @@ func TestLinuxPeerExeSelf(t *testing.T) { | |||
| 364 | 365 | if exe != self || pid != os.Getpid() { | |
| 365 | 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 | 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 | 373 | // The exclusion attach asks too: a HUD whose executable is not listed gets | |
| 417 | 374 | // `unauthorized` even with the right token, while the recorder that owns the | |
| 418 | 375 | // exclusion (listed) is unaffected. | |
M internal/adapters/wayland/captureexe.go +10 -83
| @@ -1,15 +1,10 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | 4 | "os" | |
| 8 | 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 | 10 | // peerExe names the executable behind a client connection, in two steps so a | |
| @@ -18,7 +13,8 @@ import ( | |||
| 18 | 13 | type peerExe interface { | |
| 19 | 14 | // Pidfd returns a pidfd of the peer of the connected unix socket sockFD | |
| 20 | 15 | // (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 | 18 | Pidfd(sockFD int) (*os.File, error) | |
| 23 | 19 | // Exe returns the path of the executable running as the process behind | |
| 24 | 20 | // pidfd, and its pid. A deleted executable, or a process that has | |
| @@ -26,24 +22,10 @@ type peerExe interface { | |||
| 26 | 22 | Exe(pidfd *os.File) (exe string, pid int, err error) | |
| 27 | 23 | } | |
| 28 | 24 | ||
| 29 | - | var ( | |
| 30 | - | errExeDeleted = errors.New("executable deleted") | |
| 31 | - | // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the | |
| 32 | - | // peer cannot be identified safely. | |
| 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. | |
| 25 | + | // linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+), | |
| 26 | + | // with the proof of peerexe.ExeOfPidfd. There is no fallback to the | |
| 27 | + | // SO_PEERCRED pid: it has no such proof, so a kernel without SO_PEERPIDFD | |
| 28 | + | // identifies nobody. | |
| 47 | 29 | type linuxPeerExe struct { | |
| 48 | 30 | // noPidfd simulates a kernel without SO_PEERPIDFD (tests). | |
| 49 | 31 | noPidfd bool | |
| @@ -51,67 +33,12 @@ type linuxPeerExe struct { | |||
| 51 | 33 | ||
| 52 | 34 | func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) { | |
| 53 | 35 | if l.noPidfd { | |
| 54 | - | return nil, 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) | |
| 36 | + | return nil, peerexe.ErrNoPidfd | |
| 64 | 37 | } | |
| 38 | + | return peerexe.Pidfd(sockFD) | |
| 65 | 39 | } | |
| 66 | 40 | ||
| 67 | 41 | func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) { | |
| 68 | 42 | defer runtime.KeepAlive(pidfd) | |
| 69 | - | return 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 | |
| 43 | + | return peerexe.ExeOfPidfd(int(pidfd.Fd())) | |
| 117 | 44 | } | |
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // 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 can | |
| 13 | + | // and a stable identifier. Screen-sharing portals list the windows they can | |
| 14 | 14 | // share with it, and ext_foreign_toplevel_image_capture_source_manager_v1 | |
| 15 | 15 | // takes its handles (window capture, imagecopy.go). It follows the same | |
| 16 | 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 | 206 | l.closed(id) | |
| 207 | 207 | } | |
| 208 | 208 | s.windowCaptureGone(id) | |
| 209 | + | s.castInventoryChanged() | |
| 209 | 210 | } | |
| 210 | 211 | ||
| 211 | 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 | 155 | if p.Flip != nil { | |
| 156 | 156 | s.flips[p.Output] = *p.Flip | |
| 157 | 157 | s.presentFlip(p.Output, p.Flip) | |
| 158 | + | s.castFlip(p.Output, p.Flip) | |
| 158 | 159 | } | |
| 159 | 160 | } | |
| 160 | 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 | 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 | 124 | type extraSources struct{ s *Server } | |
| @@ -180,7 +186,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i | |||
| 180 | 186 | if src != nil { | |
| 181 | 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 | 190 | res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state) | |
| 185 | 191 | if err != nil { | |
| 186 | 192 | 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 | 264 | // captureSession is one ext_image_copy_capture_session_v1. | |
| 247 | 265 | type captureSession struct { | |
| 248 | - | s *Server | |
| 266 | + | captureTarget | |
| 249 | 267 | res *ext.ExtImageCopyCaptureSessionV1 | |
| 250 | - | src *captureSource | |
| 251 | - | id uint64 // registered with core; 0 when never | |
| 252 | 268 | cursor, stopped bool | |
| 253 | 269 | frame *captureExtFrame | |
| 254 | 270 | sent image.Point // buffer size of the last constraints | |
| 255 | - | // st is the last decision of core; haveState tells there is one. | |
| 256 | - | st ports.CaptureSessionState | |
| 257 | - | haveState bool | |
| 258 | - | excl *exclusion // the live exclusion object of this session | |
| 271 | + | excl *exclusion // the live exclusion object of this session | |
| 259 | 272 | // exclSeen: the last state of core knew an exclusion. Frames of an | |
| 260 | 273 | // excluded session leave the HUD out while it is (see tag). | |
| 261 | 274 | exclSeen bool | |
| @@ -290,7 +303,7 @@ func (c *captureSession) end(send, notify bool) { | |||
| 290 | 303 | // image of its off-screen render, from the origin) and whether the workspace | |
| 291 | 304 | // is off screen. Core's last state decides where a workspace is; before it, | |
| 292 | 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 | 307 | src := c.src | |
| 295 | 308 | if src == nil { | |
| 296 | 309 | return nil, image.Rectangle{}, false, false | |
| @@ -377,24 +390,57 @@ func (c *captureSession) sendConstraints() { | |||
| 377 | 390 | c.res.SendDone() | |
| 378 | 391 | } | |
| 379 | 392 | ||
| 380 | - | // refresh follows the target: new constraints when the buffer size changed, | |
| 381 | - | // the end when nothing is left of it. | |
| 382 | - | func (c *captureSession) refresh() { | |
| 383 | - | if c.stopped || c.id == 0 { | |
| 384 | - | return | |
| 385 | - | } | |
| 393 | + | // targetFate says what became of a followed target. | |
| 394 | + | type targetFate uint8 | |
| 395 | + | ||
| 396 | + | const ( | |
| 397 | + | targetLive targetFate = iota | |
| 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 | 406 | if c.src.kind == srcWorkspace { | |
| 387 | 407 | // The workspace left the snapshot: its handle is removed, stop at once | |
| 388 | 408 | // without waiting for core's state. | |
| 389 | 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") | |
| 391 | - | c.end(true, true) | |
| 392 | - | return | |
| 410 | + | return targetWorkspaceGone, image.Rectangle{}, false | |
| 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 | 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 | 444 | c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty") | |
| 399 | 445 | c.end(true, true) | |
| 400 | 446 | case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent: | |
| @@ -407,6 +453,9 @@ func (s *Server) refreshSessions() { | |||
| 407 | 453 | for c := range s.captureSessions { | |
| 408 | 454 | c.refresh() | |
| 409 | 455 | } | |
| 456 | + | for _, c := range s.castSessions { | |
| 457 | + | c.refresh() | |
| 458 | + | } | |
| 410 | 459 | } | |
| 411 | 460 | ||
| 412 | 461 | // endCaptureSessions stops every session, and its exclusion with it, when | |
| @@ -419,6 +468,11 @@ func (s *Server) endCaptureSessions() { | |||
| 419 | 468 | } | |
| 420 | 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 | 478 | // outputGone ends the sessions of an unplugged output without waiting for | |
| @@ -430,21 +484,29 @@ func (s *Server) outputGone(o *output) { | |||
| 430 | 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 | 495 | // captureState applies what core decided about a session. | |
| 436 | 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 | 501 | c := s.sessionsByID[st.ID] | |
| 438 | 502 | if c == nil { | |
| 439 | 503 | return | |
| 440 | 504 | } | |
| 441 | - | if st.Reason.Terminal() { | |
| 442 | - | s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end") | |
| 505 | + | if c.applyState(st) { | |
| 443 | 506 | c.end(true, false) | |
| 444 | 507 | return | |
| 445 | 508 | } | |
| 446 | - | c.st, c.haveState, c.exclSeen = st, true, 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") | |
| 509 | + | c.exclSeen = st.Exclusion | |
| 448 | 510 | if ex := c.excl; ex != nil { | |
| 449 | 511 | ex.confirm(st.Layers) | |
| 450 | 512 | } | |
| @@ -457,6 +519,19 @@ func (s *Server) captureState(st ports.CaptureSessionState) { | |||
| 457 | 519 | // still in the last state, a frame is not served rather than shown with the | |
| 458 | 520 | // HUD in it (ok false). | |
| 459 | 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 | 535 | t = captureTag{session: c.id} | |
| 461 | 536 | switch c.src.kind { | |
| 462 | 537 | case srcWorkspace: | |
| @@ -475,13 +550,7 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | |||
| 475 | 550 | case srcWindow: | |
| 476 | 551 | t.taken = ports.CaptureFrameTaken{Window: c.src.window} | |
| 477 | 552 | } | |
| 478 | - | if c.excl != nil && !c.exclSeen { | |
| 479 | - | return t, false | |
| 480 | - | } | |
| 481 | - | if c.exclSeen { | |
| 482 | - | t.exclude, t.revision = true, c.st.Revision | |
| 483 | - | } | |
| 484 | - | return t, true | |
| 553 | + | return t | |
| 485 | 554 | } | |
| 486 | 555 | ||
| 487 | 556 | func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) { | |
| @@ -518,7 +587,7 @@ type captureExtFrame struct { | |||
| 518 | 587 | // A frame whose target is briefly unavailable (not active yet, rendered off | |
| 519 | 588 | // screen by another session, its indicator or exclusion not on screen yet, | |
| 520 | 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. Only | |
| 590 | + | // ends the whole screencast in screen-sharing clients. Only | |
| 522 | 591 | // a stopped session, a refused client or a wrong buffer fail at once. | |
| 523 | 592 | const ( | |
| 524 | 593 | captureRetryEvery = 16 * time.Millisecond | |
M internal/adapters/wayland/outputmanagement.go +1 -0
| @@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) { | |||
| 189 | 189 | return | |
| 190 | 190 | } | |
| 191 | 191 | s.outputHeads = h | |
| 192 | + | s.castInventoryChanged() | |
| 192 | 193 | s.refreshOutputManagers() | |
| 193 | 194 | } | |
| 194 | 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 | 160 | // changed ones resent. | |
| 161 | 161 | func (s *Server) setOutputs(c ports.SetOutputs) { | |
| 162 | 162 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | |
| 163 | + | focusChanged := s.focusedOutput != c.Focused | |
| 163 | 164 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | |
| 164 | 165 | defer func() { | |
| 165 | 166 | if changed { | |
| 166 | 167 | s.refreshOutputManagers() | |
| 167 | 168 | } | |
| 169 | + | if changed || focusChanged { | |
| 170 | + | s.castInventoryChanged() | |
| 171 | + | } | |
| 168 | 172 | }() | |
| 169 | 173 | s.focusedOutput = c.Focused | |
| 170 | 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 | 67 | case buffer && !w.mapped && x.acked && !pp.done: | |
| 68 | 68 | w.mapped = true | |
| 69 | + | s.castCommitted(w.id) | |
| 69 | 70 | s.emit(ports.PopupMapped{ID: w.id}) | |
| 70 | 71 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") | |
| 71 | 72 | case !buffer && w.mapped: | |
| @@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 146 | 147 | } | |
| 147 | 148 | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | |
| 148 | 149 | 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 | 154 | s.serial++ | |
| 150 | 155 | p.w.xdg.resource.SendConfigure(s.serial) | |
| 151 | 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 | 239 | 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 | 260 | func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |
| 243 | 261 | for { | |
| 244 | 262 | select { | |
| @@ -250,14 +268,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |||
| 250 | 268 | if !ok { | |
| 251 | 269 | return | |
| 252 | 270 | } | |
| 253 | - | if !s.display.Do(func() { | |
| 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 | - | }) { | |
| 271 | + | if !s.capturedRing.Push(done) { | |
| 261 | 272 | return | |
| 262 | 273 | } | |
| 263 | 274 | } | |
M internal/adapters/wayland/server.go +34 -3
| @@ -48,6 +48,9 @@ type Options struct { | |||
| 48 | 48 | // nil lets every client capture, unless the session is protected. The | |
| 49 | 49 | // caller keeps it current (captureallow.Store.Run). | |
| 50 | 50 | 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 | 54 | // LogDamage logs the damage of every fresh client commit at debug level | |
| 52 | 55 | // (--debug=wayland-damage). | |
| 53 | 56 | LogDamage bool | |
| @@ -78,6 +81,9 @@ type Channels struct { | |||
| 78 | 81 | // never block the backend; standalone tests may use smaller channels. | |
| 79 | 82 | Captures chan<- ports.CaptureRequest | |
| 80 | 83 | 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 | 87 | // OutputFormats are the outputs' direct scanout formats, offered in | |
| 82 | 88 | // dmabuf feedback to fullscreen surfaces. | |
| 83 | 89 | OutputFormats <-chan ports.OutputFormats | |
| @@ -111,6 +117,9 @@ type Server struct { | |||
| 111 | 117 | nextLeaseID uint64 | |
| 112 | 118 | nextCapture uint64 | |
| 113 | 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 | 123 | // captureInflight counts accepted captures until the backend completes | |
| 115 | 124 | // them, independent of client resources; only the display loop touches it. | |
| 116 | 125 | captureInflight map[uint64]struct{} | |
| @@ -119,9 +128,20 @@ type Server struct { | |||
| 119 | 128 | // sessionsByID are the sessions registered with core (imagecopy.go); | |
| 120 | 129 | // excl is the one live exclusion (exclusion.go); ownUID the compositor's. | |
| 121 | 130 | sessionsByID map[uint64]*captureSession | |
| 122 | - | nextSession uint64 | |
| 123 | - | excl *exclusion | |
| 124 | - | ownUID uint32 | |
| 131 | + | // Screencast (screencast.go): casts by the adapter's ID and by the ID | |
| 132 | + | // core knows them by; castRing carries the adapter's commands to the | |
| 133 | + | // 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 | 145 | // captureAllow, peer and peers decide mayCapture (captureauth.go): | |
| 126 | 146 | // peers keeps each client's pidfd until it is destroyed. | |
| 127 | 147 | captureAllow *captureallow.Store | |
| @@ -358,6 +378,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 358 | 378 | s.paceSignalFn = s.paceSignalled | |
| 359 | 379 | s.flushLinksFn = s.flushLinks | |
| 360 | 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 | 386 | s.leaseDevices = map[string]*leaseDevice{} | |
| 362 | 387 | s.pendingLeases = map[uint64]*leaseObject{} | |
| 363 | 388 | s.pendingLeaseCards = map[uint64]string{} | |
| @@ -549,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 549 | 574 | wg.Add(1) | |
| 550 | 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 | 582 | err := s.display.Run(ctx) | |
| 553 | 583 | s.stopSecurityContexts() | |
| 554 | 584 | wg.Wait() | |
| @@ -706,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 706 | 736 | l.add(c) | |
| 707 | 737 | s.flushLink(l) | |
| 708 | 738 | } | |
| 739 | + | s.castCommitted(c.ID) | |
| 709 | 740 | } | |
| 710 | 741 | ||
| 711 | 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 | 91 | s.lockReadiness.End(l.state.Generation) | |
| 92 | 92 | s.sessionLock = nil | |
| 93 | 93 | s.refreshToplevels() | |
| 94 | + | s.castInventoryChanged() // the windows are listed again | |
| 94 | 95 | s.updateWorkspaceManagers(s.workspaceSnapshot) | |
| 95 | 96 | for _, surface := range l.surfaces { | |
| 96 | 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 | 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 | 250 | x.server.syncInhibitors() | |
| 251 | 251 | x.server.toplevelChanged(w) | |
| 252 | + | x.server.castInventoryChanged() | |
| 252 | 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 | 254 | } else if !buffer && w.mapped { | |
| 254 | 255 | w.unmap() | |
| @@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |||
| 339 | 340 | } | |
| 340 | 341 | t.w.title = title | |
| 341 | 342 | t.w.xdg.server.toplevelChanged(t.w) | |
| 343 | + | if t.w.mapped { | |
| 344 | + | t.w.xdg.server.castInventoryChanged() | |
| 345 | + | } | |
| 342 | 346 | } | |
| 343 | 347 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |
| 344 | 348 | if appID == t.w.appID { | |
| @@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||
| 348 | 352 | if t.w.mapped { | |
| 349 | 353 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | |
| 350 | 354 | t.w.xdg.server.toplevelChanged(t.w) | |
| 355 | + | t.w.xdg.server.castInventoryChanged() | |
| 351 | 356 | } | |
| 352 | 357 | } | |
| 353 | 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 | 27 | HeadlessHDR bool | |
| 28 | 28 | // Session exports the displays to D-Bus and systemd user services | |
| 29 | 29 | // before any client starts, and withdraws them on exit. | |
| 30 | - | Session bool | |
| 30 | + | 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 | 35 | ScreenshotDir string | |
| 32 | 36 | // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a | |
| 33 | 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 | 19 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 20 | 20 | "github.com/bnema/neferwl/internal/adapters/powerkey" | |
| 21 | 21 | "github.com/bnema/neferwl/internal/adapters/sched" | |
| 22 | + | "github.com/bnema/neferwl/internal/adapters/screencast" | |
| 22 | 23 | "github.com/bnema/neferwl/internal/adapters/screensaver" | |
| 23 | 24 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |
| 24 | 25 | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| @@ -52,6 +53,7 @@ type session struct { | |||
| 52 | 53 | dmabuf ports.DMABufSupport | |
| 53 | 54 | wsIDs *workspaceid.IDs | |
| 54 | 55 | captureAllow *captureallow.Store | |
| 56 | + | cast *screencast.Service // nil without the D-Bus services | |
| 55 | 57 | server *wayland.Server | |
| 56 | 58 | childEnv []string | |
| 57 | 59 | statePath string | |
| @@ -113,8 +115,16 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 113 | 115 | } else { | |
| 114 | 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 | 125 | if s.server, err = wayland.New(wayland.Options{ | |
| 117 | 126 | CaptureAllow: s.captureAllow, | |
| 127 | + | Casts: casts, | |
| 118 | 128 | WorkspaceIDs: s.wsIDs, | |
| 119 | 129 | Security: s.security, | |
| 120 | 130 | RuntimeDir: runtimeDir, | |
| @@ -374,14 +384,24 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 374 | 384 | if hw != nil { | |
| 375 | 385 | workers.Add(1) | |
| 376 | 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 take | |
| 378 | - | // the name from the desktop they run in. | |
| 379 | - | workers.Add(1) | |
| 387 | + | } | |
| 388 | + | // Only a real session serves the session bus: headless runs would take | |
| 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 | 392 | go func() { | |
| 381 | 393 | defer workers.Done() | |
| 382 | 394 | reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity} | |
| 383 | 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 | 405 | // The same goes for the system bus: logind's locks keep the power | |
| 386 | 406 | // keys from acting before a bind sees them. | |
| 387 | 407 | workers.Add(1) | |
M internal/app/wiring.go +7 -0
| @@ -63,6 +63,10 @@ type pipes struct { | |||
| 63 | 63 | // coalesces them. | |
| 64 | 64 | idleInhibited chan bool | |
| 65 | 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 | 72 | func newPipes(realSession bool) *pipes { | |
| @@ -105,6 +109,8 @@ func newPipes(realSession bool) *pipes { | |||
| 105 | 109 | ||
| 106 | 110 | idleInhibited: make(chan bool, 1), | |
| 107 | 111 | idleActivity: make(chan struct{}, 1), | |
| 112 | + | ||
| 113 | + | castCommands: make(chan ports.CastCommand, 64), | |
| 108 | 114 | } | |
| 109 | 115 | if realSession { | |
| 110 | 116 | p.scales = make(chan ports.ScaleChanged, 8) | |
| @@ -152,6 +158,7 @@ func (p *pipes) wayland() wayland.Channels { | |||
| 152 | 158 | OutputApplied: p.appliedOutput, | |
| 153 | 159 | IdleInhibited: p.idleInhibited, | |
| 154 | 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 | 25 | // runFlags are the root command flags. | |
| 26 | 26 | type runFlags struct { | |
| 27 | - | backend, screenshot, size, input, debug, config, pprof string | |
| 28 | - | noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool | |
| 29 | - | timeout time.Duration | |
| 27 | + | backend, screenshot, size, input, debug, config, pprof string | |
| 28 | + | noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool | |
| 29 | + | timeout time.Duration | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func (f *runFlags) register(fs *flag.FlagSet) { | |
| @@ -42,6 +42,7 @@ func (f *runFlags) register(fs *flag.FlagSet) { | |||
| 42 | 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 | 43 | fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)") | |
| 44 | 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 | 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 | 52 | switch { | |
| 52 | 53 | case f.backend != "drm" && !headless: | |
| 53 | 54 | return nil, usagef("invalid backend %q", f.backend) | |
| 55 | + | case f.dbus && !headless: | |
| 56 | + | return nil, usagef("--dbus requires --backend=headless") | |
| 54 | 57 | case f.headlessHDR && !headless: | |
| 55 | 58 | return nil, usagef("--headless-hdr requires --backend=headless") | |
| 56 | 59 | case f.screenshot != "" && !headless: | |
| @@ -154,6 +157,7 @@ func (f *runFlags) run(ctx context.Context, sizes [][2]int) error { | |||
| 154 | 157 | NoXwayland: f.noXwayland, | |
| 155 | 158 | HeadlessHDR: f.headlessHDR, | |
| 156 | 159 | Session: f.session, | |
| 160 | + | DBus: f.dbus, | |
| 157 | 161 | ScreenshotDir: f.screenshot, | |
| 158 | 162 | ScreenshotRaw: f.screenshotRaw, | |
| 159 | 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 | 14 | Dst SHMBuffer | |
| 15 | 15 | Width, Height, Stride int | |
| 16 | 16 | 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 | 22 | // Session is the registered capture session (CaptureSessionOpen) the | |
| 18 | 23 | // request belongs to, 0 for wlr-screencopy. It is informational, except | |
| 19 | 24 | // with Exclude. | |
M internal/ports/devices.go +2 -1
| @@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) | |||
| 26 | 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | |
| 27 | 27 | type CaptureFrame interface { | |
| 28 | 28 | // 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 | 31 | Done() *os.File | |
| 31 | 32 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | |
| 32 | 33 | // 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 | 14 | 'foot: default terminal' | |
| 15 | 15 | 'fuzzel: launcher' | |
| 16 | 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 | 19 | 'nefercap: screenshots and screen recording') | |
| 19 | 20 | install=neferwl.install | |
| 20 | 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 | 16 | 'foot: default terminal' | |
| 17 | 17 | 'fuzzel: launcher' | |
| 18 | 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 | 21 | 'nefercap: screenshots and screen recording') | |
| 21 | 22 | provides=('neferwl') | |
| 22 | 23 | conflicts=('neferwl' 'neferwl-bin') | |
M packaging/neferwl-portals.conf +2 -2
| @@ -1,4 +1,4 @@ | |||
| 1 | 1 | [preferred] | |
| 2 | 2 | default=gtk | |
| 3 | - | org.freedesktop.impl.portal.ScreenCast=wlr | |
| 4 | - | org.freedesktop.impl.portal.Screenshot=wlr | |
| 3 | + | org.freedesktop.impl.portal.ScreenCast=gnome | |
| 4 | + | org.freedesktop.impl.portal.Screenshot=gnome | |