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 | - foot | 71 | - foot | ||
| 72 | - fuzzel | 72 | - fuzzel | ||
| 73 | - xdg-desktop-portal-gtk | 73 | - xdg-desktop-portal-gtk | ||
| 74 | - | - xdg-desktop-portal-wlr | 74 | + | - xdg-desktop-portal-gnome |
| 75 | + | - pipewire | |||
| 75 | scripts: | 76 | scripts: | ||
| 76 | postinstall: packaging/deb/postinst | 77 | postinstall: packaging/deb/postinst | ||
| 77 | contents: | 78 | contents: | ||
| @@ -208,7 +209,8 @@ aurs: | |||||
| 208 | - "foot: default terminal" | 209 | - "foot: default terminal" | ||
| 209 | - "fuzzel: launcher" | 210 | - "fuzzel: launcher" | ||
| 210 | - "xdg-desktop-portal-gtk: file pickers and settings for apps" | 211 | - "xdg-desktop-portal-gtk: file pickers and settings for apps" | ||
| 211 | - | - "xdg-desktop-portal-wlr: screen sharing and screenshots through the portal" | 212 | + | - "xdg-desktop-portal-gnome: screen sharing and screenshots through the portal" |
| 213 | + | - "pipewire: screen sharing streams" | |||
| 212 | - "nefercap: screenshots and screen recording" | 214 | - "nefercap: screenshots and screen recording" | ||
| 213 | package: |- | 215 | package: |- | ||
| 214 | install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl" | 216 | install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl" | ||
M .hexcheck.yaml +1 -1
| @@ -25,7 +25,7 @@ allow: | |||||
| 25 | ruleSettings: | 25 | ruleSettings: | ||
| 26 | missing-generated-mock-for-port: | 26 | missing-generated-mock-for-port: | ||
| 27 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | 27 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | ||
| 28 | - | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go] | 28 | + | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go] |
| 29 | suspicious-business-logic-in-adapter: | 29 | suspicious-business-logic-in-adapter: | ||
| 30 | # app is the composition root; starting core and wiring channels is its job. | 30 | # app is the composition root; starting core and wiring channels is its job. | ||
| 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | ||
M .mockery.yml +38 -0
| @@ -14,6 +14,7 @@ packages: | |||||
| 14 | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | 14 | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | ||
| 15 | interfaces: | 15 | interfaces: | ||
| 16 | CaptureFrame: | 16 | CaptureFrame: | ||
| 17 | + | CastSink: | |||
| 17 | Clock: | 18 | Clock: | ||
| 18 | ContentReturn: | 19 | ContentReturn: | ||
| 19 | Notifier: | 20 | Notifier: | ||
| @@ -110,3 +111,40 @@ packages: | |||||
| 110 | dir: internal/adapters/statefile | 111 | dir: internal/adapters/statefile | ||
| 111 | pkgname: statefile | 112 | pkgname: statefile | ||
| 112 | filename: proc_tree_mock_test.go | 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 | # Adapters stay independent: production code may import only the shared | 61 | # Adapters stay independent: production code may import only the shared | ||
| 62 | # adapter libraries below. outputkit holds what both output backends (drm, | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | ||
| 63 | # headless) run; the others are leaf helpers. | 63 | # headless) run; the others are leaf helpers. | ||
| 64 | - | ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb | 64 | + | ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb |
| 65 | adapter-imports: | 65 | adapter-imports: | ||
| 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | ||
| 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | ||
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=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 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 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 | Optional tools: | 113 | Optional tools: | ||
| 114 | 114 | ||||
| 115 | ```sh | 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 | X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested. | 119 | X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested. | ||
M docs/capture.md +15 -7
| @@ -28,10 +28,12 @@ By default, these executables may capture: | |||||
| 28 | ```text | 28 | ```text | ||
| 29 | /usr/bin/grim | 29 | /usr/bin/grim | ||
| 30 | /usr/bin/nefercap | 30 | /usr/bin/nefercap | ||
| 31 | - | /usr/lib/xdg-desktop-portal-wlr | 31 | + | /usr/lib/xdg-desktop-portal-gnome |
| 32 | - | /usr/libexec/xdg-desktop-portal-wlr | 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 | To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need: | 37 | To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need: | ||
| 36 | 38 | ||||
| 37 | ```sh | 39 | ```sh | ||
| @@ -43,7 +45,7 @@ sudoedit /etc/neferwl/capture-allow | |||||
| 43 | # one absolute executable path per line | 45 | # one absolute executable path per line | ||
| 44 | /usr/bin/grim | 46 | /usr/bin/grim | ||
| 45 | /usr/bin/nefercap | 47 | /usr/bin/nefercap | ||
| 46 | - | /usr/lib/xdg-desktop-portal-wlr | 48 | + | /usr/lib/xdg-desktop-portal-gnome |
| 47 | /opt/tools/bin/recorder | 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 | ## Portals | 64 | ## Portals | ||
| 63 | 65 | ||||
| 64 | - | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-wlr`. | 66 | + | NeferWL serves the GNOME ScreenCast, DisplayConfig, Introspect and Screenshot D-Bus interfaces, so `xdg-desktop-portal-gnome` provides screen sharing and screenshots. |
| 65 | - | - **Screenshots and recordings:** install [nefercap](https://github.com/bnema/nefercap). | 67 | + | |
| 68 | + | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-gnome` and run PipeWire. The portal dialog lists monitors and windows with their titles; the stream is a PipeWire video node. Without PipeWire, the ScreenCast interface is not offered. | |||
| 69 | + | - **Screenshots** through the portal, interactive or not: install [nefercap](https://github.com/bnema/nefercap), which writes the PNG. | |||
| 70 | + | - A cast keeps the indicator on until it ends. It ends when the session locks, the output or window goes away, the client stops it or disconnects, or NeferWL loses the D-Bus connection. When the receiving program stops reading, the stream pauses (no frames are rendered) and the cast stays open for the next reader, as in GNOME. | |||
| 71 | + | - Unsupported: area and virtual casts, and the "Select area" and "Window" choices of the screenshot dialog. | |||
| 72 | + | ||||
| 73 | + | The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the gnome portal. | |||
| 66 | 74 | ||||
| 67 | - | The packaged `neferwl-portals.conf` routes `ScreenCast` and `Screenshot` to the wlr portal. NeferWL has no window picker: set `chooser_type` and `chooser_cmd` in the wlr portal config to use slurp, wofi or bemenu (see `man xdg-desktop-portal-wlr`). | 75 | + | The D-Bus interfaces are served on the session bus when NeferWL runs on a hardware session. With `--backend=headless`, pass `-dbus` to serve them. Names already owned (by a GNOME session) are skipped. |
| 68 | 76 | ||||
| 69 | - | The wlr portal takes screenshots without asking, for any program running as you. The indicator still flashes. | 77 | + | Screenshots through the portal are taken without asking, for any program running as you whose executable is on the allowlist. The indicator still flashes. |
M docs/desktop.md +1 -1
| @@ -64,7 +64,7 @@ Use an external locker such as swaylock or hyprlock. See [Session locking](sessi | |||||
| 64 | 64 | ||||
| 65 | ## Screen capture | 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 | ## Idle and screen off | 69 | ## Idle and screen off | ||
| 70 | 70 | ||||
M examples/capture-allow +4 -4
| @@ -5,8 +5,8 @@ | |||||
| 5 | # | 5 | # | ||
| 6 | # /usr/bin/grim | 6 | # /usr/bin/grim | ||
| 7 | # /usr/bin/nefercap | 7 | # /usr/bin/nefercap | ||
| 8 | - | # /usr/lib/xdg-desktop-portal-wlr | 8 | + | # /usr/lib/xdg-desktop-portal-gnome |
| 9 | - | # /usr/libexec/xdg-desktop-portal-wlr | 9 | + | # /usr/libexec/xdg-desktop-portal-gnome |
| 10 | # | 10 | # | ||
| 11 | # To allow a tool installed elsewhere, copy this file to | 11 | # To allow a tool installed elsewhere, copy this file to | ||
| 12 | # /etc/neferwl/capture-allow as root and add its path. The file REPLACES the | 12 | # /etc/neferwl/capture-allow as root and add its path. The file REPLACES the | ||
| @@ -27,5 +27,5 @@ | |||||
| 27 | # would then be allowed to capture. | 27 | # would then be allowed to capture. | ||
| 28 | /usr/bin/grim | 28 | /usr/bin/grim | ||
| 29 | /usr/bin/nefercap | 29 | /usr/bin/nefercap | ||
| 30 | - | /usr/lib/xdg-desktop-portal-wlr | 30 | + | /usr/lib/xdg-desktop-portal-gnome |
| 31 | - | /usr/libexec/xdg-desktop-portal-wlr | 31 | + | /usr/libexec/xdg-desktop-portal-gnome |
M go.mod +2 -0
| @@ -7,8 +7,10 @@ require ( | |||||
| 7 | github.com/bnema/kvconf v0.1.2 | 7 | github.com/bnema/kvconf v0.1.2 | ||
| 8 | github.com/bnema/purego v0.13.0-bnema.1 | 8 | github.com/bnema/purego v0.13.0-bnema.1 | ||
| 9 | github.com/bnema/purego-libwayland v0.11.0 | 9 | github.com/bnema/purego-libwayland v0.11.0 | ||
| 10 | + | github.com/bnema/purego-pipewire v0.2.0 | |||
| 10 | github.com/bnema/purego-vulkan v0.6.0 | 11 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 11 | github.com/bnema/wlturbo v0.5.0 | 12 | github.com/bnema/wlturbo v0.5.0 | ||
| 13 | + | github.com/bnema/zerobus v0.2.0 | |||
| 12 | github.com/bnema/zerowrap v1.4.1 | 14 | github.com/bnema/zerowrap v1.4.1 | ||
| 13 | github.com/godbus/dbus/v5 v5.2.2 | 15 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 14 | github.com/rs/zerolog v1.35.1 | 16 | github.com/rs/zerolog v1.35.1 | ||
M go.sum +4 -0 generated file: collapsed
M internal/adapters/captureallow/policy.go +2 -2
| @@ -45,8 +45,8 @@ func Builtin() *Policy { | |||||
| 45 | builtin := [...]string{ | 45 | builtin := [...]string{ | ||
| 46 | "/usr/bin/grim", | 46 | "/usr/bin/grim", | ||
| 47 | "/usr/bin/nefercap", | 47 | "/usr/bin/nefercap", | ||
| 48 | - | "/usr/lib/xdg-desktop-portal-wlr", | 48 | + | "/usr/lib/xdg-desktop-portal-gnome", |
| 49 | - | "/usr/libexec/xdg-desktop-portal-wlr", | 49 | + | "/usr/libexec/xdg-desktop-portal-gnome", |
| 50 | } | 50 | } | ||
| 51 | p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))} | 51 | p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))} | ||
| 52 | for _, e := range builtin { | 52 | for _, e := range builtin { | ||
M internal/adapters/captureallow/policy_test.go +3 -3
| @@ -16,8 +16,8 @@ func TestParse(t *testing.T) { | |||||
| 16 | errLine int | 16 | errLine int | ||
| 17 | wantFail bool | 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", | 19 | + | {name: "paths comments blanks", in: "# header\n\n/usr/bin/grim\n /usr/lib/xdg-desktop-portal-gnome \n\t# indented comment\n/opt/a#b\n", |
| 20 | - | mode: ModeList, allowed: []string{"/usr/bin/grim", "/usr/lib/xdg-desktop-portal-wlr", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}}, | 20 | + | mode: ModeList, allowed: []string{"/usr/bin/grim", "/usr/lib/xdg-desktop-portal-gnome", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}}, |
| 21 | {name: "cleaned", in: "/usr//bin/../bin/grim\n", mode: ModeList, allowed: []string{"/usr/bin/grim"}}, | 21 | {name: "cleaned", in: "/usr//bin/../bin/grim\n", mode: ModeList, allowed: []string{"/usr/bin/grim"}}, | ||
| 22 | {name: "no trailing newline", in: "/a/b", mode: ModeList, allowed: []string{"/a/b"}}, | 22 | {name: "no trailing newline", in: "/a/b", mode: ModeList, allowed: []string{"/a/b"}}, | ||
| 23 | {name: "empty file allows nothing", in: "", mode: ModeList, refused: []string{"/usr/bin/grim"}}, | 23 | {name: "empty file allows nothing", in: "", mode: ModeList, refused: []string{"/usr/bin/grim"}}, | ||
| @@ -75,7 +75,7 @@ func TestParseTooLarge(t *testing.T) { | |||||
| 75 | 75 | ||||
| 76 | func TestBuiltin(t *testing.T) { | 76 | func TestBuiltin(t *testing.T) { | ||
| 77 | p := Builtin() | 77 | p := Builtin() | ||
| 78 | - | want := []string{"/usr/bin/grim", "/usr/bin/nefercap", "/usr/lib/xdg-desktop-portal-wlr", "/usr/libexec/xdg-desktop-portal-wlr"} | 78 | + | want := []string{"/usr/bin/grim", "/usr/bin/nefercap", "/usr/lib/xdg-desktop-portal-gnome", "/usr/libexec/xdg-desktop-portal-gnome"} |
| 79 | if p.Mode() != ModeList || p.Len() != len(want) { | 79 | if p.Mode() != ModeList || p.Len() != len(want) { | ||
| 80 | t.Fatalf("mode %v, len %d", p.Mode(), p.Len()) | 80 | t.Fatalf("mode %v, len %d", p.Mode(), p.Len()) | ||
| 81 | } | 81 | } | ||
M internal/adapters/logging/logging.go +1 -1
| @@ -22,7 +22,7 @@ import ( | |||||
| 22 | var components = map[string]bool{ | 22 | var components = map[string]bool{ | ||
| 23 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | 23 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | ||
| 24 | "render": true, "sync": true, "config": true, "app": true, | 24 | "render": true, "sync": true, "config": true, "app": true, | ||
| 25 | - | "screensaver": true, "powerkey": true, "sched": true, | 25 | + | "screensaver": true, "powerkey": true, "sched": true, "screencast": true, |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | // categories are debug switches narrower than a component, only on when | 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 | if p := recover(); p != nil { | 68 | if p := recover(); p != nil { | ||
| 69 | err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack()) | 69 | err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack()) | ||
| 70 | } | 70 | } | ||
| 71 | + | // data is only ever a mapping made here, never req.Mapped. | |||
| 71 | if data != nil { | 72 | if data != nil { | ||
| 72 | err = errors.Join(err, unix.Munmap(data)) | 73 | err = errors.Join(err, unix.Munmap(data)) | ||
| 73 | } | 74 | } | ||
| 74 | replyAt(ctx, req, err, at, replies) | 75 | replyAt(ctx, req, err, at, replies) | ||
| 75 | }() | 76 | }() | ||
| 76 | - | if req.Dst.File == nil || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) { | 77 | + | size, err := destSize(req) |
| 77 | - | err = fmt.Errorf("invalid capture destination") | 78 | + | if err != nil { |
| 78 | - | return | |||
| 79 | - | } | |||
| 80 | - | rowBytes := int64(req.Width) * 4 | |||
| 81 | - | // Validate the bound before multiplication: padding must not wrap size. | |||
| 82 | - | if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) { | |||
| 83 | - | err = fmt.Errorf("capture too large") | |||
| 84 | return | 79 | return | ||
| 85 | } | 80 | } | ||
| 86 | - | size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes | 81 | + | if req.Mapped != nil { |
| 87 | - | if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes { | 82 | + | var dst []byte |
| 88 | - | err = fmt.Errorf("capture too large") | 83 | + | if dst, err = mappedDst(req, size); err != nil { |
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | err = frame.Read(req.Region, dst, req.Stride) | |||
| 89 | return | 87 | return | ||
| 90 | } | 88 | } | ||
| 91 | var st unix.Stat_t | 89 | var st unix.Stat_t | ||
| @@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu | |||||
| 94 | return | 92 | return | ||
| 95 | } | 93 | } | ||
| 96 | if st.Size < size { | 94 | if st.Size < size { | ||
| 97 | - | err = fmt.Errorf("capture buffer truncated") | 95 | + | err = errTruncated |
| 98 | return | 96 | return | ||
| 99 | } | 97 | } | ||
| 100 | // Map only for the duration of this capture; the renderer copies BGRA rows | 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 | err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride) | 104 | err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride) | ||
| 107 | } | 105 | } | ||
| 106 | + | ||||
| 107 | + | // destSize validates the destination of req, a file or a borrowed mapping, and | |||
| 108 | + | // returns the bytes it must hold from offset 0 through the last pixel. | |||
| 109 | + | func destSize(req ports.CaptureRequest) (int64, error) { | |||
| 110 | + | if (req.Dst.File == nil) == (req.Mapped == nil) || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) { | |||
| 111 | + | return 0, errInvalidDest | |||
| 112 | + | } | |||
| 113 | + | rowBytes := int64(req.Width) * 4 | |||
| 114 | + | // Validate the bound before multiplication: padding must not wrap size. | |||
| 115 | + | if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) { | |||
| 116 | + | return 0, errTooLarge | |||
| 117 | + | } | |||
| 118 | + | size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes | |||
| 119 | + | if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes { | |||
| 120 | + | return 0, errTooLarge | |||
| 121 | + | } | |||
| 122 | + | return size, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // mappedDst returns the borrowed mapping of req from the destination offset. | |||
| 126 | + | // The output never maps, unmaps or closes it. | |||
| 127 | + | func mappedDst(req ports.CaptureRequest, size int64) ([]byte, error) { | |||
| 128 | + | if int64(len(req.Mapped)) < size { | |||
| 129 | + | return nil, errTruncated | |||
| 130 | + | } | |||
| 131 | + | return req.Mapped[req.Dst.Offset:], nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | var ( | |||
| 135 | + | errInvalidDest = errors.New("invalid capture destination") | |||
| 136 | + | errTooLarge = errors.New("capture too large") | |||
| 137 | + | errTruncated = errors.New("capture buffer truncated") | |||
| 138 | + | ) | |||
M internal/adapters/outputkit/capture/excluded.go +1 -1
| @@ -193,4 +193,4 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex | |||||
| 193 | 193 | ||||
| 194 | // Handed reports a zeroed request: one Split failed, or one already given to | 194 | // Handed reports a zeroed request: one Split failed, or one already given to | ||
| 195 | // the worker. The owner never answers it again. | 195 | // the worker. The owner never answers it again. | ||
| 196 | - | func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil } | 196 | + | func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil && q.Mapped == nil } |
A internal/adapters/outputkit/capture/norace_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package capture | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = false | |||
A internal/adapters/outputkit/capture/race_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package capture | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = true | |||
M internal/adapters/outputkit/capture/write_test.go +119 -0
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | 12 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | ||
| 13 | "github.com/bnema/neferwl/internal/ports" | 13 | "github.com/bnema/neferwl/internal/ports" | ||
| 14 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 15 | + | "golang.org/x/sys/unix" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | // writeRequest is a valid 2x2 request over a 16-byte file. | 18 | // writeRequest is a valid 2x2 request over a 16-byte file. | ||
| @@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) { | |||||
| 177 | frame.Calls = nil | 178 | frame.Calls = nil | ||
| 178 | ok.Calls = nil | 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 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | vk "github.com/bnema/purego-vulkan/vulkan" | 13 | vk "github.com/bnema/purego-vulkan/vulkan" | ||
| 14 | + | "golang.org/x/sys/unix" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv | 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 | return byte(math.Round(v * 255)) | 108 | return byte(math.Round(v * 255)) | ||
| 108 | } | 109 | } | ||
| 109 | 110 | ||||
| 111 | + | // captureScratch holds the structs a capture passes to Vulkan by pointer. | |||
| 112 | + | // The purego call makes a pointer to a local escape, so a local would be a | |||
| 113 | + | // heap allocation per capture; these live in the Renderer instead (the | |||
| 114 | + | // owner goroutine uses them, and Vulkan reads each only during its call). | |||
| 115 | + | // Every field is set in full before use. Sequential uses may share a field; | |||
| 116 | + | // the sync export uses Renderer.call.getFD and .fd. | |||
| 117 | + | type captureScratch struct { | |||
| 118 | + | cmd vk.CommandBuffer | |||
| 119 | + | fence vk.Fence | |||
| 120 | + | begin vk.CommandBufferBeginInfo | |||
| 121 | + | submit vk.SubmitInfo | |||
| 122 | + | // The copy (recordCaptureCopy). | |||
| 123 | + | region vk.BufferImageCopy | |||
| 124 | + | hb vk.BufferMemoryBarrier | |||
| 125 | + | b vk.ImageMemoryBarrier | |||
| 126 | + | // The HDR conversion pass (recordCaptureHDR). | |||
| 127 | + | hdrIn, hdrB vk.ImageMemoryBarrier | |||
| 128 | + | attachment vk.RenderingAttachmentInfo | |||
| 129 | + | info vk.RenderingInfo | |||
| 130 | + | } | |||
| 131 | + | ||||
| 110 | // captureSync is the fence and semaphore surface of a capture slot: the | 132 | // captureSync is the fence and semaphore surface of a capture slot: the | ||
| 111 | // non-blocking poll, the sync file export and the semaphore replacement | 133 | // non-blocking poll, the sync file export and the semaphore replacement | ||
| 112 | // (a seam for tests; the device implements it). | 134 | // (a seam for tests; the device implements it). | ||
| @@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer } | |||||
| 128 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | 150 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | ||
| 129 | // A zero timeout only reports the fence state (the bindings lack | 151 | // A zero timeout only reports the fence state (the bindings lack | ||
| 130 | // vkGetFenceStatus): Timeout while the copy runs. | 152 | // vkGetFenceStatus): Timeout while the copy runs. | ||
| 131 | - | return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0) | 153 | + | d.r.capCall.fence = f |
| 154 | + | return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0) | |||
| 132 | } | 155 | } | ||
| 133 | 156 | ||||
| 134 | func (d deviceSync) resetFence(f vk.Fence) vk.Result { | 157 | func (d deviceSync) resetFence(f vk.Fence) vk.Result { | ||
| 135 | - | return d.r.dd.ResetFences(d.r.device, 1, &f) | 158 | + | d.r.capCall.fence = f |
| 159 | + | return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence) | |||
| 136 | } | 160 | } | ||
| 137 | 161 | ||||
| 138 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | 162 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | ||
| 139 | - | var fd int32 | 163 | + | sc := &d.r.call |
| 140 | - | get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | 164 | + | sc.fd = -1 |
| 141 | - | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd) | 165 | + | sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} |
| 142 | - | return fd, res | 166 | + | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd) |
| 167 | + | return sc.fd, res | |||
| 143 | } | 168 | } | ||
| 144 | 169 | ||||
| 145 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | 170 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | ||
| @@ -170,14 +195,43 @@ type captureSlot struct { | |||||
| 170 | // handle kept after EndCapture is invalid by contract (its next | 195 | // handle kept after EndCapture is invalid by contract (its next | ||
| 171 | // state belongs to another lease). | 196 | // state belongs to another lease). | ||
| 172 | frame captureFrame | 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 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | 227 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | ||
| 176 | // Its fields are written by the owner before the handoff and read by the | 228 | // Its fields are written by the owner before the handoff and read by the | ||
| 177 | // worker; the owner touches them again only after EndCapture. | 229 | // worker; the owner touches them again only after EndCapture. | ||
| 178 | type captureFrame struct { | 230 | type captureFrame struct { | ||
| 179 | - | r *Renderer | 231 | + | r *Renderer |
| 180 | - | slot *captureSlot | 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 | done *os.File | 235 | done *os.File | ||
| 182 | pixels []byte // the slot's mapping, width*height*4 | 236 | pixels []byte // the slot's mapping, width*height*4 | ||
| 183 | w, h int | 237 | w, h int | ||
| @@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync { | |||||
| 301 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | 355 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | ||
| 302 | d := r.dd | 356 | d := r.dd | ||
| 303 | s.frame.valid = false | 357 | s.frame.valid = false | ||
| 304 | - | if s.frame.done != nil { | 358 | + | s.frame.done = nil |
| 305 | - | s.frame.done.Close() | 359 | + | if s.file != nil { |
| 306 | - | s.frame.done = nil | 360 | + | _ = s.file.Close() |
| 307 | } | 361 | } | ||
| 308 | if s.done != 0 { | 362 | if s.done != 0 { | ||
| 309 | r.captureSync().destroySemaphore(s.done) | 363 | r.captureSync().destroySemaphore(s.done) | ||
| @@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 425 | if s == nil { | 479 | if s == nil { | ||
| 426 | return nil, ports.ErrCaptureBusy | 480 | return nil, ports.ErrCaptureBusy | ||
| 427 | } | 481 | } | ||
| 428 | - | d := r.dd | 482 | + | d, sc := r.dd, &r.capCall |
| 429 | - | cmd := s.cmd | 483 | + | sc.cmd = s.cmd |
| 484 | + | cmd := sc.cmd | |||
| 430 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | 485 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | ||
| 431 | return nil, err | 486 | return nil, err | ||
| 432 | } | 487 | } | ||
| 433 | - | begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | 488 | + | sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} |
| 434 | - | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil { | 489 | + | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil { |
| 435 | return nil, err | 490 | return nil, err | ||
| 436 | } | 491 | } | ||
| 437 | if hdr { | 492 | if hdr { | ||
| @@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 445 | // After the frame on the same queue: the copy reads what it drew, and | 500 | // After the frame on the same queue: the copy reads what it drew, and | ||
| 446 | // the next frame's first barrier (transfer source stage) orders its | 501 | // the next frame's first barrier (transfer source stage) orders its | ||
| 447 | // writes after this read. | 502 | // writes after this read. | ||
| 448 | - | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | 503 | + | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} |
| 449 | if !debug { | 504 | if !debug { | ||
| 450 | // The export below takes the signal; a debug capture waits on | 505 | // The export below takes the signal; a debug capture waits on | ||
| 451 | // the fence only, so the semaphore must not be signalled (a | 506 | // the fence only, so the semaphore must not be signalled (a | ||
| 452 | // binary semaphore is signalled once, then waited or exported). | 507 | // binary semaphore is signalled once, then waited or exported). | ||
| 453 | - | submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.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 | return nil, err | 511 | return nil, err | ||
| 457 | } | 512 | } | ||
| 458 | s.pending = true | 513 | s.pending = true | ||
| @@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 469 | } | 524 | } | ||
| 470 | if fd >= 0 { | 525 | if fd >= 0 { | ||
| 471 | // -1: already signalled at export, nothing to wait for. | 526 | // -1: already signalled at export, nothing to wait for. | ||
| 472 | - | f.done = os.NewFile(uintptr(fd), "neferwl-capture") | 527 | + | if err := s.adoptSync(int(fd)); err != nil { |
| 473 | - | if f.done == nil { | 528 | + | return nil, err |
| 474 | - | return nil, fmt.Errorf("invalid capture sync file %d", fd) | |||
| 475 | } | 529 | } | ||
| 530 | + | f.done = s.file | |||
| 476 | } | 531 | } | ||
| 477 | } | 532 | } | ||
| 478 | s.leased, f.valid = true, true | 533 | s.leased, f.valid = true, true | ||
| @@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) { | |||||
| 487 | return | 542 | return | ||
| 488 | } | 543 | } | ||
| 489 | f.valid = false | 544 | f.valid = false | ||
| 490 | - | if f.done != nil { | 545 | + | f.done = nil // the slot keeps the descriptor for its next lease |
| 491 | - | f.done.Close() | |||
| 492 | - | f.done = nil | |||
| 493 | - | } | |||
| 494 | f.slot.leased = false | 546 | f.slot.leased = false | ||
| 495 | } | 547 | } | ||
| 496 | 548 | ||||
| @@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |||||
| 566 | // Exported targets are borrowed from the display without a layout | 618 | // Exported targets are borrowed from the display without a layout | ||
| 567 | // change: the display may be reading them at the same time. | 619 | // change: the display may be reading them at the same time. | ||
| 568 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | 620 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | ||
| 569 | - | d := r.dd | 621 | + | d, sc := r.dd, &r.capCall |
| 570 | if t.exported { | 622 | if t.exported { | ||
| 571 | // Acquire from the display. The frame released the image at | 623 | // Acquire from the display. The frame released the image at | ||
| 572 | // its color attachment stage: this acquire waits for that. | 624 | // its color attachment stage: this acquire waits for that. | ||
| 573 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} | 625 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} |
| 574 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | 626 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b) |
| 575 | } | 627 | } | ||
| 576 | - | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | 628 | + | sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} |
| 577 | - | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, ®ion) | 629 | + | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region) |
| 578 | - | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} | 630 | + | sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} |
| 579 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | 631 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil) |
| 580 | if t.exported { | 632 | if t.exported { | ||
| 581 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} | 633 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} |
| 582 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | 634 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b) |
| 583 | } | 635 | } | ||
| 584 | } | 636 | } | ||
| 585 | 637 | ||||
| @@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk. | |||||
| 587 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | 639 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | ||
| 588 | // is put back in TRANSFER_SRC, as the next frame expects. | 640 | // is put back in TRANSFER_SRC, as the next frame expects. | ||
| 589 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | 641 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | ||
| 590 | - | d, p, out := r.dd, &r.capturePass, &r.captureSDR | 642 | + | d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall |
| 591 | - | in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | 643 | + | in, b := &sc.hdrIn, &sc.hdrB |
| 592 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | 644 | + | *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} |
| 645 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in) | |||
| 593 | // The previous capture read the image as a transfer source. | 646 | // The previous capture read the image as a transfer source. | ||
| 594 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} | 647 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} |
| 595 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | 648 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) |
| 596 | - | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | 649 | + | sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} |
| 597 | - | info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | 650 | + | sc.info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment} |
| 598 | - | d.CmdBeginRendering(cmd, &info) | 651 | + | d.CmdBeginRendering(cmd, &sc.info) |
| 599 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | 652 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | ||
| 600 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | 653 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | ||
| 601 | d.CmdDraw(cmd, 3, 1, 0, 0) | 654 | d.CmdDraw(cmd, 3, 1, 0, 0) | ||
| 602 | d.CmdEndRendering(cmd) | 655 | d.CmdEndRendering(cmd) | ||
| 603 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | 656 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | ||
| 604 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | 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 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | 659 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | ||
| 607 | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal | 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 | r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s) | 662 | r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s) | ||
| 610 | } | 663 | } | ||
| 611 | 664 | ||||
M internal/adapters/vulkan/capture_test.go +206 -16
| @@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) { | |||||
| 291 | } | 291 | } | ||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | - | // The lease object of a slot is reused: a steady state of captures | 294 | + | // The lease object of a slot is reused, and a stale handle reports invalid. |
| 295 | - | // allocates only the sync file, and a stale handle reports invalid. | |||
| 296 | func TestCaptureLeaseReused(t *testing.T) { | 295 | func TestCaptureLeaseReused(t *testing.T) { | ||
| 297 | r, err := New(8, 8) | 296 | r, err := New(8, 8) | ||
| 298 | if err != nil { | 297 | if err != nil { | ||
| @@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) { | |||||
| 309 | t.Fatal("lease object not reused") | 308 | t.Fatal("lease object not reused") | ||
| 310 | } | 309 | } | ||
| 311 | r.EndCapture(second) | 310 | r.EndCapture(second) | ||
| 312 | - | cycle := func() { | 311 | + | dst := make([]byte, 4) |
| 313 | - | cf, err := r.BeginCapture() | 312 | + | if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil { |
| 314 | - | if err != nil { | 313 | + | t.Fatal("stale lease read accepted") |
| 315 | - | t.Fatal(err) | 314 | + | } |
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // pollSyncFile waits for a sync file without allocating (syncfile.Wait | |||
| 318 | + | // builds its poll set per call). | |||
| 319 | + | func pollSyncFile(f *os.File, fds []unix.PollFd) error { | |||
| 320 | + | fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN} | |||
| 321 | + | for { | |||
| 322 | + | n, err := unix.Poll(fds, 1000) | |||
| 323 | + | switch { | |||
| 324 | + | case errors.Is(err, unix.EINTR): | |||
| 325 | + | case err != nil: | |||
| 326 | + | return err | |||
| 327 | + | case n > 0 && fds[0].Revents&unix.POLLIN != 0: | |||
| 328 | + | return nil | |||
| 329 | + | case n > 0: | |||
| 330 | + | return errors.New("sync file poll error") | |||
| 331 | + | default: | |||
| 332 | + | return errors.New("sync file poll timeout") | |||
| 316 | } | 333 | } | ||
| 317 | - | if done := cf.Done(); done != nil { | 334 | + | } |
| 318 | - | if err := syncfile.Wait(context.Background(), done); err != nil { | 335 | + | } |
| 336 | + | ||||
| 337 | + | // openFDs counts the process's open descriptors. | |||
| 338 | + | func openFDs(t *testing.T) int { | |||
| 339 | + | t.Helper() | |||
| 340 | + | ents, err := os.ReadDir("/proc/self/fd") | |||
| 341 | + | if err != nil { | |||
| 342 | + | t.Skipf("no /proc/self/fd: %v", err) | |||
| 343 | + | } | |||
| 344 | + | return len(ents) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // The renderer capture cycle (lease, wait for the copy, read the rows, | |||
| 348 | + | // return the lease) allocates nothing once the slots exist, on the SDR and | |||
| 349 | + | // the HDR conversion path, and holds a constant number of descriptors: the | |||
| 350 | + | // slot keeps its sync file for every lease. No mock stands in the measured | |||
| 351 | + | // closure. | |||
| 352 | + | func TestCaptureCycleAllocations(t *testing.T) { | |||
| 353 | + | for _, tc := range []struct { | |||
| 354 | + | name string | |||
| 355 | + | new func(*testing.T) *Renderer | |||
| 356 | + | }{ | |||
| 357 | + | {"sdr", func(t *testing.T) *Renderer { | |||
| 358 | + | r, err := New(16, 8) | |||
| 359 | + | if err != nil { | |||
| 360 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 361 | + | } | |||
| 362 | + | t.Cleanup(r.Close) | |||
| 363 | + | return r | |||
| 364 | + | }}, | |||
| 365 | + | {"hdr", hdrTestRenderer}, | |||
| 366 | + | } { | |||
| 367 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 368 | + | r := tc.new(t) | |||
| 369 | + | requireCaptureSync(t, r) | |||
| 370 | + | if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil { | |||
| 319 | t.Fatal(err) | 371 | t.Fatal(err) | ||
| 320 | } | 372 | } | ||
| 373 | + | dst := make([]byte, 16*8*4) | |||
| 374 | + | fds := make([]unix.PollFd, 1) | |||
| 375 | + | region := image.Rect(0, 0, 16, 8) | |||
| 376 | + | var cycleErr error | |||
| 377 | + | cycle := func() { | |||
| 378 | + | cf, err := r.BeginCapture() | |||
| 379 | + | if err != nil { | |||
| 380 | + | cycleErr = err | |||
| 381 | + | return | |||
| 382 | + | } | |||
| 383 | + | if done := cf.Done(); done != nil { | |||
| 384 | + | if err := pollSyncFile(done, fds); err != nil { | |||
| 385 | + | cycleErr = err | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | if err := cf.Read(region, dst, 16*4); err != nil { | |||
| 389 | + | cycleErr = err | |||
| 390 | + | } | |||
| 391 | + | r.EndCapture(cf) | |||
| 392 | + | } | |||
| 393 | + | cycle() // creates the slots, the capture pass and the sync files | |||
| 394 | + | cycle() | |||
| 395 | + | if cycleErr != nil { | |||
| 396 | + | t.Fatal(cycleErr) | |||
| 397 | + | } | |||
| 398 | + | fdsBefore := openFDs(t) | |||
| 399 | + | if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 { | |||
| 400 | + | t.Errorf("%.1f allocations per capture cycle, want 0", allocs) | |||
| 401 | + | } | |||
| 402 | + | if cycleErr != nil { | |||
| 403 | + | t.Fatal(cycleErr) | |||
| 404 | + | } | |||
| 405 | + | if after := openFDs(t); after != fdsBefore { | |||
| 406 | + | t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore) | |||
| 407 | + | } | |||
| 408 | + | if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 { | |||
| 409 | + | t.Error("capture read back black") | |||
| 410 | + | } | |||
| 411 | + | }) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // adoptSync takes ownership of the descriptor it is given: the first one is | |||
| 416 | + | // wrapped, a later one is moved onto the same descriptor number and its | |||
| 417 | + | // source is closed, so nothing leaks and nothing is closed twice. | |||
| 418 | + | func TestCaptureAdoptSync(t *testing.T) { | |||
| 419 | + | pipe := func() (r, w int) { | |||
| 420 | + | var p [2]int | |||
| 421 | + | if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil { | |||
| 422 | + | t.Fatal(err) | |||
| 321 | } | 423 | } | ||
| 322 | - | r.EndCapture(cf) | 424 | + | return p[0], p[1] |
| 323 | - | } | 425 | + | } |
| 324 | - | cycle() | 426 | + | r1, w1 := pipe() |
| 325 | - | // No lease wrapper: the allocations left are the Vulkan structs the | 427 | + | r2, w2 := pipe() |
| 326 | - | // FFI boundary keeps on the heap (as in Render, budget 16) plus the | 428 | + | t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) }) |
| 327 | - | // sync file (os.NewFile and its finalizer). | 429 | + | var s captureSlot |
| 328 | - | if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 { | 430 | + | if err := s.adoptSync(r1); err != nil { |
| 329 | - | t.Errorf("%.1f allocations per capture, want <= 16", allocs) | 431 | + | t.Fatal(err) |
| 432 | + | } | |||
| 433 | + | if s.syncFD != r1 || s.file == nil { | |||
| 434 | + | t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1) | |||
| 435 | + | } | |||
| 436 | + | file := s.file | |||
| 437 | + | if err := s.adoptSync(r2); err != nil { | |||
| 438 | + | t.Fatal(err) | |||
| 439 | + | } | |||
| 440 | + | if s.file != file || s.syncFD != r1 { | |||
| 441 | + | t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD) | |||
| 442 | + | } | |||
| 443 | + | if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF { | |||
| 444 | + | t.Errorf("source descriptor still open after the move: %v", err) | |||
| 445 | + | } | |||
| 446 | + | if _, err := unix.Write(w2, []byte{7}); err != nil { | |||
| 447 | + | t.Fatal(err) | |||
| 448 | + | } | |||
| 449 | + | var b [1]byte | |||
| 450 | + | if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 { | |||
| 451 | + | t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err) | |||
| 452 | + | } | |||
| 453 | + | // A failed move still closes the source. | |||
| 454 | + | r3, w3 := pipe() | |||
| 455 | + | t.Cleanup(func() { _ = unix.Close(w3) }) | |||
| 456 | + | bad := captureSlot{file: file, syncFD: -1} | |||
| 457 | + | if err := bad.adoptSync(r3); err == nil { | |||
| 458 | + | t.Error("move onto an invalid descriptor accepted") | |||
| 459 | + | } | |||
| 460 | + | if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF { | |||
| 461 | + | t.Errorf("source descriptor leaked by a failed move: %v", err) | |||
| 462 | + | } | |||
| 463 | + | if err := file.Close(); err != nil { | |||
| 464 | + | t.Errorf("slot file close: %v", err) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // A slot keeps one descriptor for its sync files: it is the same number and | |||
| 469 | + | // the same file for every lease, closed once by Close, and a lease the | |||
| 470 | + | // device finished at export (no sync file) hands out none. | |||
| 471 | + | func TestCaptureSyncFileOwnership(t *testing.T) { | |||
| 472 | + | r, err := New(8, 8) | |||
| 473 | + | if err != nil { | |||
| 474 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 475 | + | } | |||
| 476 | + | requireCaptureSync(t, r) | |||
| 477 | + | closed := false | |||
| 478 | + | defer func() { | |||
| 479 | + | if !closed { | |||
| 480 | + | r.Close() | |||
| 481 | + | } | |||
| 482 | + | }() | |||
| 483 | + | if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil { | |||
| 484 | + | t.Fatal(err) | |||
| 485 | + | } | |||
| 486 | + | before := openFDs(t) | |||
| 487 | + | cf := captureWait(t, r).(*captureFrame) | |||
| 488 | + | s := cf.slot | |||
| 489 | + | file, fd := cf.Done(), s.syncFD | |||
| 490 | + | if file == nil || s.file != file { | |||
| 491 | + | t.Fatalf("first lease: done %v, slot file %v", file, s.file) | |||
| 492 | + | } | |||
| 493 | + | r.EndCapture(cf) | |||
| 494 | + | if cf.Done() != nil { | |||
| 495 | + | t.Error("ended lease still exposes the sync file") | |||
| 496 | + | } | |||
| 497 | + | if _, err := file.Stat(); err != nil { | |||
| 498 | + | t.Errorf("EndCapture closed the slot's sync file: %v", err) | |||
| 499 | + | } | |||
| 500 | + | for range 4 { | |||
| 501 | + | next := captureWait(t, r).(*captureFrame) | |||
| 502 | + | if next.slot == s { | |||
| 503 | + | if next.Done() != file || s.syncFD != fd { | |||
| 504 | + | t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | r.EndCapture(next) | |||
| 508 | + | } | |||
| 509 | + | // Two slots, each holding one sync file. | |||
| 510 | + | if held := openFDs(t) - before; held > captureSlots { | |||
| 511 | + | t.Errorf("%d descriptors held by %d slots", held, captureSlots) | |||
| 512 | + | } | |||
| 513 | + | r.Close() | |||
| 514 | + | closed = true | |||
| 515 | + | if _, err := file.Stat(); err == nil { | |||
| 516 | + | t.Error("Close left the slot's sync file open") | |||
| 517 | + | } | |||
| 518 | + | if got := openFDs(t); got > before { | |||
| 519 | + | t.Errorf("%d descriptors open after Close, %d before the captures", got, before) | |||
| 330 | } | 520 | } | ||
| 331 | } | 521 | } | ||
| 332 | 522 | ||||
M internal/adapters/vulkan/renderer.go +2 -0
| @@ -112,6 +112,8 @@ type Renderer struct { | |||||
| 112 | frameStages []vk.PipelineStageFlags | 112 | frameStages []vk.PipelineStageFlags | ||
| 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | ||
| 114 | call callScratch | 114 | call callScratch | ||
| 115 | + | // capCall holds the structs a capture hands Vulkan by pointer. | |||
| 116 | + | capCall captureScratch | |||
| 115 | } | 117 | } | ||
| 116 | 118 | ||||
| 117 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | 119 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | ||
M internal/adapters/wayland/captureauth.go +5 -2
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "os" | 5 | "os" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/neferwl/internal/adapters/captureallow" | 7 | "github.com/bnema/neferwl/internal/adapters/captureallow" | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/peerexe" | |||
| 8 | "github.com/bnema/purego-libwayland/server" | 9 | "github.com/bnema/purego-libwayland/server" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -13,7 +14,9 @@ import ( | |||||
| 13 | // capture sessions and frames, whatever their source (output, output region | 14 | // capture sessions and frames, whatever their source (output, output region | ||
| 14 | // or workspace), and the attach of a layer surface to a capture exclusion. A | 15 | // or workspace), and the attach of a layer surface to a capture exclusion. A | ||
| 15 | // refused client gets the failure its protocol defines (failed, or stopped | 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 | // 1. While the session is protected (ext-session-lock) no client may | 21 | // 1. While the session is protected (ext-session-lock) no client may | ||
| 19 | // capture, so nothing of the locked session reaches a buffer. | 22 | // capture, so nothing of the locked session reaches a buffer. | ||
| @@ -136,7 +139,7 @@ func (s *Server) peerIdentity(c server.Client) *peerID { | |||||
| 136 | id.pidfd, err = s.peer.Pidfd(fd) | 139 | id.pidfd, err = s.peer.Pidfd(fd) | ||
| 137 | if err != nil { | 140 | if err != nil { | ||
| 138 | id.pidfd, id.err = nil, err | 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 | s.noPidfdWarned = true | 143 | s.noPidfdWarned = true | ||
| 141 | s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check") | 144 | s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check") | ||
| 142 | } | 145 | } | ||
M internal/adapters/wayland/captureauth_test.go +4 -47
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | 13 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | ||
| 14 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | 14 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | ||
| 15 | "github.com/bnema/neferwl/internal/adapters/captureallow" | 15 | "github.com/bnema/neferwl/internal/adapters/captureallow" | ||
| 16 | + | "github.com/bnema/neferwl/internal/adapters/peerexe" | |||
| 16 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | 17 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | ||
| 17 | "github.com/bnema/neferwl/internal/ports" | 18 | "github.com/bnema/neferwl/internal/ports" | ||
| 18 | "github.com/bnema/zerowrap" | 19 | "github.com/bnema/zerowrap" | ||
| @@ -275,7 +276,7 @@ func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) { | |||||
| 275 | } | 276 | } | ||
| 276 | 277 | ||||
| 277 | func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) { | 278 | func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) { | ||
| 278 | - | for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} { | 279 | + | for name, err := range map[string]error{"deleted": peerexe.ErrExeDeleted, "unknown": errors.New("no such process")} { |
| 279 | t.Run(name, func(t *testing.T) { | 280 | t.Run(name, func(t *testing.T) { | ||
| 280 | peer := newMockpeerExe(t) | 281 | peer := newMockpeerExe(t) | ||
| 281 | pidfd := devNull(t) | 282 | pidfd := devNull(t) | ||
| @@ -350,7 +351,7 @@ func TestLinuxPeerExeSelf(t *testing.T) { | |||||
| 350 | defer unix.Close(fds[1]) | 351 | defer unix.Close(fds[1]) | ||
| 351 | var l linuxPeerExe | 352 | var l linuxPeerExe | ||
| 352 | pidfd, err := l.Pidfd(fds[0]) | 353 | pidfd, err := l.Pidfd(fds[0]) | ||
| 353 | - | if errors.Is(err, errNoPidfd) { | 354 | + | if errors.Is(err, peerexe.ErrNoPidfd) { |
| 354 | t.Skip("kernel without SO_PEERPIDFD") | 355 | t.Skip("kernel without SO_PEERPIDFD") | ||
| 355 | } | 356 | } | ||
| 356 | if err != nil { | 357 | if err != nil { | ||
| @@ -364,55 +365,11 @@ func TestLinuxPeerExeSelf(t *testing.T) { | |||||
| 364 | if exe != self || pid != os.Getpid() { | 365 | if exe != self || pid != os.Getpid() { | ||
| 365 | t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid()) | 366 | t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid()) | ||
| 366 | } | 367 | } | ||
| 367 | - | if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) { | 368 | + | if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, peerexe.ErrNoPidfd) { |
| 368 | t.Fatalf("noPidfd: %v", err) | 369 | t.Fatalf("noPidfd: %v", err) | ||
| 369 | } | 370 | } | ||
| 370 | } | 371 | } | ||
| 371 | 372 | ||||
| 372 | - | // A process whose binary was removed is refused, and a peer that already | |||
| 373 | - | // exited is never resolved from its (possibly recycled) pid. | |||
| 374 | - | func TestLinuxPeerExeDeletedAndExited(t *testing.T) { | |||
| 375 | - | dir := t.TempDir() | |||
| 376 | - | path := filepath.Join(dir, "child") | |||
| 377 | - | src, err := os.ReadFile("/bin/sleep") | |||
| 378 | - | if err != nil { | |||
| 379 | - | t.Skip("no /bin/sleep") | |||
| 380 | - | } | |||
| 381 | - | if err := os.WriteFile(path, src, 0o755); err != nil { | |||
| 382 | - | t.Fatal(err) | |||
| 383 | - | } | |||
| 384 | - | proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{}) | |||
| 385 | - | if err != nil { | |||
| 386 | - | t.Skip("cannot run a copy of sleep: ", err) | |||
| 387 | - | } | |||
| 388 | - | defer func() { _ = proc.Kill(); _, _ = proc.Wait() }() | |||
| 389 | - | _ = os.Remove(path) | |||
| 390 | - | // Wait until exec has completed so /proc/<pid>/exe is the copy. | |||
| 391 | - | deadline := time.Now().Add(2 * time.Second) | |||
| 392 | - | for { | |||
| 393 | - | if _, err := readExe(proc.Pid); errors.Is(err, errExeDeleted) { | |||
| 394 | - | break | |||
| 395 | - | } | |||
| 396 | - | if time.Now().After(deadline) { | |||
| 397 | - | t.Fatal("deleted executable not refused") | |||
| 398 | - | } | |||
| 399 | - | time.Sleep(5 * time.Millisecond) | |||
| 400 | - | } | |||
| 401 | - | pidfd, err := unix.PidfdOpen(proc.Pid, 0) | |||
| 402 | - | if err != nil { | |||
| 403 | - | t.Skip("pidfd_open: ", err) | |||
| 404 | - | } | |||
| 405 | - | defer unix.Close(pidfd) | |||
| 406 | - | if _, _, err := exeOfPidfd(pidfd); !errors.Is(err, errExeDeleted) { | |||
| 407 | - | t.Fatalf("deleted: %v", err) | |||
| 408 | - | } | |||
| 409 | - | _ = proc.Kill() | |||
| 410 | - | _, _ = proc.Wait() | |||
| 411 | - | if exe, _, err := exeOfPidfd(pidfd); err == nil { | |||
| 412 | - | t.Fatalf("exited peer resolved to %q", exe) | |||
| 413 | - | } | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | // The exclusion attach asks too: a HUD whose executable is not listed gets | 373 | // The exclusion attach asks too: a HUD whose executable is not listed gets | ||
| 417 | // `unauthorized` even with the right token, while the recorder that owns the | 374 | // `unauthorized` even with the right token, while the recorder that owns the | ||
| 418 | // exclusion (listed) is unaffected. | 375 | // exclusion (listed) is unaffected. | ||
M internal/adapters/wayland/captureexe.go +10 -83
| @@ -1,15 +1,10 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bufio" | |||
| 5 | - | "errors" | |||
| 6 | - | "fmt" | |||
| 7 | "os" | 4 | "os" | ||
| 8 | "runtime" | 5 | "runtime" | ||
| 9 | - | "strconv" | |||
| 10 | - | "strings" | |||
| 11 | 6 | ||||
| 12 | - | "golang.org/x/sys/unix" | 7 | + | "github.com/bnema/neferwl/internal/adapters/peerexe" |
| 13 | ) | 8 | ) | ||
| 14 | 9 | ||||
| 15 | // peerExe names the executable behind a client connection, in two steps so a | 10 | // peerExe names the executable behind a client connection, in two steps so a | ||
| @@ -18,7 +13,8 @@ import ( | |||||
| 18 | type peerExe interface { | 13 | type peerExe interface { | ||
| 19 | // Pidfd returns a pidfd of the peer of the connected unix socket sockFD | 14 | // Pidfd returns a pidfd of the peer of the connected unix socket sockFD | ||
| 20 | // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled | 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 | Pidfd(sockFD int) (*os.File, error) | 18 | Pidfd(sockFD int) (*os.File, error) | ||
| 23 | // Exe returns the path of the executable running as the process behind | 19 | // Exe returns the path of the executable running as the process behind | ||
| 24 | // pidfd, and its pid. A deleted executable, or a process that has | 20 | // pidfd, and its pid. A deleted executable, or a process that has | ||
| @@ -26,24 +22,10 @@ type peerExe interface { | |||||
| 26 | Exe(pidfd *os.File) (exe string, pid int, err error) | 22 | Exe(pidfd *os.File) (exe string, pid int, err error) | ||
| 27 | } | 23 | } | ||
| 28 | 24 | ||||
| 29 | - | var ( | 25 | + | // linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+), |
| 30 | - | errExeDeleted = errors.New("executable deleted") | 26 | + | // with the proof of peerexe.ExeOfPidfd. There is no fallback to the |
| 31 | - | // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the | 27 | + | // SO_PEERCRED pid: it has no such proof, so a kernel without SO_PEERPIDFD |
| 32 | - | // peer cannot be identified safely. | 28 | + | // identifies nobody. |
| 33 | - | errNoPidfd = errors.New("kernel has no SO_PEERPIDFD") | |||
| 34 | - | ) | |||
| 35 | - | ||||
| 36 | - | // linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+): | |||
| 37 | - | // | |||
| 38 | - | // 1. The pidfd pins the peer process: its pid cannot be recycled while the | |||
| 39 | - | // descriptor is open. The pid is read from /proc/self/fdinfo/<pidfd>. | |||
| 40 | - | // 2. /proc/<pid>/exe is read. | |||
| 41 | - | // 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it | |||
| 42 | - | // had exited before the read, the pid could have named someone else, and | |||
| 43 | - | // the answer is refused. | |||
| 44 | - | // | |||
| 45 | - | // There is no fallback to the SO_PEERCRED pid: it has no such proof, so a | |||
| 46 | - | // kernel without SO_PEERPIDFD identifies nobody. | |||
| 47 | type linuxPeerExe struct { | 29 | type linuxPeerExe struct { | ||
| 48 | // noPidfd simulates a kernel without SO_PEERPIDFD (tests). | 30 | // noPidfd simulates a kernel without SO_PEERPIDFD (tests). | ||
| 49 | noPidfd bool | 31 | noPidfd bool | ||
| @@ -51,67 +33,12 @@ type linuxPeerExe struct { | |||||
| 51 | 33 | ||||
| 52 | func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) { | 34 | func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) { | ||
| 53 | if l.noPidfd { | 35 | if l.noPidfd { | ||
| 54 | - | return nil, errNoPidfd | 36 | + | return nil, peerexe.ErrNoPidfd |
| 55 | - | } | |||
| 56 | - | pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD) | |||
| 57 | - | switch { | |||
| 58 | - | case err == nil: | |||
| 59 | - | return os.NewFile(uintptr(pidfd), "pidfd"), nil | |||
| 60 | - | case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP): | |||
| 61 | - | return nil, errNoPidfd | |||
| 62 | - | default: | |||
| 63 | - | return nil, fmt.Errorf("SO_PEERPIDFD: %w", err) | |||
| 64 | } | 37 | } | ||
| 38 | + | return peerexe.Pidfd(sockFD) | |||
| 65 | } | 39 | } | ||
| 66 | 40 | ||||
| 67 | func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) { | 41 | func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) { | ||
| 68 | defer runtime.KeepAlive(pidfd) | 42 | defer runtime.KeepAlive(pidfd) | ||
| 69 | - | return exeOfPidfd(int(pidfd.Fd())) | 43 | + | return peerexe.ExeOfPidfd(int(pidfd.Fd())) |
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | // exeOfPidfd runs the pidfd procedure above. | |||
| 73 | - | func exeOfPidfd(pidfd int) (string, int, error) { | |||
| 74 | - | pid, err := pidOfPidfd(pidfd) | |||
| 75 | - | if err != nil { | |||
| 76 | - | return "", 0, err | |||
| 77 | - | } | |||
| 78 | - | exe, err := readExe(pid) | |||
| 79 | - | // After the read: a process that is gone may have let its pid be reused. | |||
| 80 | - | if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil { | |||
| 81 | - | return "", pid, fmt.Errorf("peer exited: %w", alive) | |||
| 82 | - | } | |||
| 83 | - | return exe, pid, err | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the | |||
| 87 | - | // process has exited, 0 when it is outside this pid namespace. | |||
| 88 | - | func pidOfPidfd(pidfd int) (int, error) { | |||
| 89 | - | f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd)) | |||
| 90 | - | if err != nil { | |||
| 91 | - | return 0, err | |||
| 92 | - | } | |||
| 93 | - | defer f.Close() | |||
| 94 | - | sc := bufio.NewScanner(f) | |||
| 95 | - | for sc.Scan() { | |||
| 96 | - | if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok { | |||
| 97 | - | pid, err := strconv.Atoi(strings.TrimSpace(v)) | |||
| 98 | - | if err != nil || pid <= 0 { | |||
| 99 | - | return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v)) | |||
| 100 | - | } | |||
| 101 | - | return pid, nil | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | return 0, errors.New("pidfd fdinfo has no Pid") | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | // readExe reads /proc/<pid>/exe and refuses a deleted executable. | |||
| 108 | - | func readExe(pid int) (string, error) { | |||
| 109 | - | exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe") | |||
| 110 | - | if err != nil { | |||
| 111 | - | return "", err | |||
| 112 | - | } | |||
| 113 | - | if strings.HasSuffix(exe, " (deleted)") { | |||
| 114 | - | return "", errExeDeleted | |||
| 115 | - | } | |||
| 116 | - | return exe, nil | |||
| 117 | } | 44 | } | ||
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // ext-foreign-toplevel-list lists mapped toplevels, read-only: title, app_id | 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 | // share with it, and ext_foreign_toplevel_image_capture_source_manager_v1 | 14 | // share with it, and ext_foreign_toplevel_image_capture_source_manager_v1 | ||
| 15 | // takes its handles (window capture, imagecopy.go). It follows the same | 15 | // takes its handles (window capture, imagecopy.go). It follows the same | ||
| 16 | // window state and session-protection rules as wlr-foreign-toplevel. | 16 | // window state and session-protection rules as wlr-foreign-toplevel. | ||
M internal/adapters/wayland/foreigntoplevel.go +1 -0
| @@ -206,6 +206,7 @@ func (s *Server) toplevelClosed(id ports.WindowID) { | |||||
| 206 | l.closed(id) | 206 | l.closed(id) | ||
| 207 | } | 207 | } | ||
| 208 | s.windowCaptureGone(id) | 208 | s.windowCaptureGone(id) | ||
| 209 | + | s.castInventoryChanged() | |||
| 209 | } | 210 | } | ||
| 210 | 211 | ||||
| 211 | // refreshToplevels reconciles inventory after output changes or unlock: | 212 | // refreshToplevels reconciles inventory after output changes or unlock: | ||
M internal/adapters/wayland/frames.go +1 -0
| @@ -155,6 +155,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent | |||||
| 155 | if p.Flip != nil { | 155 | if p.Flip != nil { | ||
| 156 | s.flips[p.Output] = *p.Flip | 156 | s.flips[p.Output] = *p.Flip | ||
| 157 | s.presentFlip(p.Output, p.Flip) | 157 | s.presentFlip(p.Output, p.Flip) | ||
| 158 | + | s.castFlip(p.Output, p.Flip) | |||
| 158 | } | 159 | } | ||
| 159 | } | 160 | } | ||
| 160 | s.dropFeedbacks(time.Now()) | 161 | s.dropFeedbacks(time.Now()) | ||
M internal/adapters/wayland/imagecopy.go +101 -32
| @@ -113,6 +113,12 @@ func (s *Server) windowCaptureGone(id ports.WindowID) { | |||||
| 113 | c.end(true, true) | 113 | c.end(true, true) | ||
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | + | for _, c := range s.castSessions { | |||
| 117 | + | if c.src.kind == srcWindow && c.src.window == id { | |||
| 118 | + | s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("cast end") | |||
| 119 | + | c.end(ports.CaptureReasonWindowGone, nil, true, true) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 116 | } | 122 | } | ||
| 117 | 123 | ||||
| 118 | type extraSources struct{ s *Server } | 124 | type extraSources struct{ s *Server } | ||
| @@ -180,7 +186,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i | |||||
| 180 | if src != nil { | 186 | if src != nil { | ||
| 181 | cs = s.captureSources[src.Resource] | 187 | cs = s.captureSources[src.Resource] | ||
| 182 | } | 188 | } | ||
| 183 | - | state := &captureSession{s: s, src: cs, cursor: options&1 != 0} | 189 | + | state := &captureSession{captureTarget: captureTarget{s: s, src: cs}, cursor: options&1 != 0} |
| 184 | res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state) | 190 | res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state) | ||
| 185 | if err != nil { | 191 | if err != nil { | ||
| 186 | return | 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 | // captureSession is one ext_image_copy_capture_session_v1. | 264 | // captureSession is one ext_image_copy_capture_session_v1. | ||
| 247 | type captureSession struct { | 265 | type captureSession struct { | ||
| 248 | - | s *Server | 266 | + | captureTarget |
| 249 | res *ext.ExtImageCopyCaptureSessionV1 | 267 | res *ext.ExtImageCopyCaptureSessionV1 | ||
| 250 | - | src *captureSource | |||
| 251 | - | id uint64 // registered with core; 0 when never | |||
| 252 | cursor, stopped bool | 268 | cursor, stopped bool | ||
| 253 | frame *captureExtFrame | 269 | frame *captureExtFrame | ||
| 254 | sent image.Point // buffer size of the last constraints | 270 | sent image.Point // buffer size of the last constraints | ||
| 255 | - | // st is the last decision of core; haveState tells there is one. | 271 | + | excl *exclusion // the live exclusion object of this session |
| 256 | - | st ports.CaptureSessionState | |||
| 257 | - | haveState bool | |||
| 258 | - | excl *exclusion // the live exclusion object of this session | |||
| 259 | // exclSeen: the last state of core knew an exclusion. Frames of an | 272 | // exclSeen: the last state of core knew an exclusion. Frames of an | ||
| 260 | // excluded session leave the HUD out while it is (see tag). | 273 | // excluded session leave the HUD out while it is (see tag). | ||
| 261 | exclSeen bool | 274 | exclSeen bool | ||
| @@ -290,7 +303,7 @@ func (c *captureSession) end(send, notify bool) { | |||||
| 290 | // image of its off-screen render, from the origin) and whether the workspace | 303 | // image of its off-screen render, from the origin) and whether the workspace | ||
| 291 | // is off screen. Core's last state decides where a workspace is; before it, | 304 | // is off screen. Core's last state decides where a workspace is; before it, | ||
| 292 | // the last workspace snapshot does. | 305 | // the last workspace snapshot does. | ||
| 293 | - | func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok bool) { | 306 | + | func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok bool) { |
| 294 | src := c.src | 307 | src := c.src | ||
| 295 | if src == nil { | 308 | if src == nil { | ||
| 296 | return nil, image.Rectangle{}, false, false | 309 | return nil, image.Rectangle{}, false, false | ||
| @@ -377,24 +390,57 @@ func (c *captureSession) sendConstraints() { | |||||
| 377 | c.res.SendDone() | 390 | c.res.SendDone() | ||
| 378 | } | 391 | } | ||
| 379 | 392 | ||||
| 380 | - | // refresh follows the target: new constraints when the buffer size changed, | 393 | + | // targetFate says what became of a followed target. |
| 381 | - | // the end when nothing is left of it. | 394 | + | type targetFate uint8 |
| 382 | - | func (c *captureSession) refresh() { | 395 | + | |
| 383 | - | if c.stopped || c.id == 0 { | 396 | + | const ( |
| 384 | - | return | 397 | + | targetLive targetFate = iota |
| 385 | - | } | 398 | + | targetWorkspaceGone // the workspace left the snapshot |
| 399 | + | targetEmpty // an output or region with nothing left | |||
| 400 | + | ) | |||
| 401 | + | ||||
| 402 | + | // follow looks at the target now: its fate and, while it lives, its geometry | |||
| 403 | + | // (rect, ok as geometry gives them). A window or workspace that is not | |||
| 404 | + | // locatable for the moment stays live: core's next state decides. | |||
| 405 | + | func (c *captureTarget) follow() (fate targetFate, rect image.Rectangle, ok bool) { | |||
| 386 | if c.src.kind == srcWorkspace { | 406 | if c.src.kind == srcWorkspace { | ||
| 387 | // The workspace left the snapshot: its handle is removed, stop at once | 407 | // The workspace left the snapshot: its handle is removed, stop at once | ||
| 388 | // without waiting for core's state. | 408 | // without waiting for core's state. | ||
| 389 | if _, _, found := c.s.workspaceInfo(c.src.workspace); !found { | 409 | if _, _, found := c.s.workspaceInfo(c.src.workspace); !found { | ||
| 390 | - | c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end") | 410 | + | return targetWorkspaceGone, image.Rectangle{}, false |
| 391 | - | c.end(true, true) | |||
| 392 | - | return | |||
| 393 | } | 411 | } | ||
| 394 | } | 412 | } | ||
| 395 | - | _, rect, _, ok := c.geometry() | 413 | + | _, rect, _, ok = c.geometry() |
| 414 | + | if !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow { | |||
| 415 | + | return targetEmpty, rect, false | |||
| 416 | + | } | |||
| 417 | + | return targetLive, rect, ok | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // applyState takes what core decided; it reports a decision that ends the | |||
| 421 | + | // target (a terminal reason). | |||
| 422 | + | func (c *captureTarget) applyState(st ports.CaptureSessionState) (ended bool) { | |||
| 423 | + | if st.Reason.Terminal() { | |||
| 424 | + | c.s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end") | |||
| 425 | + | return true | |||
| 426 | + | } | |||
| 427 | + | c.st, c.haveState = st, true | |||
| 428 | + | c.s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state") | |||
| 429 | + | return false | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | // refresh follows the target: new constraints when the buffer size changed, | |||
| 433 | + | // the end when nothing is left of it. | |||
| 434 | + | func (c *captureSession) refresh() { | |||
| 435 | + | if c.stopped || c.id == 0 { | |||
| 436 | + | return | |||
| 437 | + | } | |||
| 438 | + | fate, rect, ok := c.follow() | |||
| 396 | switch { | 439 | switch { | ||
| 397 | - | case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow: | 440 | + | case fate == targetWorkspaceGone: |
| 441 | + | c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end") | |||
| 442 | + | c.end(true, true) | |||
| 443 | + | case fate == targetEmpty: | |||
| 398 | c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty") | 444 | c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty") | ||
| 399 | c.end(true, true) | 445 | c.end(true, true) | ||
| 400 | case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent: | 446 | case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent: | ||
| @@ -407,6 +453,9 @@ func (s *Server) refreshSessions() { | |||||
| 407 | for c := range s.captureSessions { | 453 | for c := range s.captureSessions { | ||
| 408 | c.refresh() | 454 | c.refresh() | ||
| 409 | } | 455 | } | ||
| 456 | + | for _, c := range s.castSessions { | |||
| 457 | + | c.refresh() | |||
| 458 | + | } | |||
| 410 | } | 459 | } | ||
| 411 | 460 | ||||
| 412 | // endCaptureSessions stops every session, and its exclusion with it, when | 461 | // endCaptureSessions stops every session, and its exclusion with it, when | ||
| @@ -419,6 +468,11 @@ func (s *Server) endCaptureSessions() { | |||||
| 419 | } | 468 | } | ||
| 420 | c.end(true, true) | 469 | c.end(true, true) | ||
| 421 | } | 470 | } | ||
| 471 | + | for _, c := range s.castSessions { | |||
| 472 | + | s.log.Info().Uint64("cast", c.cast).Str("reason", "session-protected").Msg("cast end") | |||
| 473 | + | c.end(ports.CaptureReasonNone, errCastProtected, true, true) | |||
| 474 | + | } | |||
| 475 | + | s.castInventoryChanged() | |||
| 422 | } | 476 | } | ||
| 423 | 477 | ||||
| 424 | // outputGone ends the sessions of an unplugged output without waiting for | 478 | // outputGone ends the sessions of an unplugged output without waiting for | ||
| @@ -430,21 +484,29 @@ func (s *Server) outputGone(o *output) { | |||||
| 430 | c.end(true, true) | 484 | c.end(true, true) | ||
| 431 | } | 485 | } | ||
| 432 | } | 486 | } | ||
| 487 | + | for _, c := range s.castSessions { | |||
| 488 | + | if c.src.kind == srcOutput && c.src.output == o { | |||
| 489 | + | s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("cast end") | |||
| 490 | + | c.end(ports.CaptureReasonOutputGone, nil, true, true) | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 433 | } | 493 | } | ||
| 434 | 494 | ||||
| 435 | // captureState applies what core decided about a session. | 495 | // captureState applies what core decided about a session. | ||
| 436 | func (s *Server) captureState(st ports.CaptureSessionState) { | 496 | func (s *Server) captureState(st ports.CaptureSessionState) { | ||
| 497 | + | if c := s.castByCore[st.ID]; c != nil { | |||
| 498 | + | c.state(st) | |||
| 499 | + | return | |||
| 500 | + | } | |||
| 437 | c := s.sessionsByID[st.ID] | 501 | c := s.sessionsByID[st.ID] | ||
| 438 | if c == nil { | 502 | if c == nil { | ||
| 439 | return | 503 | return | ||
| 440 | } | 504 | } | ||
| 441 | - | if st.Reason.Terminal() { | 505 | + | if c.applyState(st) { |
| 442 | - | s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end") | |||
| 443 | c.end(true, false) | 506 | c.end(true, false) | ||
| 444 | return | 507 | return | ||
| 445 | } | 508 | } | ||
| 446 | - | c.st, c.haveState, c.exclSeen = st, true, st.Exclusion | 509 | + | c.exclSeen = st.Exclusion |
| 447 | - | s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state") | |||
| 448 | if ex := c.excl; ex != nil { | 510 | if ex := c.excl; ex != nil { | ||
| 449 | ex.confirm(st.Layers) | 511 | ex.confirm(st.Layers) | ||
| 450 | } | 512 | } | ||
| @@ -457,6 +519,19 @@ func (s *Server) captureState(st ports.CaptureSessionState) { | |||||
| 457 | // still in the last state, a frame is not served rather than shown with the | 519 | // still in the last state, a frame is not served rather than shown with the | ||
| 458 | // HUD in it (ok false). | 520 | // HUD in it (ok false). | ||
| 459 | func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | 521 | func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | ||
| 522 | + | t = c.targetTag(hidden) | |||
| 523 | + | if c.excl != nil && !c.exclSeen { | |||
| 524 | + | return t, false | |||
| 525 | + | } | |||
| 526 | + | if c.exclSeen { | |||
| 527 | + | t.exclude, t.revision = true, c.st.Revision | |||
| 528 | + | } | |||
| 529 | + | return t, true | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | // targetTag is the part of a frame's tag the target decides: the session, | |||
| 533 | + | // what is captured and what core is told of it. | |||
| 534 | + | func (c *captureTarget) targetTag(hidden bool) (t captureTag) { | |||
| 460 | t = captureTag{session: c.id} | 535 | t = captureTag{session: c.id} | ||
| 461 | switch c.src.kind { | 536 | switch c.src.kind { | ||
| 462 | case srcWorkspace: | 537 | case srcWorkspace: | ||
| @@ -475,13 +550,7 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | |||||
| 475 | case srcWindow: | 550 | case srcWindow: | ||
| 476 | t.taken = ports.CaptureFrameTaken{Window: c.src.window} | 551 | t.taken = ports.CaptureFrameTaken{Window: c.src.window} | ||
| 477 | } | 552 | } | ||
| 478 | - | if c.excl != nil && !c.exclSeen { | 553 | + | return t |
| 479 | - | return t, false | |||
| 480 | - | } | |||
| 481 | - | if c.exclSeen { | |||
| 482 | - | t.exclude, t.revision = true, c.st.Revision | |||
| 483 | - | } | |||
| 484 | - | return t, true | |||
| 485 | } | 554 | } | ||
| 486 | 555 | ||||
| 487 | func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) { | 556 | func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) { | ||
| @@ -518,7 +587,7 @@ type captureExtFrame struct { | |||||
| 518 | // A frame whose target is briefly unavailable (not active yet, rendered off | 587 | // A frame whose target is briefly unavailable (not active yet, rendered off | ||
| 519 | // screen by another session, its indicator or exclusion not on screen yet, | 588 | // screen by another session, its indicator or exclusion not on screen yet, | ||
| 520 | // a renderer slot busy) is tried again rather than failed: failed(unknown) | 589 | // a renderer slot busy) is tried again rather than failed: failed(unknown) | ||
| 521 | - | // ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only | 590 | + | // ends the whole screencast in screen-sharing clients. Only |
| 522 | // a stopped session, a refused client or a wrong buffer fail at once. | 591 | // a stopped session, a refused client or a wrong buffer fail at once. | ||
| 523 | const ( | 592 | const ( | ||
| 524 | captureRetryEvery = 16 * time.Millisecond | 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 | return | 189 | return | ||
| 190 | } | 190 | } | ||
| 191 | s.outputHeads = h | 191 | s.outputHeads = h | ||
| 192 | + | s.castInventoryChanged() | |||
| 192 | s.refreshOutputManagers() | 193 | s.refreshOutputManagers() | ||
| 193 | } | 194 | } | ||
| 194 | func (s *Server) refreshOutputManagers() { | 195 | func (s *Server) refreshOutputManagers() { | ||
M internal/adapters/wayland/outputs.go +4 -0
| @@ -160,11 +160,15 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||||
| 160 | // changed ones resent. | 160 | // changed ones resent. | ||
| 161 | func (s *Server) setOutputs(c ports.SetOutputs) { | 161 | func (s *Server) setOutputs(c ports.SetOutputs) { | ||
| 162 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | 162 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | ||
| 163 | + | focusChanged := s.focusedOutput != c.Focused | |||
| 163 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | 164 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | ||
| 164 | defer func() { | 165 | defer func() { | ||
| 165 | if changed { | 166 | if changed { | ||
| 166 | s.refreshOutputManagers() | 167 | s.refreshOutputManagers() | ||
| 167 | } | 168 | } | ||
| 169 | + | if changed || focusChanged { | |||
| 170 | + | s.castInventoryChanged() | |||
| 171 | + | } | |||
| 168 | }() | 172 | }() | ||
| 169 | s.focusedOutput = c.Focused | 173 | s.focusedOutput = c.Focused | ||
| 170 | want := map[string]ports.OutputPlacement{} | 174 | want := map[string]ports.OutputPlacement{} | ||
M internal/adapters/wayland/popup.go +5 -0
| @@ -66,6 +66,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 66 | } | 66 | } | ||
| 67 | case buffer && !w.mapped && x.acked && !pp.done: | 67 | case buffer && !w.mapped && x.acked && !pp.done: | ||
| 68 | w.mapped = true | 68 | w.mapped = true | ||
| 69 | + | s.castCommitted(w.id) | |||
| 69 | s.emit(ports.PopupMapped{ID: w.id}) | 70 | s.emit(ports.PopupMapped{ID: w.id}) | ||
| 70 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") | 71 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") | ||
| 71 | case !buffer && w.mapped: | 72 | case !buffer && w.mapped: | ||
| @@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||||
| 146 | } | 147 | } | ||
| 147 | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | 148 | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | ||
| 148 | s := p.w.xdg.server | 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 | s.serial++ | 154 | s.serial++ | ||
| 150 | p.w.xdg.resource.SendConfigure(s.serial) | 155 | p.w.xdg.resource.SendConfigure(s.serial) | ||
| 151 | p.w.xdg.serials = append(p.w.xdg.serials, s.serial) | 156 | p.w.xdg.serials = append(p.w.xdg.serials, s.serial) | ||
A internal/adapters/wayland/postring.go +98 -0
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/purego-libwayland/server" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // postRing carries values from producer goroutines to the display goroutine | |||
| 10 | + | // without allocating. It is a transport queue, not window state: the mutex | |||
| 11 | + | // guards only the slices and the pending flag. Values are never merged or | |||
| 12 | + | // dropped, in order of arrival. | |||
| 13 | + | // | |||
| 14 | + | // It binds its drain once and posts it at most once per batch: Display.Post | |||
| 15 | + | // is unbounded (a Post per value would let a fast producer grow the queue), | |||
| 16 | + | // so one pending drain serves every value pushed before it runs. | |||
| 17 | + | type postRing[T any] struct { | |||
| 18 | + | display *server.Display | |||
| 19 | + | apply func(T) | |||
| 20 | + | drainFn func() | |||
| 21 | + | // space is signalled (never blocking) after each drain and after a push | |||
| 22 | + | // that left room, to wake blocked pushers one after the other. | |||
| 23 | + | space chan struct{} | |||
| 24 | + | ||||
| 25 | + | mu sync.Mutex | |||
| 26 | + | ring, spare []T | |||
| 27 | + | limit int | |||
| 28 | + | pending bool // a drain is posted and has not taken the ring yet | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // newPostRing returns a ring of limit values whose values apply runs on the | |||
| 32 | + | // display goroutine, in order. limit must be at least 1. | |||
| 33 | + | func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] { | |||
| 34 | + | r := &postRing[T]{ | |||
| 35 | + | display: d, | |||
| 36 | + | apply: apply, | |||
| 37 | + | space: make(chan struct{}, 1), | |||
| 38 | + | ring: make([]T, 0, limit), | |||
| 39 | + | spare: make([]T, 0, limit), | |||
| 40 | + | limit: limit, | |||
| 41 | + | } | |||
| 42 | + | r.drainFn = r.drain | |||
| 43 | + | return r | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Push queues v for the display goroutine and wakes it. A full ring blocks | |||
| 47 | + | // the caller, which must not be the display goroutine, until a drain made | |||
| 48 | + | // room; it reports false, with v dropped, once the display has stopped. | |||
| 49 | + | func (r *postRing[T]) Push(v T) bool { | |||
| 50 | + | for { | |||
| 51 | + | r.mu.Lock() | |||
| 52 | + | if len(r.ring) < r.limit { | |||
| 53 | + | r.ring = append(r.ring, v) | |||
| 54 | + | post := !r.pending | |||
| 55 | + | r.pending = true | |||
| 56 | + | room := len(r.ring) < r.limit | |||
| 57 | + | r.mu.Unlock() | |||
| 58 | + | if post { | |||
| 59 | + | r.display.Post(r.drainFn) | |||
| 60 | + | } | |||
| 61 | + | if room { | |||
| 62 | + | r.signal() // chain the wake to another blocked pusher | |||
| 63 | + | } | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | r.mu.Unlock() | |||
| 67 | + | select { | |||
| 68 | + | case <-r.space: | |||
| 69 | + | case <-r.display.Stopped(): | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (r *postRing[T]) signal() { | |||
| 76 | + | select { | |||
| 77 | + | case r.space <- struct{}{}: | |||
| 78 | + | default: | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // drain runs on the display goroutine and applies the batch in order. | |||
| 83 | + | func (r *postRing[T]) drain() { | |||
| 84 | + | r.mu.Lock() | |||
| 85 | + | batch := r.ring | |||
| 86 | + | r.ring, r.spare = r.spare[:0], nil | |||
| 87 | + | r.pending = false | |||
| 88 | + | r.mu.Unlock() | |||
| 89 | + | r.signal() | |||
| 90 | + | for _, v := range batch { | |||
| 91 | + | r.apply(v) | |||
| 92 | + | } | |||
| 93 | + | // Do not retain what the values reference in the spare batch. | |||
| 94 | + | clear(batch) | |||
| 95 | + | r.mu.Lock() | |||
| 96 | + | r.spare = batch[:0] | |||
| 97 | + | r.mu.Unlock() | |||
| 98 | + | } | |||
A internal/adapters/wayland/postring_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Pushing a capture reply and applying it on the display goroutine allocates | |||
| 14 | + | // nothing in steady state. The count includes the display goroutine: | |||
| 15 | + | // AllocsPerRun reads the global malloc counter. | |||
| 16 | + | func TestCapturedForwardAllocations(t *testing.T) { | |||
| 17 | + | if raceEnabled { | |||
| 18 | + | t.Skip("the race detector makes the purego wake callback allocate") | |||
| 19 | + | } | |||
| 20 | + | s, _, _, _, _, _ := presentServer(t) | |||
| 21 | + | done := ports.CaptureDone{ID: 77, Output: "HEADLESS-1", Time: time.Unix(1, 2)} | |||
| 22 | + | // Posts run in order: once the marker ran, the drain before it did. The | |||
| 23 | + | // marker is bound once and the channel reused, so waiting allocates nothing. | |||
| 24 | + | ran := make(chan struct{}, 1) | |||
| 25 | + | marker := func() { ran <- struct{}{} } | |||
| 26 | + | for range 4 { | |||
| 27 | + | s.capturedRing.Push(done) | |||
| 28 | + | s.display.Post(marker) | |||
| 29 | + | <-ran | |||
| 30 | + | } | |||
| 31 | + | allocs := testing.AllocsPerRun(200, func() { | |||
| 32 | + | s.capturedRing.Push(done) | |||
| 33 | + | s.display.Post(marker) | |||
| 34 | + | <-ran | |||
| 35 | + | }) | |||
| 36 | + | if allocs != 0 { | |||
| 37 | + | t.Errorf("push + drain: %.1f allocs, want 0", allocs) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // A full ring blocks the push until the display goroutine drains it, and no | |||
| 42 | + | // value is lost or reordered. | |||
| 43 | + | func TestPostRingFullBlocksWithoutLoss(t *testing.T) { | |||
| 44 | + | s, _, _, _, _, _ := presentServer(t) | |||
| 45 | + | const limit, total = 4, 3*4 + 1 | |||
| 46 | + | var got []int // display goroutine only | |||
| 47 | + | r := newPostRing(s.display, limit, func(v int) { got = append(got, v) }) | |||
| 48 | + | ||||
| 49 | + | // Stall the display goroutine so nothing drains. | |||
| 50 | + | release := make(chan struct{}) | |||
| 51 | + | stalled := make(chan struct{}) | |||
| 52 | + | s.display.Post(func() { close(stalled); <-release }) | |||
| 53 | + | <-stalled | |||
| 54 | + | ||||
| 55 | + | pushed := make(chan int, total) | |||
| 56 | + | var wg sync.WaitGroup | |||
| 57 | + | wg.Add(1) | |||
| 58 | + | go func() { | |||
| 59 | + | defer wg.Done() | |||
| 60 | + | for i := range total { | |||
| 61 | + | if !r.Push(i) { | |||
| 62 | + | t.Errorf("push %d refused", i) | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | pushed <- i | |||
| 66 | + | } | |||
| 67 | + | }() | |||
| 68 | + | for range limit { | |||
| 69 | + | select { | |||
| 70 | + | case <-pushed: | |||
| 71 | + | case <-time.After(2 * time.Second): | |||
| 72 | + | t.Fatal("pushes below the limit blocked") | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | select { | |||
| 76 | + | case i := <-pushed: | |||
| 77 | + | t.Fatalf("push %d returned on a full ring", i) | |||
| 78 | + | case <-time.After(50 * time.Millisecond): | |||
| 79 | + | } | |||
| 80 | + | close(release) | |||
| 81 | + | wg.Wait() | |||
| 82 | + | // The marker runs after every drain posted before it. | |||
| 83 | + | var final []int | |||
| 84 | + | ran := make(chan struct{}) | |||
| 85 | + | for { | |||
| 86 | + | s.display.Post(func() { | |||
| 87 | + | if len(got) == total { | |||
| 88 | + | final = append(final, got...) | |||
| 89 | + | } | |||
| 90 | + | ran <- struct{}{} | |||
| 91 | + | }) | |||
| 92 | + | <-ran | |||
| 93 | + | if final != nil { | |||
| 94 | + | break | |||
| 95 | + | } | |||
| 96 | + | time.Sleep(time.Millisecond) | |||
| 97 | + | } | |||
| 98 | + | for i, v := range final { | |||
| 99 | + | if v != i { | |||
| 100 | + | t.Fatalf("applied %v, want 0..%d in order", final, total-1) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // A push blocked on a full ring resumes once the display drains it. | |||
| 106 | + | func TestPostRingBlockedPushResumes(t *testing.T) { | |||
| 107 | + | s, _, _, _, _, _ := presentServer(t) | |||
| 108 | + | r := newPostRing(s.display, 1, func(int) {}) | |||
| 109 | + | release := make(chan struct{}) | |||
| 110 | + | stalled := make(chan struct{}) | |||
| 111 | + | s.display.Post(func() { close(stalled); <-release }) | |||
| 112 | + | <-stalled | |||
| 113 | + | if !r.Push(1) { | |||
| 114 | + | t.Fatal("first push refused") | |||
| 115 | + | } | |||
| 116 | + | result := make(chan bool, 1) | |||
| 117 | + | go func() { result <- r.Push(2) }() | |||
| 118 | + | select { | |||
| 119 | + | case ok := <-result: | |||
| 120 | + | t.Fatalf("push on a full ring returned %v", ok) | |||
| 121 | + | case <-time.After(50 * time.Millisecond): | |||
| 122 | + | } | |||
| 123 | + | close(release) | |||
| 124 | + | select { | |||
| 125 | + | case ok := <-result: | |||
| 126 | + | if !ok { | |||
| 127 | + | t.Fatal("push refused while the display runs") | |||
| 128 | + | } | |||
| 129 | + | case <-time.After(2 * time.Second): | |||
| 130 | + | t.Fatal("blocked push never resumed") | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // A push blocked on a full ring returns false once the display has stopped, | |||
| 135 | + | // instead of waiting for a drain that never comes. | |||
| 136 | + | func TestPostRingPushStopsWithDisplay(t *testing.T) { | |||
| 137 | + | s, err := New(Options{RuntimeDir: t.TempDir()}, Channels{}, logging.For(context.Background(), "wayland")) | |||
| 138 | + | if err != nil { | |||
| 139 | + | t.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 142 | + | done := make(chan error, 1) | |||
| 143 | + | go func() { done <- s.Run(ctx) }() | |||
| 144 | + | cancel() | |||
| 145 | + | <-done | |||
| 146 | + | r := newPostRing(s.display, 1, func(int) {}) | |||
| 147 | + | if !r.Push(1) { // room: queued, its drain is dropped by the stopped display | |||
| 148 | + | t.Fatal("first push refused") | |||
| 149 | + | } | |||
| 150 | + | result := make(chan bool, 1) | |||
| 151 | + | go func() { result <- r.Push(2) }() | |||
| 152 | + | select { | |||
| 153 | + | case ok := <-result: | |||
| 154 | + | if ok { | |||
| 155 | + | t.Fatal("push on a stopped display reported success") | |||
| 156 | + | } | |||
| 157 | + | case <-time.After(2 * time.Second): | |||
| 158 | + | t.Fatal("push blocked after the display stopped") | |||
| 159 | + | } | |||
| 160 | + | } | |||
A internal/adapters/wayland/screencast.go +500 -0
| @@ -0,0 +1,500 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "image" | |||
| 6 | + | "reflect" | |||
| 7 | + | "slices" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Screencast. The screencast adapter (a D-Bus service that feeds PipeWire | |||
| 15 | + | // streams) drives casts through ports.CastCommand on Channels.CastCommands | |||
| 16 | + | // and hears back through Options.Casts (ports.CastSink). A cast is a capture | |||
| 17 | + | // target registered with core like an ext-image-copy-capture session | |||
| 18 | + | // (ports.CaptureSessionOpen), so the capture indicator stays on while it | |||
| 19 | + | // lives, but its frames are written straight into the PipeWire buffer the | |||
| 20 | + | // adapter lends (ports.CaptureRequest.Mapped) and it has no wayland client: | |||
| 21 | + | // the adapter checked its caller, so casts bypass mayCapture, not protected(). | |||
| 22 | + | // | |||
| 23 | + | // Everything here runs on the display goroutine, which owns the casts. The | |||
| 24 | + | // frame cycle (CastFrame, the capture reply, CastFrameDone) and the damage | |||
| 25 | + | // events allocate nothing; opening a cast and publishing the inventory may. | |||
| 26 | + | ||||
| 27 | + | // maxCasts bounds the live casts. Each has at most one frame in flight, and | |||
| 28 | + | // a frame takes one of the maxCaptureInflight admission credits like any | |||
| 29 | + | // capture, so capturedRingSize (MaxCaptureInflight + maxCasts) holds every | |||
| 30 | + | // reply. | |||
| 31 | + | const maxCasts = 8 | |||
| 32 | + | ||||
| 33 | + | // castRingSize bounds the cast commands waiting for the display goroutine. | |||
| 34 | + | const castRingSize = 64 | |||
| 35 | + | ||||
| 36 | + | var ( | |||
| 37 | + | errCastNoTarget = errors.New("cast: target missing") | |||
| 38 | + | errCastLimit = errors.New("cast: too many casts") | |||
| 39 | + | errCastProtected = errors.New("cast: session is protected") | |||
| 40 | + | errCastStopped = errors.New("cast: stopped") | |||
| 41 | + | errCastNotReady = errors.New("cast: target not ready") | |||
| 42 | + | errCastSize = errors.New("cast: frame size differs from the target") | |||
| 43 | + | errCastBusy = errors.New("cast: frame already in flight") | |||
| 44 | + | errCastUnknown = errors.New("cast: unknown cast") | |||
| 45 | + | ) | |||
| 46 | + | ||||
| 47 | + | // castSession is one cast. | |||
| 48 | + | type castSession struct { | |||
| 49 | + | captureTarget | |||
| 50 | + | cast uint64 // the adapter's ID | |||
| 51 | + | cursor bool | |||
| 52 | + | // first: CaptureFrameTaken was sent, which turns the indicator on. | |||
| 53 | + | first bool | |||
| 54 | + | size image.Point // the size the adapter was last told | |||
| 55 | + | // dirty: the window committed since the last CastDamage (window casts). | |||
| 56 | + | dirty bool | |||
| 57 | + | stopped bool | |||
| 58 | + | // frame is the frame the adapter waits for (waiting) and, once its | |||
| 59 | + | // capture was accepted, whether the output owns the buffer (inflight). | |||
| 60 | + | frame ports.CastCommand | |||
| 61 | + | waiting bool | |||
| 62 | + | inflight bool | |||
| 63 | + | deadline time.Time | |||
| 64 | + | retryTimer ports.Timer | |||
| 65 | + | // doneFn, retryFn and timerFn are bound once, so a frame binds no | |||
| 66 | + | // closure: the capture reply, the retry on the display goroutine and the | |||
| 67 | + | // timer callback that posts it. | |||
| 68 | + | doneFn func(ports.CaptureDone) | |||
| 69 | + | retryFn func() | |||
| 70 | + | timerFn func() | |||
| 71 | + | // ending: the cast ended while a capture still wrote into the adapter's | |||
| 72 | + | // buffer; CastStopped follows its reply. | |||
| 73 | + | ending, announce bool | |||
| 74 | + | endReason ports.CaptureReason | |||
| 75 | + | endErr error | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // postRetry is the retry timer's callback, on the timer's goroutine: it | |||
| 79 | + | // posts the retry to the display goroutine. | |||
| 80 | + | func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) } | |||
| 81 | + | ||||
| 82 | + | // push hands an event to the adapter. | |||
| 83 | + | func (c *castSession) push(ev ports.CastEvent) { | |||
| 84 | + | ev.ID = c.cast | |||
| 85 | + | c.s.pushCast(ev) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // pushCast sends ev to the sink; Push never blocks. | |||
| 89 | + | func (s *Server) pushCast(ev ports.CastEvent) { | |||
| 90 | + | if s.casts != nil { | |||
| 91 | + | s.casts.Push(ev) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // applyCast runs one command of the adapter on the display goroutine. | |||
| 96 | + | func (s *Server) applyCast(cmd ports.CastCommand) { | |||
| 97 | + | switch cmd.Kind { | |||
| 98 | + | case ports.CastOpen: | |||
| 99 | + | s.openCast(cmd) | |||
| 100 | + | case ports.CastFrame: | |||
| 101 | + | c := s.castSessions[cmd.ID] | |||
| 102 | + | switch { | |||
| 103 | + | case c == nil: | |||
| 104 | + | s.refuseCastFrame(cmd, errCastUnknown) | |||
| 105 | + | case c.waiting: | |||
| 106 | + | s.refuseCastFrame(cmd, errCastBusy) | |||
| 107 | + | default: | |||
| 108 | + | c.frame, c.waiting = cmd, true | |||
| 109 | + | c.deadline = s.clock.Now().Add(captureRetryFor) | |||
| 110 | + | c.attempt() | |||
| 111 | + | } | |||
| 112 | + | case ports.CastClose: | |||
| 113 | + | if c := s.castSessions[cmd.ID]; c != nil { | |||
| 114 | + | c.end(ports.CaptureReasonNone, nil, true, false) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // refuseCastFrame answers a frame no cast takes, so the adapter gets its | |||
| 120 | + | // buffer back. | |||
| 121 | + | func (s *Server) refuseCastFrame(cmd ports.CastCommand, err error) { | |||
| 122 | + | s.pushCast(ports.CastEvent{Kind: ports.CastFrameDone, ID: cmd.ID, Buf: cmd.Buf, Err: err}) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // refuseCast ends a cast that never began. | |||
| 126 | + | func (s *Server) refuseCast(id uint64, reason ports.CaptureReason, err error) { | |||
| 127 | + | s.log.Info().Uint64("cast", id).Str("reason", string(reason)).Err(err).Msg("cast refused") | |||
| 128 | + | s.pushCast(ports.CastEvent{Kind: ports.CastStopped, ID: id, Reason: reason, Err: err}) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // openCast registers a cast with core and tells the adapter its size. | |||
| 132 | + | func (s *Server) openCast(cmd ports.CastCommand) { | |||
| 133 | + | switch { | |||
| 134 | + | case s.castSessions[cmd.ID] != nil: | |||
| 135 | + | s.log.Warn().Uint64("cast", cmd.ID).Msg("cast open: duplicate ID ignored") | |||
| 136 | + | return | |||
| 137 | + | case len(s.castSessions) >= maxCasts: | |||
| 138 | + | s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastLimit) | |||
| 139 | + | return | |||
| 140 | + | case s.protected(): | |||
| 141 | + | s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastProtected) | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | src := &captureSource{kind: srcDead} | |||
| 145 | + | open := ports.CaptureSessionOpen{} | |||
| 146 | + | gone := ports.CaptureReasonOutputGone | |||
| 147 | + | if cmd.Window != 0 { | |||
| 148 | + | gone = ports.CaptureReasonWindowGone | |||
| 149 | + | if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil { | |||
| 150 | + | src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen} | |||
| 151 | + | open.Window = cmd.Window | |||
| 152 | + | } | |||
| 153 | + | } else if o := s.outputByNameExact(cmd.Output); o != nil { | |||
| 154 | + | src = &captureSource{kind: srcOutput, output: o} | |||
| 155 | + | open.Output = o.name() | |||
| 156 | + | } | |||
| 157 | + | c := &castSession{captureTarget: captureTarget{s: s, src: src}, cast: cmd.ID, cursor: cmd.Cursor} | |||
| 158 | + | if src.kind == srcDead { | |||
| 159 | + | s.refuseCast(cmd.ID, gone, errCastNoTarget) | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | _, rect, _, ok := c.geometry() | |||
| 163 | + | if !ok { | |||
| 164 | + | s.refuseCast(cmd.ID, gone, errCastNoTarget) | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | c.doneFn, c.retryFn, c.timerFn = c.onDone, c.retryNow, c.postRetry | |||
| 168 | + | s.nextSession++ | |||
| 169 | + | c.id = s.nextSession | |||
| 170 | + | open.ID = c.id | |||
| 171 | + | c.size = image.Pt(rect.Dx(), rect.Dy()) | |||
| 172 | + | s.castSessions[cmd.ID], s.castByCore[c.id] = c, c | |||
| 173 | + | s.emit(open) | |||
| 174 | + | s.log.Info().Uint64("cast", cmd.ID).Uint64("session", c.id).Int("w", c.size.X).Int("h", c.size.Y).Msg("cast open") | |||
| 175 | + | c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y}) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // state applies what core decided about the cast. | |||
| 179 | + | func (c *castSession) state(st ports.CaptureSessionState) { | |||
| 180 | + | if c.applyState(st) { | |||
| 181 | + | c.end(st.Reason, nil, false, true) | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | c.refresh() | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // refresh follows the target: a new size goes to the adapter, which has to | |||
| 188 | + | // renegotiate its stream; nothing left of the target ends the cast. | |||
| 189 | + | func (c *castSession) refresh() { | |||
| 190 | + | if c.stopped { | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | fate, rect, ok := c.follow() | |||
| 194 | + | switch { | |||
| 195 | + | case fate == targetWorkspaceGone: | |||
| 196 | + | c.end(ports.CaptureReasonWorkspaceGone, nil, true, true) | |||
| 197 | + | case fate == targetEmpty: | |||
| 198 | + | c.s.log.Info().Uint64("cast", c.cast).Msg("cast end: target empty") | |||
| 199 | + | c.end(ports.CaptureReasonOutputGone, nil, true, true) | |||
| 200 | + | case ok && image.Pt(rect.Dx(), rect.Dy()) != c.size: | |||
| 201 | + | c.size = image.Pt(rect.Dx(), rect.Dy()) | |||
| 202 | + | c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y}) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // end stops the cast once. notify tells core (which has to forget it unless | |||
| 207 | + | // it decided the end itself), announce tells the adapter (CastStopped; not | |||
| 208 | + | // for its own CastClose). A capture that still writes into the adapter's | |||
| 209 | + | // buffer finishes first: its CastFrameDone, then CastStopped, so CastStopped | |||
| 210 | + | // means the buffers are the adapter's again. | |||
| 211 | + | func (c *castSession) end(reason ports.CaptureReason, err error, notify, announce bool) { | |||
| 212 | + | if c.stopped { | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | s := c.s | |||
| 216 | + | c.stopped = true | |||
| 217 | + | if c.retryTimer != nil { | |||
| 218 | + | c.retryTimer.Stop() | |||
| 219 | + | } | |||
| 220 | + | delete(s.castSessions, c.cast) | |||
| 221 | + | delete(s.castByCore, c.id) | |||
| 222 | + | if notify { | |||
| 223 | + | s.emit(ports.CaptureSessionClose{ID: c.id}) | |||
| 224 | + | } | |||
| 225 | + | c.announce, c.endReason, c.endErr = announce, reason, err | |||
| 226 | + | if c.inflight { | |||
| 227 | + | c.ending = true | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | if c.waiting { | |||
| 231 | + | c.frameDone(errCastStopped, time.Time{}) | |||
| 232 | + | } | |||
| 233 | + | c.finish() | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // finish tells the adapter the cast is over. | |||
| 237 | + | func (c *castSession) finish() { | |||
| 238 | + | if c.announce { | |||
| 239 | + | c.push(ports.CastEvent{Kind: ports.CastStopped, Reason: c.endReason, Err: c.endErr}) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // frameDone answers the frame the adapter waits for. | |||
| 244 | + | func (c *castSession) frameDone(err error, at time.Time) { | |||
| 245 | + | c.waiting = false | |||
| 246 | + | c.push(ports.CastEvent{Kind: ports.CastFrameDone, Buf: c.frame.Buf, Err: err, Time: at}) | |||
| 247 | + | c.frame = ports.CastCommand{} | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // retryNow is the posted retry timer: the frame is tried again. | |||
| 251 | + | func (c *castSession) retryNow() { | |||
| 252 | + | if !c.stopped && c.waiting && !c.inflight { | |||
| 253 | + | c.attempt() | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // retry tries the frame again shortly, or fails it past its deadline, as a | |||
| 258 | + | // frame of an ext session does (captureExtFrame.retry). | |||
| 259 | + | func (c *castSession) retry(why error) { | |||
| 260 | + | s := c.s | |||
| 261 | + | if !s.clock.Now().Before(c.deadline) { | |||
| 262 | + | s.log.Info().Uint64("cast", c.cast).Err(why).Msg("cast frame failed after retries") | |||
| 263 | + | c.frameDone(why, time.Time{}) | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | if c.retryTimer == nil { | |||
| 267 | + | c.retryTimer = s.clock.AfterFunc(captureRetryEvery, c.timerFn) | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | c.retryTimer.Reset(captureRetryEvery) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // attempt serves the waiting frame from the target as it is now. | |||
| 274 | + | func (c *castSession) attempt() { | |||
| 275 | + | s := c.s | |||
| 276 | + | if s.protected() { | |||
| 277 | + | c.end(ports.CaptureReasonNone, errCastProtected, true, true) | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | o, rect, hidden, ok := c.geometry() | |||
| 281 | + | if !ok || c.haveState && !c.st.Active { | |||
| 282 | + | c.retry(errCastNotReady) | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | if rect.Dx() != c.frame.Width || rect.Dy() != c.frame.Height { | |||
| 286 | + | // The target moved on: the adapter has to renegotiate before it can | |||
| 287 | + | // ask again; tell it now if refresh has not yet. | |||
| 288 | + | c.refresh() | |||
| 289 | + | if !c.stopped { | |||
| 290 | + | c.frameDone(errCastSize, time.Time{}) | |||
| 291 | + | } | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | if !s.requestCast(c, o, rect, c.targetTag(hidden)) { | |||
| 295 | + | c.retry(ports.ErrCaptureBusy) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // requestCast queues the capture of the waiting frame into the adapter's | |||
| 300 | + | // buffer. The reply is the cast's own prebound function, not a closure per | |||
| 301 | + | // frame. False: the in-flight bound or the renderer queue is full. | |||
| 302 | + | func (s *Server) requestCast(c *castSession, o *output, rect image.Rectangle, tag captureTag) bool { | |||
| 303 | + | f := c.frame | |||
| 304 | + | if o == nil || s.channels.Captures == nil || s.channels.Captured == nil || len(s.captureInflight) >= maxCaptureInflight { | |||
| 305 | + | return false | |||
| 306 | + | } | |||
| 307 | + | s.nextCapture++ | |||
| 308 | + | id := s.nextCapture | |||
| 309 | + | s.captureInflight[id] = struct{}{} | |||
| 310 | + | s.captureReplies[id] = c.doneFn | |||
| 311 | + | req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: c.cursor, Mapped: f.Data, Width: f.Width, Height: f.Height, Stride: f.Stride, Format: f.Format, Session: tag.session, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true} | |||
| 312 | + | select { | |||
| 313 | + | case s.channels.Captures <- req: | |||
| 314 | + | default: | |||
| 315 | + | delete(s.captureReplies, id) | |||
| 316 | + | delete(s.captureInflight, id) | |||
| 317 | + | return false | |||
| 318 | + | } | |||
| 319 | + | c.inflight = true | |||
| 320 | + | if !c.first { | |||
| 321 | + | // The indicator is on screen while the frame is rendered; the cast | |||
| 322 | + | // keeps it on for as long as it lives. | |||
| 323 | + | c.first = true | |||
| 324 | + | taken := tag.taken | |||
| 325 | + | taken.Session = tag.session | |||
| 326 | + | s.emit(taken) | |||
| 327 | + | } | |||
| 328 | + | return true | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // onDone settles the capture of the waiting frame. | |||
| 332 | + | func (c *castSession) onDone(done ports.CaptureDone) { | |||
| 333 | + | c.inflight = false | |||
| 334 | + | if errors.Is(done.Err, ports.ErrCaptureTransient) && !c.stopped { | |||
| 335 | + | c.retry(done.Err) | |||
| 336 | + | return | |||
| 337 | + | } | |||
| 338 | + | err := done.Err | |||
| 339 | + | if err != nil { | |||
| 340 | + | c.s.log.Info().Uint64("cast", c.cast).Err(err).Msg("cast frame failed") | |||
| 341 | + | } | |||
| 342 | + | if c.ending { | |||
| 343 | + | // The cast is over: the adapter must not queue this frame. | |||
| 344 | + | err = errors.Join(errCastStopped, err) | |||
| 345 | + | } | |||
| 346 | + | if c.waiting { | |||
| 347 | + | c.frameDone(err, done.Time) | |||
| 348 | + | } | |||
| 349 | + | if c.ending { | |||
| 350 | + | c.ending = false | |||
| 351 | + | c.finish() | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // castFlip answers a flip of an output: the casts on it have something new | |||
| 356 | + | // to show. An output cast always has; a window cast only when its window | |||
| 357 | + | // committed since the last damage. Display goroutine; no allocation. | |||
| 358 | + | func (s *Server) castFlip(output string, f *ports.FlipInfo) { | |||
| 359 | + | if len(s.castSessions) == 0 { | |||
| 360 | + | return | |||
| 361 | + | } | |||
| 362 | + | var at time.Time | |||
| 363 | + | for _, c := range s.castSessions { | |||
| 364 | + | switch c.src.kind { | |||
| 365 | + | case srcOutput: | |||
| 366 | + | if c.src.output.name() != output { | |||
| 367 | + | continue | |||
| 368 | + | } | |||
| 369 | + | case srcWindow: | |||
| 370 | + | if !c.dirty { | |||
| 371 | + | continue | |||
| 372 | + | } | |||
| 373 | + | if o, _, _, ok := c.geometry(); !ok || o.name() != output { | |||
| 374 | + | continue | |||
| 375 | + | } | |||
| 376 | + | default: | |||
| 377 | + | continue | |||
| 378 | + | } | |||
| 379 | + | if at.IsZero() { | |||
| 380 | + | at = flipTime(f) | |||
| 381 | + | } | |||
| 382 | + | c.dirty = false | |||
| 383 | + | c.push(ports.CastEvent{Kind: ports.CastDamage, Time: at}) | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // flipTime is when a flip happened, on CLOCK_MONOTONIC as capture times are. | |||
| 388 | + | func flipTime(f *ports.FlipInfo) time.Time { | |||
| 389 | + | if f.When > 0 { | |||
| 390 | + | return time.Unix(0, int64(f.When)) | |||
| 391 | + | } | |||
| 392 | + | var ts unix.Timespec | |||
| 393 | + | if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil { | |||
| 394 | + | return time.Time{} | |||
| 395 | + | } | |||
| 396 | + | return time.Unix(ts.Sec, ts.Nsec) | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // castCommitted marks the casts of a window that committed content. A popup | |||
| 400 | + | // is drawn with its toplevel (captureWindowScene), so its commits, map, | |||
| 401 | + | // unmap and reposition mark the casts of the toplevel it hangs from, found | |||
| 402 | + | // through the same parent chain as toplevelRoot. | |||
| 403 | + | func (s *Server) castCommitted(id ports.WindowID) { | |||
| 404 | + | if len(s.castSessions) == 0 { | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | // A popup chain is as deep as menus nest; the bound only guards a cycle. | |||
| 408 | + | for range 64 { | |||
| 409 | + | w := s.windows[id] | |||
| 410 | + | if w == nil || w.popup == nil || w.popup.parent == nil { | |||
| 411 | + | break | |||
| 412 | + | } | |||
| 413 | + | id = w.popup.parent.id | |||
| 414 | + | } | |||
| 415 | + | for _, c := range s.castSessions { | |||
| 416 | + | if c.src.kind == srcWindow && c.src.window == id { | |||
| 417 | + | c.dirty = true | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // forwardCasts moves the adapter's commands to the display goroutine through | |||
| 423 | + | // castRing, in order; a full ring blocks this goroutine, never the display. | |||
| 424 | + | func (s *Server) forwardCasts(ctxDone <-chan struct{}) { | |||
| 425 | + | for { | |||
| 426 | + | select { | |||
| 427 | + | case <-ctxDone: | |||
| 428 | + | return | |||
| 429 | + | case <-s.display.Stopped(): | |||
| 430 | + | return | |||
| 431 | + | case cmd, ok := <-s.channels.CastCommands: | |||
| 432 | + | if !ok { | |||
| 433 | + | return | |||
| 434 | + | } | |||
| 435 | + | if !s.castRing.Push(cmd) { | |||
| 436 | + | return | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // castInventoryChanged schedules a publication of the inventory after the | |||
| 443 | + | // outputs, the titles, the app ids or the mapped windows may have changed. | |||
| 444 | + | // Changes made in one display loop turn share one publication. | |||
| 445 | + | func (s *Server) castInventoryChanged() { | |||
| 446 | + | if s.casts == nil || s.castInvPosted { | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | s.castInvPosted = true | |||
| 450 | + | s.display.Post(s.castInvFn) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // publishCastInventory sends the inventory when it differs from the last one. | |||
| 454 | + | func (s *Server) publishCastInventory() { | |||
| 455 | + | s.castInvPosted = false | |||
| 456 | + | inv := s.castInventory() | |||
| 457 | + | if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) { | |||
| 458 | + | return | |||
| 459 | + | } | |||
| 460 | + | // The receiver owns what it is given: keep a copy of our own to compare. | |||
| 461 | + | s.castInvLast, s.castInvSent = inv, true | |||
| 462 | + | s.casts.Inventory(s.castInventory()) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | // castInventory is the outputs and, unless the session is protected, the | |||
| 466 | + | // mapped toplevels now. | |||
| 467 | + | func (s *Server) castInventory() ports.CastInventory { | |||
| 468 | + | inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))} | |||
| 469 | + | for _, o := range s.outputs { | |||
| 470 | + | p := o.place | |||
| 471 | + | out := ports.CastOutput{Info: p.Info, Current: -1, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Scale: p.Scale, Transform: p.Transform} | |||
| 472 | + | for _, h := range s.outputHeads.Heads { | |||
| 473 | + | if h.Info.Name == p.Info.Name { | |||
| 474 | + | out.Modes = slices.Clone(h.Modes) | |||
| 475 | + | if h.Current != nil { | |||
| 476 | + | out.Current = slices.Index(out.Modes, *h.Current) | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | if out.Current < 0 { | |||
| 481 | + | // No head inventory (headless) or the mode is not listed: the | |||
| 482 | + | // mode the output runs. | |||
| 483 | + | mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0} | |||
| 484 | + | if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool { | |||
| 485 | + | return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli | |||
| 486 | + | }); out.Current < 0 { | |||
| 487 | + | out.Modes, out.Current = append(out.Modes, mode), len(out.Modes) | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | inv.Outputs = append(inv.Outputs, out) | |||
| 491 | + | } | |||
| 492 | + | if s.protected() { | |||
| 493 | + | return inv | |||
| 494 | + | } | |||
| 495 | + | for _, id := range s.mappedToplevels() { | |||
| 496 | + | w := s.windows[id] | |||
| 497 | + | inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title}) | |||
| 498 | + | } | |||
| 499 | + | return inv | |||
| 500 | + | } | |||
A internal/adapters/wayland/screencast_test.go +697 -0
| @@ -0,0 +1,697 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "slices" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/go-wayland-bindings/server/xdgshell" | |||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 13 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // castLayout: two small outputs, so frames are cheap and a flip of the other | |||
| 19 | + | // output can be told from a flip of the cast's. | |||
| 20 | + | var castLayout = ports.Layout{ | |||
| 21 | + | {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1}, | |||
| 22 | + | {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // castHarness runs a server with the cast channels wired: the commands go in | |||
| 26 | + | // through casts, the events and inventories the server publishes are recorded | |||
| 27 | + | // by the generated CastSink mock into got and invs, and the capture requests | |||
| 28 | + | // and replies are the test's. | |||
| 29 | + | type castHarness struct { | |||
| 30 | + | t *testing.T | |||
| 31 | + | s *Server | |||
| 32 | + | dir string | |||
| 33 | + | events chan ports.ClientEvent | |||
| 34 | + | commands chan ports.ClientCommand | |||
| 35 | + | requests chan ports.CaptureRequest | |||
| 36 | + | replies chan ports.CaptureDone | |||
| 37 | + | casts chan ports.CastCommand | |||
| 38 | + | got chan ports.CastEvent | |||
| 39 | + | invs chan ports.CastInventory | |||
| 40 | + | changes chan ports.SecurityState | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // newCastHarness starts the server; withSink false leaves Options.Casts nil | |||
| 44 | + | // (the allocation test measures the server alone). | |||
| 45 | + | func newCastHarness(t *testing.T, withSink bool) *castHarness { | |||
| 46 | + | t.Helper() | |||
| 47 | + | h := &castHarness{ | |||
| 48 | + | t: t, | |||
| 49 | + | dir: t.TempDir(), | |||
| 50 | + | events: make(chan ports.ClientEvent, 256), | |||
| 51 | + | commands: make(chan ports.ClientCommand, 16), | |||
| 52 | + | requests: make(chan ports.CaptureRequest, maxCaptureInflight), | |||
| 53 | + | replies: make(chan ports.CaptureDone, maxCaptureInflight+2), | |||
| 54 | + | casts: make(chan ports.CastCommand, 16), | |||
| 55 | + | got: make(chan ports.CastEvent, 4096), | |||
| 56 | + | invs: make(chan ports.CastInventory, 64), | |||
| 57 | + | changes: make(chan ports.SecurityState, 16), | |||
| 58 | + | } | |||
| 59 | + | opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}} | |||
| 60 | + | if withSink { | |||
| 61 | + | sink := portsmocks.NewMockCastSink(t) | |||
| 62 | + | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe() | |||
| 63 | + | sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe() | |||
| 64 | + | opts.Casts = sink | |||
| 65 | + | } | |||
| 66 | + | ch := Channels{ | |||
| 67 | + | Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts, | |||
| 68 | + | SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), | |||
| 69 | + | } | |||
| 70 | + | s, err := New(opts, ch, logging.For(context.Background(), "wayland")) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | h.s = s | |||
| 75 | + | relayContents(t, s) | |||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 77 | + | done := make(chan error, 1) | |||
| 78 | + | go func() { done <- s.Run(ctx) }() | |||
| 79 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 80 | + | return h | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // sync returns once the display goroutine ran everything posted before. | |||
| 84 | + | func (h *castHarness) sync() { | |||
| 85 | + | h.t.Helper() | |||
| 86 | + | ran := make(chan struct{}) | |||
| 87 | + | h.s.display.Post(func() { close(ran) }) | |||
| 88 | + | select { | |||
| 89 | + | case <-ran: | |||
| 90 | + | case <-time.After(3 * time.Second): | |||
| 91 | + | h.t.Fatal("display goroutine stalled") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // next is the next event pushed to the sink, which must have that kind. | |||
| 96 | + | func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent { | |||
| 97 | + | h.t.Helper() | |||
| 98 | + | select { | |||
| 99 | + | case ev := <-h.got: | |||
| 100 | + | if ev.Kind != kind { | |||
| 101 | + | h.t.Fatalf("event %+v, want kind %d", ev, kind) | |||
| 102 | + | } | |||
| 103 | + | return ev | |||
| 104 | + | case <-time.After(3 * time.Second): | |||
| 105 | + | h.t.Fatalf("no cast event of kind %d", kind) | |||
| 106 | + | return ports.CastEvent{} | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // none says that no event is pending once the display goroutine caught up. | |||
| 111 | + | func (h *castHarness) none() { | |||
| 112 | + | h.t.Helper() | |||
| 113 | + | h.sync() | |||
| 114 | + | select { | |||
| 115 | + | case ev := <-h.got: | |||
| 116 | + | h.t.Fatalf("unexpected cast event %+v", ev) | |||
| 117 | + | default: | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (h *castHarness) request() ports.CaptureRequest { | |||
| 122 | + | h.t.Helper() | |||
| 123 | + | select { | |||
| 124 | + | case r := <-h.requests: | |||
| 125 | + | return r | |||
| 126 | + | case <-time.After(3 * time.Second): | |||
| 127 | + | h.t.Fatal("no capture request") | |||
| 128 | + | return ports.CaptureRequest{} | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // noRequest says that no capture was requested. | |||
| 133 | + | func (h *castHarness) noRequest(wait time.Duration) { | |||
| 134 | + | h.t.Helper() | |||
| 135 | + | select { | |||
| 136 | + | case r := <-h.requests: | |||
| 137 | + | h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output) | |||
| 138 | + | case <-time.After(wait): | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // castClientEvent waits for the next ClientEvent of type T, skipping others. | |||
| 143 | + | func castClientEvent[T ports.ClientEvent](h *castHarness) T { | |||
| 144 | + | h.t.Helper() | |||
| 145 | + | for { | |||
| 146 | + | select { | |||
| 147 | + | case ev := <-h.events: | |||
| 148 | + | if v, ok := ev.(T); ok { | |||
| 149 | + | return v | |||
| 150 | + | } | |||
| 151 | + | case <-time.After(3 * time.Second): | |||
| 152 | + | var zero T | |||
| 153 | + | h.t.Fatalf("no %T", zero) | |||
| 154 | + | return zero | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // complete answers a capture request the way an output does. | |||
| 160 | + | func (h *castHarness) complete(req ports.CaptureRequest, err error) { | |||
| 161 | + | h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)} | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) { | |||
| 165 | + | h.t.Helper() | |||
| 166 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true} | |||
| 167 | + | size := h.next(ports.CastSize) | |||
| 168 | + | return size, castClientEvent[ports.CaptureSessionOpen](h).ID | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // state sends core's decision and waits until the display goroutine took it. | |||
| 172 | + | func (h *castHarness) state(core uint64, st ports.CaptureSessionState) { | |||
| 173 | + | h.t.Helper() | |||
| 174 | + | h.commands <- st | |||
| 175 | + | for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { | |||
| 176 | + | var seen bool | |||
| 177 | + | h.s.display.Do(func() { seen = h.s.castByCore[core].haveState }) | |||
| 178 | + | if seen { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | h.t.Fatal("state not applied") | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand { | |||
| 186 | + | return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1} | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (h *castHarness) flip(output string) { | |||
| 190 | + | h.t.Helper() | |||
| 191 | + | h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}}) | |||
| 192 | + | h.sync() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // An output cast: open registers it with core and reports its size; a frame is | |||
| 196 | + | // captured into the lent mapping, indicated once, and reported done. | |||
| 197 | + | func TestCastOpenSizeFrameDone(t *testing.T) { | |||
| 198 | + | h := newCastHarness(t, true) | |||
| 199 | + | size, core := h.open(7, "HEADLESS-1") | |||
| 200 | + | if size.ID != 7 || size.Width != 64 || size.Height != 32 { | |||
| 201 | + | t.Fatalf("size %+v", size) | |||
| 202 | + | } | |||
| 203 | + | if core == 0 { | |||
| 204 | + | t.Fatal("cast not registered with core") | |||
| 205 | + | } | |||
| 206 | + | data := make([]byte, 64*32*4) | |||
| 207 | + | h.casts <- castFrame(7, 3, data, 64, 32) | |||
| 208 | + | req := h.request() | |||
| 209 | + | if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil || | |||
| 210 | + | len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 { | |||
| 211 | + | t.Fatalf("request %+v", req) | |||
| 212 | + | } | |||
| 213 | + | taken := castClientEvent[ports.CaptureFrameTaken](h) | |||
| 214 | + | if taken.Session != core || taken.Output != "HEADLESS-1" { | |||
| 215 | + | t.Fatalf("taken %+v", taken) | |||
| 216 | + | } | |||
| 217 | + | h.complete(req, nil) | |||
| 218 | + | done := h.next(ports.CastFrameDone) | |||
| 219 | + | if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) { | |||
| 220 | + | t.Fatalf("done %+v", done) | |||
| 221 | + | } | |||
| 222 | + | // Later frames are not indicated again: the cast keeps the indicator on. | |||
| 223 | + | h.casts <- castFrame(7, 4, data, 64, 32) | |||
| 224 | + | h.complete(h.request(), nil) | |||
| 225 | + | if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil { | |||
| 226 | + | t.Fatalf("second done %+v", done) | |||
| 227 | + | } | |||
| 228 | + | h.sync() | |||
| 229 | + | for len(h.events) > 0 { | |||
| 230 | + | if ev := <-h.events; ev != nil { | |||
| 231 | + | if _, again := ev.(ports.CaptureFrameTaken); again { | |||
| 232 | + | t.Fatalf("second frame indicated again: %+v", ev) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | // CastClose ends the cast without a reply and tells core. | |||
| 237 | + | h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7} | |||
| 238 | + | if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core { | |||
| 239 | + | t.Fatalf("close %+v", c) | |||
| 240 | + | } | |||
| 241 | + | h.none() | |||
| 242 | + | // A frame for a closed cast gives its buffer back. | |||
| 243 | + | h.casts <- castFrame(7, 5, data, 64, 32) | |||
| 244 | + | if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil { | |||
| 245 | + | t.Fatalf("frame of a closed cast: %+v", done) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // A target that is missing refuses the cast. | |||
| 250 | + | func TestCastOpenRefused(t *testing.T) { | |||
| 251 | + | h := newCastHarness(t, true) | |||
| 252 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"} | |||
| 253 | + | if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil { | |||
| 254 | + | t.Fatalf("stopped %+v", ev) | |||
| 255 | + | } | |||
| 256 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99} | |||
| 257 | + | if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone { | |||
| 258 | + | t.Fatalf("stopped %+v", ev) | |||
| 259 | + | } | |||
| 260 | + | for id := uint64(10); id < 10+maxCasts+1; id++ { | |||
| 261 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"} | |||
| 262 | + | } | |||
| 263 | + | for range maxCasts { | |||
| 264 | + | h.next(ports.CastSize) | |||
| 265 | + | } | |||
| 266 | + | if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) { | |||
| 267 | + | t.Fatalf("over the limit: %+v", ev) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // A window cast follows the window and stops when it closes. | |||
| 272 | + | func TestCastWindowCloseStops(t *testing.T) { | |||
| 273 | + | h := newCastHarness(t, true) | |||
| 274 | + | c := protocolClient(t, h.s, h.dir) | |||
| 275 | + | w, _, xdg := surfaceMapper(t, c, h.events)() | |||
| 276 | + | registerProtocol(t, c, xdg) | |||
| 277 | + | top := xdg + 1 | |||
| 278 | + | placeWindow(t, h.s, h.commands, w, "HEADLESS-1") | |||
| 279 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID} | |||
| 280 | + | if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 { | |||
| 281 | + | t.Fatalf("size %+v", size) | |||
| 282 | + | } | |||
| 283 | + | core := castClientEvent[ports.CaptureSessionOpen](h) | |||
| 284 | + | if core.Window != w.ID { | |||
| 285 | + | t.Fatalf("open %+v", core) | |||
| 286 | + | } | |||
| 287 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy) | |||
| 288 | + | roundtrip(t, c) | |||
| 289 | + | ev := h.next(ports.CastStopped) | |||
| 290 | + | if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone { | |||
| 291 | + | t.Fatalf("stopped %+v", ev) | |||
| 292 | + | } | |||
| 293 | + | if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID { | |||
| 294 | + | t.Fatalf("close %+v", cl) | |||
| 295 | + | } | |||
| 296 | + | h.none() | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // A session lock ends every cast and refuses new ones. | |||
| 300 | + | func TestCastSessionLockStops(t *testing.T) { | |||
| 301 | + | h := newCastHarness(t, true) | |||
| 302 | + | h.open(1, "HEADLESS-1") | |||
| 303 | + | data := make([]byte, 64*32*4) | |||
| 304 | + | h.casts <- castFrame(1, 0, data, 64, 32) | |||
| 305 | + | req := h.request() | |||
| 306 | + | c := protocolClient(t, h.s, h.dir) | |||
| 307 | + | if _, err := bindLockManager(t, c).Lock(); err != nil { | |||
| 308 | + | t.Fatal(err) | |||
| 309 | + | } | |||
| 310 | + | roundtrip(t, c) | |||
| 311 | + | // The capture still writes into the lent buffer: its reply comes first. | |||
| 312 | + | h.none() | |||
| 313 | + | h.complete(req, nil) | |||
| 314 | + | if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) { | |||
| 315 | + | t.Fatalf("done %+v", done) | |||
| 316 | + | } | |||
| 317 | + | if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) { | |||
| 318 | + | t.Fatalf("stopped %+v", ev) | |||
| 319 | + | } | |||
| 320 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"} | |||
| 321 | + | if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) { | |||
| 322 | + | t.Fatalf("open under lock: %+v", ev) | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // An unplugged output ends the casts on it, after the capture in flight. | |||
| 327 | + | func TestCastOutputUnplugStops(t *testing.T) { | |||
| 328 | + | h := newCastHarness(t, true) | |||
| 329 | + | h.open(1, "HEADLESS-1") | |||
| 330 | + | h.open(2, "HEADLESS-2") | |||
| 331 | + | data := make([]byte, 64*32*4) | |||
| 332 | + | h.casts <- castFrame(1, 9, data, 64, 32) | |||
| 333 | + | req := h.request() | |||
| 334 | + | h.commands <- ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"} | |||
| 335 | + | h.none() | |||
| 336 | + | h.complete(req, nil) | |||
| 337 | + | if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil { | |||
| 338 | + | t.Fatalf("a frame written for a dead cast must not be queued: %+v", done) | |||
| 339 | + | } | |||
| 340 | + | if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone { | |||
| 341 | + | t.Fatalf("stopped %+v", ev) | |||
| 342 | + | } | |||
| 343 | + | // The cast on the other output lives. | |||
| 344 | + | h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32) | |||
| 345 | + | h.complete(h.request(), nil) | |||
| 346 | + | if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil { | |||
| 347 | + | t.Fatalf("done %+v", done) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // A new mode renegotiates (CastSize); a frame of the old size is refused. | |||
| 352 | + | func TestCastSizeChange(t *testing.T) { | |||
| 353 | + | h := newCastHarness(t, true) | |||
| 354 | + | h.open(1, "HEADLESS-1") | |||
| 355 | + | bigger := ports.Layout{castLayout[0], castLayout[1]} | |||
| 356 | + | bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64 | |||
| 357 | + | h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"} | |||
| 358 | + | if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 { | |||
| 359 | + | t.Fatalf("size %+v", size) | |||
| 360 | + | } | |||
| 361 | + | h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32) | |||
| 362 | + | if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) { | |||
| 363 | + | t.Fatalf("done %+v", done) | |||
| 364 | + | } | |||
| 365 | + | h.noRequest(20 * time.Millisecond) | |||
| 366 | + | h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64) | |||
| 367 | + | h.complete(h.request(), nil) | |||
| 368 | + | if done := h.next(ports.CastFrameDone); done.Err != nil { | |||
| 369 | + | t.Fatalf("done %+v", done) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // A transient failure is retried on the cadence of an ext frame; past the | |||
| 374 | + | // deadline the frame fails with the error. | |||
| 375 | + | func TestCastTransientRetry(t *testing.T) { | |||
| 376 | + | h := newCastHarness(t, true) | |||
| 377 | + | h.open(1, "HEADLESS-1") | |||
| 378 | + | data := make([]byte, 64*32*4) | |||
| 379 | + | h.casts <- castFrame(1, 0, data, 64, 32) | |||
| 380 | + | first := h.request() | |||
| 381 | + | h.complete(first, ports.ErrCaptureBusy) | |||
| 382 | + | start := time.Now() | |||
| 383 | + | retried := h.request() | |||
| 384 | + | if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 { | |||
| 385 | + | t.Fatalf("retry %+v after %v", retried, time.Since(start)) | |||
| 386 | + | } | |||
| 387 | + | h.complete(retried, nil) | |||
| 388 | + | if done := h.next(ports.CastFrameDone); done.Err != nil { | |||
| 389 | + | t.Fatalf("done %+v", done) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // Never ready: retried until the deadline, then reported. | |||
| 393 | + | h.casts <- castFrame(1, 1, data, 64, 32) | |||
| 394 | + | attempts := 0 | |||
| 395 | + | for { | |||
| 396 | + | select { | |||
| 397 | + | case req := <-h.requests: | |||
| 398 | + | attempts++ | |||
| 399 | + | h.complete(req, ports.ErrCaptureBusy) | |||
| 400 | + | continue | |||
| 401 | + | case ev := <-h.got: | |||
| 402 | + | if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) { | |||
| 403 | + | t.Fatalf("event %+v", ev) | |||
| 404 | + | } | |||
| 405 | + | case <-time.After(5 * time.Second): | |||
| 406 | + | t.Fatal("frame neither retried nor failed") | |||
| 407 | + | } | |||
| 408 | + | break | |||
| 409 | + | } | |||
| 410 | + | if attempts < 10 { | |||
| 411 | + | t.Fatalf("%d attempts in %v", attempts, captureRetryFor) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // Until core says the cast is active the frame waits, then goes out. | |||
| 416 | + | func TestCastWaitsForActiveState(t *testing.T) { | |||
| 417 | + | h := newCastHarness(t, true) | |||
| 418 | + | _, core := h.open(1, "HEADLESS-1") | |||
| 419 | + | h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"}) | |||
| 420 | + | h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32) | |||
| 421 | + | h.noRequest(60 * time.Millisecond) | |||
| 422 | + | h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true} | |||
| 423 | + | h.complete(h.request(), nil) | |||
| 424 | + | if done := h.next(ports.CastFrameDone); done.Err != nil { | |||
| 425 | + | t.Fatalf("done %+v", done) | |||
| 426 | + | } | |||
| 427 | + | // A terminal decision of core ends the cast without telling core. | |||
| 428 | + | h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone} | |||
| 429 | + | if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone { | |||
| 430 | + | t.Fatalf("stopped %+v", ev) | |||
| 431 | + | } | |||
| 432 | + | h.sync() | |||
| 433 | + | for len(h.events) > 0 { | |||
| 434 | + | if _, closed := (<-h.events).(ports.CaptureSessionClose); closed { | |||
| 435 | + | t.Fatal("core told of an end it decided") | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // A flip of the cast's output damages an output cast, and only that flip. | |||
| 441 | + | func TestCastFlipDamagesOutputCast(t *testing.T) { | |||
| 442 | + | h := newCastHarness(t, true) | |||
| 443 | + | h.open(1, "HEADLESS-1") | |||
| 444 | + | h.open(2, "HEADLESS-2") | |||
| 445 | + | h.flip("HEADLESS-1") | |||
| 446 | + | ev := h.next(ports.CastDamage) | |||
| 447 | + | if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) { | |||
| 448 | + | t.Fatalf("damage %+v", ev) | |||
| 449 | + | } | |||
| 450 | + | h.none() | |||
| 451 | + | h.flip("HEADLESS-2") | |||
| 452 | + | if ev = h.next(ports.CastDamage); ev.ID != 2 { | |||
| 453 | + | t.Fatalf("damage %+v", ev) | |||
| 454 | + | } | |||
| 455 | + | // Every flip damages an output cast, committed or not. | |||
| 456 | + | h.flip("HEADLESS-1") | |||
| 457 | + | h.flip("HEADLESS-1") | |||
| 458 | + | h.next(ports.CastDamage) | |||
| 459 | + | h.next(ports.CastDamage) | |||
| 460 | + | h.none() | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | // A window cast is damaged by a flip of its output only after the window | |||
| 464 | + | // committed since the last damage. | |||
| 465 | + | func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) { | |||
| 466 | + | h := newCastHarness(t, true) | |||
| 467 | + | c := protocolClient(t, h.s, h.dir) | |||
| 468 | + | w, _, _ := surfaceMapper(t, c, h.events)() | |||
| 469 | + | placeWindow(t, h.s, h.commands, w, "HEADLESS-1") | |||
| 470 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID} | |||
| 471 | + | h.next(ports.CastSize) | |||
| 472 | + | ||||
| 473 | + | h.flip("HEADLESS-1") | |||
| 474 | + | h.none() // nothing committed since the cast began | |||
| 475 | + | commit := func() { | |||
| 476 | + | if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) { | |||
| 477 | + | t.Fatal("display stopped") | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | commit() | |||
| 481 | + | h.flip("HEADLESS-2") | |||
| 482 | + | h.none() // not the window's output | |||
| 483 | + | h.flip("HEADLESS-1") | |||
| 484 | + | if ev := h.next(ports.CastDamage); ev.ID != 1 { | |||
| 485 | + | t.Fatalf("damage %+v", ev) | |||
| 486 | + | } | |||
| 487 | + | h.flip("HEADLESS-1") | |||
| 488 | + | h.none() // the damage consumed the commit | |||
| 489 | + | commit() | |||
| 490 | + | commit() | |||
| 491 | + | h.flip("HEADLESS-1") | |||
| 492 | + | h.next(ports.CastDamage) | |||
| 493 | + | h.none() // two commits, one damage | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // A popup is drawn with its toplevel: a static toplevel whose popup (or a | |||
| 497 | + | // popup of that popup) keeps committing, mapping, unmapping or moving still | |||
| 498 | + | // damages the window cast. The popups are windows of the server, as an | |||
| 499 | + | // xdg_popup makes them. | |||
| 500 | + | func TestCastWindowDamagedByPopupChanges(t *testing.T) { | |||
| 501 | + | h := newCastHarness(t, true) | |||
| 502 | + | c := protocolClient(t, h.s, h.dir) | |||
| 503 | + | w, _, _ := surfaceMapper(t, c, h.events)() | |||
| 504 | + | placeWindow(t, h.s, h.commands, w, "HEADLESS-1") | |||
| 505 | + | h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID} | |||
| 506 | + | h.next(ports.CastSize) | |||
| 507 | + | ||||
| 508 | + | var menu, sub ports.WindowID | |||
| 509 | + | if !h.s.display.Do(func() { | |||
| 510 | + | top := h.s.windows[w.ID] | |||
| 511 | + | m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true} | |||
| 512 | + | m.popup = &popup{w: m, parent: top} | |||
| 513 | + | h.s.windows[m.id] = m | |||
| 514 | + | s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true} | |||
| 515 | + | s2.popup = &popup{w: s2, parent: m} | |||
| 516 | + | h.s.windows[s2.id] = s2 | |||
| 517 | + | h.s.nextWindow += 2 | |||
| 518 | + | menu, sub = m.id, s2.id | |||
| 519 | + | }) { | |||
| 520 | + | t.Fatal("display stopped") | |||
| 521 | + | } | |||
| 522 | + | t.Cleanup(func() { | |||
| 523 | + | h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) }) | |||
| 524 | + | }) | |||
| 525 | + | h.flip("HEADLESS-1") | |||
| 526 | + | h.none() // nothing changed yet | |||
| 527 | + | change := func(name string, f func()) { | |||
| 528 | + | t.Helper() | |||
| 529 | + | if !h.s.display.Do(f) { | |||
| 530 | + | t.Fatal("display stopped") | |||
| 531 | + | } | |||
| 532 | + | h.flip("HEADLESS-1") | |||
| 533 | + | if ev := h.next(ports.CastDamage); ev.ID != 1 { | |||
| 534 | + | t.Fatalf("%s: damage %+v", name, ev) | |||
| 535 | + | } | |||
| 536 | + | h.flip("HEADLESS-1") | |||
| 537 | + | h.none() // the damage consumed the change | |||
| 538 | + | } | |||
| 539 | + | content := func(id ports.WindowID) func() { | |||
| 540 | + | return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) } | |||
| 541 | + | } | |||
| 542 | + | change("popup commit", content(menu)) | |||
| 543 | + | change("popup commit again", content(menu)) | |||
| 544 | + | change("nested popup commit", content(sub)) | |||
| 545 | + | change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() }) | |||
| 546 | + | change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) }) | |||
| 547 | + | change("popup reposition", func() { h.s.castCommitted(menu) }) | |||
| 548 | + | // A window nobody casts leaves the cast alone. | |||
| 549 | + | if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) { | |||
| 550 | + | t.Fatal("display stopped") | |||
| 551 | + | } | |||
| 552 | + | h.flip("HEADLESS-1") | |||
| 553 | + | h.none() | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | // The inventory is published once per change, not once per frame or flip, and | |||
| 557 | + | // changes of one display loop turn share one publication. | |||
| 558 | + | func TestCastInventoryPublishedOncePerChange(t *testing.T) { | |||
| 559 | + | h := newCastHarness(t, true) | |||
| 560 | + | inv := func() ports.CastInventory { | |||
| 561 | + | t.Helper() | |||
| 562 | + | select { | |||
| 563 | + | case v := <-h.invs: | |||
| 564 | + | return v | |||
| 565 | + | case <-time.After(3 * time.Second): | |||
| 566 | + | t.Fatal("no inventory") | |||
| 567 | + | return ports.CastInventory{} | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | noInv := func() { | |||
| 571 | + | t.Helper() | |||
| 572 | + | h.sync() | |||
| 573 | + | h.sync() | |||
| 574 | + | select { | |||
| 575 | + | case v := <-h.invs: | |||
| 576 | + | t.Fatalf("unexpected inventory %+v", v) | |||
| 577 | + | default: | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | first := inv() | |||
| 581 | + | if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 { | |||
| 582 | + | t.Fatalf("first inventory %+v", first) | |||
| 583 | + | } | |||
| 584 | + | for _, o := range first.Outputs { | |||
| 585 | + | if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width { | |||
| 586 | + | t.Fatalf("current mode of %s: %+v", o.Info.Name, o) | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | noInv() | |||
| 590 | + | ||||
| 591 | + | c := protocolClient(t, h.s, h.dir) | |||
| 592 | + | w, _, xdg := surfaceMapper(t, c, h.events)() | |||
| 593 | + | registerProtocol(t, c, xdg) | |||
| 594 | + | top := xdg + 1 | |||
| 595 | + | if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID { | |||
| 596 | + | t.Fatalf("mapped inventory %+v", got) | |||
| 597 | + | } | |||
| 598 | + | noInv() | |||
| 599 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor") | |||
| 600 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor") | |||
| 601 | + | roundtrip(t, c) | |||
| 602 | + | var got ports.CastInventory | |||
| 603 | + | for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() { | |||
| 604 | + | } | |||
| 605 | + | noInv() | |||
| 606 | + | // The same title again changes nothing. | |||
| 607 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor") | |||
| 608 | + | roundtrip(t, c) | |||
| 609 | + | noInv() | |||
| 610 | + | ||||
| 611 | + | // Frames and flips do not publish. | |||
| 612 | + | h.open(1, "HEADLESS-1") | |||
| 613 | + | data := make([]byte, 64*32*4) | |||
| 614 | + | for i := range 20 { | |||
| 615 | + | h.flip("HEADLESS-1") | |||
| 616 | + | h.casts <- castFrame(1, i%3, data, 64, 32) | |||
| 617 | + | h.complete(h.request(), nil) | |||
| 618 | + | } | |||
| 619 | + | noInv() | |||
| 620 | + | ||||
| 621 | + | // Several changes in one turn: one publication, of the last state. An | |||
| 622 | + | // unchanged inventory is not published at all. | |||
| 623 | + | h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() }) | |||
| 624 | + | noInv() | |||
| 625 | + | h.s.display.Do(func() { | |||
| 626 | + | for _, title := range []string{"a", "b", "c"} { | |||
| 627 | + | h.s.windows[w.ID].title = title | |||
| 628 | + | h.s.castInventoryChanged() | |||
| 629 | + | } | |||
| 630 | + | }) | |||
| 631 | + | if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" { | |||
| 632 | + | t.Fatalf("inventory %+v", got) | |||
| 633 | + | } | |||
| 634 | + | noInv() | |||
| 635 | + | ||||
| 636 | + | // An output moving is a change; the focused output too. | |||
| 637 | + | moved := slices.Clone(castLayout) | |||
| 638 | + | moved[1].X = 200 | |||
| 639 | + | h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"} | |||
| 640 | + | if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" { | |||
| 641 | + | t.Fatalf("inventory %+v", got) | |||
| 642 | + | } | |||
| 643 | + | noInv() | |||
| 644 | + | ||||
| 645 | + | // Under a session lock the windows are not listed. | |||
| 646 | + | lc := protocolClient(t, h.s, h.dir) | |||
| 647 | + | if _, err := bindLockManager(t, lc).Lock(); err != nil { | |||
| 648 | + | t.Fatal(err) | |||
| 649 | + | } | |||
| 650 | + | roundtrip(t, lc) | |||
| 651 | + | for got = inv(); len(got.Windows) != 0; got = inv() { | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // Pushing a cast frame, capturing it and answering the flip allocates nothing | |||
| 656 | + | // in steady state: the cast command rides the post ring, the request and the | |||
| 657 | + | // reply are plain values, the reply function is bound once. The count | |||
| 658 | + | // includes the display goroutine (AllocsPerRun reads the global malloc | |||
| 659 | + | // counter). Nothing in the closure is a testify mock; the sink is left out so | |||
| 660 | + | // the measured code is the server's. | |||
| 661 | + | func TestCastFrameAllocations(t *testing.T) { | |||
| 662 | + | if raceEnabled { | |||
| 663 | + | t.Skip("the race detector makes the purego wake callback allocate") | |||
| 664 | + | } | |||
| 665 | + | h := newCastHarness(t, false) | |||
| 666 | + | s := h.s | |||
| 667 | + | s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"}) | |||
| 668 | + | core := castClientEvent[ports.CaptureSessionOpen](h).ID | |||
| 669 | + | data := make([]byte, 64*32*4) | |||
| 670 | + | frame := castFrame(1, 0, data, 64, 32) | |||
| 671 | + | flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}} | |||
| 672 | + | ran := make(chan struct{}, 1) | |||
| 673 | + | marker := func() { ran <- struct{}{} } | |||
| 674 | + | at := time.Unix(1, 2) | |||
| 675 | + | cycle := func() { | |||
| 676 | + | s.castRing.Push(frame) | |||
| 677 | + | req := <-h.requests | |||
| 678 | + | s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at}) | |||
| 679 | + | s.Presentation().Push(flip) | |||
| 680 | + | s.display.Post(marker) | |||
| 681 | + | <-ran | |||
| 682 | + | } | |||
| 683 | + | for range 4 { | |||
| 684 | + | cycle() | |||
| 685 | + | } | |||
| 686 | + | if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core { | |||
| 687 | + | t.Fatalf("taken %+v", taken) | |||
| 688 | + | } | |||
| 689 | + | var live int | |||
| 690 | + | s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) }) | |||
| 691 | + | if live != 1 { | |||
| 692 | + | t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live) | |||
| 693 | + | } | |||
| 694 | + | if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 { | |||
| 695 | + | t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs) | |||
| 696 | + | } | |||
| 697 | + | } | |||
M internal/adapters/wayland/screencopy.go +19 -8
| @@ -239,6 +239,24 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||||
| 239 | return 0, false | 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 | func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | 260 | func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | ||
| 243 | for { | 261 | for { | ||
| 244 | select { | 262 | select { | ||
| @@ -250,14 +268,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |||||
| 250 | if !ok { | 268 | if !ok { | ||
| 251 | return | 269 | return | ||
| 252 | } | 270 | } | ||
| 253 | - | if !s.display.Do(func() { | 271 | + | if !s.capturedRing.Push(done) { |
| 254 | - | delete(s.captureInflight, done.ID) | |||
| 255 | - | if fn := s.captureReplies[done.ID]; fn != nil { | |||
| 256 | - | delete(s.captureReplies, done.ID) | |||
| 257 | - | fn(done) | |||
| 258 | - | } | |||
| 259 | - | s.maybeLocked() | |||
| 260 | - | }) { | |||
| 261 | return | 272 | return | ||
| 262 | } | 273 | } | ||
| 263 | } | 274 | } | ||
M internal/adapters/wayland/server.go +34 -3
| @@ -48,6 +48,9 @@ type Options struct { | |||||
| 48 | // nil lets every client capture, unless the session is protected. The | 48 | // nil lets every client capture, unless the session is protected. The | ||
| 49 | // caller keeps it current (captureallow.Store.Run). | 49 | // caller keeps it current (captureallow.Store.Run). | ||
| 50 | CaptureAllow *captureallow.Store | 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 | // LogDamage logs the damage of every fresh client commit at debug level | 54 | // LogDamage logs the damage of every fresh client commit at debug level | ||
| 52 | // (--debug=wayland-damage). | 55 | // (--debug=wayland-damage). | ||
| 53 | LogDamage bool | 56 | LogDamage bool | ||
| @@ -78,6 +81,9 @@ type Channels struct { | |||||
| 78 | // never block the backend; standalone tests may use smaller channels. | 81 | // never block the backend; standalone tests may use smaller channels. | ||
| 79 | Captures chan<- ports.CaptureRequest | 82 | Captures chan<- ports.CaptureRequest | ||
| 80 | Captured <-chan ports.CaptureDone | 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 | // OutputFormats are the outputs' direct scanout formats, offered in | 87 | // OutputFormats are the outputs' direct scanout formats, offered in | ||
| 82 | // dmabuf feedback to fullscreen surfaces. | 88 | // dmabuf feedback to fullscreen surfaces. | ||
| 83 | OutputFormats <-chan ports.OutputFormats | 89 | OutputFormats <-chan ports.OutputFormats | ||
| @@ -111,6 +117,9 @@ type Server struct { | |||||
| 111 | nextLeaseID uint64 | 117 | nextLeaseID uint64 | ||
| 112 | nextCapture uint64 | 118 | nextCapture uint64 | ||
| 113 | captureReplies map[uint64]func(ports.CaptureDone) | 119 | captureReplies map[uint64]func(ports.CaptureDone) | ||
| 120 | + | // capturedRing hands backend replies to the display goroutine | |||
| 121 | + | // (forwardCaptured); the display goroutine applies them in order. | |||
| 122 | + | capturedRing *postRing[ports.CaptureDone] | |||
| 114 | // captureInflight counts accepted captures until the backend completes | 123 | // captureInflight counts accepted captures until the backend completes | ||
| 115 | // them, independent of client resources; only the display loop touches it. | 124 | // them, independent of client resources; only the display loop touches it. | ||
| 116 | captureInflight map[uint64]struct{} | 125 | captureInflight map[uint64]struct{} | ||
| @@ -119,9 +128,20 @@ type Server struct { | |||||
| 119 | // sessionsByID are the sessions registered with core (imagecopy.go); | 128 | // sessionsByID are the sessions registered with core (imagecopy.go); | ||
| 120 | // excl is the one live exclusion (exclusion.go); ownUID the compositor's. | 129 | // excl is the one live exclusion (exclusion.go); ownUID the compositor's. | ||
| 121 | sessionsByID map[uint64]*captureSession | 130 | sessionsByID map[uint64]*captureSession | ||
| 122 | - | nextSession uint64 | 131 | + | // Screencast (screencast.go): casts by the adapter's ID and by the ID |
| 123 | - | excl *exclusion | 132 | + | // core knows them by; castRing carries the adapter's commands to the |
| 124 | - | ownUID uint32 | 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 | // captureAllow, peer and peers decide mayCapture (captureauth.go): | 145 | // captureAllow, peer and peers decide mayCapture (captureauth.go): | ||
| 126 | // peers keeps each client's pidfd until it is destroyed. | 146 | // peers keeps each client's pidfd until it is destroyed. | ||
| 127 | captureAllow *captureallow.Store | 147 | captureAllow *captureallow.Store | ||
| @@ -358,6 +378,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 358 | s.paceSignalFn = s.paceSignalled | 378 | s.paceSignalFn = s.paceSignalled | ||
| 359 | s.flushLinksFn = s.flushLinks | 379 | s.flushLinksFn = s.flushLinks | ||
| 360 | s.sink = newPresentSink(s) | 380 | s.sink = newPresentSink(s) | ||
| 381 | + | s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured) | |||
| 382 | + | s.casts = opts.Casts | |||
| 383 | + | s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{} | |||
| 384 | + | s.castRing = newPostRing(d, castRingSize, s.applyCast) | |||
| 385 | + | s.castInvFn = s.publishCastInventory | |||
| 361 | s.leaseDevices = map[string]*leaseDevice{} | 386 | s.leaseDevices = map[string]*leaseDevice{} | ||
| 362 | s.pendingLeases = map[uint64]*leaseObject{} | 387 | s.pendingLeases = map[uint64]*leaseObject{} | ||
| 363 | s.pendingLeaseCards = map[uint64]string{} | 388 | s.pendingLeaseCards = map[uint64]string{} | ||
| @@ -549,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 549 | wg.Add(1) | 574 | wg.Add(1) | ||
| 550 | go func() { defer wg.Done(); s.syncWait.run(ctx) }() | 575 | go func() { defer wg.Done(); s.syncWait.run(ctx) }() | ||
| 551 | } | 576 | } | ||
| 577 | + | if s.casts != nil && s.channels.CastCommands != nil { | |||
| 578 | + | wg.Add(1) | |||
| 579 | + | go func() { defer wg.Done(); s.forwardCasts(ctx.Done()) }() | |||
| 580 | + | } | |||
| 581 | + | s.castInventoryChanged() | |||
| 552 | err := s.display.Run(ctx) | 582 | err := s.display.Run(ctx) | ||
| 553 | s.stopSecurityContexts() | 583 | s.stopSecurityContexts() | ||
| 554 | wg.Wait() | 584 | wg.Wait() | ||
| @@ -706,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||||
| 706 | l.add(c) | 736 | l.add(c) | ||
| 707 | s.flushLink(l) | 737 | s.flushLink(l) | ||
| 708 | } | 738 | } | ||
| 739 | + | s.castCommitted(c.ID) | |||
| 709 | } | 740 | } | ||
| 710 | 741 | ||||
| 711 | // maxCommandBatch bounds how many queued commands one display round trip applies. | 742 | // maxCommandBatch bounds how many queued commands one display round trip applies. | ||
M internal/adapters/wayland/session_lock.go +1 -0
| @@ -91,6 +91,7 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) { | |||||
| 91 | s.lockReadiness.End(l.state.Generation) | 91 | s.lockReadiness.End(l.state.Generation) | ||
| 92 | s.sessionLock = nil | 92 | s.sessionLock = nil | ||
| 93 | s.refreshToplevels() | 93 | s.refreshToplevels() | ||
| 94 | + | s.castInventoryChanged() // the windows are listed again | |||
| 94 | s.updateWorkspaceManagers(s.workspaceSnapshot) | 95 | s.updateWorkspaceManagers(s.workspaceSnapshot) | ||
| 95 | for _, surface := range l.surfaces { | 96 | for _, surface := range l.surfaces { | ||
| 96 | surface.unmap() | 97 | surface.unmap() | ||
M internal/adapters/wayland/xdg.go +5 -0
| @@ -249,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 249 | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1}) | 249 | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1}) | ||
| 250 | x.server.syncInhibitors() | 250 | x.server.syncInhibitors() | ||
| 251 | x.server.toplevelChanged(w) | 251 | x.server.toplevelChanged(w) | ||
| 252 | + | x.server.castInventoryChanged() | |||
| 252 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | 253 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | ||
| 253 | } else if !buffer && w.mapped { | 254 | } else if !buffer && w.mapped { | ||
| 254 | w.unmap() | 255 | w.unmap() | ||
| @@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |||||
| 339 | } | 340 | } | ||
| 340 | t.w.title = title | 341 | t.w.title = title | ||
| 341 | t.w.xdg.server.toplevelChanged(t.w) | 342 | t.w.xdg.server.toplevelChanged(t.w) | ||
| 343 | + | if t.w.mapped { | |||
| 344 | + | t.w.xdg.server.castInventoryChanged() | |||
| 345 | + | } | |||
| 342 | } | 346 | } | ||
| 343 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | 347 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | ||
| 344 | if appID == t.w.appID { | 348 | if appID == t.w.appID { | ||
| @@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||||
| 348 | if t.w.mapped { | 352 | if t.w.mapped { | ||
| 349 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | 353 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | ||
| 350 | t.w.xdg.server.toplevelChanged(t.w) | 354 | t.w.xdg.server.toplevelChanged(t.w) | ||
| 355 | + | t.w.xdg.server.castInventoryChanged() | |||
| 351 | } | 356 | } | ||
| 352 | } | 357 | } | ||
| 353 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | 358 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | ||
M internal/app/app.go +5 -1
| @@ -27,7 +27,11 @@ type Options struct { | |||||
| 27 | HeadlessHDR bool | 27 | HeadlessHDR bool | ||
| 28 | // Session exports the displays to D-Bus and systemd user services | 28 | // Session exports the displays to D-Bus and systemd user services | ||
| 29 | // before any client starts, and withdraws them on exit. | 29 | // before any client starts, and withdraws them on exit. | ||
| 30 | - | Session 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 | ScreenshotDir string | 35 | ScreenshotDir string | ||
| 32 | // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a | 36 | // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a | ||
| 33 | // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR). | 37 | // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR). | ||
A internal/app/screencast_test.go +442 -0
| @@ -0,0 +1,442 @@ | |||||
| 1 | + | //go:build screencast | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "runtime" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "sync/atomic" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 20 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 21 | + | "github.com/bnema/zerobus" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // The screencast tests need a session bus of their own (run them under | |||
| 25 | + | // dbus-run-session), the user's PipeWire and gst-launch-1.0 as the consumer. | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | scName = "org.gnome.Mutter.ScreenCast" | |||
| 29 | + | scPath = "/org/gnome/Mutter/ScreenCast" | |||
| 30 | + | scSession = "org.gnome.Mutter.ScreenCast.Session" | |||
| 31 | + | scStream = "org.gnome.Mutter.ScreenCast.Stream" | |||
| 32 | + | scDenied = "org.freedesktop.DBus.Error.AccessDenied" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // castApp is a headless NeferWL serving the D-Bus services. | |||
| 36 | + | type castApp struct { | |||
| 37 | + | input chan<- ports.InputEvent | |||
| 38 | + | stop func() | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func requireCastEnv(t *testing.T) { | |||
| 42 | + | t.Helper() | |||
| 43 | + | if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" { | |||
| 44 | + | t.Skip("no session bus: run under dbus-run-session") | |||
| 45 | + | } | |||
| 46 | + | if _, err := exec.LookPath("gst-launch-1.0"); err != nil { | |||
| 47 | + | t.Skip("gst-launch-1.0 unavailable") | |||
| 48 | + | } | |||
| 49 | + | if err := exec.Command("pw-dump").Run(); err != nil { | |||
| 50 | + | t.Skipf("PipeWire is not running: %v", err) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // startCastApp runs the compositor with the capture allowlist content. The | |||
| 55 | + | // PipeWire socket stays where it is: only the Wayland socket and the state | |||
| 56 | + | // move to temporary directories. | |||
| 57 | + | func startCastApp(t *testing.T, allow string) *castApp { | |||
| 58 | + | t.Helper() | |||
| 59 | + | t.Setenv("PIPEWIRE_RUNTIME_DIR", os.Getenv("XDG_RUNTIME_DIR")) | |||
| 60 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 61 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 62 | + | t.Setenv("HOME", t.TempDir()) | |||
| 63 | + | allowPath := filepath.Join(t.TempDir(), "capture-allow") | |||
| 64 | + | if err := os.WriteFile(allowPath, []byte(allow), 0o644); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 68 | + | inputReady := make(chan chan<- ports.InputEvent, 1) | |||
| 69 | + | done := make(chan error, 1) | |||
| 70 | + | go func() { | |||
| 71 | + | done <- run(ctx, Options{Backend: "headless", DBus: true, NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{320, 240}}, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())}, | |||
| 72 | + | func(in chan<- ports.InputEvent) { inputReady <- in }) | |||
| 73 | + | }() | |||
| 74 | + | a := &castApp{} | |||
| 75 | + | select { | |||
| 76 | + | case a.input = <-inputReady: | |||
| 77 | + | case <-time.After(10 * time.Second): | |||
| 78 | + | cancel() | |||
| 79 | + | t.Fatal("app did not start") | |||
| 80 | + | } | |||
| 81 | + | a.stop = func() { | |||
| 82 | + | cancel() | |||
| 83 | + | select { | |||
| 84 | + | case err := <-done: | |||
| 85 | + | if err != nil && !strings.Contains(err.Error(), "context canceled") { | |||
| 86 | + | t.Errorf("Run: %v", err) | |||
| 87 | + | } | |||
| 88 | + | case <-time.After(10 * time.Second): | |||
| 89 | + | t.Error("Run did not stop") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | t.Cleanup(a.stop) | |||
| 93 | + | return a | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // castClient is a client of the D-Bus services. | |||
| 97 | + | func castClient(t *testing.T) *zerobus.Conn { | |||
| 98 | + | t.Helper() | |||
| 99 | + | c, err := zerobus.SessionBus() | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 104 | + | return c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func waitOwner(t *testing.T, c *zerobus.Conn, name string) { | |||
| 108 | + | t.Helper() | |||
| 109 | + | for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { | |||
| 110 | + | e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s") | |||
| 111 | + | e.Str(name) | |||
| 112 | + | m, err := c.Call() | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | if m.Body().Bool() { | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | t.Fatalf("%s never appeared", name) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func emptyProps(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) } | |||
| 124 | + | ||||
| 125 | + | // call returns the error name, "" on success. | |||
| 126 | + | func call(c *zerobus.Conn, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) { | |||
| 127 | + | e := c.NewCall(scName, path, iface, member, sig) | |||
| 128 | + | if body != nil { | |||
| 129 | + | body(e) | |||
| 130 | + | } | |||
| 131 | + | m, err := c.Call() | |||
| 132 | + | if err == nil { | |||
| 133 | + | return "", m | |||
| 134 | + | } | |||
| 135 | + | if de, ok := err.(*zerobus.Error); ok { | |||
| 136 | + | return de.Name, m | |||
| 137 | + | } | |||
| 138 | + | return err.Error(), m | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func recordMonitor(c *zerobus.Conn, session, connector string) (string, string) { | |||
| 142 | + | name, m := call(c, session, scSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) { | |||
| 143 | + | e.Str(connector) | |||
| 144 | + | a := e.BeginArray('{') | |||
| 145 | + | e.Struct() | |||
| 146 | + | e.Str("cursor-mode") | |||
| 147 | + | e.Variant("u") | |||
| 148 | + | e.Uint32(1) | |||
| 149 | + | e.EndArray(a) | |||
| 150 | + | }) | |||
| 151 | + | if name != "" { | |||
| 152 | + | return name, "" | |||
| 153 | + | } | |||
| 154 | + | return "", strings.Clone(m.Body().ObjectPath()) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // A monitor cast streams live to a PipeWire consumer, allocates next to | |||
| 158 | + | // nothing per frame, refuses another client's calls and ends on Stop. | |||
| 159 | + | func TestScreencastStreamsMonitor(t *testing.T) { | |||
| 160 | + | requireCastEnv(t) | |||
| 161 | + | app := startCastApp(t, "*\n") | |||
| 162 | + | c := castClient(t) | |||
| 163 | + | waitOwner(t, c, scName) | |||
| 164 | + | // The inventory reaches the service after the outputs appear. | |||
| 165 | + | var session, stream string | |||
| 166 | + | for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(50 * time.Millisecond) { | |||
| 167 | + | name, m := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps) | |||
| 168 | + | if name != "" { | |||
| 169 | + | t.Fatalf("CreateSession: %s", name) | |||
| 170 | + | } | |||
| 171 | + | session = strings.Clone(m.Body().ObjectPath()) | |||
| 172 | + | var errName string | |||
| 173 | + | if errName, stream = recordMonitor(c, session, "HEADLESS-1"); errName == "" { | |||
| 174 | + | break | |||
| 175 | + | } | |||
| 176 | + | call(c, session, scSession, "Stop", "", nil) | |||
| 177 | + | if time.Now().After(deadline) { | |||
| 178 | + | t.Fatalf("RecordMonitor: %s", errName) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | rule := "type='signal',interface='" + scStream + "'" | |||
| 182 | + | if err := c.AddMatch(rule); err != nil { | |||
| 183 | + | t.Fatal(err) | |||
| 184 | + | } | |||
| 185 | + | if err := c.AddMatch("type='signal',interface='" + scSession + "'"); err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | if name, _ := call(c, session, scSession, "Start", "", nil); name != "" { | |||
| 189 | + | t.Fatalf("Start: %s", name) | |||
| 190 | + | } | |||
| 191 | + | node := uint32(0) | |||
| 192 | + | for node == 0 { | |||
| 193 | + | m, err := c.ReadMessage() | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatal(err) | |||
| 196 | + | } | |||
| 197 | + | if m.Member == "PipeWireStreamAdded" && m.Path == stream && m.Destination == c.UniqueName() { | |||
| 198 | + | node = m.Body().Uint32() | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Pointer motion damages the output while the consumer runs. The events | |||
| 203 | + | // are boxed once: sending them allocates nothing. | |||
| 204 | + | stopMoving := make(chan struct{}) | |||
| 205 | + | var stopOnce sync.Once | |||
| 206 | + | stopMotion := func() { stopOnce.Do(func() { close(stopMoving) }) } | |||
| 207 | + | a := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 40, Y: 40}}) | |||
| 208 | + | b := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 200, Y: 160}}) | |||
| 209 | + | moved := make(chan struct{}) | |||
| 210 | + | go func() { | |||
| 211 | + | defer close(moved) | |||
| 212 | + | tick := time.NewTicker(16 * time.Millisecond) | |||
| 213 | + | defer tick.Stop() | |||
| 214 | + | for i := 0; ; i++ { | |||
| 215 | + | ev := a | |||
| 216 | + | if i%2 == 1 { | |||
| 217 | + | ev = b | |||
| 218 | + | } | |||
| 219 | + | // Neither the send nor the wait may outlive the test: the app | |||
| 220 | + | // may have stopped reading. | |||
| 221 | + | select { | |||
| 222 | + | case app.input <- ev: | |||
| 223 | + | case <-stopMoving: | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | select { | |||
| 227 | + | case <-tick.C: | |||
| 228 | + | case <-stopMoving: | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | }() | |||
| 233 | + | defer func() { stopMotion(); <-moved }() | |||
| 234 | + | ||||
| 235 | + | gst := exec.Command("gst-launch-1.0", "-v", "pipewiresrc", "path="+itoa(node), "stream-properties=props,media.type=Video", "num-buffers=300", "!", "fakesink", "silent=false") | |||
| 236 | + | out, err := gst.StdoutPipe() | |||
| 237 | + | if err != nil { | |||
| 238 | + | t.Fatal(err) | |||
| 239 | + | } | |||
| 240 | + | if err := gst.Start(); err != nil { | |||
| 241 | + | t.Fatal(err) | |||
| 242 | + | } | |||
| 243 | + | var frames atomic.Int64 | |||
| 244 | + | go func() { | |||
| 245 | + | // Counted on raw reads: a line per frame would allocate a string. | |||
| 246 | + | buf := make([]byte, 64<<10) | |||
| 247 | + | for { | |||
| 248 | + | n, err := out.Read(buf) | |||
| 249 | + | frames.Add(int64(bytes.Count(buf[:n], []byte("chain ")))) | |||
| 250 | + | if err != nil { | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | }() | |||
| 255 | + | exited := make(chan error, 1) | |||
| 256 | + | go func() { exited <- gst.Wait() }() | |||
| 257 | + | ||||
| 258 | + | // Steady state: 5 seconds after the stream warmed up. Every allocation is | |||
| 259 | + | // recorded (MemProfileRate 1) and attributed by its stack: the cast path | |||
| 260 | + | // (screencast, wayland casts, purego-pipewire, zerobus) must allocate | |||
| 261 | + | // less than once per frame, and so must the renderer capture path it | |||
| 262 | + | // shares with ext-image-copy-capture (BeginCapture, EndCapture and the | |||
| 263 | + | // pipeline worker). The headless output's own display frame (Render's | |||
| 264 | + | // sync file, the flip report, injected input) allocates whether or not | |||
| 265 | + | // a cast runs: it is reported in the whole-process figure, not asserted. | |||
| 266 | + | time.Sleep(2 * time.Second) | |||
| 267 | + | runtime.MemProfileRate = 1 | |||
| 268 | + | f0 := frames.Load() | |||
| 269 | + | before := allocSnapshot() | |||
| 270 | + | var m0, m1 runtime.MemStats | |||
| 271 | + | runtime.ReadMemStats(&m0) | |||
| 272 | + | time.Sleep(5 * time.Second) | |||
| 273 | + | runtime.ReadMemStats(&m1) | |||
| 274 | + | after := allocSnapshot() | |||
| 275 | + | f1 := frames.Load() | |||
| 276 | + | runtime.MemProfileRate = 512 * 1024 | |||
| 277 | + | n := f1 - f0 | |||
| 278 | + | if n < 60 { | |||
| 279 | + | t.Errorf("only %d frames in 5 s", n) | |||
| 280 | + | } else { | |||
| 281 | + | cast, other := after.sub(before) | |||
| 282 | + | per := float64(cast) / float64(n) | |||
| 283 | + | t.Logf("%d frames: cast path %.3f mallocs per frame, whole process %.2f (renderer and runtime included; %d other profiled)", | |||
| 284 | + | n, per, float64(m1.Mallocs-m0.Mallocs)/float64(n), other) | |||
| 285 | + | if per >= 1 { | |||
| 286 | + | t.Errorf("cast path: %.2f mallocs per frame over %d frames, want < 1", per, n) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // The consumer got its 300 buffers and left. | |||
| 291 | + | select { | |||
| 292 | + | case err := <-exited: | |||
| 293 | + | if err != nil { | |||
| 294 | + | t.Errorf("gst-launch-1.0: %v", err) | |||
| 295 | + | } | |||
| 296 | + | case <-time.After(60 * time.Second): | |||
| 297 | + | _ = gst.Process.Kill() | |||
| 298 | + | t.Fatal("gst-launch-1.0 did not finish") | |||
| 299 | + | } | |||
| 300 | + | stopMotion() | |||
| 301 | + | ||||
| 302 | + | // A second client is another unique name: it is allowed (the list is | |||
| 303 | + | // off) but the session is not its own. | |||
| 304 | + | other := castClient(t) | |||
| 305 | + | if name, _ := call(other, session, scSession, "Stop", "", nil); name != scDenied { | |||
| 306 | + | t.Errorf("Stop by another client: %q", name) | |||
| 307 | + | } | |||
| 308 | + | if name, _ := call(other, stream, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str(scStream) }); name != scDenied { | |||
| 309 | + | t.Errorf("stream GetAll by another client: %q", name) | |||
| 310 | + | } | |||
| 311 | + | // A window that is not mapped cannot be recorded. | |||
| 312 | + | if name, _ := call(c, session, scSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) { | |||
| 313 | + | a := e.BeginArray('{') | |||
| 314 | + | e.Struct() | |||
| 315 | + | e.Str("window-id") | |||
| 316 | + | e.Variant("t") | |||
| 317 | + | e.Uint64(99999) | |||
| 318 | + | e.EndArray(a) | |||
| 319 | + | }); name == "" { | |||
| 320 | + | t.Error("RecordWindow of a closed window succeeded") | |||
| 321 | + | } | |||
| 322 | + | if name, _ := call(c, session, scSession, "Stop", "", nil); name != "" { | |||
| 323 | + | t.Fatalf("Stop: %s", name) | |||
| 324 | + | } | |||
| 325 | + | for closed := false; !closed; { | |||
| 326 | + | m, err := c.ReadMessage() | |||
| 327 | + | if err != nil { | |||
| 328 | + | t.Fatal(err) | |||
| 329 | + | } | |||
| 330 | + | closed = m.Member == "Closed" && m.Path == session | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // A caller whose executable is not on the list gets AccessDenied from | |||
| 335 | + | // every object, and nothing is captured. | |||
| 336 | + | func TestScreencastAllowlistDenies(t *testing.T) { | |||
| 337 | + | requireCastEnv(t) | |||
| 338 | + | startCastApp(t, "/usr/bin/grim\n") | |||
| 339 | + | c := castClient(t) | |||
| 340 | + | waitOwner(t, c, scName) | |||
| 341 | + | if name, _ := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps); name != scDenied { | |||
| 342 | + | t.Errorf("CreateSession: %q", name) | |||
| 343 | + | } | |||
| 344 | + | if name, _ := call(c, scPath, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str("org.gnome.Mutter.ScreenCast") }); name != scDenied { | |||
| 345 | + | t.Errorf("GetAll: %q", name) | |||
| 346 | + | } | |||
| 347 | + | for _, x := range []struct{ dest, path, iface, member string }{ | |||
| 348 | + | {"org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"}, | |||
| 349 | + | {"org.gnome.Shell.Introspect", "/org/gnome/Shell/Introspect", "org.gnome.Shell.Introspect", "GetWindows"}, | |||
| 350 | + | {"org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", "InteractiveScreenshot"}, | |||
| 351 | + | } { | |||
| 352 | + | e := c.NewCall(x.dest, x.path, x.iface, x.member, "") | |||
| 353 | + | _ = e | |||
| 354 | + | if _, err := c.Call(); err == nil || !strings.Contains(err.Error(), scDenied) { | |||
| 355 | + | t.Errorf("%s: %v", x.member, err) | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) } | |||
| 361 | + | ||||
| 362 | + | // castPath names the packages whose allocations the cast path owns. | |||
| 363 | + | var castPath = []string{ | |||
| 364 | + | "internal/adapters/screencast.", | |||
| 365 | + | "purego-pipewire", | |||
| 366 | + | "bnema/zerobus", | |||
| 367 | + | "wayland.(*castSession)", | |||
| 368 | + | "wayland.(*Server).requestCast", | |||
| 369 | + | "wayland.(*Server).castFlip", | |||
| 370 | + | "wayland.(*Server).forwardCasts", | |||
| 371 | + | "wayland.(*Server).forwardCaptured", | |||
| 372 | + | "wayland.postRing", | |||
| 373 | + | "outputkit/capture.writeFrame", | |||
| 374 | + | // The renderer capture path, shared with ext-image-copy-capture and | |||
| 375 | + | // screencopy: lease, copy, read, return. | |||
| 376 | + | "vulkan.(*Renderer).BeginCapture", | |||
| 377 | + | "vulkan.(*Renderer).EndCapture", | |||
| 378 | + | "outputkit/capture.(*Pipeline).SubmitScoped", | |||
| 379 | + | "outputkit/capture.(*Pipeline).process", | |||
| 380 | + | "outputkit/capture.(*Pipeline).Recycle", | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | // allocs counts profiled allocation objects by stack, for the cast path and | |||
| 384 | + | // for the rest. | |||
| 385 | + | type allocs struct { | |||
| 386 | + | cast, other map[string]int64 | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func allocSnapshot() allocs { | |||
| 390 | + | runtime.GC() // the memory profile is as of the last completed GC | |||
| 391 | + | var recs []runtime.MemProfileRecord | |||
| 392 | + | for { | |||
| 393 | + | recs = make([]runtime.MemProfileRecord, 4096) | |||
| 394 | + | n, ok := runtime.MemProfile(recs, true) | |||
| 395 | + | if ok { | |||
| 396 | + | recs = recs[:n] | |||
| 397 | + | break | |||
| 398 | + | } | |||
| 399 | + | recs = make([]runtime.MemProfileRecord, n+256) | |||
| 400 | + | if n, ok = runtime.MemProfile(recs, true); ok { | |||
| 401 | + | recs = recs[:n] | |||
| 402 | + | break | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | a := allocs{cast: map[string]int64{}, other: map[string]int64{}} | |||
| 406 | + | for i := range recs { | |||
| 407 | + | pcs := recs[i].Stack() | |||
| 408 | + | frames := runtime.CallersFrames(pcs) | |||
| 409 | + | var key strings.Builder | |||
| 410 | + | isCast := false | |||
| 411 | + | for { | |||
| 412 | + | f, more := frames.Next() | |||
| 413 | + | key.WriteString(f.Function) | |||
| 414 | + | key.WriteByte(';') | |||
| 415 | + | for _, p := range castPath { | |||
| 416 | + | if strings.Contains(f.Function, p) { | |||
| 417 | + | isCast = true | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | if !more { | |||
| 421 | + | break | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | if isCast { | |||
| 425 | + | a.cast[key.String()] += recs[i].AllocObjects | |||
| 426 | + | } else { | |||
| 427 | + | a.other[key.String()] += recs[i].AllocObjects | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | return a | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // sub returns the objects allocated between two snapshots. | |||
| 434 | + | func (a allocs) sub(b allocs) (cast, other int64) { | |||
| 435 | + | for k, v := range a.cast { | |||
| 436 | + | cast += v - b.cast[k] | |||
| 437 | + | } | |||
| 438 | + | for k, v := range a.other { | |||
| 439 | + | other += v - b.other[k] | |||
| 440 | + | } | |||
| 441 | + | return cast, other | |||
| 442 | + | } | |||
M internal/app/session.go +23 -3
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/bnema/neferwl/internal/adapters/logging" | 19 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| 20 | "github.com/bnema/neferwl/internal/adapters/powerkey" | 20 | "github.com/bnema/neferwl/internal/adapters/powerkey" | ||
| 21 | "github.com/bnema/neferwl/internal/adapters/sched" | 21 | "github.com/bnema/neferwl/internal/adapters/sched" | ||
| 22 | + | "github.com/bnema/neferwl/internal/adapters/screencast" | |||
| 22 | "github.com/bnema/neferwl/internal/adapters/screensaver" | 23 | "github.com/bnema/neferwl/internal/adapters/screensaver" | ||
| 23 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | 24 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | ||
| 24 | "github.com/bnema/neferwl/internal/adapters/statefile" | 25 | "github.com/bnema/neferwl/internal/adapters/statefile" | ||
| @@ -52,6 +53,7 @@ type session struct { | |||||
| 52 | dmabuf ports.DMABufSupport | 53 | dmabuf ports.DMABufSupport | ||
| 53 | wsIDs *workspaceid.IDs | 54 | wsIDs *workspaceid.IDs | ||
| 54 | captureAllow *captureallow.Store | 55 | captureAllow *captureallow.Store | ||
| 56 | + | cast *screencast.Service // nil without the D-Bus services | |||
| 55 | server *wayland.Server | 57 | server *wayland.Server | ||
| 56 | childEnv []string | 58 | childEnv []string | ||
| 57 | statePath string | 59 | statePath string | ||
| @@ -113,8 +115,16 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 113 | } else { | 115 | } else { | ||
| 114 | s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog) | 116 | s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog) | ||
| 115 | } | 117 | } | ||
| 118 | + | // Only a real session (or a test that asks) serves the session bus: | |||
| 119 | + | // headless runs would take the names from the desktop they run in. | |||
| 120 | + | var casts ports.CastSink | |||
| 121 | + | if s.hw != nil || opts.DBus { | |||
| 122 | + | s.cast = screencast.New(s.captureAllow.Policy, s.p.castCommands, logging.For(ctx, "screencast")) | |||
| 123 | + | casts = s.cast | |||
| 124 | + | } | |||
| 116 | if s.server, err = wayland.New(wayland.Options{ | 125 | if s.server, err = wayland.New(wayland.Options{ | ||
| 117 | CaptureAllow: s.captureAllow, | 126 | CaptureAllow: s.captureAllow, | ||
| 127 | + | Casts: casts, | |||
| 118 | WorkspaceIDs: s.wsIDs, | 128 | WorkspaceIDs: s.wsIDs, | ||
| 119 | Security: s.security, | 129 | Security: s.security, | ||
| 120 | RuntimeDir: runtimeDir, | 130 | RuntimeDir: runtimeDir, | ||
| @@ -374,14 +384,24 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||||
| 374 | if hw != nil { | 384 | if hw != nil { | ||
| 375 | workers.Add(1) | 385 | workers.Add(1) | ||
| 376 | go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }() | 386 | go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }() | ||
| 377 | - | // Only a real session serves the session bus: headless runs would take | 387 | + | } |
| 378 | - | // the name from the desktop they run in. | 388 | + | // Only a real session serves the session bus: headless runs would take |
| 379 | - | workers.Add(1) | 389 | + | // the names from the desktop they run in, unless a test asks (DBus). |
| 390 | + | if hw != nil || opts.DBus { | |||
| 391 | + | workers.Add(2) | |||
| 380 | go func() { | 392 | go func() { | ||
| 381 | defer workers.Done() | 393 | defer workers.Done() | ||
| 382 | reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity} | 394 | reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity} | ||
| 383 | screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver")) | 395 | screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver")) | ||
| 384 | }() | 396 | }() | ||
| 397 | + | go func() { | |||
| 398 | + | defer workers.Done() | |||
| 399 | + | // nefercap finds this compositor through the session environment. | |||
| 400 | + | s.cast.SetEnv(childEnv) | |||
| 401 | + | s.cast.Run(ctx, busRetry) | |||
| 402 | + | }() | |||
| 403 | + | } | |||
| 404 | + | if hw != nil { | |||
| 385 | // The same goes for the system bus: logind's locks keep the power | 405 | // The same goes for the system bus: logind's locks keep the power | ||
| 386 | // keys from acting before a bind sees them. | 406 | // keys from acting before a bind sees them. | ||
| 387 | workers.Add(1) | 407 | workers.Add(1) | ||
M internal/app/wiring.go +7 -0
| @@ -63,6 +63,10 @@ type pipes struct { | |||||
| 63 | // coalesces them. | 63 | // coalesces them. | ||
| 64 | idleInhibited chan bool | 64 | idleInhibited chan bool | ||
| 65 | idleActivity chan struct{} | 65 | idleActivity chan struct{} | ||
| 66 | + | ||||
| 67 | + | // Screencast commands to wayland (the screencast adapter's side is the | |||
| 68 | + | // sender); the events come back through wayland.Options.Casts. | |||
| 69 | + | castCommands chan ports.CastCommand | |||
| 66 | } | 70 | } | ||
| 67 | 71 | ||||
| 68 | func newPipes(realSession bool) *pipes { | 72 | func newPipes(realSession bool) *pipes { | ||
| @@ -105,6 +109,8 @@ func newPipes(realSession bool) *pipes { | |||||
| 105 | 109 | ||||
| 106 | idleInhibited: make(chan bool, 1), | 110 | idleInhibited: make(chan bool, 1), | ||
| 107 | idleActivity: make(chan struct{}, 1), | 111 | idleActivity: make(chan struct{}, 1), | ||
| 112 | + | ||||
| 113 | + | castCommands: make(chan ports.CastCommand, 64), | |||
| 108 | } | 114 | } | ||
| 109 | if realSession { | 115 | if realSession { | ||
| 110 | p.scales = make(chan ports.ScaleChanged, 8) | 116 | p.scales = make(chan ports.ScaleChanged, 8) | ||
| @@ -152,6 +158,7 @@ func (p *pipes) wayland() wayland.Channels { | |||||
| 152 | OutputApplied: p.appliedOutput, | 158 | OutputApplied: p.appliedOutput, | ||
| 153 | IdleInhibited: p.idleInhibited, | 159 | IdleInhibited: p.idleInhibited, | ||
| 154 | IdleActivity: p.idleActivity, | 160 | IdleActivity: p.idleActivity, | ||
| 161 | + | CastCommands: p.castCommands, | |||
| 155 | } | 162 | } | ||
| 156 | } | 163 | } | ||
| 157 | 164 | ||||
M internal/cli/run.go +7 -3
| @@ -24,9 +24,9 @@ import ( | |||||
| 24 | 24 | ||||
| 25 | // runFlags are the root command flags. | 25 | // runFlags are the root command flags. | ||
| 26 | type runFlags struct { | 26 | type runFlags struct { | ||
| 27 | - | backend, screenshot, size, input, debug, config, pprof string | 27 | + | backend, screenshot, size, input, debug, config, pprof string |
| 28 | - | noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool | 28 | + | noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool |
| 29 | - | timeout time.Duration | 29 | + | timeout time.Duration |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | func (f *runFlags) register(fs *flag.FlagSet) { | 32 | func (f *runFlags) register(fs *flag.FlagSet) { | ||
| @@ -42,6 +42,7 @@ func (f *runFlags) register(fs *flag.FlagSet) { | |||||
| 42 | fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)") | 42 | fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)") | ||
| 43 | fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)") | 43 | fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)") | ||
| 44 | fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | 44 | fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | ||
| 45 | + | fs.BoolVar(&f.dbus, "dbus", false, "headless: serve the session D-Bus services; for tests under dbus-run-session") | |||
| 45 | fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | 46 | fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| @@ -51,6 +52,8 @@ func (f *runFlags) validate(args []string) ([][2]int, error) { | |||||
| 51 | switch { | 52 | switch { | ||
| 52 | case f.backend != "drm" && !headless: | 53 | case f.backend != "drm" && !headless: | ||
| 53 | return nil, usagef("invalid backend %q", f.backend) | 54 | return nil, usagef("invalid backend %q", f.backend) | ||
| 55 | + | case f.dbus && !headless: | |||
| 56 | + | return nil, usagef("--dbus requires --backend=headless") | |||
| 54 | case f.headlessHDR && !headless: | 57 | case f.headlessHDR && !headless: | ||
| 55 | return nil, usagef("--headless-hdr requires --backend=headless") | 58 | return nil, usagef("--headless-hdr requires --backend=headless") | ||
| 56 | case f.screenshot != "" && !headless: | 59 | case f.screenshot != "" && !headless: | ||
| @@ -154,6 +157,7 @@ func (f *runFlags) run(ctx context.Context, sizes [][2]int) error { | |||||
| 154 | NoXwayland: f.noXwayland, | 157 | NoXwayland: f.noXwayland, | ||
| 155 | HeadlessHDR: f.headlessHDR, | 158 | HeadlessHDR: f.headlessHDR, | ||
| 156 | Session: f.session, | 159 | Session: f.session, | ||
| 160 | + | DBus: f.dbus, | |||
| 157 | ScreenshotDir: f.screenshot, | 161 | ScreenshotDir: f.screenshot, | ||
| 158 | ScreenshotRaw: f.screenshotRaw, | 162 | ScreenshotRaw: f.screenshotRaw, | ||
| 159 | Sizes: sizes, | 163 | Sizes: sizes, | ||
A internal/mocks/ports/screencast_mock.go +126 -0
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package portsmocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewMockCastSink creates a new instance of MockCastSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewMockCastSink(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *MockCastSink { | |||
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | + | helper.Helper() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | mock := &MockCastSink{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | + | helper.Helper() | |||
| 28 | + | } | |||
| 29 | + | mock.AssertExpectations(t) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | return mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MockCastSink is an autogenerated mock type for the CastSink type | |||
| 36 | + | type MockCastSink struct { | |||
| 37 | + | mock.Mock | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type MockCastSink_Expecter struct { | |||
| 41 | + | mock *mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (_m *MockCastSink) EXPECT() *MockCastSink_Expecter { | |||
| 45 | + | return &MockCastSink_Expecter{mock: &_m.Mock} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Inventory provides a mock function for the type MockCastSink | |||
| 49 | + | func (_mock *MockCastSink) Inventory(castInventory ports.CastInventory) { | |||
| 50 | + | _mock.Called(castInventory) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // MockCastSink_Inventory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inventory' | |||
| 55 | + | type MockCastSink_Inventory_Call struct { | |||
| 56 | + | *mock.Call | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Inventory is a helper method to define mock.On call | |||
| 60 | + | // - castInventory ports.CastInventory | |||
| 61 | + | func (_e *MockCastSink_Expecter) Inventory(castInventory any) *MockCastSink_Inventory_Call { | |||
| 62 | + | return &MockCastSink_Inventory_Call{Call: _e.mock.On("Inventory", castInventory)} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (_c *MockCastSink_Inventory_Call) Run(run func(castInventory ports.CastInventory)) *MockCastSink_Inventory_Call { | |||
| 66 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 67 | + | var arg0 ports.CastInventory | |||
| 68 | + | if args[0] != nil { | |||
| 69 | + | arg0 = args[0].(ports.CastInventory) | |||
| 70 | + | } | |||
| 71 | + | run( | |||
| 72 | + | arg0, | |||
| 73 | + | ) | |||
| 74 | + | }) | |||
| 75 | + | return _c | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *MockCastSink_Inventory_Call) Return() *MockCastSink_Inventory_Call { | |||
| 79 | + | _c.Call.Return() | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (_c *MockCastSink_Inventory_Call) RunAndReturn(run func(castInventory ports.CastInventory)) *MockCastSink_Inventory_Call { | |||
| 84 | + | _c.Run(run) | |||
| 85 | + | return _c | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Push provides a mock function for the type MockCastSink | |||
| 89 | + | func (_mock *MockCastSink) Push(castEvent ports.CastEvent) { | |||
| 90 | + | _mock.Called(castEvent) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // MockCastSink_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push' | |||
| 95 | + | type MockCastSink_Push_Call struct { | |||
| 96 | + | *mock.Call | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Push is a helper method to define mock.On call | |||
| 100 | + | // - castEvent ports.CastEvent | |||
| 101 | + | func (_e *MockCastSink_Expecter) Push(castEvent any) *MockCastSink_Push_Call { | |||
| 102 | + | return &MockCastSink_Push_Call{Call: _e.mock.On("Push", castEvent)} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (_c *MockCastSink_Push_Call) Run(run func(castEvent ports.CastEvent)) *MockCastSink_Push_Call { | |||
| 106 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 107 | + | var arg0 ports.CastEvent | |||
| 108 | + | if args[0] != nil { | |||
| 109 | + | arg0 = args[0].(ports.CastEvent) | |||
| 110 | + | } | |||
| 111 | + | run( | |||
| 112 | + | arg0, | |||
| 113 | + | ) | |||
| 114 | + | }) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *MockCastSink_Push_Call) Return() *MockCastSink_Push_Call { | |||
| 119 | + | _c.Call.Return() | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (_c *MockCastSink_Push_Call) RunAndReturn(run func(castEvent ports.CastEvent)) *MockCastSink_Push_Call { | |||
| 124 | + | _c.Run(run) | |||
| 125 | + | return _c | |||
| 126 | + | } | |||
M internal/ports/capture_messages.go +5 -0
| @@ -14,6 +14,11 @@ type CaptureRequest struct { | |||||
| 14 | Dst SHMBuffer | 14 | Dst SHMBuffer | ||
| 15 | Width, Height, Stride int | 15 | Width, Height, Stride int | ||
| 16 | Format uint32 | 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 | // Session is the registered capture session (CaptureSessionOpen) the | 22 | // Session is the registered capture session (CaptureSessionOpen) the | ||
| 18 | // request belongs to, 0 for wlr-screencopy. It is informational, except | 23 | // request belongs to, 0 for wlr-screencopy. It is informational, except | ||
| 19 | // with Exclude. | 24 | // with Exclude. | ||
M internal/ports/devices.go +2 -1
| @@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) | |||||
| 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | ||
| 27 | type CaptureFrame interface { | 27 | type CaptureFrame interface { | ||
| 28 | // Done is the sync file signalled when the copy finished; nil means it | 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 | Done() *os.File | 31 | Done() *os.File | ||
| 31 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | 32 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | ||
| 32 | // dst, stride bytes per row. Valid after Done signalled and before | 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 | 'foot: default terminal' | 14 | 'foot: default terminal' | ||
| 15 | 'fuzzel: launcher' | 15 | 'fuzzel: launcher' | ||
| 16 | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | 16 | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | ||
| 17 | - | 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal' | 17 | + | 'xdg-desktop-portal-gnome: screen sharing and screenshots through the portal' |
| 18 | + | 'pipewire: screen sharing streams' | |||
| 18 | 'nefercap: screenshots and screen recording') | 19 | 'nefercap: screenshots and screen recording') | ||
| 19 | install=neferwl.install | 20 | install=neferwl.install | ||
| 20 | source=("$pkgname-$pkgver.tar.gz") | 21 | source=("$pkgname-$pkgver.tar.gz") | ||
M packaging/aur/neferwl-git/PKGBUILD +2 -1
| @@ -16,7 +16,8 @@ optdepends=('xwayland-satellite>=0.7: X11 apps' | |||||
| 16 | 'foot: default terminal' | 16 | 'foot: default terminal' | ||
| 17 | 'fuzzel: launcher' | 17 | 'fuzzel: launcher' | ||
| 18 | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | 18 | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | ||
| 19 | - | 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal' | 19 | + | 'xdg-desktop-portal-gnome: screen sharing and screenshots through the portal' |
| 20 | + | 'pipewire: screen sharing streams' | |||
| 20 | 'nefercap: screenshots and screen recording') | 21 | 'nefercap: screenshots and screen recording') | ||
| 21 | provides=('neferwl') | 22 | provides=('neferwl') | ||
| 22 | conflicts=('neferwl' 'neferwl-bin') | 23 | conflicts=('neferwl' 'neferwl-bin') | ||
M packaging/neferwl-portals.conf +2 -2
| @@ -1,4 +1,4 @@ | |||||
| 1 | [preferred] | 1 | [preferred] | ||
| 2 | default=gtk | 2 | default=gtk | ||
| 3 | - | org.freedesktop.impl.portal.ScreenCast=wlr | 3 | + | org.freedesktop.impl.portal.ScreenCast=gnome |
| 4 | - | org.freedesktop.impl.portal.Screenshot=wlr | 4 | + | org.freedesktop.impl.portal.Screenshot=gnome |