detached 78083b5

feat(capture): generic capture with compositor-imposed indicator and allowlist (#74)

* feat(workspace): expose the stable workspace ID in ext-workspace and the state file

* feat(capture): replace neferwl-capture-v1 with workspace and region sources and capture exclusion

Capture now goes through ext-image-copy-capture. neferwl_image_capture_source_manager_v1
adds workspace and output-region sources; neferwl_capture_exclusion_manager_v1 leaves a
recorder's HUD layers out of the frames of its own session (one live exclusion, token,
same-UID attach, pre-map guarantee, revision fence). Core owns sessions, the exclusion and
the hidden workspace rendered off screen; wlr-screencopy keeps working untagged. Every
capture path asks one function, mayCapture, so an allowlist can plug in later. The old
private protocol, its adapter, ports types and session/ping/record logic are removed.

* docs(capture): describe the ext-image-copy-capture sources and exclusion

* feat(capture): show a compositor-imposed indicator for every capture

Core owns a capture indicator no client can turn off: a session that produced
a frame keeps a red border on its target while it lives, and every captured
frame (ext-image-copy-capture or wlr-screencopy, any source) keeps it for one
second after, on the core clock. A workspace rendered off screen shows a pill
in the top-right corner of its output instead. The renderer draws
Scene.CaptureIndicators over the physical output only; all capture renders
(clean, plain, off-screen) leave it out. Wayland reports each captured frame
with CaptureFrameTaken. Nothing is allocated or drawn while no capture is live.

* feat(captureallow): parse and watch the screen-capture allowlist

* feat(capture): gate capture on an executable allowlist

mayCapture resolves the executable behind each client connection once
(SO_PEERPIDFD, verified after the readlink) and checks it against
/etc/neferwl/capture-allow. A missing file or a * line disables the check,
an invalid file refuses every client. The file is reloaded on change.

* feat(wayland): serve wp_security_context_v1 and refuse capture to sandboxed clients

Connections accepted on a committed context's socket are adopted as
sandboxed clients; mayCapture refuses them whatever the allowlist says, and
they cannot create nested contexts.

* build(packaging): ship a capture allowlist example and route ScreenCast and Screenshot to wlr

* docs(capture): document the capture rules, the allowlist and portals

* test(wayland): accept EPIPE on the ack that follows a fatal commit

The commit before the first ack is itself a protocol error, so the
compositor may close the connection before the test writes the later
ack. Treat EPIPE/ECONNRESET on that write as the expected race and keep
asserting the protocol error.

* test(wayland): wait for the unlocked workspace activation

The activation is delivered on the display goroutine after the commit
roundtrip, so a non-blocking receive could run first. Wait up to two
seconds instead.
commit
78083b5c189ee906b138f2b07c2b30b401dcceb9
author
bnema · · 2026-10-01 03:01 UTC
committer
GitHub · · 2026-10-01 03:01 UTC
parent
39f7a28
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
99 files · +9906 -3802
M .goreleaser.yaml +4 -0
@@ -35,6 +35,7 @@ archives:
3535 - LICENSE
3636 - README.md
3737 - examples/config
38+ - examples/capture-allow
3839 - packaging/neferwl-session
3940 - packaging/neferwl.service
4041 - packaging/neferwl-shutdown.target
@@ -144,6 +145,8 @@ aurs:
144145 - "foot: default terminal"
145146 - "fuzzel: launcher"
146147 - "xdg-desktop-portal-gtk: file pickers and settings for apps"
148+ - "xdg-desktop-portal-wlr: screen sharing and screenshots through the portal"
149+ - "nefercap: screenshots and screen recording"
147150 package: |-
148151 install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl"
149152 install -Dm755 ./packaging/neferwl-session "${pkgdir}/usr/bin/neferwl-session"
@@ -152,5 +155,6 @@ aurs:
152155 install -Dm644 ./packaging/neferwl.desktop "${pkgdir}/usr/share/wayland-sessions/neferwl.desktop"
153156 install -Dm644 ./packaging/neferwl-portals.conf "${pkgdir}/usr/share/xdg-desktop-portal/neferwl-portals.conf"
154157 install -Dm644 ./examples/config "${pkgdir}/usr/share/doc/neferwl/config.example"
158+ install -Dm644 ./examples/capture-allow "${pkgdir}/usr/share/doc/neferwl/capture-allow.example"
155159 install -Dm644 ./README.md "${pkgdir}/usr/share/doc/neferwl/README.md"
156160 install -Dm644 ./LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
M .mockery.yml +5 -0
@@ -45,6 +45,11 @@ packages:
4545 dir: internal/adapters/wayland
4646 pkgname: wayland
4747 filename: proc_env_mock_test.go
48+ peerExe:
49+ config:
50+ dir: internal/adapters/wayland
51+ pkgname: wayland
52+ filename: peer_exe_mock_test.go
4853 acquirePoller:
4954 config:
5055 dir: internal/adapters/wayland
M README.md +2 -1
@@ -27,7 +27,7 @@ The design is deliberately small, and it will stay small: no animations, no them
2727 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace.
2828 - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window.
2929 - **Multi-monitor.** Each output has its own workspaces. When an output is unplugged its workspaces move to another one, and return when it is plugged back. Fractional scale per output.
30-- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, grim, cliphist, swayidle, wlr-randr and input methods such as fcitx5 work through their standard protocols. See [Desktop integration](docs/desktop.md).
30+- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, nefercap, cliphist, swayidle, wlr-randr and input methods such as fcitx5 work through their standard protocols. See [Desktop integration](docs/desktop.md).
3131 - **Config.** One `key = value` file, reloaded on save without closing windows. A JSON state file describes outputs, workspaces and windows for scripts.
3232
3333 Config example:
@@ -111,6 +111,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
111111 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
112112 - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts.
113113 - [Session locking](docs/session-lock.md): external lockers, output confirmation, owner recovery and security limits.
114+- [Screen capture](docs/capture.md): capture protocols, the indicator, the executable allowlist, sandboxed clients and portals.
114115 - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.
115116 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
116117
A docs/capture.md +87 -0
@@ -0,0 +1,87 @@
1+# Screen capture
2+
3+NeferWL serves `zwlr_screencopy_manager_v1` and `ext_image_copy_capture_manager_v1`, with `ext_output_image_capture_source_manager_v1` (an output) and `neferwl_image_capture_source_manager_v1` (a workspace, or a region of an output). `grim` uses either protocol; recorders use ext-image-copy-capture. The sources, buffers and limits are in [Headless mode](headless.md). The source XML of the NeferWL extensions is in `internal/adapters/wayland/imagecapture/`.
4+
5+Three rules decide what a capture does. They apply to every protocol and every source.
6+
7+## The indicator
8+
9+Every capture is visible on screen, and no client can turn that off: the compositor draws a red border around the target of a capture while it lives and for one second after each captured frame. The indicator is in no captured image. Details are in [Headless mode](headless.md).
10+
11+## Who may capture
12+
13+One function of the Wayland adapter (`mayCapture`) decides, for wlr-screencopy frames, ext-image-copy-capture sessions and frames, and the attach of a layer surface to a capture exclusion. A client that may not capture gets the failure its protocol defines (`failed`, or `stopped` for a session), and nothing is captured or drawn. In order:
14+
15+1. **A locked session.** While an `ext-session-lock` lock is held, no client may capture. See [Session locking](session-lock.md).
16+2. **A sandboxed client.** A client that connects through a `wp_security_context_v1` socket (Flatpak, for instance) never captures, whatever the allowlist says: it asks the desktop portal, which runs outside the sandbox. NeferWL serves `wp_security_context_manager_v1`. It accepts connections on the socket a sandbox engine hands it until the engine closes `close_fd` or NeferWL stops, and a sandboxed client cannot create a security context itself (the `nested` protocol error). The engine must call `set_sandbox_engine` before `commit`; a context committed without it is an `invalid_metadata` protocol error, so every sandboxed client is tagged with its engine. NeferWL cannot hide globals from one client, so a sandboxed client still sees the capture globals; its requests are refused. Only capture is restricted for sandboxed clients: other privileged protocols, such as virtual keyboard, output management or data control, are not.
17+3. **The executable allowlist**, below.
18+
19+## The allowlist
20+
21+NeferWL ships a built-in allowlist. These executables may capture:
22+
23+```text
24+/usr/bin/grim
25+/usr/bin/nefercap
26+/usr/lib/xdg-desktop-portal-wlr
27+/usr/libexec/xdg-desktop-portal-wlr
28+```
29+
30+`/etc/neferwl/capture-allow` replaces the built-in list entirely when it exists. The path is fixed. It is not a config key, because a program running as your user could edit the config and give itself access; the file belongs to root. The package does not install the file.
31+
32+To allow a tool installed elsewhere, copy the example to the fixed path as root and add the path. Keep the built-in entries you still need, because the file replaces them:
33+
34+```sh
35+sudo install -Dm644 /usr/share/doc/neferwl/capture-allow.example /etc/neferwl/capture-allow
36+sudoedit /etc/neferwl/capture-allow
37+```
38+
39+Each line is the resolved executable path, the one `/proc/<pid>/exe` shows. Symlinks in the list are not followed: list the real file (`/usr/bin/python3.12`, not a `/usr/bin/python3` link to it), because the kernel reports the target of a link, never the link.
40+
41+```text
42+# one absolute executable path per line
43+/usr/bin/grim
44+/usr/bin/nefercap
45+/usr/lib/xdg-desktop-portal-wlr
46+/usr/libexec/xdg-desktop-portal-wlr
47+/opt/tools/bin/recorder
48+```
49+
50+- Blank lines and lines starting with `#` are ignored.
51+- A line with only `*` disables the check: every client may capture.
52+- A missing file means the built-in list. NeferWL logs one info line (`capture allowlist file missing, using the built-in list`) with the path. Deleting the file goes back to the built-in list.
53+- An unreadable or invalid file (a relative path, or anything that is not a path) lets no client capture. NeferWL logs an error naming the line and keeps running; fix the file and it recovers.
54+- An untrustworthy file lets no client capture either, and is logged as an error naming the problem (see below).
55+- Comparison is exact, on the cleaned path. There are no globs or directories.
56+
57+NeferWL reads the file at startup and again whenever it changes, within a fraction of a second. It watches the directory, so replacing the file atomically works. If `/etc/neferwl` does not exist yet it checks again every two seconds, so creating the directory and the file applies within about two seconds. A change applies to the next capture request of every client; sessions and frames already captured are not revoked. If the watch cannot be set up, NeferWL logs a warning and the file stays as loaded: restart to apply changes.
58+
59+### The file must be root's
60+
61+The allowlist is only as good as the ability to write it. NeferWL therefore checks, at every load:
62+
63+- the file is opened without following a symlink and without blocking, and the open descriptor must be a regular file owned by root (uid 0) and writable by nobody but its owner (no group or other write bit);
64+- every directory from `/` down to the file's (`/etc`, `/etc/neferwl`) must be a real directory, not a symlink, owned by root and not group- or other-writable (a sticky directory is accepted).
65+
66+Anything else (a symlink, a FIFO, a file or directory owned by your user or writable by others) lets no client capture, and NeferWL logs an error that names the file or directory. A missing file, or a missing `/etc/neferwl` under a trustworthy `/etc`, gives the built-in list. If `/etc` or its ancestors fail the checks, the result is the closed policy, not the built-in list.
67+
68+### What identifies a client
69+
70+The executable actually running behind the connection, not its name, app-id or `argv[0]`. The first time a client asks to capture, NeferWL takes a pidfd of the peer (`SO_PEERPIDFD`, Linux 6.5 and later) and keeps it until the client disconnects. It pins the process, so its pid cannot be reused. At every wlr-screencopy copy, every ext-image-copy-capture session creation and every exclusion attach, NeferWL reads the pid from the pidfd, reads `/proc/<pid>/exe`, then checks that the process is still alive. The verdict is never cached, only the pidfd: a program that `exec`s another binary after connecting is judged by the new one at its next request. The frames of an ext session do not read `/proc` again: they use the executable resolved at the client's latest check (the creation of a session, or a later wlr-screencopy request or session on the same connection). The current allowlist, the lock and the sandbox tag are still applied to each of them. A process that exited is refused, and so is an executable that was deleted or replaced on disk (`(deleted)`), or one that cannot be determined.
71+
72+There is no fallback for a kernel without `SO_PEERPIDFD`: the pid from `SO_PEERCRED` could be reused between the read and the check, so it identifies nobody. With a list (built-in or from the file), no client may capture on such a kernel, and NeferWL logs one warning the first time a client asks. Use a kernel 6.5 or later, or `*` to disable the check.
73+
74+A refused client is logged once per distinct executable, at info level: `capture refused` with the pid, the executable path and the reason. No token or request content is logged.
75+
76+### Limits
77+
78+- **Binaries you own.** An executable your user can write to, such as one under your home directory, can be replaced by any program of yours. Listing it trusts every program that can write there. Prefer root-owned paths.
79+- **Interpreters.** A script runs as its interpreter: the executable behind the connection is `/usr/bin/python3`, not the script. Never list `python`, `bash`, `node` or any other interpreter, because every script they run would be allowed to capture. A program in such a language that needs capture has to be started through a compiled launcher that you list.
80+- **Threat model.** The allowlist stops unknown programs from capturing directly. It does not stop an attacker running as your user who drives or injects into a listed program: handing the connection's file descriptor to a child after `exec`, `LD_PRELOAD` into a listed program, `ptrace`, or simply asking it to capture. Such a program is then the one the kernel reports, and it is allowed. The indicator still shows while it captures.
81+- The allowlist limits who may ask, not what they do with the image. Whatever a listed program receives, it can send anywhere.
82+
83+## Portals
84+
85+For screen sharing in browsers and other applications, install `xdg-desktop-portal-wlr`; for screenshots and recordings, install [nefercap](https://github.com/bnema/nefercap). The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. The wlr portal is in the built-in list as `/usr/lib/xdg-desktop-portal-wlr` and `/usr/libexec/xdg-desktop-portal-wlr`; if it lives elsewhere, add its path in `/etc/neferwl/capture-allow`. What to share is picked with a chooser that the portal starts itself, an external tool such as slurp, wofi or bemenu (see `chooser_type` and `chooser_cmd` in the `xdg-desktop-portal-wlr` man page). NeferWL has no chooser of its own, so install one. Sandboxed applications use the same portal: they never capture directly.
86+
87+A listed program captures on behalf of whoever asks it. Any program running as your user can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes for each of them.
M docs/desktop.md +10 -7
@@ -16,13 +16,12 @@ Pipes (`|`), `&&`, redirections, variables (`$HOME`), `~` and quotes have no spe
1616
1717 ```sh
1818 #!/bin/sh
19-# ~/.local/bin/screenshot-area: select an area, save it and copy it.
20-file="$HOME/Pictures/$(date +%F-%T).png"
21-grim -g "$(slurp)" "$file" && wl-copy < "$file"
19+# ~/.local/bin/screenshot-main: save the main monitor with a dated name.
20+nefercap screenshot -output DP-1 -file "$HOME/Pictures/$(date +%F-%T).png"
2221 ```
2322
2423 ```text
25-bind.cmd+shift+p = spawn screenshot-area
24+bind.cmd+shift+p = spawn screenshot-main
2625 ```
2726
2827 Make the script executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. It can use any shell, including fish (`#!/usr/bin/env fish`).
@@ -72,6 +71,10 @@ Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` tak
7271
7372 External lockers use `ext-session-lock-v1`. NeferWL isolates desktop input and captures, covers every output, and stays protected if the locker dies. Authentication belongs to the locker; screen-off and a visual cover do not replace locking. See [Session locking](session-lock.md) for confirmation, recovery and security limits.
7473
74+## Screen capture
75+
76+nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext_image_copy_capture_v1`. Every capture shows a red border, only the executables on the built-in list (`grim`, `nefercap`, `xdg-desktop-portal-wlr`) or in `/etc/neferwl/capture-allow`, which replaces it, may capture, and sandboxed clients use the portal. See [Screen capture](capture.md).
77+
7578 ## Idle and screen off
7679
7780 NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on.
@@ -90,7 +93,7 @@ exec wlopm --off '*'
9093
9194 ## Bars
9295
93-NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling.
96+NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.
9497
9598 NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.
9699
@@ -98,9 +101,9 @@ NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, foc
98101
99102 While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists:
100103
101-- every output, with its active numbered workspace, workspace count and the name of the workspace on screen;
104+- every output, with its active numbered workspace, workspace count, and the name (`workspace`) and ID string (`workspace_id`) of the workspace on screen;
102105 - the focused output and window;
103-- every window, with its app ID, PID, output, workspace and whether it is on screen (`visible`);
106+- every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);
104107 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.
105108
106109 ```sh
M docs/headless.md +6 -4
@@ -9,13 +9,15 @@ WAYLAND_DISPLAY=<name from the log> foot
99
1010 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
1111
12-Nefercap uses the private `neferwl_capture_manager_v1` protocol for recording sessions. A session targets an output, a fixed output-local region or a workspace's stable identity. Workspace recording uses the workspace's real layout rather than overview previews. While hidden, it uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires a whole-workspace target and requests matching its exact physical dimensions; cropped targets or requests are unsupported.
12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait (their frames fail) until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
1313
14-The compositor draws a red border around the visible recording target. An explicitly authorized HUD stays visible over fullscreen without keyboard focus. The session's captures omit the border, HUD and its popups; other clients' standard captures include them. HUD authorization requires the session's random token and a matching socket peer UID, never a layer namespace or app-id. Output removal, power-off, owner disconnect and a five-second keepalive timeout stop the session.
14+`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
1515
16-Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order.
16+Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
1717
18-`/tmp/neferwl-shots/latest.png` shows the current unlocked frame, including the recording border and HUD. During session protection, protocol captures are refused and debug PNG files stop updating; an existing file still contains an earlier unlocked frame. See [Session locking](session-lock.md). `--timeout 5s` stops NeferWL after 5 seconds.
18+Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order.
19+
20+`/tmp/neferwl-shots/latest.png` shows the current unlocked frame, including any HUD. During session protection, protocol captures are refused and debug PNG files stop updating; an existing file still contains an earlier unlocked frame. See [Session locking](session-lock.md). `--timeout 5s` stops NeferWL after 5 seconds.
1921
2022 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
2123
M docs/performance.md +1 -1
@@ -8,7 +8,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
88 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
1010 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11-- **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition; hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
11+- **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition, and so does the capture indicator (red border or pill, shown while a session lives and for one second after each captured frame; see `docs/headless.md`), which also costs a second composition per capture frame because no capture holds it (a request waits at most 250 ms for the scene that shows its indicator, without drawing anything while it waits); hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
1212 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1313
1414 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.
M docs/session-lock.md +1 -1
@@ -23,7 +23,7 @@ Returning activity may wake powered-off outputs but never unlocks. There is no e
2323 - Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner.
2424 - A same-user process that controls the session or a legitimate lock owner is outside this isolation boundary. Root, hostile kernel/driver code and another active VT are not isolated by the compositor.
2525 - Clipboard pipes already handed to clients before acquisition cannot be recalled. Later receive/set requests are denied, including requests on old offer objects. Information a client received before locking cannot be erased from that client.
26-- Captures admitted before acquisition may complete; `locked` waits for their copy/FD-write completion even if the requesting protocol object has been destroyed. New captures, including private recording and hidden-workspace capture, are denied.
26+- Captures admitted before acquisition may complete; `locked` waits for their copy/FD-write completion even if the requesting protocol object has been destroyed. New captures are denied for every protocol and source: wlr-screencopy, and ext-image-copy-capture of an output, an output region or a workspace, hidden workspaces included. Engaging the lock stops every open ext-image-copy-capture session and its exclusion, and clears the capture indicator; a client asks for a new session after the unlock.
2727 - The script state file is not a security API and is not scrubbed by locking. Its existing file permissions and same-user access boundary remain unchanged.
2828 - Native KMS calls and compositor GPU work can stall. Stalls never produce a successful protection acknowledgement; bounded lock latency is not guaranteed. Protected shutdown never restores saved desktop pixels, but disabling an output is best effort if KMS authority is lost.
2929 - Refused or transient protected KMS commits keep the output registered and dark, and retry with a 50 ms to 2 s backoff. An output stops, and the locker loses it, only when the renderer fails to clear it or no image format at all is accepted. If the driver keeps refusing to disable an output, its previous content can stay lit; `locked` is still withheld.
A examples/capture-allow +31 -0
@@ -0,0 +1,31 @@
1+# NeferWL screen-capture allowlist example.
2+#
3+# Without /etc/neferwl/capture-allow, NeferWL allows these executables to
4+# capture the screen (the built-in list):
5+#
6+# /usr/bin/grim
7+# /usr/bin/nefercap
8+# /usr/lib/xdg-desktop-portal-wlr
9+# /usr/libexec/xdg-desktop-portal-wlr
10+#
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
13+# built-in list entirely, so keep the entries you still need.
14+#
15+# Format: one absolute executable path per line, the resolved path that
16+# /proc/<pid>/exe shows (a symlink is never followed). Blank lines and lines
17+# starting with '#' are ignored. A line with only '*' disables the check:
18+# every client may capture. An invalid file (a relative path, anything that is
19+# not a path) lets no client capture, and the log names the line.
20+# NeferWL reads the file at startup and again whenever it changes.
21+#
22+# The file and every directory down from / must be root-owned, not group- or
23+# other-writable, and not symlinks; otherwise no client may capture.
24+# See docs/capture.md.
25+#
26+# Never list an interpreter (python, bash, node...): every script it runs
27+# would then be allowed to capture.
28+/usr/bin/grim
29+/usr/bin/nefercap
30+/usr/lib/xdg-desktop-portal-wlr
31+/usr/libexec/xdg-desktop-portal-wlr
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/purego v0.13.0-bnema.1
7- github.com/bnema/purego-libwayland v0.7.0
7+ github.com/bnema/purego-libwayland v0.8.0
88 github.com/bnema/purego-vulkan v0.6.0
99 github.com/bnema/wlturbo v0.5.0
1010 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2 generated file: collapsed
D internal/adapters/capture/clean.go +0 -148
@@ -1,148 +0,0 @@
1-package capture
2-
3-import (
4- "errors"
5- "slices"
6-
7- "github.com/bnema/neferwl/internal/ports"
8-)
9-
10-// ErrSessionInactive fails a Clean request whose session is not the one the
11-// scene carries. Serving it as a normal capture would leak the session's
12-// HUD, its native border, or a workspace it no longer targets.
13-var ErrSessionInactive = errors.New("capture session is not active")
14-
15-// ErrSessionStale fails a Clean request stamped with a session revision newer
16-// than the scene's: the scene predates a change of what a clean capture must
17-// hide (a new HUD layer, popup, target), so its exclusion list could leak.
18-var ErrSessionStale = errors.New("capture session scene is older than the request")
19-
20-// cleaner derives the scene of clean captures: the frame without the native
21-// session border and without the excluded layers and popups. Its scratch
22-// slices are reused, so a warmed output allocates nothing.
23-type cleaner struct {
24- windows []ports.SceneWindow
25- layers []ports.SceneLayer
26-}
27-
28-// Shows reports whether a content change of id needs a new frame: the
29-// displayed scene draws it, or the hidden workspace of a capture session does
30-// (its child render and the reads it releases). Flips and Shown still count
31-// the displayed scene only.
32-func Shows(s ports.Scene, id ports.WindowID) bool {
33- return s.Shows(id) || s.CaptureScene != nil && s.CaptureScene.Shows(id)
34-}
35-
36-// BorderVisible reports whether the scene's native session border draws at
37-// least one pixel: a color, and a width that survives the renderer's clamp to
38-// half the smaller side of the target. The renderer draws by the same rule.
39-func BorderVisible(s ports.Scene) bool {
40- c := s.Capture
41- return c != nil && c.BorderColor != "" && min(c.BorderWidth, c.TargetRect.W/2, c.TargetRect.H/2) > 0
42-}
43-
44-// cleanDiffers reports whether the clean scene differs from the displayed
45-// one: a native border is drawn, or an excluded surface is in the scene.
46-// When it does not, clean requests are served from the displayed frame.
47-func cleanDiffers(s ports.Scene) bool {
48- c := s.Capture
49- if c == nil {
50- return false
51- }
52- if BorderVisible(s) {
53- return true
54- }
55- if len(c.Excluded) == 0 {
56- return false
57- }
58- for _, w := range s.Windows {
59- if slices.Contains(c.Excluded, w.ID) {
60- return true
61- }
62- }
63- for _, l := range s.Layers {
64- if slices.Contains(c.Excluded, l.ID) {
65- return true
66- }
67- }
68- return false
69-}
70-
71-// scene returns s without Capture (so no border) and without the excluded
72-// windows (popups included) and layers. Seq is 0, so the renderer redraws
73-// the whole target instead of reusing a displayed frame. The result is valid
74-// until the next call. Without a session s is returned unchanged.
75-func (c *cleaner) scene(s ports.Scene) ports.Scene {
76- if s.Capture == nil {
77- return s
78- }
79- ex := s.Capture.Excluded
80- s.Capture, s.Seq = nil, 0
81- if len(ex) == 0 {
82- return s
83- }
84- c.windows = c.windows[:0]
85- for _, w := range s.Windows {
86- if !slices.Contains(ex, w.ID) {
87- c.windows = append(c.windows, w)
88- }
89- }
90- c.layers = c.layers[:0]
91- for _, l := range s.Layers {
92- if !slices.Contains(ex, l.ID) {
93- c.layers = append(c.layers, l)
94- }
95- }
96- s.Windows, s.Layers = c.windows, c.layers
97- return s
98-}
99-
100-// CleanScene is the scene a Clean capture is taken from; see Split.
101-func (p *Pipeline) CleanScene(s ports.Scene) ports.Scene { return p.clean.scene(s) }
102-
103-// Split answers or orders the requests of one output frame. Clean requests
104-// of a session other than the scene's, or with no session in the scene, are
105-// failed with ErrSessionInactive, those newer than the scene's Revision with
106-// ErrSessionStale (never served as normal captures), and removed. The rest is reordered in place: normal is the prefix, drawn from
107-// the displayed frame; clean is the following requests, drawn from CleanScene
108-// rendered first, or from the child scene when s.CaptureScene is set (see
109-// Offscreen). clean is empty when the clean frame would equal the displayed
110-// one, its requests then stay in normal. The entries past
111-// len(normal)+len(clean) are zeroed. No allocation.
112-func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, clean []ports.CaptureRequest) {
113- n := 0
114- for _, q := range reqs {
115- if q.Clean {
116- var err error
117- switch {
118- case s.Capture == nil || q.Session != s.Capture.Session:
119- err = ErrSessionInactive
120- case s.Capture.Revision < q.CaptureRevision:
121- err = ErrSessionStale
122- }
123- if err != nil {
124- Fail(p.ctx, q, err, p.replies)
125- continue
126- }
127- }
128- reqs[n] = q
129- n++
130- }
131- clear(reqs[n:])
132- // A hidden workspace (CaptureScene) is always a separate frame.
133- if s.CaptureScene == nil && !cleanDiffers(s) {
134- return reqs[:n], nil
135- }
136- j := 0
137- for i := range n {
138- if !reqs[i].Clean {
139- reqs[i], reqs[j] = reqs[j], reqs[i]
140- j++
141- }
142- }
143- return reqs[:j], reqs[j:n]
144-}
145-
146-// Handed reports a zeroed request: one Split failed, or one already given to
147-// the worker. The owner never answers it again.
148-func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }
D internal/adapters/capture/clean_test.go +0 -157
@@ -1,157 +0,0 @@
1-package capture
2-
3-import (
4- "context"
5- "testing"
6-
7- "github.com/bnema/neferwl/internal/ports"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func cleanTestScene() ports.Scene {
12- return ports.Scene{
13- Seq: 5,
14- Windows: []ports.SceneWindow{
15- {ID: 1}, {ID: 20, Popup: true, OverLayers: true}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3},
16- },
17- Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}, {ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}},
18- Capture: &ports.SceneCapture{
19- Session: 7, BorderWidth: 2, BorderColor: ports.CaptureBorderColor,
20- Excluded: []ports.WindowID{10, 20},
21- },
22- }
23-}
24-
25-func TestCleanSceneDropsOnlyExcludedAndBorder(t *testing.T) {
26- p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
27- defer p.Close(nil)
28- s := cleanTestScene()
29- c := p.CleanScene(s)
30- require.Nil(t, c.Capture)
31- require.Zero(t, c.Seq)
32- require.Equal(t, []ports.SceneWindow{{ID: 1}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3}}, c.Windows)
33- require.Equal(t, []ports.SceneLayer{{ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}}, c.Layers)
34- // The displayed scene is not mutated.
35- require.Len(t, s.Windows, 4)
36- require.Len(t, s.Layers, 3)
37- require.NotNil(t, s.Capture)
38- require.Equal(t, uint64(5), s.Seq)
39-}
40-
41-func TestCleanSceneWithoutSessionUnchangedNoAlloc(t *testing.T) {
42- p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
43- defer p.Close(nil)
44- s := cleanTestScene()
45- s.Capture = nil
46- require.Equal(t, s, p.CleanScene(s))
47- reqs := make([]ports.CaptureRequest, 3)
48- for i := range reqs {
49- reqs[i].ID = uint64(i + 1)
50- }
51- if allocs := testing.AllocsPerRun(100, func() {
52- _ = p.CleanScene(s)
53- p.Split(s, reqs)
54- }); allocs != 0 {
55- t.Fatalf("no session: %.1f allocs, want 0", allocs)
56- }
57-}
58-
59-func TestCleanSceneWarmedNoAlloc(t *testing.T) {
60- p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
61- defer p.Close(nil)
62- s := cleanTestScene()
63- p.CleanScene(s)
64- if allocs := testing.AllocsPerRun(100, func() { _ = p.CleanScene(s) }); allocs != 0 {
65- t.Fatalf("warmed clean scene: %.1f allocs, want 0", allocs)
66- }
67-}
68-
69-func TestSplitOrdersAndFailsClosed(t *testing.T) {
70- replies := make(chan ports.CaptureDone, 8)
71- p := NewPipeline(context.Background(), replies)
72- defer p.Close(nil)
73- s := cleanTestScene()
74- reqs := []ports.CaptureRequest{
75- {ID: 1, Clean: true, Session: 7},
76- {ID: 2},
77- {ID: 3, Clean: true, Session: 8}, // another session: never served
78- {ID: 4, Clean: true, Session: 7},
79- {ID: 5},
80- }
81- normal, clean := p.Split(s, reqs)
82- ids := func(q []ports.CaptureRequest) (out []uint64) {
83- for _, r := range q {
84- out = append(out, r.ID)
85- }
86- return out
87- }
88- require.ElementsMatch(t, []uint64{2, 5}, ids(normal))
89- require.ElementsMatch(t, []uint64{1, 4}, ids(clean))
90- for _, q := range clean {
91- require.True(t, q.Clean)
92- }
93- for _, q := range normal {
94- require.False(t, q.Clean)
95- }
96- got := awaitCapture(t, replies)
97- require.Equal(t, uint64(3), got.ID)
98- require.ErrorIs(t, got.Err, ErrSessionInactive)
99- for _, q := range reqs[len(normal)+len(clean):] {
100- require.True(t, Handed(q), "tail must be zeroed")
101- }
102-}
103-
104-func TestSplitCleanWithoutSceneSessionFails(t *testing.T) {
105- replies := make(chan ports.CaptureDone, 4)
106- p := NewPipeline(context.Background(), replies)
107- defer p.Close(nil)
108- s := cleanTestScene()
109- s.Capture = nil
110- normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}})
111- require.Len(t, normal, 1)
112- require.Equal(t, uint64(2), normal[0].ID)
113- require.Empty(t, clean)
114- got := awaitCapture(t, replies)
115- require.Equal(t, uint64(1), got.ID)
116- require.ErrorIs(t, got.Err, ErrSessionInactive)
117-}
118-
119-// A session with nothing to hide (no border, no excluded surface in the
120-// scene) is served from the displayed frame: no second composition.
121-func TestSplitNoSecondCompositionWhenCleanEqualsDisplayed(t *testing.T) {
122- p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
123- defer p.Close(nil)
124- s := cleanTestScene()
125- s.Capture.BorderWidth, s.Capture.Excluded = 0, []ports.WindowID{99}
126- normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}})
127- require.Len(t, normal, 2)
128- require.Empty(t, clean)
129-}
130-
131-func TestSplitStandardCapturesKeepSessionScene(t *testing.T) {
132- p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
133- defer p.Close(nil)
134- normal, clean := p.Split(cleanTestScene(), []ports.CaptureRequest{{ID: 1}, {ID: 2}})
135- require.Len(t, normal, 2)
136- require.Empty(t, clean)
137-}
138-
139-// A request stamped with a revision the scene has not reached is refused:
140-// the scene's exclusion list may predate a newly mapped HUD layer.
141-func TestSplitStaleRevisionFailsClosed(t *testing.T) {
142- replies := make(chan ports.CaptureDone, 4)
143- p := NewPipeline(context.Background(), replies)
144- defer p.Close(nil)
145- s := cleanTestScene()
146- s.Capture.Revision = 3
147- normal, clean := p.Split(s, []ports.CaptureRequest{
148- {ID: 1, Clean: true, Session: 7, CaptureRevision: 4},
149- {ID: 2, Clean: true, Session: 7, CaptureRevision: 3},
150- {ID: 3, Clean: true, Session: 7, CaptureRevision: 2},
151- })
152- require.Empty(t, normal)
153- require.Len(t, clean, 2)
154- got := awaitCapture(t, replies)
155- require.Equal(t, uint64(1), got.ID)
156- require.ErrorIs(t, got.Err, ErrSessionStale)
157-}
A internal/adapters/capture/excluded.go +193 -0
@@ -0,0 +1,193 @@
1+package capture
2+
3+import (
4+ "errors"
5+ "os"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// A request with Exclude leaves out the exclusion's HUD layers and popups;
12+// every other capture shows them. No capture shows the capture indicator
13+// (Scene.CaptureIndicators): a scene that carries it is never the source of a
14+// capture image. Requests of the displayed frame are then served from the
15+// frame rendered without it first (SubmitPlain, ExcludedScene).
16+
17+// ErrSessionInactive fails an Exclude request whose session is not the one
18+// the scene carries. Serving it as a normal capture would leak its HUD.
19+var ErrSessionInactive = errors.New("capture exclusion is not active")
20+
21+// ErrSessionStale fails an Exclude request stamped with a revision newer than
22+// the scene's: the scene predates a change of what an excluded frame must
23+// hide (a new HUD layer, popup, target), so its exclusion list could leak.
24+var ErrSessionStale = errors.New("capture exclusion scene is older than the request")
25+
26+// ErrWorkspaceMoved fails a workspace request whose workspace is neither on
27+// screen nor the one rendered off screen in the scene: it moved, went away,
28+// or the scene predates the session.
29+var ErrWorkspaceMoved = errors.New("captured workspace is not where the request expected")
30+
31+// excluder derives the scene of Exclude captures: the frame without the
32+// excluded layers and popups. Its scratch slices are reused,
33+// so a warmed output allocates nothing.
34+type excluder struct {
35+ windows []ports.SceneWindow
36+ layers []ports.SceneLayer
37+}
38+
39+// Shows reports whether a content change of id needs a new frame: the
40+// displayed scene draws it, or the hidden workspace of a capture does (its
41+// child render and the reads it releases). Flips and Shown still count the
42+// displayed scene only.
43+func Shows(s ports.Scene, id ports.WindowID) bool {
44+ return s.Shows(id) || s.CaptureScene != nil && s.CaptureScene.Shows(id)
45+}
46+
47+// excludedShown reports whether the scene draws an excluded surface.
48+func excludedShown(s ports.Scene) bool {
49+ c := s.Capture
50+ if c == nil || len(c.Excluded) == 0 {
51+ return false
52+ }
53+ for _, w := range s.Windows {
54+ if slices.Contains(c.Excluded, w.ID) {
55+ return true
56+ }
57+ }
58+ for _, l := range s.Layers {
59+ if slices.Contains(c.Excluded, l.ID) {
60+ return true
61+ }
62+ }
63+ return false
64+}
65+
66+// plain returns s without its capture state and indicators, Seq 0 so the
67+// renderer redraws the whole target instead of reusing a displayed frame.
68+func plain(s ports.Scene) ports.Scene {
69+ s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0
70+ return s
71+}
72+
73+// SubmitPlain serves the displayed-frame requests of a scene that carries the
74+// capture indicator: the frame is rendered without it, first (same renderer,
75+// queue order, like ExcludedScene), and copied before the displayed one is
76+// drawn. It reports whether it took the requests (and zeroed them); false
77+// when the scene has no indicator or there is nothing to serve, the caller
78+// submits them after the displayed frame as usual. On a render error the
79+// caller still owns the requests.
80+//
81+// Admission is the session gate's: the requests are handed over with
82+// SubmitScoped under the scene's epoch, so a locked or changed epoch fails
83+// them before BeginCapture. The render fence is returned, never closed here:
84+// the plain render reads client buffers even when the requests are rejected,
85+// so the caller owns it (hold or wait it) whatever happened, error included.
86+func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {
87+ if len(normal) == 0 || len(s.CaptureIndicators) == 0 {
88+ return false, nil, nil
89+ }
90+ done, err = r.Render(plain(s), surfaces)
91+ if err != nil {
92+ return false, done, err
93+ }
94+ p.SubmitScoped(s.Security, r, normal)
95+ clear(normal)
96+ return true, done, nil
97+}
98+
99+// scene returns s without the excluded windows
100+// (popups included) and layers. The result is valid until the next call.
101+func (c *excluder) scene(s ports.Scene) ports.Scene {
102+ var ex []ports.WindowID
103+ if s.Capture != nil {
104+ ex = s.Capture.Excluded
105+ }
106+ s = plain(s)
107+ if len(ex) == 0 {
108+ return s
109+ }
110+ c.windows = c.windows[:0]
111+ for _, w := range s.Windows {
112+ if !slices.Contains(ex, w.ID) {
113+ c.windows = append(c.windows, w)
114+ }
115+ }
116+ c.layers = c.layers[:0]
117+ for _, l := range s.Layers {
118+ if !slices.Contains(ex, l.ID) {
119+ c.layers = append(c.layers, l)
120+ }
121+ }
122+ s.Windows, s.Layers = c.windows, c.layers
123+ return s
124+}
125+
126+// ExcludedScene is the scene of Exclude captures: no excluded surface; see Split. Valid until the next call.
127+func (p *Pipeline) ExcludedScene(s ports.Scene) ports.Scene { return p.excl.scene(s) }
128+
129+// Split answers or orders the requests of one output frame, in place. No
130+// allocation. Requests that cannot be served are failed and removed:
131+// Exclude requests of a session other than the scene's, or with no exclusion
132+// in it (ErrSessionInactive), and those newer than its Revision
133+// (ErrSessionStale); workspace requests whose workspace is not where they
134+// expected (ErrWorkspaceMoved). The rest is reordered:
135+// - normal is the prefix, drawn from the displayed frame;
136+// - excluded follows: Exclude requests, drawn from ExcludedScene rendered first,
137+// or from the displayed frame (they stay in normal) when it shows no
138+// excluded surface and no capture indicator;
139+// - hidden: the requests of the workspace rendered off screen
140+// (OffScreen), served from CaptureScene (see SubmitHidden); they come last.
141+//
142+// The entries past the groups are zeroed.
143+func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, excluded, hidden []ports.CaptureRequest) {
144+ n := 0
145+ for _, q := range reqs {
146+ var err error
147+ switch {
148+ case q.Exclude && (s.Capture == nil || s.Capture.Session == 0 || q.Session != s.Capture.Session):
149+ err = ErrSessionInactive
150+ case q.Exclude && s.Capture.Revision < q.CaptureRevision:
151+ err = ErrSessionStale
152+ case q.Workspace != 0 && (s.Capture == nil || q.OffScreen && q.Workspace != s.Capture.Workspace || !q.OffScreen && q.Workspace != s.Capture.Shown):
153+ err = ErrWorkspaceMoved
154+ }
155+ if err != nil {
156+ Fail(p.ctx, q, err, p.replies)
157+ continue
158+ }
159+ reqs[n] = q
160+ n++
161+ }
162+ clear(reqs[n:])
163+ reqs = reqs[:n]
164+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0
165+ group := func(q ports.CaptureRequest) int {
166+ switch {
167+ case q.OffScreen:
168+ return 2
169+ case q.Exclude && excl:
170+ return 1
171+ }
172+ return 0
173+ }
174+ var counts [3]int
175+ for _, q := range reqs {
176+ counts[group(q)]++
177+ }
178+ j := 0
179+ for g := range 3 {
180+ for i := j; i < n; i++ {
181+ if group(reqs[i]) == g {
182+ reqs[i], reqs[j] = reqs[j], reqs[i]
183+ j++
184+ }
185+ }
186+ }
187+ a, b := counts[0], counts[0]+counts[1]
188+ return reqs[:a], reqs[a:b], reqs[b:n]
189+}
190+
191+// Handed reports a zeroed request: one Split failed, or one already given to
192+// the worker. The owner never answers it again.
193+func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }
A internal/adapters/capture/excluded_test.go +176 -0
@@ -0,0 +1,176 @@
1+package capture
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func excludedTestScene() ports.Scene {
12+ return ports.Scene{
13+ Seq: 5,
14+ Windows: []ports.SceneWindow{
15+ {ID: 1}, {ID: 20, Popup: true, OverLayers: true}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3},
16+ },
17+ Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}, {ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}},
18+ Capture: &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10, 20}},
19+ }
20+}
21+
22+func ids(q []ports.CaptureRequest) (out []uint64) {
23+ for _, r := range q {
24+ out = append(out, r.ID)
25+ }
26+ return out
27+}
28+
29+func TestExcludedSceneDropsOnlyExcluded(t *testing.T) {
30+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
31+ defer p.Close(nil)
32+ s := excludedTestScene()
33+ c := p.ExcludedScene(s)
34+ require.Nil(t, c.Capture)
35+ require.Zero(t, c.Seq)
36+ require.Equal(t, []ports.SceneWindow{{ID: 1}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3}}, c.Windows)
37+ require.Equal(t, []ports.SceneLayer{{ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}}, c.Layers)
38+ // The displayed scene is not mutated.
39+ require.Len(t, s.Windows, 4)
40+ require.Len(t, s.Layers, 3)
41+ require.NotNil(t, s.Capture)
42+ require.Equal(t, uint64(5), s.Seq)
43+}
44+
45+func TestSplitNoAlloc(t *testing.T) {
46+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
47+ defer p.Close(nil)
48+ for name, s := range map[string]ports.Scene{"no capture": {Windows: excludedTestScene().Windows}, "exclusion": excludedTestScene()} {
49+ reqs := make([]ports.CaptureRequest, 3)
50+ for i := range reqs {
51+ reqs[i].ID = uint64(i + 1)
52+ }
53+ if allocs := testing.AllocsPerRun(100, func() {
54+ _ = p.ExcludedScene(s)
55+ p.Split(s, reqs)
56+ }); allocs != 0 {
57+ t.Fatalf("%s: %.1f allocs, want 0", name, allocs)
58+ }
59+ }
60+}
61+
62+func TestSplitOrdersAndFailsClosed(t *testing.T) {
63+ replies := make(chan ports.CaptureDone, 8)
64+ p := NewPipeline(context.Background(), replies)
65+ defer p.Close(nil)
66+ s := excludedTestScene()
67+ s.Capture.Revision = 1
68+ reqs := []ports.CaptureRequest{
69+ {ID: 1, Exclude: true, Session: 7, CaptureRevision: 1},
70+ {ID: 2},
71+ {ID: 3, Exclude: true, Session: 8, CaptureRevision: 1}, // another session: never served
72+ {ID: 4, Exclude: true, Session: 7, CaptureRevision: 1},
73+ {ID: 5},
74+ }
75+ normal, excluded, hidden := p.Split(s, reqs)
76+ require.ElementsMatch(t, []uint64{2, 5}, ids(normal))
77+ require.ElementsMatch(t, []uint64{1, 4}, ids(excluded))
78+ require.Empty(t, hidden)
79+ for _, q := range excluded {
80+ require.True(t, q.Exclude)
81+ }
82+ got := awaitCapture(t, replies)
83+ require.Equal(t, uint64(3), got.ID)
84+ require.ErrorIs(t, got.Err, ErrSessionInactive)
85+ for _, q := range reqs[len(normal)+len(excluded):] {
86+ require.True(t, Handed(q), "tail must be zeroed")
87+ }
88+}
89+
90+func TestSplitExcludeWithoutSceneExclusionFails(t *testing.T) {
91+ replies := make(chan ports.CaptureDone, 4)
92+ p := NewPipeline(context.Background(), replies)
93+ defer p.Close(nil)
94+ s := excludedTestScene()
95+ s.Capture = nil
96+ normal, excluded, _ := p.Split(s, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}})
97+ require.Equal(t, []uint64{2}, ids(normal))
98+ require.Empty(t, excluded)
99+ got := awaitCapture(t, replies)
100+ require.Equal(t, uint64(1), got.ID)
101+ require.ErrorIs(t, got.Err, ErrSessionInactive)
102+}
103+
104+// An exclusion with nothing to hide in the scene is served from the displayed
105+// frame: no second composition.
106+func TestSplitNoSecondCompositionWhenNothingExcludedIsShown(t *testing.T) {
107+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
108+ defer p.Close(nil)
109+ s := excludedTestScene()
110+ s.Capture.Excluded = []ports.WindowID{99}
111+ normal, excluded, _ := p.Split(s, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}})
112+ require.Len(t, normal, 2)
113+ require.Empty(t, excluded)
114+}
115+
116+// Every other capture keeps the HUD, even while an exclusion is live.
117+func TestSplitStandardCapturesKeepExclusionScene(t *testing.T) {
118+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
119+ defer p.Close(nil)
120+ normal, excluded, _ := p.Split(excludedTestScene(), []ports.CaptureRequest{{ID: 1}, {ID: 2}})
121+ require.Len(t, normal, 2)
122+ require.Empty(t, excluded)
123+}
124+
125+// A request stamped with a revision the scene has not reached is refused:
126+// the scene's exclusion list may predate a newly mapped HUD layer.
127+func TestSplitStaleRevisionFailsClosed(t *testing.T) {
128+ replies := make(chan ports.CaptureDone, 4)
129+ p := NewPipeline(context.Background(), replies)
130+ defer p.Close(nil)
131+ s := excludedTestScene()
132+ s.Capture.Revision = 3
133+ normal, excluded, _ := p.Split(s, []ports.CaptureRequest{
134+ {ID: 1, Exclude: true, Session: 7, CaptureRevision: 4},
135+ {ID: 2, Exclude: true, Session: 7, CaptureRevision: 3},
136+ {ID: 3, Exclude: true, Session: 7, CaptureRevision: 2},
137+ })
138+ require.Empty(t, normal)
139+ require.Len(t, excluded, 2)
140+ got := awaitCapture(t, replies)
141+ require.Equal(t, uint64(1), got.ID)
142+ require.ErrorIs(t, got.Err, ErrSessionStale)
143+}
144+
145+// A workspace request is served only if its workspace is still where the
146+// request expected: on screen (Shown), or the one rendered off screen.
147+func TestSplitWorkspaceMovedFailsClosed(t *testing.T) {
148+ replies := make(chan ports.CaptureDone, 8)
149+ p := NewPipeline(context.Background(), replies)
150+ defer p.Close(nil)
151+ s := ports.Scene{Capture: &ports.SceneCapture{Shown: 3, Workspace: 4}}
152+ reqs := []ports.CaptureRequest{
153+ {ID: 1, Workspace: 3}, // on screen, still shown
154+ {ID: 2, Workspace: 4, OffScreen: true}, // off screen, still rendered
155+ {ID: 3, Workspace: 4}, // was on screen, now hidden
156+ {ID: 4, Workspace: 3, OffScreen: true}, // was hidden, now shown
157+ {ID: 5, Workspace: 9, OffScreen: true}, // not rendered at all
158+ {ID: 6},
159+ }
160+ normal, excluded, hidden := p.Split(s, reqs)
161+ require.ElementsMatch(t, []uint64{1, 6}, ids(normal))
162+ require.Empty(t, excluded)
163+ require.Equal(t, []uint64{2}, ids(hidden))
164+ failed := map[uint64]bool{}
165+ for range 3 {
166+ d := awaitCapture(t, replies)
167+ require.ErrorIs(t, d.Err, ErrWorkspaceMoved)
168+ failed[d.ID] = true
169+ }
170+ require.Equal(t, map[uint64]bool{3: true, 4: true, 5: true}, failed)
171+ // Without any capture state no workspace request is served.
172+ normal, _, hidden = p.Split(ports.Scene{}, []ports.CaptureRequest{{ID: 7, Workspace: 3}})
173+ require.Empty(t, normal)
174+ require.Empty(t, hidden)
175+ require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
176+}
A internal/adapters/capture/fence.go +162 -0
@@ -0,0 +1,162 @@
1+package capture
2+
3+import (
4+ "errors"
5+ "image"
6+ "math"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// The indicator fence. A capture is requested straight from wayland to the
13+// output owner, while core learns of it (CaptureFrameTaken) on its own
14+// channel and only then publishes a scene that carries the capture indicator.
15+// An output that served the request from the scene it already had would copy
16+// pixels before the border or pill was ever on screen. So the output owner
17+// holds every request until its current scene shows the mark the request
18+// needs, and serves it from that scene: the indicator is composed in the same
19+// frame as the capture or in an earlier one, never after it.
20+//
21+// Wayland stamps every capture it requests with Indicate. The mark needed is
22+// derived from the request alone:
23+// - OffScreen (a workspace rendered for capture only): a pill;
24+// - any other: a border whose rectangle covers the request's region.
25+//
26+// A mark that draws nothing (an empty rectangle, a border that would be 0
27+// wide, a pill not fully inside the output: ports.CaptureIndicator.DrawableIn) never counts as shown. Core
28+// inflates thin targets so their mark is drawable.
29+//
30+// Delivery is gated on the display (Pipeline.BeginGate, EndGate). The frame
31+// of a capture is rendered without the indicator and copied before the
32+// indicated frame, but its pixels are written and its reply sent only after
33+// the owner committed (DRM) or presented (headless) the frame that carries
34+// the indicator. When that frame fails to render or to commit, or its path
35+// ends in any other way, the capture fails with ErrIndicatorMissing (and the
36+// cause): a capture is never delivered for an indicator that was not shown.
37+// A scene without an indicator gates nothing and costs nothing.
38+//
39+// A request whose mark does not come (the target vanished, so core has
40+// nothing to mark) fails with ErrIndicatorMissing after HoldFor: nothing is
41+// ever served without its indicator. The fence costs nothing while no request
42+// is pending.
43+
44+// HoldFor bounds how long a request waits for its indicator.
45+const HoldFor = 250 * time.Millisecond
46+
47+// ErrIndicatorMissing fails a request whose indicator was not on screen in
48+// time.
49+var ErrIndicatorMissing = errors.New("capture indicator is not on screen")
50+
51+// IndicatorShown reports whether scene s shows the indicator request q needs.
52+// A request that does not ask for one (Indicate false) needs none.
53+func IndicatorShown(s ports.Scene, q ports.CaptureRequest) bool {
54+ if !q.Indicate {
55+ return true
56+ }
57+ for _, m := range s.CaptureIndicators {
58+ if !m.DrawableIn(s.OutputWidth, s.OutputHeight) {
59+ // Defense in depth: a mark whose border would be empty shows
60+ // nothing, so it vouches for no capture.
61+ continue
62+ }
63+ if q.OffScreen {
64+ if m.Pill {
65+ return true
66+ }
67+ continue
68+ }
69+ if !m.Pill && covers(s, m.Rect, q.Region) {
70+ return true
71+ }
72+ }
73+ return false
74+}
75+
76+// covers reports whether the border of logical rect m, drawn over a target
77+// region, covers the physical region r: m is projected to physical pixels the
78+// way wayland projects a logical target (rounded outwards). An edge of m on an
79+// output edge extends to it: the output size is not always a multiple of the
80+// scale.
81+func covers(s ports.Scene, m ports.Rect, r image.Rectangle) bool {
82+ scale := s.Scale
83+ if scale <= 0 {
84+ scale = 1
85+ }
86+ x0, y0 := int(math.Floor(float64(m.X)*scale)), int(math.Floor(float64(m.Y)*scale))
87+ x1, y1 := int(math.Ceil(float64(m.X+m.W)*scale)), int(math.Ceil(float64(m.Y+m.H)*scale))
88+ if m.X <= 0 {
89+ x0 = math.MinInt
90+ }
91+ if m.Y <= 0 {
92+ y0 = math.MinInt
93+ }
94+ if m.X+m.W >= s.OutputWidth {
95+ x1 = math.MaxInt
96+ }
97+ if m.Y+m.H >= s.OutputHeight {
98+ y1 = math.MaxInt
99+ }
100+ return r.Min.X >= x0 && r.Min.Y >= y0 && r.Max.X <= x1 && r.Max.Y <= y1
101+}
102+
103+// expired reports whether q has waited for its indicator for HoldFor.
104+func expired(q ports.CaptureRequest, now time.Time) bool {
105+ return now.Sub(q.Since) >= HoldFor
106+}
107+
108+// Expire fails the requests that waited HoldFor without their indicator
109+// being shown by s, and returns the others, in order, in place (the entries
110+// past them are zeroed). wait is how long until the next one expires, 0 when
111+// none waits for its indicator. No allocation.
112+func (p *Pipeline) Expire(s ports.Scene, reqs []ports.CaptureRequest, now time.Time) (kept []ports.CaptureRequest, wait time.Duration) {
113+ n := 0
114+ for _, q := range reqs {
115+ if !IndicatorShown(s, q) {
116+ if expired(q, now) {
117+ Fail(p.ctx, q, ErrIndicatorMissing, p.replies)
118+ continue
119+ }
120+ if d := HoldFor - now.Sub(q.Since); wait == 0 || d < wait {
121+ wait = d
122+ }
123+ }
124+ reqs[n] = q
125+ n++
126+ }
127+ clear(reqs[n:])
128+ return reqs[:n], wait
129+}
130+
131+// Hold splits the requests of one output frame by the indicator of s: ready
132+// is the prefix whose indicator s shows (they go to Split), held the requests
133+// that keep waiting for a scene that shows theirs. Expired ones are failed
134+// first (see Expire). ready and held are consecutive in reqs; the entries
135+// past them are zeroed. No allocation.
136+func (p *Pipeline) Hold(s ports.Scene, reqs []ports.CaptureRequest, now time.Time) (ready, held []ports.CaptureRequest) {
137+ reqs, _ = p.Expire(s, reqs, now)
138+ j := 0
139+ for i := range reqs {
140+ if IndicatorShown(s, reqs[i]) {
141+ reqs[i], reqs[j] = reqs[j], reqs[i]
142+ j++
143+ }
144+ }
145+ return reqs[:j], reqs[j:]
146+}
147+
148+// Waiting keeps, in place, the requests of a frame that still wait for the
149+// indicator of s: the ones neither handed over nor failed, whose indicator s
150+// does not show. The entries past them are zeroed. The capacity is kept, so
151+// the result replaces the owner's request list.
152+func Waiting(reqs []ports.CaptureRequest, s ports.Scene) []ports.CaptureRequest {
153+ n := 0
154+ for _, q := range reqs {
155+ if !Handed(q) && !IndicatorShown(s, q) {
156+ reqs[n] = q
157+ n++
158+ }
159+ }
160+ clear(reqs[n:])
161+ return reqs[:n]
162+}
A internal/adapters/capture/fence_test.go +172 -0
@@ -0,0 +1,172 @@
1+package capture
2+
3+import (
4+ "context"
5+ "image"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func fenceScene(scale float64, w, h int, marks ...ports.CaptureIndicator) ports.Scene {
14+ return ports.Scene{Scale: scale, OutputWidth: w, OutputHeight: h, CaptureIndicators: marks}
15+}
16+
17+func fenceRequest(id uint64, region image.Rectangle, since time.Time) ports.CaptureRequest {
18+ return ports.CaptureRequest{ID: id, Region: region, Indicate: true, Since: since}
19+}
20+
21+// A request is served only from a scene that shows its mark: a border that
22+// contains the target on this output, or a pill for a workspace rendered for
23+// capture only. Requests that ask for none need none.
24+func TestIndicatorShown(t *testing.T) {
25+ whole := image.Rect(0, 0, 200, 100)
26+ border := func(r ports.Rect) ports.CaptureIndicator { return ports.CaptureIndicator{Rect: r} }
27+ pill := ports.CaptureIndicator{Pill: true, Rect: ports.Rect{X: 180, Y: 8, W: 12, H: 12}}
28+ for _, tc := range []struct {
29+ name string
30+ scene ports.Scene
31+ req ports.CaptureRequest
32+ want bool
33+ }{
34+ {"no indicator", fenceScene(1, 200, 100), fenceRequest(1, whole, time.Time{}), false},
35+ {"request without a mark to show", fenceScene(1, 200, 100), ports.CaptureRequest{ID: 1, Region: whole}, true},
36+ {"whole output border", fenceScene(1, 200, 100, border(ports.Rect{W: 200, H: 100})), fenceRequest(1, whole, time.Time{}), true},
37+ {"pill is not a border", fenceScene(1, 200, 100, pill), fenceRequest(1, whole, time.Time{}), false},
38+ {"region border", fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 20, H: 20})), fenceRequest(1, image.Rect(10, 10, 30, 30), time.Time{}), true},
39+ {"border of another region", fenceScene(1, 200, 100, border(ports.Rect{X: 50, Y: 10, W: 20, H: 20})), fenceRequest(1, image.Rect(10, 10, 30, 30), time.Time{}), false},
40+ {"smaller border than the region", fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 10, H: 10})), fenceRequest(1, image.Rect(10, 10, 30, 30), time.Time{}), false},
41+ {"larger border contains the region", fenceScene(1, 200, 100, border(ports.Rect{W: 200, H: 100})), fenceRequest(1, image.Rect(10, 10, 30, 30), time.Time{}), true},
42+ {"scaled region rounds outwards", fenceScene(1.5, 200, 100, border(ports.Rect{X: 1, Y: 1, W: 3, H: 3})), fenceRequest(1, image.Rect(1, 1, 6, 6), time.Time{}), true},
43+ {"scaled region outside", fenceScene(1.5, 200, 100, border(ports.Rect{X: 1, Y: 1, W: 3, H: 3})), fenceRequest(1, image.Rect(1, 1, 7, 6), time.Time{}), false},
44+ {"edge at the output edge reaches it", fenceScene(1.5, 201, 100, border(ports.Rect{X: 0, Y: 0, W: 201, H: 100})), fenceRequest(1, image.Rect(0, 0, 302, 150), time.Time{}), true},
45+ {"undrawable thin border shows nothing", fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 1, H: 20})), fenceRequest(1, image.Rect(10, 10, 11, 30), time.Time{}), false},
46+ {"undrawable flat border shows nothing", fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 20, H: 1})), fenceRequest(1, image.Rect(10, 10, 30, 11), time.Time{}), false},
47+ {"smallest drawable border", fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 2, H: 2})), fenceRequest(1, image.Rect(10, 10, 12, 12), time.Time{}), true},
48+ {"pill cut by a short output shows nothing", fenceScene(1, 200, 8, ports.CaptureIndicator{Pill: true, Rect: ports.Rect{X: 180, Y: 8, W: 12, H: 12}}), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, false},
49+ {"pill partly outside shows nothing", fenceScene(1, 200, 100, ports.CaptureIndicator{Pill: true, Rect: ports.Rect{X: 195, Y: 8, W: 12, H: 12}}), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, false},
50+ {"whole-output pill of a tiny output", fenceScene(1, 200, 8, ports.CaptureIndicator{Pill: true, Rect: ports.Rect{W: 200, H: 8}}), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, true},
51+ {"empty pill shows nothing", fenceScene(1, 200, 100, ports.CaptureIndicator{Pill: true}), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, false},
52+ {"off-screen needs the pill", fenceScene(1, 200, 100, pill), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, true},
53+ {"off-screen border is not its mark", fenceScene(1, 200, 100, border(ports.Rect{W: 200, H: 100})), ports.CaptureRequest{ID: 1, Indicate: true, OffScreen: true, Workspace: 3, Region: whole}, false},
54+ } {
55+ t.Run(tc.name, func(t *testing.T) {
56+ require.Equal(t, tc.want, IndicatorShown(tc.scene, tc.req))
57+ })
58+ }
59+}
60+
61+// A request waits HoldFor for its indicator, then fails closed; one whose
62+// indicator shows is never failed. No allocation.
63+func TestExpireFailsOnlyTheRequestsWhoseIndicatorNeverCame(t *testing.T) {
64+ replies := make(chan ports.CaptureDone, 4)
65+ p := NewPipeline(context.Background(), replies)
66+ defer p.Close(nil)
67+ now := time.Unix(100, 0)
68+ s := fenceScene(1, 200, 100, ports.CaptureIndicator{Rect: ports.Rect{W: 200, H: 100}})
69+ shown := fenceRequest(1, image.Rect(0, 0, 200, 100), now.Add(-time.Hour))
70+ late := fenceRequest(2, image.Rect(0, 0, 200, 100), now.Add(-time.Hour))
71+ late.OffScreen = true
72+ young := fenceRequest(3, image.Rect(0, 0, 200, 100), now.Add(-HoldFor/2))
73+ young.OffScreen = true
74+ kept, wait := p.Expire(s, []ports.CaptureRequest{shown, late, young}, now)
75+ require.Equal(t, []uint64{1, 3}, ids(kept))
76+ require.Equal(t, HoldFor/2, wait)
77+ d := <-replies
78+ require.Equal(t, uint64(2), d.ID)
79+ require.ErrorIs(t, d.Err, ErrIndicatorMissing)
80+ // Nothing waits: no timer needed.
81+ _, wait = p.Expire(s, []ports.CaptureRequest{shown}, now)
82+ require.Zero(t, wait)
83+
84+ reqs := []ports.CaptureRequest{young, shown}
85+ if n := testing.AllocsPerRun(100, func() { _, _ = p.Expire(s, reqs, now) }); n != 0 {
86+ t.Fatalf("%v allocations", n)
87+ }
88+}
89+
90+// Hold hands over the requests whose indicator the scene shows and keeps the
91+// others, in place.
92+func TestHoldSplitsByIndicator(t *testing.T) {
93+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 4))
94+ defer p.Close(nil)
95+ now := time.Unix(100, 0)
96+ none := fenceScene(1, 200, 100)
97+ with := fenceScene(1, 200, 100, ports.CaptureIndicator{Rect: ports.Rect{W: 200, H: 100}})
98+ a := fenceRequest(1, image.Rect(0, 0, 200, 100), now)
99+ b := fenceRequest(2, image.Rect(0, 0, 200, 100), now)
100+ reqs := []ports.CaptureRequest{a, b}
101+ ready, held := p.Hold(none, reqs, now)
102+ require.Empty(t, ready)
103+ require.Equal(t, []uint64{1, 2}, ids(held))
104+ ready, held = p.Hold(with, reqs, now)
105+ require.Equal(t, []uint64{1, 2}, ids(ready))
106+ require.Empty(t, held)
107+ if n := testing.AllocsPerRun(100, func() { _, _ = p.Hold(with, reqs, now) }); n != 0 {
108+ t.Fatalf("%v allocations", n)
109+ }
110+}
111+
112+// Waiting keeps what was neither handed over nor shown.
113+func TestWaitingKeepsOnlyHeldRequests(t *testing.T) {
114+ now := time.Unix(100, 0)
115+ with := fenceScene(1, 200, 100, ports.CaptureIndicator{Rect: ports.Rect{W: 200, H: 100}})
116+ held := fenceRequest(3, image.Rect(0, 0, 200, 100), now)
117+ held.OffScreen = true
118+ reqs := []ports.CaptureRequest{{}, held, fenceRequest(4, image.Rect(0, 0, 200, 100), now)}
119+ got := Waiting(reqs, with)
120+ require.Equal(t, []uint64{3}, ids(got))
121+ require.Equal(t, 3, cap(reqs[:cap(got)]), "the owner's storage is kept")
122+ require.True(t, Handed(reqs[1]) && Handed(reqs[2]), "entries past the kept ones are zeroed")
123+}
124+
125+// A scale or mode change while a request is held: the request keeps its
126+// physical region, the fence judges it against the scene that will draw it.
127+// Whatever the new scene draws, the region is served only when the drawn mark
128+// covers it in that scene's pixels; otherwise it stays held and fails after
129+// HoldFor. Nothing is served unindicated.
130+func TestHeldRequestAcrossScaleAndModeChange(t *testing.T) {
131+ replies := make(chan ports.CaptureDone, 4)
132+ p := NewPipeline(context.Background(), replies)
133+ defer p.Close(nil)
134+ now := time.Unix(100, 0)
135+ region := image.Rect(10, 10, 30, 30) // physical, asked at scale 1
136+ q := fenceRequest(1, region, now)
137+ border := func(r ports.Rect) ports.CaptureIndicator { return ports.CaptureIndicator{Rect: r} }
138+ old := fenceScene(1, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 20, H: 20}))
139+ require.True(t, IndicatorShown(old, q))
140+
141+ for _, tc := range []struct {
142+ name string
143+ scene ports.Scene
144+ shown bool
145+ }{
146+ {"scale doubled, mark of the old logical region", fenceScene(2, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 20, H: 20})), false},
147+ {"scale doubled, mark re-derived for the region", fenceScene(2, 200, 100, border(ports.Rect{X: 5, Y: 5, W: 10, H: 10})), true},
148+ {"scale halved, old mark too small in pixels", fenceScene(0.5, 200, 100, border(ports.Rect{X: 10, Y: 10, W: 20, H: 20})), false},
149+ {"mode change, mark of the new whole output", fenceScene(1, 400, 200, border(ports.Rect{W: 400, H: 200})), true},
150+ {"mode change, no mark yet", fenceScene(1, 400, 200), false},
151+ } {
152+ t.Run(tc.name, func(t *testing.T) {
153+ require.Equal(t, tc.shown, IndicatorShown(tc.scene, q))
154+ reqs := []ports.CaptureRequest{q}
155+ ready, held := p.Hold(tc.scene, reqs, now)
156+ if tc.shown {
157+ require.Equal(t, []uint64{1}, ids(ready))
158+ return
159+ }
160+ require.Empty(t, ready)
161+ require.Equal(t, []uint64{1}, ids(held))
162+ // Still held before HoldFor; failed after, never served.
163+ kept, wait := p.Expire(tc.scene, held, now.Add(HoldFor/2))
164+ require.Len(t, kept, 1)
165+ require.Positive(t, wait)
166+ kept, _ = p.Expire(tc.scene, kept, now.Add(HoldFor))
167+ require.Empty(t, kept)
168+ d := <-replies
169+ require.ErrorIs(t, d.Err, ErrIndicatorMissing)
170+ })
171+ }
172+}
A internal/adapters/capture/indicator_test.go +114 -0
@@ -0,0 +1,114 @@
1+package capture
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func indicatorScene() ports.Scene {
16+ s := excludedTestScene()
17+ s.Capture = nil
18+ s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 10, H: 10}}, {Pill: true, Rect: ports.Rect{X: 1, Y: 1, W: 3, H: 3}}}
19+ return s
20+}
21+
22+// No capture scene ever carries the indicator: excluded scenes, plain scenes
23+// and the off-screen child are derived without it.
24+func TestCaptureScenesNeverCarryTheIndicator(t *testing.T) {
25+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
26+ defer p.Close(nil)
27+ s := indicatorScene()
28+ require.Nil(t, p.ExcludedScene(s).CaptureIndicators)
29+ s.Capture = excludedTestScene().Capture
30+ require.Nil(t, p.ExcludedScene(s).CaptureIndicators)
31+ require.Nil(t, plain(s).CaptureIndicators)
32+ require.Len(t, s.CaptureIndicators, 2, "the displayed scene keeps its indicator")
33+ // Even an exclusion-less request is fine: it is drawn from the plain frame.
34+ normal, excluded, _ := p.Split(s, []ports.CaptureRequest{{ID: 1}, {ID: 2, Exclude: true, Session: 7}})
35+ require.Equal(t, []uint64{1}, ids(normal))
36+ require.Equal(t, []uint64{2}, ids(excluded), "an Exclude request is drawn from ExcludedScene, never the displayed frame")
37+}
38+
39+// The requests of the displayed frame are answered from a frame rendered
40+// without the indicator before the displayed one.
41+func TestSubmitPlainRendersWithoutIndicatorFirst(t *testing.T) {
42+ replies := make(chan ports.CaptureDone, 1)
43+ p := NewPipeline(context.Background(), replies)
44+ r := portsmocks.NewMockRenderer(t)
45+ frame := portsmocks.NewMockCaptureFrame(t)
46+ frame.EXPECT().Done().Return((*os.File)(nil))
47+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(nil).Once()
48+ var rendered []ports.Scene
49+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
50+ rendered = append(rendered, s)
51+ return nil, nil
52+ }).Once()
53+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
54+ r.EXPECT().EndCapture(frame).Return().Once()
55+ q := pipelineRequest(t, 1)
56+ reqs := []ports.CaptureRequest{q}
57+ took, done, err := p.SubmitPlain(r, indicatorScene(), nil, reqs)
58+ require.NoError(t, err)
59+ require.Nil(t, done)
60+ require.True(t, took)
61+ require.True(t, Handed(reqs[0]))
62+ require.Len(t, rendered, 1)
63+ require.Nil(t, rendered[0].CaptureIndicators)
64+ require.Zero(t, rendered[0].Seq)
65+ require.Equal(t, uint64(1), awaitCapture(t, replies).ID)
66+ p.Close(r)
67+ frame.Calls = nil
68+}
69+
70+// Without indicator, or without requests, nothing is rendered and nothing
71+// allocates: the feature is free while no capture is shown.
72+func TestSubmitPlainIdleCostsNothing(t *testing.T) {
73+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
74+ defer p.Close(nil)
75+ r := portsmocks.NewMockRenderer(t)
76+ s := excludedTestScene()
77+ reqs := []ports.CaptureRequest{{ID: 1}}
78+ if took, _, err := p.SubmitPlain(r, s, nil, reqs); took || err != nil {
79+ t.Fatalf("took %v err %v without an indicator", took, err)
80+ }
81+ if took, _, err := p.SubmitPlain(r, indicatorScene(), nil, nil); took || err != nil {
82+ t.Fatalf("took %v err %v without requests", took, err)
83+ }
84+ if n := testing.AllocsPerRun(100, func() { _, _, _ = p.SubmitPlain(r, s, nil, reqs) }); n != 0 {
85+ t.Fatalf("%v allocations", n)
86+ }
87+}
88+
89+// A scene that carries the indicator is still gated: once the session is
90+// protected its plain frame is never copied, and the requests fail with the
91+// security error, closed, exactly once.
92+func TestSubmitPlainRejectsProtectedEpoch(t *testing.T) {
93+ locked := ports.SecurityState{Generation: 3, Protected: true}
94+ replies := make(chan ports.CaptureDone, 2)
95+ p := NewPipeline(context.Background(), replies)
96+ security := portsmocks.NewMockSessionSecurity(t)
97+ security.EXPECT().Snapshot().Return(locked).Maybe()
98+ p.Security = security
99+ r := portsmocks.NewMockRenderer(t)
100+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
101+ q := pipelineRequest(t, 1)
102+ file := q.Dst.File
103+ s := indicatorScene()
104+ s.Security = locked
105+ took, _, err := p.SubmitPlain(r, s, nil, []ports.CaptureRequest{q})
106+ require.NoError(t, err)
107+ require.True(t, took)
108+ p.Close(r)
109+ require.Len(t, replies, 1)
110+ require.ErrorIs(t, (<-replies).Err, ErrSecurityState)
111+ r.AssertNotCalled(t, "BeginCapture")
112+ _, statErr := file.Stat()
113+ require.ErrorIs(t, statErr, os.ErrClosed)
114+}
M internal/adapters/capture/offscreen.go +30 -38
@@ -15,11 +15,11 @@ import (
1515 )
1616
1717 // Offscreen capture: a session of a workspace that is not on screen is drawn
18-// by a dedicated child renderer from Scene.CaptureScene, only for clean
19-// requests and never shown. The child is a separate renderer instance: it has
18+// by a dedicated child renderer from Scene.CaptureScene, only for requests
19+// of that workspace and never shown. The child is a separate renderer instance: it has
2020 // its own Vulkan device, so its fences are not ordered against the display's.
21-// It is created on the first clean request of the session and closed when the
22-// scene stops carrying CaptureScene (once idle), when the session or the frame
21+// It is created on the first request of the workspace and closed when the
22+// scene stops carrying CaptureScene (once idle), when the workspace or the frame
2323 // size changes, and with the output.
2424
2525 const (
@@ -34,7 +34,7 @@ const (
3434 )
3535
3636 var (
37- // ErrOffscreenUnavailable fails a clean request whose source is a hidden
37+ // ErrOffscreenUnavailable fails a request whose source is a hidden
3838 // workspace that this output cannot draw (no child renderer factory).
3939 ErrOffscreenUnavailable = errors.New("workspace capture source is unavailable")
4040 // ErrOffscreenGeometry fails a request the child frame cannot serve.
@@ -65,22 +65,21 @@ func (s *fenceSlot) release() {
6565
6666 // offscreen is owned by the goroutine of its Pipeline.
6767 type offscreen struct {
68- factory func(w, h int) (ports.Renderer, error)
69- r ports.Renderer
70- p *Pipeline // leases of r
71- w, h int
72- session uint64
68+ factory func(w, h int) (ports.Renderer, error)
69+ r ports.Renderer
70+ p *Pipeline // leases of r
71+ w, h int
72+ workspace uint64
7373 // slots[:nf] are the unfinished child renders, oldest first.
7474 slots [maxChildFences]fenceSlot
7575 nf int
76- clean cleaner
7776 // capped is the scratch map CapHiddenSeen returns.
7877 capped map[ports.WindowID]uint64
7978 // reads is the scratch map of the oldest outstanding child reads.
8079 reads map[ports.WindowID]uint64
8180 }
8281
83-// EnableOffscreen lets the pipeline serve clean requests of a hidden
82+// EnableOffscreen lets the pipeline serve requests of a hidden
8483 // workspace with renderers made by factory. Without it they fail closed
8584 // with ErrOffscreenUnavailable.
8685 func (p *Pipeline) EnableOffscreen(factory func(w, h int) (ports.Renderer, error)) {
@@ -208,7 +207,7 @@ const (
208207 // A terminal fence error cannot be waited out, and its hold does not expire:
209208 // the child pipeline is cancelled and joined, then the device is closed (which
210209 // waits for the GPU), and only then are the holds cleared, so every later
211-// report lifts them. The next clean request builds a new child.
210+// report lifts them. The next request builds a new child.
212211 func (o *offscreen) reap() {
213212 k := 0
214213 failed := false
@@ -293,7 +292,7 @@ func (o *offscreen) close() {
293292 for i := range o.slots {
294293 o.slots[i].release()
295294 }
296- o.nf, o.w, o.h, o.session = 0, 0, 0, 0
295+ o.nf, o.w, o.h, o.workspace = 0, 0, 0, 0
297296 }
298297
299298 // childSize is the physical child image: the workspace frame at the scene
@@ -328,12 +327,11 @@ func (o *offscreen) track(done *os.File, drawn ports.Scene, surfaces map[ports.W
328327 o.nf++
329328 }
330329
331-// SubmitHidden serves the clean requests of s from the child renderer, which
332-// draws s.CaptureScene without the session's excluded surfaces. It consumes
330+// SubmitHidden serves the workspace requests of s from the child renderer, which
331+// draws s.CaptureScene without the capture indicator. It consumes
333332 // every request: answered by the worker, or failed here (the display is not
334333 // stopped by a child failure). Request regions are physical output pixels and
335-// must cover the whole child frame; they are rebased to its origin. The target
336-// must be the whole workspace frame too. Anything smaller needs the viewport
334+// must cover the whole child frame; they are rebased to its origin. Anything smaller needs the viewport
337335 // origin in output coordinates, which the scene does not carry, and fails with
338336 // ErrOffscreenGeometry.
339337 func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, reqs []ports.CaptureRequest) {
@@ -352,7 +350,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
352350 case s.Off:
353351 fail(errors.New("output off"), reqs)
354352 return
355- case s.CaptureScene == nil || s.Capture == nil:
353+ case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0:
356354 fail(ErrOffscreenUnavailable, reqs)
357355 return
358356 case o == nil || o.factory == nil:
@@ -364,17 +362,11 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
364362 }
365363 cs := s.CaptureScene
366364 w, h, err := childSize(cs)
367- if err == nil {
368- t := s.Capture.TargetRect
369- if t.W != cs.OutputWidth || t.H != cs.OutputHeight {
370- err = fmt.Errorf("%w: target %dx%d is not the frame %dx%d", ErrOffscreenGeometry, t.W, t.H, cs.OutputWidth, cs.OutputHeight)
371- }
372- }
373365 if err != nil {
374366 fail(err, reqs)
375367 return
376368 }
377- if o.r != nil && (o.session != s.Capture.Session || o.w != w || o.h != h) {
369+ if o.r != nil && (o.workspace != s.Capture.Workspace || o.w != w || o.h != h) {
378370 if o.busy() {
379371 fail(ErrOffscreenBusy, reqs)
380372 return
@@ -393,15 +385,15 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
393385 fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)
394386 return
395387 }
396- o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session
388+ o.r, o.p, o.w, o.h, o.workspace = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Workspace
397389 o.p.Security = p.Security
398390 }
399- // Request regions are physical pixels of the output; TargetRect is
400- // relative to the workspace viewport, so it cannot place them. The child
401- // image is the whole target (checked above): a request for all of it has
402- // the child's size and starts at the child's origin. A smaller region
403- // would need the viewport origin in output coordinates, which the scene
404- // does not carry; guessing it would return the wrong crop, so it fails.
391+ // Request regions are physical pixels of the output, and the scene does
392+ // not carry the workspace viewport's origin in output coordinates, so they
393+ // cannot place a crop. The child image is the whole frame: a request for
394+ // all of it has the child's size and starts at the child's origin. A
395+ // smaller region would need that origin; guessing it would return the
396+ // wrong crop, so it fails.
405397 k := 0
406398 for _, q := range reqs {
407399 if q.Region.Dx() != w || q.Region.Dy() != h {
@@ -417,10 +409,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
417409 if k == 0 {
418410 return
419411 }
420- // The child never shows the session's own surfaces or border.
421- child := *cs
422- child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded}
423- child = o.clean.scene(child)
412+ // The child never shows the indicator; it has no layers, so nothing of
413+ // an exclusion can be in it.
414+ child := plain(*cs)
424415 if !p.captureAllowed(s.Security) {
425416 fail(ErrSecurityState, reqs)
426417 return
@@ -435,7 +426,8 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
435426 fail(fmt.Errorf("render workspace: %w", err), reqs)
436427 return
437428 }
438- // Immutable parent scene epoch, not the cleaner's rewritten child scene.
429+ o.p.gating = p.gating
430+ // Immutable parent scene epoch, not the excluder's rewritten child scene.
439431 // SubmitScoped rechecks immediately before native BeginCapture.
440432 o.p.SubmitScoped(s.Security, o.r, reqs)
441433 }
M internal/adapters/capture/offscreen_test.go +45 -9
@@ -6,6 +6,7 @@ import (
66 "image"
77 "os"
88 "testing"
9+ "time"
910
1011 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1112 "github.com/bnema/neferwl/internal/ports"
@@ -16,7 +17,7 @@ import (
1617 func hiddenScene() ports.Scene {
1718 return ports.Scene{
1819 Scale: 2, OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1}},
19- Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 100, H: 50}, Revision: 1, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor},
20+ Capture: &ports.SceneCapture{Workspace: 9},
2021 CaptureScene: &ports.Scene{
2122 OutputWidth: 100, OutputHeight: 50, Scale: 2, Seq: 9,
2223 Windows: []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}},
@@ -27,7 +28,7 @@ func hiddenScene() ports.Scene {
2728
2829 func hiddenRequest(t *testing.T, id uint64, region image.Rectangle) ports.CaptureRequest {
2930 q := pipelineRequest(t, id)
30- q.Clean, q.Session, q.CaptureRevision = true, 7, 1
31+ q.Workspace, q.OffScreen = 9, true
3132 q.Region, q.Width, q.Height, q.Stride = region, region.Dx(), region.Dy(), region.Dx()*4
3233 require.NoError(t, q.Dst.File.Truncate(int64(q.Stride*q.Height)))
3334 return q
@@ -44,7 +45,7 @@ func childMock(t *testing.T, w, h int) (*portsmocks.MockRenderer, *portsmocks.Mo
4445 return r, frame
4546 }
4647
47-func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) {
48+func TestSubmitHiddenDrawsChildAndRebasesRegion(t *testing.T) {
4849 replies := make(chan ports.CaptureDone, 4)
4950 p := NewPipeline(context.Background(), replies)
5051 child, _ := childMock(t, 200, 100)
@@ -56,13 +57,12 @@ func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) {
5657 var made [2]int
5758 p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil })
5859 s := hiddenScene()
59- s.Capture.TargetRect = ports.Rect{W: 100, H: 50}
6060 p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(480, 360, 680, 460))})
6161 require.Equal(t, [2]int{200, 100}, made, "child image is the frame in physical pixels")
6262 require.Nil(t, scene.Capture)
6363 require.Zero(t, scene.Seq)
64- require.Equal(t, []ports.SceneWindow{{ID: 5}}, scene.Windows, "excluded popup left out")
65- require.Equal(t, []ports.SceneLayer{{ID: 11}}, scene.Layers, "excluded HUD layer left out")
64+ require.Equal(t, []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}}, scene.Windows)
65+ require.Equal(t, []ports.SceneLayer{{ID: 10}, {ID: 11}}, scene.Layers)
6666 require.NoError(t, awaitCapture(t, replies).Err)
6767 p.Close(nil)
6868 }
@@ -74,9 +74,9 @@ func TestSubmitHiddenFailsClosed(t *testing.T) {
7474 want error
7575 }{
7676 {"output off", func(s *ports.Scene) { s.Off = true }, nil},
77- {"target is not the frame", func(s *ports.Scene) { s.Capture.TargetRect.W = 50 }, ErrOffscreenGeometry},
78- {"frame too large", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 20000, 20000 }, ErrOffscreenGeometry},
79- {"frame empty", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 0, 0 }, ErrOffscreenGeometry},
77+ {"no hidden workspace in the scene", func(s *ports.Scene) { s.Capture.Workspace = 0 }, ErrOffscreenUnavailable},
78+ {"frame too large", func(s *ports.Scene) { s.CaptureScene.OutputWidth = 20000 }, ErrOffscreenGeometry},
79+ {"frame empty", func(s *ports.Scene) { s.CaptureScene.OutputWidth = 0 }, ErrOffscreenGeometry},
8080 } {
8181 t.Run(tc.name, func(t *testing.T) {
8282 replies := make(chan ports.CaptureDone, 2)
@@ -126,6 +126,42 @@ func TestSubmitHiddenRegionOutsideFrameFailsOthersServed(t *testing.T) {
126126 p.Close(nil)
127127 }
128128
129+// A hidden-workspace capture submitted while the indicator gate is open is held
130+// by the child pipeline: no reply until the owner's verdict, then it is
131+// released (nil) or failed (error) like a displayed capture.
132+func TestSubmitHiddenHeldByGateUntilVerdict(t *testing.T) {
133+ for _, tc := range []struct {
134+ name string
135+ verdict error
136+ }{
137+ {"released", nil},
138+ {"failed", GateVerdict(errors.New("frame dropped"))},
139+ } {
140+ t.Run(tc.name, func(t *testing.T) {
141+ replies := make(chan ports.CaptureDone, 4)
142+ p := NewPipeline(context.Background(), replies)
143+ child, _ := childMock(t, 200, 100)
144+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
145+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
146+ p.BeginGate()
147+ p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
148+ select {
149+ case d := <-replies:
150+ t.Fatalf("answered before the gate verdict: %v", d.Err)
151+ case <-time.After(100 * time.Millisecond):
152+ }
153+ p.EndGate(tc.verdict)
154+ got := awaitCapture(t, replies)
155+ if tc.verdict == nil {
156+ require.NoError(t, got.Err)
157+ } else {
158+ require.ErrorIs(t, got.Err, ErrIndicatorMissing)
159+ }
160+ p.Close(nil)
161+ })
162+ }
163+}
164+
129165 func TestSubmitHiddenChildClosesWhenSessionEnds(t *testing.T) {
130166 replies := make(chan ports.CaptureDone, 4)
131167 p := NewPipeline(context.Background(), replies)
M internal/adapters/capture/pipeline.go +85 -4
@@ -1,6 +1,7 @@
11 package capture
22
33 import (
4+ "cmp"
45 "context"
56 "errors"
67 "fmt"
@@ -26,6 +27,11 @@ type batch struct {
2627 requests [MaxRequests]ports.CaptureRequest
2728 count int
2829 at time.Time
30+ // gated: the worker delivers the requests only when the owner releases
31+ // the batch (see Pipeline.BeginGate). release carries its verdict; it is
32+ // made once and lives as long as the batch.
33+ gated bool
34+ release chan error
2935 }
3036
3137 // Pipeline moves fence waiting and SHM copies off the renderer's owner.
@@ -39,14 +45,22 @@ type Pipeline struct {
3945 jobs chan *batch
4046 completed chan *batch
4147 batches [pipelineSlots]batch
42- clean cleaner // scratch of CleanScene, owner only
43- off *offscreen
44- worker sync.WaitGroup
48+ // gating is set between BeginGate and EndGate; held are the batches
49+ // submitted meanwhile, waiting for their verdict. Owner only.
50+ gating bool
51+ held [pipelineSlots]*batch
52+ nheld int
53+ excl excluder // scratch of ExcludedScene, owner only
54+ off *offscreen
55+ worker sync.WaitGroup
4556 }
4657
4758 func NewPipeline(ctx context.Context, replies chan<- ports.CaptureDone) *Pipeline {
4859 ctx, cancel := context.WithCancel(ctx)
4960 p := &Pipeline{ctx: ctx, cancel: cancel, replies: replies, jobs: make(chan *batch, pipelineSlots), completed: make(chan *batch, pipelineSlots)}
61+ for i := range p.batches {
62+ p.batches[i].release = make(chan error, 1)
63+ }
5064 p.worker.Go(p.run)
5165 return p
5266 }
@@ -58,7 +72,55 @@ func (p *Pipeline) Completed() <-chan *batch { return p.completed }
5872 // Recycle returns a completed GPU readback lease on the renderer's owner.
5973 func (p *Pipeline) Recycle(b *batch, r ports.Renderer) {
6074 r.EndCapture(b.frame)
61- *b = batch{}
75+ rel := b.release
76+ select { // a verdict nobody took (the worker was cancelled) must not leak to the next use
77+ case <-rel:
78+ default:
79+ }
80+ *b = batch{release: rel}
81+}
82+
83+// GateVerdict is what a batch held by the indicator gate is failed with when
84+// the frame that carries the indicator did not reach the display: err wrapped
85+// in ErrIndicatorMissing; nil stays nil (released).
86+func GateVerdict(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ return fmt.Errorf("%w: %w", ErrIndicatorMissing, err)
91+}
92+
93+// ErrFrameNotPresented fails a gated batch whose owner never gave a verdict:
94+// the frame it waited for was dropped on a path that did not present it.
95+var ErrFrameNotPresented = fmt.Errorf("%w: frame not presented", ErrIndicatorMissing)
96+
97+// BeginGate makes the batches submitted from now on wait for EndGate: their
98+// worker does its fence wait, then holds the pixels back until the owner says
99+// the frame that carries the capture indicator is on screen. See EndGate.
100+func (p *Pipeline) BeginGate() { p.gating = true }
101+
102+// EndGate gives the verdict on the batches held since BeginGate and stops
103+// gating. A nil err releases them: their captures are written and answered. A
104+// non-nil err fails every one of their requests with it: no pixel is
105+// delivered. It covers the child pipeline of a hidden workspace too. Nothing
106+// held, nothing done: no allocation, no cost while no capture is gated.
107+func (p *Pipeline) EndGate(err error) {
108+ if p == nil {
109+ return
110+ }
111+ p.gating = false
112+ for i := range p.nheld {
113+ b := p.held[i]
114+ p.held[i] = nil
115+ select {
116+ case b.release <- err:
117+ default: // the verdict is already there
118+ }
119+ }
120+ p.nheld = 0
121+ if p.off != nil && p.off.p != nil {
122+ p.off.p.EndGate(err)
123+ }
62124 }
63125
64126 // SubmitScoped consumes requests with explicit immutable scene admission. The
@@ -97,6 +159,10 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req
97159 }
98160 b.count = copy(b.requests[:], requests)
99161 b.at = monotonicNow()
162+ if b.gated = p.gating; b.gated {
163+ p.held[p.nheld] = b
164+ p.nheld++
165+ }
100166 // There are exactly two reusable batches and two queue slots, so this
101167 // send cannot wait: no batch can be queued twice before recycling.
102168 p.jobs <- b
@@ -140,6 +206,21 @@ func (p *Pipeline) process(b *batch) {
140206 if err == nil && b.frame.Done() != nil {
141207 err = syncfile.Wait(p.ctx, b.frame.Done())
142208 }
209+ if b.gated {
210+ // The verdict is always taken, so none is left in the channel. The
211+ // owner's verdict, when it gave one, says why the capture failed
212+ // better than a cancelled context.
213+ var v error
214+ select {
215+ case v = <-b.release:
216+ case <-p.ctx.Done():
217+ select {
218+ case v = <-b.release:
219+ default:
220+ }
221+ }
222+ err = cmp.Or(v, err, p.ctx.Err())
223+ }
143224 for i := range b.count {
144225 q := b.requests[i]
145226 if err != nil {
A internal/adapters/captureallow/policy.go +151 -0
@@ -0,0 +1,151 @@
1+// Package captureallow reads the screen-capture allowlist: the executables
2+// that may capture the screen. The file is /etc/neferwl/capture-allow, one
3+// absolute executable path per line; it is root-owned and fixed, never read
4+// from the user config that a program of the same user could edit.
5+package captureallow
6+
7+import (
8+ "bufio"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "maps"
13+ "path/filepath"
14+ "strings"
15+)
16+
17+// DefaultPath is where the allowlist lives.
18+const DefaultPath = "/etc/neferwl/capture-allow"
19+
20+// maxSize bounds what is read: a bigger file is invalid.
21+const maxSize = 1 << 20
22+
23+// Mode is how a policy decides.
24+type Mode int
25+
26+const (
27+ // ModeList allows the listed executables only.
28+ ModeList Mode = iota
29+ // ModeDisabled allows every client: a `*` line.
30+ ModeDisabled
31+ // ModeClosed allows no client: the file is unreadable or invalid.
32+ ModeClosed
33+)
34+
35+// Policy is an immutable allowlist. The zero value allows nothing.
36+type Policy struct {
37+ mode Mode
38+ exes map[string]struct{}
39+}
40+
41+// Builtin returns the built-in list policy, used when the file is missing. A
42+// file replaces the list entirely. The list is built on each call, so no
43+// package state can be changed.
44+func Builtin() *Policy {
45+ builtin := [...]string{
46+ "/usr/bin/grim",
47+ "/usr/bin/nefercap",
48+ "/usr/lib/xdg-desktop-portal-wlr",
49+ "/usr/libexec/xdg-desktop-portal-wlr",
50+ }
51+ p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))}
52+ for _, e := range builtin {
53+ p.exes[e] = struct{}{}
54+ }
55+ return p
56+}
57+
58+// Disabled returns the policy that lets every client capture.
59+func Disabled() *Policy { return &Policy{mode: ModeDisabled} }
60+
61+// Closed returns the policy that lets no client capture.
62+func Closed() *Policy { return &Policy{mode: ModeClosed} }
63+
64+// Mode reports how the policy decides.
65+func (p *Policy) Mode() Mode { return p.mode }
66+
67+// Len is the number of listed executables.
68+func (p *Policy) Len() int { return len(p.exes) }
69+
70+func (p *Policy) equal(o *Policy) bool {
71+ return p.mode == o.mode && maps.Equal(p.exes, o.exes)
72+}
73+
74+// Allows reports whether the executable at path may capture. The comparison
75+// is exact, on the cleaned absolute path.
76+func (p *Policy) Allows(exe string) bool {
77+ switch p.mode {
78+ case ModeDisabled:
79+ return true
80+ case ModeClosed:
81+ return false
82+ }
83+ if !filepath.IsAbs(exe) {
84+ return false
85+ }
86+ _, ok := p.exes[filepath.Clean(exe)]
87+ return ok
88+}
89+
90+// ParseError names the offending line of an invalid file.
91+type ParseError struct {
92+ Line int
93+ Text string
94+ Msg string
95+}
96+
97+func (e *ParseError) Error() string {
98+ return fmt.Sprintf("line %d: %s: %q", e.Line, e.Msg, e.Text)
99+}
100+
101+// Parse reads the file format:
102+//
103+// - blank lines and lines starting with `#` are ignored;
104+// - a line `*` alone disables the check;
105+// - any other line is an absolute executable path, taken whole after
106+// trimming surrounding white space (a `#` inside a line is part of the
107+// path).
108+//
109+// Any other content is an error naming its line; the policy is then nil.
110+func Parse(r io.Reader) (*Policy, error) {
111+ p := &Policy{mode: ModeList, exes: map[string]struct{}{}}
112+ all := false
113+ sc := bufio.NewScanner(io.LimitReader(r, maxSize+1))
114+ sc.Buffer(make([]byte, 0, 4096), maxSize+1)
115+ n, read := 0, 0
116+ for sc.Scan() {
117+ n++
118+ raw := sc.Text()
119+ read += len(raw) + 1
120+ if read > maxSize {
121+ return nil, &ParseError{Line: n, Text: clip(raw), Msg: "file too large"}
122+ }
123+ line := strings.TrimSpace(raw)
124+ switch {
125+ case line == "" || strings.HasPrefix(line, "#"):
126+ case line == "*":
127+ all = true
128+ case !filepath.IsAbs(line) || strings.ContainsRune(line, 0) || strings.HasSuffix(line, "/"):
129+ return nil, &ParseError{Line: n, Text: clip(line), Msg: "want an absolute executable path"}
130+ default:
131+ p.exes[filepath.Clean(line)] = struct{}{}
132+ }
133+ }
134+ if err := sc.Err(); err != nil {
135+ if errors.Is(err, bufio.ErrTooLong) {
136+ return nil, &ParseError{Line: n + 1, Msg: "file too large"}
137+ }
138+ return nil, err
139+ }
140+ if all {
141+ return Disabled(), nil
142+ }
143+ return p, nil
144+}
145+
146+func clip(s string) string {
147+ if len(s) > 120 {
148+ return s[:120] + "..."
149+ }
150+ return s
151+}
A internal/adapters/captureallow/policy_test.go +100 -0
@@ -0,0 +1,100 @@
1+package captureallow
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestParse(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ mode Mode
14+ allowed []string
15+ refused []string
16+ errLine int
17+ wantFail bool
18+ }{
19+ {name: "paths comments blanks", in: "# header\n\n/usr/bin/grim\n /usr/lib/xdg-desktop-portal-wlr \n\t# indented comment\n/opt/a#b\n",
20+ mode: ModeList, allowed: []string{"/usr/bin/grim", "/usr/lib/xdg-desktop-portal-wlr", "/opt/a#b"}, refused: []string{"/usr/bin/grim2", "/usr/bin", "grim", ""}},
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"}},
23+ {name: "empty file allows nothing", in: "", mode: ModeList, refused: []string{"/usr/bin/grim"}},
24+ {name: "star disables", in: "# all\n*\n", mode: ModeDisabled, allowed: []string{"/anything"}},
25+ {name: "star with paths still disables", in: "/usr/bin/grim\n*\n", mode: ModeDisabled, allowed: []string{"/x"}},
26+ {name: "relative", in: "/ok\ngrim\n", errLine: 2, wantFail: true},
27+ {name: "dot relative", in: "./grim\n", errLine: 1, wantFail: true},
28+ {name: "glob is not star", in: "/usr/bin/*\n", mode: ModeList, allowed: []string{"/usr/bin/*"}, refused: []string{"/usr/bin/grim"}},
29+ {name: "star inside garbage", in: "# c\n\n* \nfoo bar\n", errLine: 4, wantFail: true},
30+ {name: "directory", in: "/usr/bin/\n", errLine: 1, wantFail: true},
31+ {name: "nul", in: "/usr/bin/g\x00rim\n", errLine: 1, wantFail: true},
32+ {name: "windows newline", in: "/a/b\r\n", mode: ModeList, allowed: []string{"/a/b"}},
33+ }
34+ for _, tc := range tests {
35+ t.Run(tc.name, func(t *testing.T) {
36+ p, err := Parse(strings.NewReader(tc.in))
37+ if tc.wantFail {
38+ var pe *ParseError
39+ if !errors.As(err, &pe) || pe.Line != tc.errLine || p != nil {
40+ t.Fatalf("Parse = %v, %v; want ParseError on line %d", p, err, tc.errLine)
41+ }
42+ return
43+ }
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if p.Mode() != tc.mode {
48+ t.Fatalf("mode %v, want %v", p.Mode(), tc.mode)
49+ }
50+ for _, e := range tc.allowed {
51+ if !p.Allows(e) {
52+ t.Errorf("%q refused", e)
53+ }
54+ }
55+ for _, e := range tc.refused {
56+ if p.Allows(e) {
57+ t.Errorf("%q allowed", e)
58+ }
59+ }
60+ })
61+ }
62+}
63+
64+func TestParseTooLarge(t *testing.T) {
65+ _, err := Parse(strings.NewReader(strings.Repeat("# filler line\n", maxSize/10)))
66+ var pe *ParseError
67+ if !errors.As(err, &pe) {
68+ t.Fatalf("err = %v, want ParseError", err)
69+ }
70+ _, err = Parse(strings.NewReader("/" + strings.Repeat("a", maxSize+10)))
71+ if !errors.As(err, &pe) {
72+ t.Fatalf("long line err = %v, want ParseError", err)
73+ }
74+}
75+
76+func TestBuiltin(t *testing.T) {
77+ p := Builtin()
78+ want := []string{"/usr/bin/grim", "/usr/bin/nefercap", "/usr/lib/xdg-desktop-portal-wlr", "/usr/libexec/xdg-desktop-portal-wlr"}
79+ if p.Mode() != ModeList || p.Len() != len(want) {
80+ t.Fatalf("mode %v, len %d", p.Mode(), p.Len())
81+ }
82+ for _, e := range want {
83+ if !p.Allows(e) {
84+ t.Fatalf("%s refused", e)
85+ }
86+ }
87+ if p.Allows("/usr/bin/other") {
88+ t.Fatal("unlisted allowed")
89+ }
90+}
91+
92+func TestPolicyModes(t *testing.T) {
93+ if !Disabled().Allows("/x") || Closed().Allows("/x") || (&Policy{}).Allows("/x") {
94+ t.Fatal("Disabled allows, Closed and the zero value refuse")
95+ }
96+ p, _ := Parse(strings.NewReader("/a\n"))
97+ if p.Allows("a") {
98+ t.Fatal("relative exe allowed")
99+ }
100+}
A internal/adapters/captureallow/store.go +289 -0
@@ -0,0 +1,289 @@
1+package captureallow
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "slices"
11+ "sync/atomic"
12+ "syscall"
13+ "time"
14+ "unsafe"
15+
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// load reads the file at path, which must be trustworthy. owner is the uid
21+// that must own the file, and may own its directories besides root.
22+// Production passes 0; tests pass their own uid. A missing file gives the
23+// built-in policy and an error wrapping fs.ErrNotExist; any other failure gives
24+// the closed policy and the error, so a broken or tampered-with file never
25+// opens capture.
26+//
27+// The file is opened without following a symlink and without blocking (a FIFO
28+// must not hang the compositor), then checked on the descriptor: a regular
29+// file, owned by owner, writable by nobody else. Every directory from / down
30+// to the file's own must be a real directory (not a symlink), owned by root or
31+// owner, and not group- or other-writable unless sticky. Anything else fails
32+// closed. Only a missing file, or a missing directory that holds the file
33+// (/etc/neferwl) under checked ancestors, gives the built-in list.
34+func load(path string, owner uint32) (*Policy, error) {
35+ path = filepath.Clean(path)
36+ if !filepath.IsAbs(path) {
37+ return Closed(), fmt.Errorf("%s: path is not absolute", path)
38+ }
39+ missing, err := checkDirs(path, owner)
40+ if err != nil {
41+ return Closed(), err
42+ }
43+ if missing {
44+ return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
45+ }
46+ var fd int
47+ for {
48+ fd, err = unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
49+ if err != unix.EINTR {
50+ break
51+ }
52+ }
53+ if err != nil {
54+ if errors.Is(err, unix.ENOENT) {
55+ return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
56+ }
57+ if errors.Is(err, unix.ELOOP) {
58+ return Closed(), fmt.Errorf("%s: is a symbolic link", path)
59+ }
60+ return Closed(), &fs.PathError{Op: "open", Path: path, Err: err}
61+ }
62+ f := os.NewFile(uintptr(fd), path)
63+ defer f.Close()
64+ var st unix.Stat_t
65+ if err := unix.Fstat(fd, &st); err != nil {
66+ return Closed(), &fs.PathError{Op: "stat", Path: path, Err: err}
67+ }
68+ switch {
69+ case st.Mode&unix.S_IFMT != unix.S_IFREG:
70+ return Closed(), fmt.Errorf("%s: not a regular file", path)
71+ case st.Uid != owner:
72+ return Closed(), fmt.Errorf("%s: owned by uid %d, want %d", path, st.Uid, owner)
73+ case st.Mode&0o022 != 0:
74+ return Closed(), fmt.Errorf("%s: writable by group or others (mode %04o)", path, st.Mode&0o7777)
75+ }
76+ // Reads of a regular file do not block; leave non-blocking mode off so the
77+ // os.File does not go through the poller.
78+ _ = unix.SetNonblock(fd, false)
79+ p, err := Parse(f)
80+ if err != nil {
81+ return Closed(), err
82+ }
83+ return p, nil
84+}
85+
86+// checkDirs checks every directory from / to the one holding path. It reports
87+// missing when that last directory does not exist.
88+func checkDirs(path string, owner uint32) (missing bool, err error) {
89+ var chain []string
90+ for d := filepath.Dir(path); ; d = filepath.Dir(d) {
91+ chain = append(chain, d)
92+ if d == "/" {
93+ break
94+ }
95+ }
96+ slices.Reverse(chain)
97+ for i, d := range chain {
98+ fi, err := os.Lstat(d)
99+ if err != nil {
100+ if errors.Is(err, fs.ErrNotExist) && i == len(chain)-1 {
101+ return true, nil
102+ }
103+ return false, err
104+ }
105+ st, ok := fi.Sys().(*syscall.Stat_t)
106+ switch {
107+ case !fi.IsDir():
108+ return false, fmt.Errorf("%s: not a real directory (symbolic link?)", d)
109+ case !ok:
110+ return false, fmt.Errorf("%s: owner unknown", d)
111+ case st.Uid != 0 && st.Uid != owner:
112+ return false, fmt.Errorf("%s: owned by uid %d, want root", d, st.Uid)
113+ case fi.Mode().Perm()&0o022 != 0 && fi.Mode()&os.ModeSticky == 0:
114+ return false, fmt.Errorf("%s: writable by group or others (mode %04o)", d, fi.Mode().Perm())
115+ }
116+ }
117+ return false, nil
118+}
119+
120+// Store holds the current policy and reloads it when the file changes. Policy
121+// is safe from any goroutine.
122+type Store struct {
123+ path string
124+ owner uint32
125+ log zerowrap.Logger
126+ cur atomic.Pointer[Policy]
127+ // retryInterval is the wait before watching a missing directory again.
128+ retryInterval time.Duration
129+}
130+
131+// NewStore loads path once and logs the outcome: an Info when the file is
132+// missing (the built-in list is then in force), an Error naming the line when it is
133+// invalid (no client may capture). Run keeps it current.
134+func NewStore(path string, log zerowrap.Logger) *Store {
135+ return NewStoreOwnedBy(path, 0, log)
136+}
137+
138+// NewStoreOwnedBy is NewStore for a file owned by owner instead of root, which
139+// then may also own its directories. For tests only, which cannot create
140+// root-owned files: the compositor uses NewStore.
141+func NewStoreOwnedBy(path string, owner uint32, log zerowrap.Logger) *Store {
142+ s := &Store{path: path, owner: owner, log: log, retryInterval: 2 * time.Second}
143+ s.reload(true, false)
144+ return s
145+}
146+
147+// Policy is the policy in force.
148+func (s *Store) Policy() *Policy { return s.cur.Load() }
149+
150+// reload loads the file and logs the outcome. With quiet set it logs only
151+// when the policy changed: the check that closes the gap between the first
152+// load and the watch must not repeat the startup line.
153+func (s *Store) reload(startup, quiet bool) {
154+ p, err := load(s.path, s.owner)
155+ old := s.cur.Swap(p)
156+ if quiet && old != nil && old.equal(p) {
157+ return
158+ }
159+ switch {
160+ case errors.Is(err, fs.ErrNotExist):
161+ s.log.Info().Str("path", s.path).Msg("capture allowlist file missing, using the built-in list")
162+ case err != nil:
163+ e := s.log.Error().Err(err).Str("path", s.path)
164+ var pe *ParseError
165+ if errors.As(err, &pe) {
166+ e = e.Int("line", pe.Line)
167+ }
168+ e.Msg("capture allowlist invalid, no client may capture")
169+ case p.Mode() == ModeDisabled:
170+ s.log.Warn().Str("path", s.path).Msg("capture allowlist disables the check, every client may capture")
171+ case startup:
172+ s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist loaded")
173+ default:
174+ s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist reloaded")
175+ }
176+}
177+
178+// Run reloads the file whenever it changes, until ctx is done. It watches the
179+// parent directory, so an atomic replacement is seen; a symlinked file's
180+// target is not watched. While the directory is missing it retries every two
181+// seconds. An error means the watch could not be set up: the policy then
182+// stays as loaded ("restart to apply").
183+func (s *Store) Run(ctx context.Context) error {
184+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
185+ if err != nil {
186+ return err
187+ }
188+ defer unix.Close(fd)
189+ dir, name := filepath.Dir(s.path), filepath.Base(s.path)
190+ wd := -1
191+ var retry, pending time.Time
192+ quiet := false
193+ buf := make([]byte, 4096)
194+ for {
195+ if ctx.Err() != nil {
196+ return nil
197+ }
198+ if wd < 0 && (retry.IsZero() || !time.Now().Before(retry)) {
199+ retry = time.Time{}
200+ w, err := unix.InotifyAddWatch(fd, dir, watchMask)
201+ switch {
202+ case err == nil:
203+ wd = w
204+ // The file may have changed before the watch existed.
205+ pending, quiet = time.Now(), true
206+ case errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR):
207+ retry = time.Now().Add(s.retryInterval)
208+ default:
209+ return err
210+ }
211+ }
212+ timeout := 200
213+ if !retry.IsZero() {
214+ timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
215+ }
216+ if !pending.IsZero() {
217+ remaining := time.Until(pending)
218+ if remaining <= 0 {
219+ pending = time.Time{}
220+ s.reload(false, quiet)
221+ quiet = false
222+ continue
223+ }
224+ timeout = min(timeout, int(remaining.Milliseconds())+1)
225+ }
226+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
227+ if _, err := unix.Poll(fds, timeout); err != nil {
228+ if err == unix.EINTR {
229+ continue
230+ }
231+ return err
232+ }
233+ if fds[0].Revents&unix.POLLIN == 0 {
234+ continue
235+ }
236+ for {
237+ n, err := unix.Read(fd, buf)
238+ if err == unix.EINTR {
239+ continue
240+ }
241+ if err != nil || n == 0 {
242+ break
243+ }
244+ for off := 0; off+unix.SizeofInotifyEvent <= n; {
245+ ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
246+ size := unix.SizeofInotifyEvent + int(ev.Len)
247+ if off+size > n {
248+ break
249+ }
250+ reload, lost := eventAction(ev.Mask, buf[off+unix.SizeofInotifyEvent:off+size], name)
251+ if lost {
252+ // The directory is gone: watch again once it is back.
253+ wd = -1
254+ retry = time.Now().Add(s.retryInterval)
255+ }
256+ if reload {
257+ // Every write restarts the debounce: never read half a file.
258+ pending, quiet = time.Now().Add(100*time.Millisecond), false
259+ }
260+ off += size
261+ }
262+ }
263+ }
264+}
265+
266+// watchMask is what the directory watch listens for.
267+const watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_CREATE | unix.IN_DELETE | unix.IN_ATTRIB
268+
269+// eventAction says what one inotify event asks for: reload the file, and
270+// watch the directory again because it was removed. A queue overflow means
271+// events were lost, so the file is reloaded whatever it was.
272+func eventAction(mask uint32, raw []byte, name string) (reload, lost bool) {
273+ if mask&unix.IN_Q_OVERFLOW != 0 {
274+ return true, false
275+ }
276+ if mask&unix.IN_IGNORED != 0 {
277+ reload, lost = true, true
278+ }
279+ for len(raw) > 0 && raw[len(raw)-1] == 0 {
280+ raw = raw[:len(raw)-1]
281+ }
282+ switch {
283+ case len(raw) == 0 && mask&unix.IN_ATTRIB != 0:
284+ reload = true // the directory itself was chmod'ed or chown'ed
285+ case len(raw) > 0 && mask&watchMask != 0 && string(raw) == name:
286+ reload = true
287+ }
288+ return reload, lost
289+}
A internal/adapters/captureallow/store_test.go +302 -0
@@ -0,0 +1,302 @@
1+package captureallow
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+ "golang.org/x/sys/unix"
17+)
18+
19+// logBuf is a locked bytes.Buffer, a real io.Writer.
20+type logBuf struct {
21+ mu sync.Mutex
22+ b bytes.Buffer
23+}
24+
25+func (l *logBuf) Write(p []byte) (int, error) {
26+ l.mu.Lock()
27+ defer l.mu.Unlock()
28+ return l.b.Write(p)
29+}
30+
31+func (l *logBuf) String() string {
32+ l.mu.Lock()
33+ defer l.mu.Unlock()
34+ return l.b.String()
35+}
36+
37+func testLog(out *logBuf) zerowrap.Logger {
38+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "captureallow")
39+}
40+
41+// uid owns every file the tests create, standing in for root.
42+var uid = uint32(os.Getuid())
43+
44+func newTestStore(path string, log zerowrap.Logger) *Store { return NewStoreOwnedBy(path, uid, log) }
45+
46+func TestLoad(t *testing.T) {
47+ dir := t.TempDir()
48+ p, err := load(filepath.Join(dir, "missing"), uid)
49+ if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
50+ t.Fatalf("missing: %v, %v", p.Mode(), err)
51+ }
52+ bad := filepath.Join(dir, "bad")
53+ _ = os.WriteFile(bad, []byte("/ok\nrelative\n"), 0o644)
54+ p, err = load(bad, uid)
55+ var pe *ParseError
56+ if !errors.As(err, &pe) || pe.Line != 2 || p.Mode() != ModeClosed || p.Allows("/ok") {
57+ t.Fatalf("invalid: %v, %v", p.Mode(), err)
58+ }
59+ // A directory is unreadable as a file: fail closed, not disabled.
60+ p, err = load(dir, uid)
61+ if err == nil || p.Mode() != ModeClosed {
62+ t.Fatalf("directory: %v, %v", p.Mode(), err)
63+ }
64+}
65+
66+func TestStoreStartupLogs(t *testing.T) {
67+ dir := t.TempDir()
68+ var out logBuf
69+ s := newTestStore(filepath.Join(dir, "capture-allow"), testLog(&out))
70+ if !s.Policy().Allows("/usr/bin/grim") || s.Policy().Allows("/any") {
71+ t.Fatal("missing file must give the built-in list")
72+ }
73+ log := out.String()
74+ if !strings.Contains(log, `"level":"info"`) || !strings.Contains(log, "capture allowlist file missing, using the built-in list") || !strings.Contains(log, `"component":"captureallow"`) || !strings.Contains(log, filepath.Join(dir, "capture-allow")) {
75+ t.Fatalf("log = %s", log)
76+ }
77+ bad := filepath.Join(dir, "bad")
78+ _ = os.WriteFile(bad, []byte("/ok\n\nnope\n"), 0o644)
79+ out = logBuf{}
80+ s = newTestStore(bad, testLog(&out))
81+ if s.Policy().Allows("/ok") {
82+ t.Fatal("invalid file must fail closed")
83+ }
84+ log = out.String()
85+ if !strings.Contains(log, `"level":"error"`) || !strings.Contains(log, `"line":3`) {
86+ t.Fatalf("log = %s", log)
87+ }
88+}
89+
90+func waitFor(t *testing.T, what string, cond func() bool) {
91+ t.Helper()
92+ deadline := time.Now().Add(5 * time.Second)
93+ for !cond() {
94+ if time.Now().After(deadline) {
95+ t.Fatal("timeout: " + what)
96+ }
97+ time.Sleep(5 * time.Millisecond)
98+ }
99+}
100+
101+func TestStoreReloads(t *testing.T) {
102+ dir := t.TempDir()
103+ path := filepath.Join(dir, "capture-allow")
104+ var out logBuf
105+ s := newTestStore(path, testLog(&out))
106+ ctx, cancel := context.WithCancel(context.Background())
107+ done := make(chan error, 1)
108+ go func() { done <- s.Run(ctx) }()
109+ defer func() {
110+ cancel()
111+ if err := <-done; err != nil {
112+ t.Errorf("Run: %v", err)
113+ }
114+ }()
115+ write := func(content string) {
116+ tmp := path + ".tmp"
117+ if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := os.Rename(tmp, path); err != nil { // atomic, as packages and editors do
121+ t.Fatal(err)
122+ }
123+ }
124+ write("/usr/bin/grim\n")
125+ waitFor(t, "list", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/grim") })
126+ if s.Policy().Allows("/usr/bin/other") {
127+ t.Fatal("unlisted allowed")
128+ }
129+ write("/usr/bin/other\nbroken\n")
130+ waitFor(t, "closed", func() bool { return s.Policy().Mode() == ModeClosed })
131+ write("*\n")
132+ waitFor(t, "disabled", func() bool { return s.Policy().Mode() == ModeDisabled })
133+ // In-place rewrite.
134+ if err := os.WriteFile(path, []byte("/usr/bin/third\n"), 0o644); err != nil {
135+ t.Fatal(err)
136+ }
137+ waitFor(t, "in place", func() bool { return s.Policy().Allows("/usr/bin/third") && !s.Policy().Allows("/x") })
138+ if err := os.Remove(path); err != nil {
139+ t.Fatal(err)
140+ }
141+ waitFor(t, "removed", func() bool { return s.Policy().Allows("/usr/bin/grim") && !s.Policy().Allows("/usr/bin/third") })
142+ if s.Policy().Mode() != ModeList || s.Policy().Len() != Builtin().Len() {
143+ t.Fatal("deleting the file must restore the built-in list")
144+ }
145+}
146+
147+func TestStoreWaitsForMissingDirectory(t *testing.T) {
148+ dir := filepath.Join(t.TempDir(), "etc", "neferwl")
149+ path := filepath.Join(dir, "capture-allow")
150+ var out logBuf
151+ s := newTestStore(path, testLog(&out))
152+ s.retryInterval = 20 * time.Millisecond
153+ ctx, cancel := context.WithCancel(context.Background())
154+ done := make(chan error, 1)
155+ go func() { done <- s.Run(ctx) }()
156+ defer func() { cancel(); <-done }()
157+ time.Sleep(60 * time.Millisecond)
158+ if err := os.MkdirAll(dir, 0o755); err != nil {
159+ t.Fatal(err)
160+ }
161+ if err := os.WriteFile(path, []byte("/usr/bin/other\n"), 0o644); err != nil {
162+ t.Fatal(err)
163+ }
164+ waitFor(t, "list after mkdir", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/other") })
165+}
166+
167+func mustWrite(t *testing.T, path, content string, mode os.FileMode) {
168+ t.Helper()
169+ if err := os.WriteFile(path, []byte(content), mode); err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := os.Chmod(path, mode); err != nil { // umask
173+ t.Fatal(err)
174+ }
175+}
176+
177+// Every way the file or its directories can be untrustworthy fails closed.
178+func TestLoadRefusesUntrusted(t *testing.T) {
179+ good := func(t *testing.T) (dir, path string) {
180+ dir = filepath.Join(t.TempDir(), "neferwl")
181+ if err := os.Mkdir(dir, 0o755); err != nil {
182+ t.Fatal(err)
183+ }
184+ path = filepath.Join(dir, "capture-allow")
185+ mustWrite(t, path, "/usr/bin/grim\n", 0o644)
186+ return dir, path
187+ }
188+ if p, err := load(func() string { _, p := good(t); return p }(), uid); err != nil || !p.Allows("/usr/bin/grim") {
189+ t.Fatalf("trusted file: %v", err)
190+ }
191+ for _, tc := range []struct {
192+ name string
193+ setup func(t *testing.T, dir, path string) string
194+ owner uint32
195+ want string
196+ }{
197+ {"group writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o664); return path }, uid, "writable by group or others"},
198+ {"other writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o646); return path }, uid, "writable by group or others"},
199+ {"wrong owner", func(t *testing.T, _, path string) string { return path }, uid + 1, "owned by uid"},
200+ {"symlink file", func(t *testing.T, dir, path string) string {
201+ link := filepath.Join(dir, "link")
202+ if err := os.Symlink(path, link); err != nil {
203+ t.Fatal(err)
204+ }
205+ return link
206+ }, uid, "symbolic link"},
207+ {"fifo", func(t *testing.T, dir, _ string) string {
208+ p := filepath.Join(dir, "fifo")
209+ if err := unix.Mkfifo(p, 0o644); err != nil {
210+ t.Fatal(err)
211+ }
212+ return p
213+ }, uid, "not a regular file"},
214+ {"group writable directory", func(t *testing.T, dir, path string) string {
215+ if err := os.Chmod(dir, 0o775); err != nil {
216+ t.Fatal(err)
217+ }
218+ return path
219+ }, uid, "writable by group or others"},
220+ {"symlinked directory", func(t *testing.T, dir, path string) string {
221+ link := filepath.Join(filepath.Dir(dir), "via")
222+ if err := os.Symlink(dir, link); err != nil {
223+ t.Fatal(err)
224+ }
225+ return filepath.Join(link, "capture-allow")
226+ }, uid, "not a real directory"},
227+ {"directory owned by someone else", func(t *testing.T, _, path string) string { return path }, 1, "owned by uid"},
228+ {"missing ancestor", func(t *testing.T, dir, _ string) string {
229+ return filepath.Join(dir, "gone", "deeper", "capture-allow")
230+ }, uid, "no such file"},
231+ } {
232+ t.Run(tc.name, func(t *testing.T) {
233+ dir, path := good(t)
234+ path = tc.setup(t, dir, path)
235+ p, err := load(path, tc.owner)
236+ if err == nil || p.Mode() != ModeClosed || p.Allows("/usr/bin/grim") || !strings.Contains(err.Error(), tc.want) {
237+ t.Fatalf("mode %v, err %v; want closed, %q", p.Mode(), err, tc.want)
238+ }
239+ })
240+ }
241+ // A sticky, world-writable ancestor (like /tmp) is fine; the directory
242+ // holding the file being absent gives the built-in list.
243+ t.Run("missing holding directory", func(t *testing.T) {
244+ dir, _ := good(t)
245+ p, err := load(filepath.Join(dir, "sub", "capture-allow"), uid)
246+ if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
247+ t.Fatalf("mode %v, err %v", p.Mode(), err)
248+ }
249+ })
250+ t.Run("sticky directory", func(t *testing.T) {
251+ dir, path := good(t)
252+ if err := os.Chmod(dir, 0o777|os.ModeSticky); err != nil {
253+ t.Fatal(err)
254+ }
255+ if p, err := load(path, uid); err != nil || p.Mode() != ModeList {
256+ t.Fatalf("mode %v, err %v", p.Mode(), err)
257+ }
258+ })
259+}
260+
261+// The real production check: this file is not root's, so load closes.
262+func TestLoadProductionOwner(t *testing.T) {
263+ if os.Getuid() == 0 {
264+ t.Skip("running as root")
265+ }
266+ path := filepath.Join(t.TempDir(), "capture-allow")
267+ mustWrite(t, path, "*\n", 0o644)
268+ if p, err := load(path, 0); err == nil || p.Mode() != ModeClosed {
269+ t.Fatalf("mode %v, err %v", p.Mode(), err)
270+ }
271+}
272+
273+func TestEventAction(t *testing.T) {
274+ name := []byte("capture-allow\x00\x00\x00")
275+ for _, tc := range []struct {
276+ mask uint32
277+ raw []byte
278+ reload, lost bool
279+ }{
280+ {unix.IN_Q_OVERFLOW, nil, true, false},
281+ {unix.IN_IGNORED, nil, true, true},
282+ {unix.IN_CLOSE_WRITE, name, true, false},
283+ {unix.IN_ATTRIB, name, true, false},
284+ {unix.IN_ATTRIB, nil, true, false},
285+ {unix.IN_CLOSE_WRITE, []byte("other\x00\x00\x00"), false, false},
286+ } {
287+ if reload, lost := eventAction(tc.mask, tc.raw, "capture-allow"); reload != tc.reload || lost != tc.lost {
288+ t.Errorf("mask %#x %q = %v, %v; want %v, %v", tc.mask, tc.raw, reload, lost, tc.reload, tc.lost)
289+ }
290+ }
291+}
292+
293+func TestStoreLogsUntrusted(t *testing.T) {
294+ dir := t.TempDir()
295+ path := filepath.Join(dir, "capture-allow")
296+ mustWrite(t, path, "*\n", 0o666)
297+ var out logBuf
298+ s := newTestStore(path, testLog(&out))
299+ if s.Policy().Allows("/any") || !strings.Contains(out.String(), `"level":"error"`) || !strings.Contains(out.String(), "writable by group or others") {
300+ t.Fatalf("log = %s", out.String())
301+ }
302+}
R internal/adapters/drm/capture_session_test.go → internal/adapters/drm/capture_test.go +321 -49 renamed, 54% similar
@@ -5,6 +5,8 @@ import (
55 "errors"
66 "image"
77 "os"
8+ "sync"
9+ "sync/atomic"
810 "testing"
911 "time"
1012
@@ -15,27 +17,28 @@ import (
1517 "github.com/stretchr/testify/require"
1618 )
1719
18-func sessionRequest(t *testing.T, id uint64, clean bool) ports.CaptureRequest {
20+// sessionRequest is a capture request; exclude sets the exclusion of session 7.
21+func sessionRequest(t *testing.T, id uint64, exclude bool) ports.CaptureRequest {
1922 t.Helper()
2023 f, err := os.CreateTemp(t.TempDir(), "capture")
2124 require.NoError(t, err)
2225 t.Cleanup(func() { _ = f.Close() })
2326 require.NoError(t, f.Truncate(16))
2427 q := ports.CaptureRequest{ID: id, Dst: ports.SHMBuffer{File: f}, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1}
25- if clean {
26- q.Clean, q.Session = true, 7
28+ if exclude {
29+ q.Exclude, q.Session, q.CaptureRevision = true, 7, 1
2730 }
2831 return q
2932 }
3033
3134 func drmSessionScene() ports.Scene {
3235 return ports.Scene{Seq: 4, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
33- Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}}
36+ Capture: &ports.SceneCapture{Session: 7, Revision: 1, Excluded: []ports.WindowID{10}}}
3437 }
3538
36-// The clean frame is drawn and copied before the displayed frame, on the
39+// The excluded frame is drawn and copied before the displayed frame, on the
3740 // same target; the standard capture is taken after the displayed frame.
38-func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) {
41+func TestSubmitFrameExcludeBeforeDisplayedThenStandard(t *testing.T) {
3942 o, _, _ := testOutput(t)
4043 o.cursor = nil
4144 var order []string
@@ -44,8 +47,8 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) {
4447 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
4548 if s.Capture == nil {
4649 require.Zero(t, s.Seq)
47- require.Len(t, s.Layers, 0, "excluded HUD layer left out of the clean frame")
48- order = append(order, "clean")
50+ require.Len(t, s.Layers, 0, "excluded HUD layer left out of the excluded frame")
51+ order = append(order, "excluded")
4952 } else {
5053 require.Len(t, s.Layers, 1, "displayed frame keeps the HUD")
5154 order = append(order, "shown")
@@ -66,7 +69,7 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) {
6669 direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline)
6770 require.NoError(t, err)
6871 require.False(t, direct)
69- require.Equal(t, []string{"clean", "copy", "shown", "copy"}, order)
72+ require.Equal(t, []string{"excluded", "copy", "shown", "copy"}, order)
7073 for _, q := range requests {
7174 require.True(t, capture.Handed(q), "requests handed to the worker")
7275 }
@@ -74,24 +77,30 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) {
7477 require.Len(t, replies, 2)
7578 }
7679
77-// A fullscreen window would be scanned out (no border): a session forces
78-// composition even when no capture is requested this frame.
79-func TestSessionScenePreventsDirectScanoutDecision(t *testing.T) {
80+// A fullscreen window would be scanned out: a capture request of the
81+// displayed frame forces composition.
82+func TestCaptureRequestPreventsDirectScanout(t *testing.T) {
8083 o, _, _ := testOutput(t)
8184 o.cursor = nil
8285 r := portsmocks.NewMockRenderer(t)
8386 r.EXPECT().UseTarget(0).Return().Once()
8487 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
85- pipeline := capture.NewPipeline(context.Background(), nil)
86- defer pipeline.Close(r)
87- direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, nil, pipeline)
88+ frame := portsmocks.NewMockCaptureFrame(t)
89+ frame.EXPECT().Done().Return(nil).Maybe()
90+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
91+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
92+ r.EXPECT().EndCapture(frame).Return().Once()
93+ replies := make(chan ports.CaptureDone, 2)
94+ pipeline := capture.NewPipeline(context.Background(), replies)
95+ direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, []ports.CaptureRequest{sessionRequest(t, 2, false)}, pipeline)
8896 require.NoError(t, err)
8997 require.False(t, direct)
9098 require.Equal(t, "capture", o.reason)
99+ pipeline.Close(r)
91100 }
92101
93-// A full render failing after the clean group was handed off: the caller
94-// still owns only the standard request; the clean one is zeroed, so it is
102+// A full render failing after the Exclude group was handed off: the caller
103+// still owns only the standard request; the Exclude one is zeroed, so it is
95104 // never answered twice.
96105 func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) {
97106 o, _, _ := testOutput(t)
@@ -121,12 +130,12 @@ func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) {
121130 }
122131 require.Equal(t, 1, owned)
123132 pipeline.Close(r)
124- require.Len(t, replies, 1) // the clean capture, answered once by the worker
133+ require.Len(t, replies, 1) // the Exclude capture, answered once by the worker
125134 }
126135
127-// A clean request of a session the scene no longer carries fails closed
136+// An Exclude request of a session the scene no longer carries fails closed
128137 // and is never captured from the displayed frame.
129-func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) {
138+func TestSubmitFrameExcludeRequestWithoutSessionFails(t *testing.T) {
130139 o, _, _ := testOutput(t)
131140 o.cursor = nil
132141 r := portsmocks.NewMockRenderer(t)
@@ -145,19 +154,17 @@ func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) {
145154 require.True(t, capture.Handed(requests[0]))
146155 }
147156
148-// A session with no visible border (and no HUD in the scene) must not cost
149-// the fullscreen window its direct scanout; a visible border must.
150-func TestSessionMetadataWithoutBorderKeepsDirectScanout(t *testing.T) {
157+// Capture state alone (a session, an exclusion, a hidden workspace) must
158+// not cost a fullscreen window its direct scanout: only requests that read
159+// the displayed frame do.
160+func TestCaptureStateKeepsDirectScanout(t *testing.T) {
151161 for _, tc := range []struct {
152- name string
153- capture *ports.SceneCapture
154- wantDirect bool
162+ name string
163+ capture *ports.SceneCapture
155164 }{
156- {"no session", nil, true},
157- {"session, no border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}}, true},
158- {"session, zero width", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderColor: ports.CaptureBorderColor}, true},
159- {"session, border wider than the target allows", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 1, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, true},
160- {"session, visible border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, false},
165+ {"no session", nil},
166+ {"session", &ports.SceneCapture{Shown: 3}},
167+ {"exclusion", &ports.SceneCapture{Session: 7, Revision: 1, Excluded: []ports.WindowID{10}}},
161168 } {
162169 t.Run(tc.name, func(t *testing.T) {
163170 o, k, _ := testOutput(t)
@@ -166,22 +173,17 @@ func TestSessionMetadataWithoutBorderKeepsDirectScanout(t *testing.T) {
166173 r := portsmocks.NewMockRenderer(t)
167174 s, c := fullscreenScene()
168175 s.Capture = tc.capture
169- if tc.wantDirect {
170- k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
171- } else {
172- r.EXPECT().UseTarget(0).Return().Once()
173- r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
174- }
176+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
175177 pipeline := capture.NewPipeline(context.Background(), nil)
176178 defer pipeline.Close(r)
177179 direct, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, pipeline)
178180 require.NoError(t, err)
179- require.Equal(t, tc.wantDirect, direct)
181+ require.True(t, direct)
180182 })
181183 }
182184 }
183185
184-// A clean request for a hidden workspace with no child renderer factory
186+// A request for a hidden workspace with no child renderer factory
185187 // fails closed; it is neither served from the displayed frame nor does it
186188 // stop the output.
187189 func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) {
@@ -195,12 +197,40 @@ func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) {
195197 defer pipeline.Close(r)
196198 scene := drmSessionScene()
197199 scene.CaptureScene = &ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
198- requests := []ports.CaptureRequest{sessionRequest(t, 1, true)}
200+ scene.Capture.Workspace = 9
201+ requests := []ports.CaptureRequest{sessionRequest(t, 1, false)}
202+ requests[0].Workspace, requests[0].OffScreen = 9, true
199203 _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline)
200204 require.NoError(t, err)
201205 require.ErrorIs(t, (<-replies).Err, capture.ErrOffscreenUnavailable)
202206 }
203207
208+// A hidden request whose scene carries no CaptureScene still goes to the
209+// hidden path, which fails it closed: it is neither dropped unanswered nor
210+// served from the displayed frame.
211+func TestSubmitFrameHiddenWorkspaceWithoutCaptureSceneFails(t *testing.T) {
212+ o, _, _ := testOutput(t)
213+ o.cursor = nil
214+ r := portsmocks.NewMockRenderer(t)
215+ r.EXPECT().UseTarget(0).Return().Once()
216+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() // the display only
217+ replies := make(chan ports.CaptureDone, 2)
218+ pipeline := capture.NewPipeline(context.Background(), replies)
219+ defer pipeline.Close(r)
220+ scene := drmSessionScene()
221+ scene.Capture.Workspace = 9
222+ requests := []ports.CaptureRequest{sessionRequest(t, 1, false)}
223+ requests[0].Workspace, requests[0].OffScreen = 9, true
224+ _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline)
225+ require.NoError(t, err)
226+ select {
227+ case d := <-replies:
228+ require.ErrorIs(t, d.Err, capture.ErrOffscreenUnavailable)
229+ case <-time.After(3 * time.Second):
230+ t.Fatal("hidden request without CaptureScene was dropped unanswered")
231+ }
232+}
233+
204234 // A report caps only the windows an unfinished child render drew; the display's
205235 // own windows are reported at their full content Seq, and the report goes on
206236 // (nothing waits for the child).
@@ -222,10 +252,11 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
222252 pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
223253 o.capHidden = pipeline.CapHiddenSeen
224254 scene := ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2,
225- Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}},
255+ Capture: &ports.SceneCapture{Workspace: 9},
226256 CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 2, H: 2}}}}}
227257 surfaces := map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}
228- req := sessionRequest(t, 1, true)
258+ req := sessionRequest(t, 1, false)
259+ req.Workspace, req.OffScreen = 9, true
229260 req.Region, req.Width, req.Height = image.Rect(0, 0, 2, 2), 2, 2
230261 pipeline.SubmitHidden(scene, surfaces, []ports.CaptureRequest{req})
231262
@@ -275,12 +306,10 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) {
275306 pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
276307 o.capHidden = pipeline.CapHiddenSeen
277308 s, c := fullscreenScene()
278- s.Capture = &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Revision: 1, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}
309+ s.Capture = &ports.SceneCapture{Workspace: 9}
279310 s.CaptureScene = &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}
280- // Hidden workspace: core sends the on-screen border off (BorderWidth 0).
281- s.Capture.BorderWidth, s.Capture.BorderColor = 0, ""
282- req := sessionRequest(t, 1, true)
283- req.CaptureRevision = 1
311+ req := sessionRequest(t, 1, false)
312+ req.Workspace, req.OffScreen = 9, true
284313 req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800
285314 require.NoError(t, req.Dst.File.Truncate(800*100))
286315 direct, err := o.submitFrame(context.Background(), display, s, c, map[ports.WindowID]uint64{}, []ports.CaptureRequest{req}, pipeline)
@@ -352,12 +381,13 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
352381 contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}
353382 // The request waits for the scene; once a display frame is pending no
354383 // request is served, so it must be queued before the scene arrives.
355- req := sessionRequest(t, 1, true)
384+ req := sessionRequest(t, 1, false)
385+ req.Workspace, req.OffScreen = 9, true
356386 req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800
357387 require.NoError(t, req.Dst.File.Truncate(800*100))
358388 captures <- req
359389 scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 200, OutputHeight: 100,
360- Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}},
390+ Capture: &ports.SceneCapture{Workspace: 9},
361391 CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}}
362392
363393 next := func(match func(ports.OutputPresented) bool) ports.OutputPresented {
@@ -392,3 +422,245 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
392422 cancel()
393423 require.NoError(t, <-done)
394424 }
425+
426+// A capture requested before the scene that shows its indicator is held: the
427+// frame on screen is not redrawn for it and nothing is copied. The first scene
428+// that shows the border draws the frame without it, copies that, then draws
429+// and commits the frame with the border.
430+func TestRunCaptureWaitsForItsIndicatorScene(t *testing.T) {
431+ o, k, commits, commitMu := testOutputMu(t)
432+ o.tearing, o.cursor, o.fbs = false, nil, [2]uint32{}
433+ flips := make(chan flipEvent, 4)
434+ o.flipped = flips
435+ buf := func() ports.DMABuf {
436+ f, w, _ := os.Pipe()
437+ w.Close()
438+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
439+ }
440+ // Only what the renderer draws for scenes after the modeset's black frame.
441+ var mu sync.Mutex
442+ var order []string
443+ note := func(what string) {
444+ mu.Lock()
445+ defer mu.Unlock()
446+ order = append(order, what)
447+ }
448+ r := portsmocks.NewMockRenderer(t)
449+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
450+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
451+ r.EXPECT().UseTarget(mock.Anything).Return()
452+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
453+ switch {
454+ case s.OutputWidth == 0:
455+ // The modeset's black frame.
456+ case s.Seq == 0:
457+ note("plain")
458+ case len(s.CaptureIndicators) > 0:
459+ note("indicated")
460+ default:
461+ note("bare")
462+ }
463+ return nil, nil
464+ })
465+ frame := portsmocks.NewMockCaptureFrame(t)
466+ frame.EXPECT().Done().Return(nil).Maybe()
467+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
468+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
469+ note("copy")
470+ return frame, nil
471+ }).Once()
472+ r.EXPECT().EndCapture(frame).Return().Maybe()
473+ r.EXPECT().Close().Return().Once()
474+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
475+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
476+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
477+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
478+
479+ scenes := make(chan ports.Scene, 2)
480+ captures := make(chan ports.CaptureRequest)
481+ captured := make(chan ports.CaptureDone, 4)
482+ ctx, cancel := context.WithCancel(context.Background())
483+ done := make(chan error, 1)
484+ go func() {
485+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 16), captures, captured)
486+ }()
487+ count := func() int {
488+ commitMu.Lock()
489+ defer commitMu.Unlock()
490+ return len(*commits)
491+ }
492+ last := func() commitRec {
493+ commitMu.Lock()
494+ defer commitMu.Unlock()
495+ return (*commits)[len(*commits)-1]
496+ }
497+ waitFor(t, func() bool { return count() == 2 }) // modeset
498+ bare := ports.Scene{Seq: 1, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}}
499+ scenes <- bare
500+ waitFor(t, func() bool { return count() == 3 })
501+ flips <- eventOf(last())
502+
503+ req := sessionRequest(t, 1, false)
504+ req.Indicate = true
505+ captures <- req
506+ select {
507+ case d := <-captured:
508+ t.Fatalf("served from a scene without the indicator: %+v", d)
509+ case <-time.After(60 * time.Millisecond):
510+ }
511+ require.Equal(t, 3, count(), "no frame is drawn for a held request")
512+
513+ indicated := bare
514+ indicated.Seq = 2
515+ indicated.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
516+ scenes <- indicated
517+ waitFor(t, func() bool { return count() == 4 })
518+ select {
519+ case d := <-captured:
520+ require.NoError(t, d.Err)
521+ case <-time.After(2 * time.Second):
522+ t.Fatal("not served once the indicator scene exists")
523+ }
524+ flips <- eventOf(last())
525+ cancel()
526+ require.NoError(t, <-done)
527+ mu.Lock()
528+ defer mu.Unlock()
529+ require.Equal(t, []string{"bare", "plain", "copy", "indicated"}, order, "copied from the frame without the indicator, committed together with the indicated frame")
530+}
531+
532+// A request whose indicator never comes fails after the bounded wait, and no
533+// frame is drawn or copied for it.
534+func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
535+ o, k, commits, commitMu := testOutputMu(t)
536+ o.tearing, o.cursor, o.fbs = false, nil, [2]uint32{}
537+ flips := make(chan flipEvent, 2)
538+ o.flipped = flips
539+ buf := func() ports.DMABuf {
540+ f, w, _ := os.Pipe()
541+ w.Close()
542+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
543+ }
544+ r := portsmocks.NewMockRenderer(t)
545+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
546+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
547+ r.EXPECT().UseTarget(mock.Anything).Return()
548+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
549+ r.EXPECT().Close().Return().Once()
550+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
551+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
552+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
553+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
554+ scenes := make(chan ports.Scene, 1)
555+ captures := make(chan ports.CaptureRequest)
556+ captured := make(chan ports.CaptureDone, 4)
557+ ctx, cancel := context.WithCancel(context.Background())
558+ done := make(chan error, 1)
559+ go func() {
560+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 16), captures, captured)
561+ }()
562+ count := func() int {
563+ commitMu.Lock()
564+ defer commitMu.Unlock()
565+ return len(*commits)
566+ }
567+ waitFor(t, func() bool { return count() == 2 })
568+ scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 2, OutputHeight: 2}
569+ waitFor(t, func() bool { return count() == 3 })
570+ commitMu.Lock()
571+ flips <- eventOf((*commits)[2])
572+ commitMu.Unlock()
573+ start := time.Now()
574+ req := sessionRequest(t, 1, false)
575+ req.Indicate = true
576+ captures <- req
577+ select {
578+ case d := <-captured:
579+ require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
580+ require.GreaterOrEqual(t, time.Since(start), capture.HoldFor-20*time.Millisecond)
581+ case <-time.After(3 * time.Second):
582+ t.Fatal("a held request was never answered")
583+ }
584+ require.Equal(t, 3, count())
585+ cancel()
586+ require.NoError(t, <-done)
587+}
588+
589+func indicatedScene() ports.Scene {
590+ return ports.Scene{Seq: 4, OutputWidth: 200, OutputHeight: 100, Scale: 1, Windows: []ports.SceneWindow{{ID: 1}},
591+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 200, H: 100}}}}
592+}
593+
594+// A capture is delivered only when the frame that carries its indicator is
595+// committed: the plain frame is rendered and copied, but the pixels stay
596+// behind the gate and the capture fails when that frame fails to render or to
597+// commit. The mock frame has no Read: a delivery would panic.
598+func TestSubmitFrameFailedIndicatedFrameDeliversNothing(t *testing.T) {
599+ boom := errors.New("boom")
600+ for _, tc := range []struct {
601+ name string
602+ renders []error // plain, then indicated
603+ commit []error
604+ }{
605+ {"render error", []error{nil, boom}, nil},
606+ {"commit error", []error{nil, nil}, []error{boom}},
607+ } {
608+ t.Run(tc.name, func(t *testing.T) {
609+ o, _, _ := testOutput(t, tc.commit...)
610+ o.cursor = nil
611+ r := portsmocks.NewMockRenderer(t)
612+ r.EXPECT().UseTarget(0).Return().Once()
613+ for _, err := range tc.renders {
614+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, err).Once()
615+ }
616+ frame := portsmocks.NewMockCaptureFrame(t)
617+ frame.EXPECT().Done().Return(nil).Maybe()
618+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
619+ r.EXPECT().EndCapture(frame).Return().Once()
620+ replies := make(chan ports.CaptureDone, 4)
621+ pipeline := capture.NewPipeline(context.Background(), replies)
622+ q := sessionRequest(t, 1, false)
623+ _, err := o.submitFrame(context.Background(), r, indicatedScene(), nil, nil, []ports.CaptureRequest{q}, pipeline)
624+ require.Error(t, err)
625+ var done ports.CaptureDone
626+ select {
627+ case done = <-replies:
628+ case <-time.After(5 * time.Second):
629+ t.Fatal("no reply")
630+ }
631+ pipeline.Close(r)
632+ require.ErrorIs(t, done.Err, capture.ErrIndicatorMissing)
633+ require.ErrorIs(t, done.Err, boom)
634+ })
635+ }
636+}
637+
638+// The same frame committed: the capture is delivered.
639+func TestSubmitFrameIndicatedFrameCommittedDelivers(t *testing.T) {
640+ o, _, _ := testOutput(t)
641+ o.cursor = nil
642+ r := portsmocks.NewMockRenderer(t)
643+ r.EXPECT().UseTarget(0).Return().Once()
644+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Twice()
645+ frame := portsmocks.NewMockCaptureFrame(t)
646+ frame.EXPECT().Done().Return(nil).Maybe()
647+ var reads atomic.Int32
648+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
649+ reads.Add(1)
650+ return nil
651+ }).Maybe()
652+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
653+ r.EXPECT().EndCapture(frame).Return().Once()
654+ replies := make(chan ports.CaptureDone, 4)
655+ pipeline := capture.NewPipeline(context.Background(), replies)
656+ _, err := o.submitFrame(context.Background(), r, indicatedScene(), nil, nil, []ports.CaptureRequest{sessionRequest(t, 1, false)}, pipeline)
657+ require.NoError(t, err)
658+ select {
659+ case done := <-replies:
660+ require.NoError(t, done.Err)
661+ case <-time.After(5 * time.Second):
662+ t.Fatal("no reply")
663+ }
664+ pipeline.Close(r)
665+ require.Equal(t, int32(1), reads.Load())
666+}
M internal/adapters/drm/frame_submit.go +73 -29
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "context"
5+ "time"
56
67 "github.com/bnema/neferwl/internal/adapters/capture"
78 "github.com/bnema/neferwl/internal/ports"
@@ -23,6 +24,7 @@ func (e renderError) Unwrap() error { return e.err }
2324 // without the overlay; a renderError stops the output; other errors are
2425 // commit errors for commitFailed.
2526 func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, pipeline *capture.Pipeline) (direct bool, err error) {
27+ presented := false // the frame was committed: what it gated is released
2628 if !o.sceneCurrent(scene) {
2729 return false, errSecurityScene
2830 }
@@ -33,32 +35,46 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
3335 // windows it does not draw is discarded, not presented.
3436 f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)}
3537 o.wantContent(scene, surfaces)
36- // Clean requests that cannot be served (their session is not the
37- // scene's) are failed here, never answered from the displayed frame.
38- normal, clean := pipeline.Split(scene, requests)
38+ // Requests that cannot be served (exclusion of another session, a
39+ // workspace that moved) are failed here, never answered from the
40+ // displayed frame.
41+ // A request whose capture indicator the scene does not show yet is held
42+ // (the caller keeps it, see capture.Waiting).
43+ ready, _ := pipeline.Hold(scene, requests, time.Now())
44+ if len(scene.CaptureIndicators) > 0 {
45+ // The captures of this frame are delivered only once the frame that
46+ // carries their indicator is committed, whatever the path out (see
47+ // capture.Pipeline.EndGate): no failed or dropped frame serves one.
48+ pipeline.BeginGate()
49+ defer func() {
50+ if err == nil && !presented {
51+ err = capture.ErrFrameNotPresented // a panic on the way out
52+ }
53+ pipeline.EndGate(capture.GateVerdict(err))
54+ }()
55+ }
56+ normal, excluded, hidden := pipeline.Split(scene, ready)
3957 pipeline.Retire(scene)
40- hiddenClean := len(clean) > 0 && scene.CaptureScene != nil
41- if hiddenClean {
58+ if len(hidden) > 0 {
4259 if !o.sceneCurrent(scene) || o.protected {
4360 return false, errSecurityScene
4461 }
4562 // A hidden workspace: the child renderer draws it, whatever the display
4663 // does (scanout included). Failures answer the requests and never stop
4764 // the output.
48- pipeline.SubmitHidden(scene, surfaces, clean)
65+ pipeline.SubmitHidden(scene, surfaces, hidden)
4966 // Publish child holds before queuing a display flip, which may stall
5067 // past the stale-report timeout. Do not advance the display's Seen
5168 // until its own GPU work finishes.
5269 if o.capHidden != nil {
5370 o.report(nil, o.seenSnapshot)
5471 }
55- clear(clean)
56- clean = nil
72+ clear(hidden)
5773 }
58- // Captures of the displayed frame, and a visible native session border,
59- // force composition: scanout and the overlay plane would skip them. The
60- // hidden-workspace child needs none, so it keeps direct scanout.
61- decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene))
74+ // Displayed-frame captures, Exclude requests and the capture indicator
75+ // force composition (scanout and the overlay plane would skip them); the
76+ // hidden-workspace child does not.
77+ decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(excluded) > 0 || len(scene.CaptureIndicators) > 0)
6278 if !o.sceneCurrent(scene) || o.protected {
6379 decision.overlay.close()
6480 return false, errSecurityScene
@@ -66,42 +82,69 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
6682 if decision.fb != 0 {
6783 c := decision.content
6884 if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
85+ presented = err == nil
6986 return true, err
7087 }
7188 decision = o.composeFrame(scene, surfaces)
7289 }
7390 ov, composed := decision.overlay, decision.composed
7491 r.UseTarget(o.back)
75- if len(clean) > 0 {
76- // The clean frame goes first to the same target and is copied by
92+ // Captures never show the capture indicator: their frame goes first.
93+ took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)
94+ if perr != nil {
95+ o.holdRead(pdone)
96+ if pdone != nil {
97+ pdone.Close()
98+ }
99+ ov.close()
100+ return false, renderError{perr}
101+ }
102+ // Not capture admission: an engage during the render retains its client
103+ // reads; SubmitScoped already failed the requests of a stale epoch.
104+ if !o.sceneCurrent(scene) || o.protected {
105+ o.holdRead(pdone)
106+ if pdone != nil {
107+ pdone.Close()
108+ }
109+ ov.close()
110+ return false, errSecurityScene
111+ }
112+ if pdone != nil {
113+ pdone.Close()
114+ }
115+ if took {
116+ normal = nil // answered from the frame without the indicator
117+ }
118+ if len(excluded) > 0 {
119+ // The excluded frame goes first to the same target and is copied by
77120 // the capture; the displayed frame is drawn over it in queue order.
78- // Cost: a second composition on frames with a clean request.
79- cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces)
80- if cerr != nil {
81- o.holdRead(cdone)
82- if cdone != nil {
83- cdone.Close()
121+ // Cost: a second composition on frames with an excluded request.
122+ xdone, xerr := r.Render(pipeline.ExcludedScene(composed), surfaces)
123+ if xerr != nil {
124+ o.holdRead(xdone)
125+ if xdone != nil {
126+ xdone.Close()
84127 }
85128 ov.close()
86- return false, renderError{cerr}
129+ return false, renderError{xerr}
87130 }
88- // A clean composition is not capture admission. Engage may have
131+ // An excluded composition is not capture admission. Engage may have
89132 // happened while Render submitted GPU work; retain its client reads
90133 // and leave the request credit with the caller on rejection.
91134 if !o.sceneCurrent(scene) || o.protected {
92- o.holdRead(cdone)
93- if cdone != nil {
94- cdone.Close()
135+ o.holdRead(xdone)
136+ if xdone != nil {
137+ xdone.Close()
95138 }
96139 ov.close()
97140 return false, errSecurityScene
98141 }
99- if cdone != nil {
100- cdone.Close()
142+ if xdone != nil {
143+ xdone.Close()
101144 }
102- pipeline.SubmitScoped(scene.Security, r, clean)
145+ pipeline.SubmitScoped(scene.Security, r, excluded)
103146 // Ownership moved to the worker (or an immediate failure reply).
104- clear(clean)
147+ clear(excluded)
105148 }
106149 done, rerr := r.Render(composed, surfaces)
107150 if rerr != nil {
@@ -150,6 +193,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
150193 // layer, subsurfaces, size mismatch).
151194 game := o.vrrProp != 0 && fullscreenShown(&scene)
152195 err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov)
196+ presented = err == nil
153197 if err != nil {
154198 // The GPU may still read client buffers for this frame.
155199 o.holdRead(done)
M internal/adapters/drm/output.go +26 -3
@@ -1160,6 +1160,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11601160 gapTimer := time.NewTimer(time.Hour)
11611161 gapTimer.Stop()
11621162 defer gapTimer.Stop()
1163+ // A request waits for a scene that shows its capture indicator, at most
1164+ // capture.HoldFor (see capture.Hold).
1165+ holdTimer := time.NewTimer(time.Hour)
1166+ holdTimer.Stop()
1167+ defer holdTimer.Stop()
11631168 retryTimer.Stop()
11641169 vrrTimer.Stop()
11651170 cursorTimer.Stop()
@@ -1267,6 +1272,18 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12671272 } else {
12681273 gapTimer.Stop()
12691274 }
1275+ var holdDue <-chan time.Time
1276+ if len(requests) > 0 {
1277+ var wait time.Duration
1278+ requests, wait = pipeline.Expire(scene, requests, time.Now())
1279+ if wait > 0 {
1280+ holdTimer.Reset(wait)
1281+ holdDue = holdTimer.C
1282+ }
1283+ }
1284+ if holdDue == nil {
1285+ holdTimer.Stop()
1286+ }
12701287 // A refused protected commit is retried after a bounded backoff.
12711288 var protectDue <-chan time.Time
12721289 if o.protected && enabled && !o.securityPrepared && o.protectBackoffActive() {
@@ -1314,6 +1331,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13141331 case <-cursorWake:
13151332 stateDirty = true
13161333 case <-gapDue:
1334+ case <-holdDue:
1335+ continue
13171336 case <-ctx.Done():
13181337 return nil
13191338 case b := <-pipeline.Completed():
@@ -1330,8 +1349,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13301349 } else if len(requests) == cap(requests) {
13311350 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
13321351 } else {
1352+ q.Since = time.Now()
13331353 requests = append(requests, q)
1334- dirty = haveScene
1354+ // Its indicator may already be on screen; else it waits for the
1355+ // scene that shows it.
1356+ dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
13351357 }
13361358 case on := <-active:
13371359 o.observeSecurity()
@@ -1513,9 +1535,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
15131535 }
15141536 capture.Fail(ctx, q, err, captured)
15151537 }
1538+ clear(requests) // held ones included: their frame is gone
15161539 }
1517- clear(requests)
1518- requests = requestStorage[:0]
1540+ // Requests still waiting for their indicator stay; the rest is gone.
1541+ requests = capture.Waiting(requests, scene)
15191542 if errors.As(err, &fatal) {
15201543 return err
15211544 }
M internal/adapters/drm/security_capture_test.go +3 -3
@@ -13,7 +13,7 @@ import (
1313 "github.com/stretchr/testify/mock"
1414 )
1515
16-func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) {
16+func TestSecurityEngageDuringExcludeRenderRejectsCaptureTransfer(t *testing.T) {
1717 o, _, commits := testOutput(t)
1818 o.cursor = nil
1919 var state atomic.Uint64
@@ -34,10 +34,10 @@ func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) {
3434 t.Fatalf("capture admission result: %v", err)
3535 }
3636 if len(o.readFences) != 1 || o.readDone() {
37- t.Fatal("rejected clean render dropped GPU reads")
37+ t.Fatal("rejected excluded render dropped GPU reads")
3838 }
3939 if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
40- t.Fatal("returned clean fence not closed")
40+ t.Fatal("returned excluded fence not closed")
4141 }
4242 // Match Run's remaining-credit rejection: nothing was transferred to the
4343 // worker, each request is failed once, and every owned FD is closed.
R internal/adapters/headless/capture_session_test.go → internal/adapters/headless/capture_test.go +199 -16 renamed, 51% similar
@@ -7,6 +7,7 @@ import (
77 "os"
88 "reflect"
99 "sync"
10+ "sync/atomic"
1011 "testing"
1112 "time"
1213
@@ -50,9 +51,9 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa
5051 ev := &events{}
5152 r := portsmocks.NewMockRenderer(t)
5253 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
53- // Clean scenes are renumbered 0; the tests' displayed scenes are not.
54+ // Excluded and plain scenes are renumbered 0; the tests' displayed scenes are not.
5455 if s.Seq == 0 {
55- ev.add("render:clean")
56+ ev.add("render:excluded")
5657 } else {
5758 ev.add("render:full")
5859 }
@@ -99,23 +100,23 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa
99100
100101 func sessionScene() ports.Scene {
101102 return ports.Scene{Seq: 2, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
102- Capture: &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}}
103+ Capture: &ports.SceneCapture{Session: 7, Revision: 1, Excluded: []ports.WindowID{10}}}
103104 }
104105
105-func TestCleanCaptureOrderedBeforeDisplayedFrame(t *testing.T) {
106- order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2)
107- // Clean composition and its copy come first; the displayed frame and
106+func TestExcludeCaptureOrderedBeforeDisplayedFrame(t *testing.T) {
107+ order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
108+ // The Exclude composition and its copy come first; the displayed frame and
108109 // the standard capture (HUD and border included) follow.
109- require.Equal(t, []string{"render:clean", "begin", "render:full", "begin"}, order)
110+ require.Equal(t, []string{"render:excluded", "begin", "render:full", "begin"}, order)
110111 for _, d := range replies {
111112 require.NoError(t, d.Err)
112113 }
113114 }
114115
115-func TestCleanCaptureFailsClosedWithoutMatchingSession(t *testing.T) {
116+func TestExcludeCaptureFailsClosedWithoutMatchingSession(t *testing.T) {
116117 scene := sessionScene()
117118 scene.Capture = nil
118- order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2)
119+ order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
119120 require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only
120121 byID := map[uint64]error{}
121122 for _, d := range replies {
@@ -146,7 +147,7 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
146147 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
147148 child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
148149 require.Nil(t, s.Capture)
149- require.Len(t, s.Layers, 0, "HUD left out of the child frame")
150+ require.Len(t, s.Layers, 0, "a workspace rendered off screen has no layers")
150151 return read, nil
151152 }).Once()
152153 child.EXPECT().BeginCapture().Return(frame, nil).Once()
@@ -154,8 +155,8 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
154155 child.EXPECT().Close().Return().Once()
155156 scene := sessionScene()
156157 scene.OutputWidth, scene.OutputHeight, scene.Scale = 2, 2, 1
157- scene.Capture.TargetRect = ports.Rect{W: 2, H: 2}
158- scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}}}
158+ scene.Capture.Workspace = 9
159+ scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}}
159160 ctx, cancel := context.WithCancel(context.Background())
160161 defer cancel()
161162 scenes := make(chan ports.Scene, 1)
@@ -170,7 +171,7 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
170171 NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },
171172 }, scenes, nil, nil, incoming)
172173 }()
173- q := ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174+ q := ports.CaptureRequest{ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174175 incoming <- q
175176 scenes <- scene
176177 // The owner may report an idle frame before receiving the first scene.
@@ -211,12 +212,21 @@ waitHold:
211212 require.Equal(t, [2]int{2, 2}, made)
212213 }
213214
214-// GO007: a render error while a clean request was already handed to a worker
215+// A hidden request whose scene carries no CaptureScene fails closed instead of
216+// being dropped or served from the displayed frame.
217+func TestHiddenWorkspaceCaptureWithoutCaptureSceneFails(t *testing.T) {
218+ scene := sessionScene()
219+ scene.Capture.Workspace = 9
220+ _, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Workspace: 9, OffScreen: true}}, 1)
221+ require.ErrorIs(t, replies[0].Err, capture.ErrOffscreenUnavailable)
222+}
223+
224+// GO007: a render error while an Exclude request was already handed to a worker
215225 // must not answer it a second time as request 0.
216226 func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
217227 display := portsmocks.NewMockRenderer(t)
218228 boom := errors.New("boom")
219- display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // clean frame
229+ display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // excluded frame
220230 display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once()
221231 frame := portsmocks.NewMockCaptureFrame(t)
222232 frame.EXPECT().Done().Return(nil).Maybe()
@@ -234,7 +244,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
234244 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies,
235245 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming)
236246 }()
237- incoming <- ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
247+ incoming <- ports.CaptureRequest{ID: 1, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
238248 scenes <- sessionScene()
239249 select {
240250 case err := <-done:
@@ -275,3 +285,176 @@ func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {
275285 require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable")
276286 require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(changed.Seen).Pointer(), "seen still unchanged")
277287 }
288+
289+// A scene that shows the capture indicator is never the source of a capture:
290+// the standard capture is copied from a frame drawn without it, before the
291+// displayed frame.
292+func TestStandardCaptureNeverHoldsTheIndicator(t *testing.T) {
293+ scene := sessionScene()
294+ scene.Capture = nil
295+ scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
296+ order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 2}}, 1)
297+ require.Equal(t, []string{"render:excluded", "begin", "render:full"}, order)
298+ require.NoError(t, replies[0].Err)
299+ // An Exclude request of the session is served from its own excluded frame.
300+ scene = sessionScene()
301+ scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
302+ order, replies = runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
303+ require.Equal(t, []string{"render:excluded", "begin", "render:excluded", "begin", "render:full"}, order)
304+ for _, d := range replies {
305+ require.NoError(t, d.Err)
306+ }
307+}
308+
309+// A capture is requested before core published the scene that shows its
310+// indicator: it is held, not served from the scene the output already has, and
311+// served from the first scene that shows the border, with the indicator on
312+// screen in that same frame.
313+func TestCaptureWaitsForItsIndicatorScene(t *testing.T) {
314+ ev := &events{}
315+ r := portsmocks.NewMockRenderer(t)
316+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
317+ switch {
318+ case s.Seq == 0:
319+ ev.add("render:plain")
320+ case len(s.CaptureIndicators) > 0:
321+ ev.add("render:indicated")
322+ default:
323+ ev.add("render:bare")
324+ }
325+ return nil, nil
326+ }).Maybe()
327+ frame := portsmocks.NewMockCaptureFrame(t)
328+ frame.EXPECT().Done().Return(nil).Maybe()
329+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
330+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
331+ ev.add("begin")
332+ return frame, nil
333+ }).Maybe()
334+ r.EXPECT().EndCapture(frame).Return().Maybe()
335+ r.EXPECT().Close().Return().Once()
336+ ctx, cancel := context.WithCancel(context.Background())
337+ defer cancel()
338+ scenes := make(chan ports.Scene, 2)
339+ incoming := make(chan ports.CaptureRequest)
340+ replies := make(chan ports.CaptureDone, 4)
341+ done := make(chan error, 1)
342+ go func() {
343+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
344+ }()
345+ bare := ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}}
346+ scenes <- bare
347+ require.Eventually(t, func() bool { return len(ev.get()) == 1 }, 2*time.Second, time.Millisecond)
348+ require.Equal(t, []string{"render:bare"}, ev.get())
349+
350+ incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
351+ select {
352+ case d := <-replies:
353+ t.Fatalf("served from a scene without the indicator: %+v", d)
354+ case <-time.After(50 * time.Millisecond):
355+ }
356+ require.Equal(t, []string{"render:bare"}, ev.get(), "nothing is copied, nothing is redrawn for a held request")
357+
358+ indicated := bare
359+ indicated.Seq = 3
360+ indicated.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
361+ scenes <- indicated
362+ select {
363+ case d := <-replies:
364+ require.NoError(t, d.Err)
365+ require.Equal(t, uint64(1), d.ID)
366+ case <-time.After(2 * time.Second):
367+ t.Fatal("not served once the indicator scene exists")
368+ }
369+ require.Equal(t, []string{"render:bare", "render:plain", "begin", "render:indicated"}, ev.get(), "copied from the frame without the indicator, which is on screen in the same frame")
370+ cancel()
371+ require.NoError(t, <-done)
372+}
373+
374+// A request whose indicator never comes (core marks nothing) fails after the
375+// bounded wait, and is never served.
376+func TestCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
377+ r := portsmocks.NewMockRenderer(t)
378+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
379+ r.EXPECT().Close().Return().Once()
380+ ctx, cancel := context.WithCancel(context.Background())
381+ defer cancel()
382+ scenes := make(chan ports.Scene, 1)
383+ incoming := make(chan ports.CaptureRequest)
384+ replies := make(chan ports.CaptureDone, 4)
385+ done := make(chan error, 1)
386+ go func() {
387+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
388+ }()
389+ scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
390+ start := time.Now()
391+ incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
392+ select {
393+ case d := <-replies:
394+ require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
395+ require.GreaterOrEqual(t, time.Since(start), capture.HoldFor-20*time.Millisecond)
396+ case <-time.After(3 * time.Second):
397+ t.Fatal("a held request was never answered")
398+ }
399+ cancel()
400+ require.NoError(t, <-done)
401+}
402+
403+// The capture of a frame is delivered once that frame, with its indicator, is
404+// presented. When its render fails, the capture fails and no pixel is written
405+// (the mock frame counts reads); when it succeeds it is delivered.
406+func TestCaptureDeliveredOnlyWhenTheIndicatedFramePresents(t *testing.T) {
407+ boom := errors.New("boom")
408+ for _, tc := range []struct {
409+ name string
410+ render error
411+ }{{"presented", nil}, {"render error", boom}} {
412+ t.Run(tc.name, func(t *testing.T) {
413+ var reads atomic.Int32
414+ r := portsmocks.NewMockRenderer(t)
415+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
416+ if s.Seq != 0 {
417+ return nil, tc.render // the indicated frame
418+ }
419+ return nil, nil // the plain frame
420+ }).Maybe()
421+ frame := portsmocks.NewMockCaptureFrame(t)
422+ frame.EXPECT().Done().Return(nil).Maybe()
423+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
424+ reads.Add(1)
425+ return nil
426+ }).Maybe()
427+ r.EXPECT().BeginCapture().Return(frame, nil).Maybe()
428+ r.EXPECT().EndCapture(frame).Return().Maybe()
429+ r.EXPECT().Close().Return().Once()
430+ ctx, cancel := context.WithCancel(context.Background())
431+ defer cancel()
432+ scenes := make(chan ports.Scene, 1)
433+ incoming := make(chan ports.CaptureRequest)
434+ replies := make(chan ports.CaptureDone, 4)
435+ done := make(chan error, 1)
436+ go func() {
437+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
438+ }()
439+ incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
440+ scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}},
441+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}}
442+ var d ports.CaptureDone
443+ select {
444+ case d = <-replies:
445+ case <-time.After(3 * time.Second):
446+ t.Fatal("no reply")
447+ }
448+ if tc.render == nil {
449+ require.NoError(t, d.Err)
450+ require.Equal(t, int32(1), reads.Load())
451+ cancel()
452+ require.NoError(t, <-done)
453+ return
454+ }
455+ require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
456+ require.Zero(t, reads.Load(), "no pixel delivered")
457+ require.ErrorIs(t, <-done, boom)
458+ })
459+ }
460+}
M internal/adapters/headless/output.go +83 -20
@@ -2,6 +2,7 @@ package headless
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "golang.org/x/sys/unix"
78 "image"
@@ -94,6 +95,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9495 pipeline.EnableOffscreen(opts.NewCaptureRenderer)
9596 }
9697 defer func() {
98+ // An error path out of a gated frame: what it held is failed.
99+ pipeline.EndGate(capture.ErrFrameNotPresented)
97100 cancelCaptures()
98101 pipeline.Close(r)
99102 for _, q := range requests {
@@ -123,6 +126,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
123126 var security ports.SecurityState
124127 securityKnown := false
125128 failRequests := func(reason string) {
129+ // Whatever this frame had handed to a worker is failed with it.
130+ pipeline.EndGate(capture.GateVerdict(errors.New(reason)))
126131 for _, q := range requests {
127132 if !capture.Handed(q) {
128133 capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
@@ -206,7 +211,15 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
206211 }
207212 trim := clk.NewTicker(trimEvery)
208213 defer trim.Stop()
214+ // One timer for the wait of held requests, reset per pass (a time.After
215+ // per loop pass would leave a timer behind each).
216+ holdTimer := time.NewTimer(time.Hour)
217+ holdTimer.Stop()
218+ defer holdTimer.Stop()
209219 for {
220+ // A frame that left its gate open (any path out but its present) fails
221+ // what it held: nothing is delivered for a frame that did not present.
222+ pipeline.EndGate(capture.ErrFrameNotPresented)
210223 if ctx.Err() != nil {
211224 return nil
212225 }
@@ -220,7 +233,23 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
220233 if pending != nil {
221234 retry = time.After(time.Millisecond)
222235 }
236+ // Requests wait for a scene that shows their indicator, at most
237+ // capture.HoldFor (see capture.Hold).
238+ var holdDue <-chan time.Time
239+ if len(requests) > 0 {
240+ var wait time.Duration
241+ requests, wait = pipeline.Expire(scene, requests, time.Now())
242+ if wait > 0 {
243+ holdTimer.Reset(wait)
244+ holdDue = holdTimer.C
245+ }
246+ }
247+ if holdDue == nil {
248+ holdTimer.Stop()
249+ }
223250 select {
251+ case <-holdDue:
252+ continue
224253 case <-ctx.Done():
225254 return nil
226255 case _, ok := <-opts.SecurityChanges:
@@ -275,8 +304,11 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
275304 } else if len(requests) == cap(requests) {
276305 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
277306 } else {
307+ q.Since = time.Now()
278308 requests = append(requests, q)
279- dirty = haveScene
309+ // Its indicator may already be on screen; else it waits for the
310+ // scene that shows it.
311+ dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
280312 }
281313 case <-retry:
282314 if err := checkSecurity(); err != nil {
@@ -416,20 +448,26 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
416448 continue
417449 }
418450 start := time.Now()
419- // Clean requests of a session the scene does not carry are failed
420- // here. The others draw the clean frame first (same renderer, queue
421- // order), then the displayed one; see capture.Pipeline.Split.
422- normal, clean := pipeline.Split(scene, requests)
451+ // Requests the scene cannot serve (exclusion of another session, a
452+ // workspace that moved) are failed here; see capture.Pipeline.Split.
453+ // A request whose indicator the scene does not show yet stays held.
454+ ready, _ := pipeline.Hold(scene, requests, time.Now())
455+ if len(scene.CaptureIndicators) > 0 {
456+ // The captures of this frame are delivered only once its frame,
457+ // with the indicator, is presented (capture.Pipeline.EndGate).
458+ pipeline.BeginGate()
459+ }
460+ normal, excluded, hidden := pipeline.Split(scene, ready)
423461 pipeline.Retire(scene)
424- if len(clean) > 0 && scene.CaptureScene != nil {
462+ if len(hidden) > 0 {
425463 // A hidden workspace: a child renderer draws it. The display's
426464 // fences do not cover the child, so wait for it before reporting.
427465 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
428466 failRequests("security epoch changed")
429467 continue
430468 }
431- pipeline.SubmitHidden(scene, surfaces, clean)
432- clear(clean)
469+ pipeline.SubmitHidden(scene, surfaces, hidden)
470+ clear(hidden)
433471 // A slow child may outlive Wayland's stale-report timeout even
434472 // though this owner waits. Publish its non-expiring holds first.
435473 if opts.Presented != nil {
@@ -449,34 +487,58 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
449487 }
450488 return fmt.Errorf("workspace frame fence: %w", err)
451489 }
452- } else if len(clean) > 0 {
490+ }
491+ // Captures never show the capture indicator: their frame goes first.
492+ took, pdone, perr := pipeline.SubmitPlain(r, scene, surfaces, normal)
493+ if pdone != nil {
494+ // The plain render reads client buffers even when its requests
495+ // are rejected by a gate transition: own and wait its fence
496+ // before any Seen report or protection check can proceed.
497+ waitErr := syncfile.Wait(ctx, pdone)
498+ _ = pdone.Close()
499+ if waitErr != nil {
500+ if ctx.Err() != nil {
501+ return nil
502+ }
503+ return fmt.Errorf("plain frame fence: %w", waitErr)
504+ }
505+ }
506+ if perr != nil {
507+ return fmt.Errorf("render plain frame: %w", perr)
508+ }
509+ if took {
510+ normal = nil // answered from the frame without the indicator
511+ }
512+ if len(excluded) > 0 {
513+ // Exclude requests: the frame without the excluded surfaces is
514+ // drawn first (same renderer, queue order), then the displayed one.
453515 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
454516 failRequests("security epoch changed")
455517 continue
456518 }
457- cdone, renderErr := r.Render(pipeline.CleanScene(scene), surfaces)
458- if cdone != nil {
459- // Clean composition reads client buffers even when the displayed
519+ xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces)
520+ if xdone != nil {
521+ // The excluded-frame composition reads client buffers even when the displayed
460522 // frame is skipped by a gate/off transition. Own and wait its
461523 // fence before any Seen report or protection check can proceed.
462- waitErr := syncfile.Wait(ctx, cdone)
463- _ = cdone.Close()
524+ waitErr := syncfile.Wait(ctx, xdone)
525+ _ = xdone.Close()
464526 if waitErr != nil {
465527 if ctx.Err() != nil {
466528 return nil
467529 }
468- return fmt.Errorf("clean frame fence: %w", waitErr)
530+ return fmt.Errorf("excluded frame fence: %w", waitErr)
469531 }
470532 }
471533 if renderErr != nil {
472- return fmt.Errorf("render clean frame: %w", renderErr)
534+ return fmt.Errorf("render excluded frame: %w", renderErr)
473535 }
474536 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
475537 failRequests("security epoch changed")
476538 continue
477539 }
478- pipeline.SubmitScoped(scene.Security, r, clean)
479- clear(clean)
540+ pipeline.SubmitScoped(scene.Security, r, excluded)
541+ clear(excluded)
480542 }
481543 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
482544 failRequests("security epoch changed")
@@ -508,8 +570,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
508570 continue
509571 }
510572 pipeline.SubmitScoped(scene.Security, r, normal)
511- clear(requests)
512- requests = requestStorage[:0]
573+ // The indicated frame is presented: release what it gated.
574+ pipeline.EndGate(nil)
575+ requests = capture.Waiting(requests, scene)
513576 frame++
514577 pending = opts.flipped(pending, seen, scene, surfaces)
515578 if opts.ScreenshotDir != "" && !opts.protected() {
M internal/adapters/headless/security_capture_test.go +5 -5
@@ -17,10 +17,10 @@ import (
1717 // Change the gate AFTER the output's post-Render check returns the admitting
1818 // state. SubmitScoped must close this last gap before native BeginCapture.
1919 func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
20- for _, clean := range []bool{false, true} {
20+ for _, excluded := range []bool{false, true} {
2121 name := "normal"
22- if clean {
23- name = "clean"
22+ if excluded {
23+ name = "excluded"
2424 }
2525 t.Run(name, func(t *testing.T) {
2626 admitted := ports.SecurityState{Generation: 2}
@@ -64,9 +64,9 @@ func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
6464 done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
6565 }()
6666 dst := sessionCaptureFile(t)
67- incoming <- ports.CaptureRequest{ID: 91, Clean: clean, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
67+ incoming <- ports.CaptureRequest{ID: 91, Exclude: excluded, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
6868 scene := ports.Scene{Security: admitted, Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
69- if clean {
69+ if excluded {
7070 scene = sessionScene()
7171 scene.Security = admitted
7272 scene.Scale = 1
R internal/adapters/headless/security_clean_fence_test.go → internal/adapters/headless/security_exclude_fence_test.go +8 -8 renamed, 87% similar
@@ -14,7 +14,7 @@ import (
1414 "github.com/stretchr/testify/mock"
1515 )
1616
17-func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
17+func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
1818 for _, finish := range []string{"signal", "cancel", "render-error"} {
1919 t.Run(finish, func(t *testing.T) {
2020 admitted := ports.SecurityState{Generation: 2}
@@ -32,7 +32,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
3232 r.EXPECT().Close().Return().Once()
3333 rendered := make(chan struct{}, 1)
3434 releaseRender := make(chan struct{})
35- failure := errors.New("clean render failed after submission")
35+ failure := errors.New("excluded render failed after submission")
3636 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Security == admitted && s.Seq == 0 }), mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
3737 rendered <- struct{}{}
3838 <-releaseRender
@@ -59,7 +59,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
5959 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 7, Width: 2, Height: 2, Presented: presented, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, incoming)
6060 }()
6161 dst := sessionCaptureFile(t)
62- incoming <- ports.CaptureRequest{ID: 99, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
62+ incoming <- ports.CaptureRequest{ID: 99, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
6363 contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}}
6464 scene := sessionScene()
6565 scene.Security = admitted
@@ -69,7 +69,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
6969 scenes <- scene
7070 securityReceive(t, rendered)
7171 // Prior non-render reports are outside this read lifetime; establish the
72- // cut at clean GPU submission before exercising transition/off handling.
72+ // cut at excluded GPU submission before exercising transition/off handling.
7373 for len(presented) > 0 {
7474 <-presented
7575 }
@@ -81,15 +81,15 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
8181 // close/report path without assuming a polling schedule.
8282 select {
8383 case report := <-presented:
84- t.Fatalf("GPU reads reported before clean fence: %+v", report)
84+ t.Fatalf("GPU reads reported before excluded fence: %+v", report)
8585 case <-time.After(150 * time.Millisecond):
8686 }
8787 if _, err := fence.Stat(); err != nil {
88- t.Fatalf("unsignalled clean read fence closed: %v", err)
88+ t.Fatalf("unsignalled excluded read fence closed: %v", err)
8989 }
9090 select {
9191 case proof := <-proofs:
92- t.Fatalf("protection path bypassed pending clean reads: %+v", proof)
92+ t.Fatalf("protection path bypassed pending excluded reads: %+v", proof)
9393 default:
9494 }
9595 if finish == "cancel" {
@@ -112,7 +112,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
112112 t.Fatal(runErr)
113113 }
114114 if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
115- t.Fatalf("clean fence leaked: %v", err)
115+ t.Fatalf("excluded fence leaked: %v", err)
116116 }
117117 reply := securityReceive(t, replies)
118118 if reply.ID != 99 || reply.Err == nil {
M internal/adapters/statefile/statefile.go +22 -15
@@ -13,6 +13,7 @@ import (
1313 "strconv"
1414 "strings"
1515
16+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
1617 "github.com/bnema/neferwl/internal/ports"
1718 "github.com/bnema/zerowrap"
1819 )
@@ -39,13 +40,16 @@ type State struct {
3940 }
4041
4142 // Output is one output: its active numbered workspace (0 while a hidden one
42-// is shown), how many numbered workspaces it has, and the name of the
43-// workspace on screen.
43+// is shown), how many numbered workspaces it has, and the name and ID of the
44+// workspace on screen. WorkspaceID is the same string ext_workspace_handle_v1
45+// sends as its id (package workspaceid): "name:<name>" for a configured
46+// workspace, else "<launch prefix>-<core ID>".
4447 type Output struct {
45- Name string `json:"name"`
46- Active int `json:"active"`
47- Count int `json:"count"`
48- Workspace string `json:"workspace"`
48+ Name string `json:"name"`
49+ Active int `json:"active"`
50+ Count int `json:"count"`
51+ Workspace string `json:"workspace"`
52+ WorkspaceID string `json:"workspace_id"`
4953 }
5054
5155 // Window is one mapped window.
@@ -55,7 +59,10 @@ type Window struct {
5559 PID int `json:"pid"`
5660 Output string `json:"output"`
5761 Workspace int `json:"workspace"`
58- Visible bool `json:"visible"`
62+ // WorkspaceID is the ID of the window's workspace, numbered or hidden
63+ // (see Output.WorkspaceID).
64+ WorkspaceID string `json:"workspace_id"`
65+ Visible bool `json:"visible"`
5966 // Floating is set for native floats (dialogs) and stashed windows.
6067 Floating bool `json:"floating"`
6168 // StashIndex is the 1-based place of a stashed window in its
@@ -66,28 +73,28 @@ type Window struct {
6673 Hidden bool `json:"hidden"`
6774 }
6875
69-func fromPorts(st ports.State) State {
76+func fromPorts(st ports.State, ids *workspaceid.IDs) State {
7077 out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
7178 for _, o := range st.Outputs {
72- out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace})
79+ out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
7380 }
7481 for _, w := range st.Windows {
75- out.Windows = append(out.Windows, window(w))
82+ out.Windows = append(out.Windows, window(w, ids))
7683 }
7784 if st.Window != nil {
78- w := window(*st.Window)
85+ w := window(*st.Window, ids)
7986 out.Window = &w
8087 }
8188 return out
8289 }
8390
84-func window(w ports.WindowState) Window {
85- return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden}
91+func window(w ports.WindowState, ids *workspaceid.IDs) Window {
92+ return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden}
8693 }
8794
8895 // Run writes every state it receives to path until ctx ends, then removes
8996 // the file: a stale state must not outlive the session.
90-func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error {
97+func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
9198 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
9299 return err
93100 }
@@ -99,7 +106,7 @@ func Run(ctx context.Context, path string, states <-chan ports.State, log zerowr
99106 case <-ctx.Done():
100107 return nil
101108 case st := <-states:
102- if err := write(path, fromPorts(st)); err != nil {
109+ if err := write(path, fromPorts(st, ids)); err != nil {
103110 // Scripts lose their state; the session goes on.
104111 log.Warn().Err(err).Str("path", path).Msg("write state")
105112 }
M internal/adapters/statefile/statefile_test.go +28 -7
@@ -11,14 +11,15 @@ import (
1111 "testing"
1212 "time"
1313
14+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
1415 "github.com/bnema/neferwl/internal/ports"
1516 "github.com/bnema/zerowrap"
1617 )
1718
1819 var sample = State{
1920 Output: "DP-2",
20- Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web"}},
21- Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, Visible: true}},
21+ Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1, WorkspaceID: "p-1"}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web", WorkspaceID: "name:web"}},
22+ Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, WorkspaceID: "p-1", Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, WorkspaceID: "name:web", Visible: true}},
2223 }
2324
2425 // The file holds the latest state while the session runs; a stale file from
@@ -37,10 +38,10 @@ func TestRunWritesAndRemoves(t *testing.T) {
3738 states := make(chan ports.State, 1)
3839 ctx, cancel := context.WithCancel(context.Background())
3940 done := make(chan error, 1)
40- go func() { done <- Run(ctx, path, states, zerowrap.Default()) }()
41- focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}
42- states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1}}, Window: &focused, Windows: []ports.WindowState{focused}}
43- want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}}}
41+ go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
42+ focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
43+ states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
44+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}}
4445 deadline := time.Now().Add(2 * time.Second)
4546 for {
4647 if got, err := Read(path); err == nil && got.Output != "stale" {
@@ -65,7 +66,7 @@ func TestRunWritesAndRemoves(t *testing.T) {
6566
6667 // Stash fields reach the file with their script-facing names.
6768 func TestWindowStashFields(t *testing.T) {
68- got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true})
69+ got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
6970 if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden {
7071 t.Fatalf("%+v", got)
7172 }
@@ -137,3 +138,23 @@ func TestLinuxProcTreeParent(t *testing.T) {
137138 t.Fatal(ppid, ok)
138139 }
139140 }
141+
142+// A configured workspace is named after its configured name, any other one
143+// after the launch prefix and its core ID: the string of ext_workspace_handle_v1.
144+func TestWorkspaceIDStrings(t *testing.T) {
145+ ids := workspaceid.WithPrefix("0a1b2c3d")
146+ st := fromPorts(ports.State{
147+ Outputs: []ports.OutputState{{Name: "DP-2", Workspace: "web", WorkspaceID: 7}, {Name: "DP-3", WorkspaceID: 8}},
148+ Windows: []ports.WindowState{{ID: 1, WorkspaceID: 7, WorkspaceName: "web"}, {ID: 2, WorkspaceID: 8}},
149+ }, ids)
150+ if st.Outputs[0].WorkspaceID != "name:web" || st.Outputs[1].WorkspaceID != "0a1b2c3d-8" {
151+ t.Fatalf("outputs %+v", st.Outputs)
152+ }
153+ if st.Windows[0].WorkspaceID != "name:web" || st.Windows[1].WorkspaceID != "0a1b2c3d-8" {
154+ t.Fatalf("windows %+v", st.Windows)
155+ }
156+ data, err := json.Marshal(st.Windows[1])
157+ if err != nil || !strings.Contains(string(data), `"workspace_id":"0a1b2c3d-8"`) {
158+ t.Fatalf("%s %v", data, err)
159+ }
160+}
A internal/adapters/vulkan/capture_indicator_test.go +93 -0
@@ -0,0 +1,93 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func indicatorScene(m ...ports.CaptureIndicator) ports.Scene {
12+ return ports.Scene{Seq: 1, Scale: 1, Background: "#101010", CaptureIndicators: m}
13+}
14+
15+// indicatorDraws walks s and returns the physical rects drawn, all in the
16+// indicator red.
17+func indicatorDraws(t *testing.T, r *Renderer, s ports.Scene) []image.Rectangle {
18+ t.Helper()
19+ var out []image.Rectangle
20+ for _, d := range r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))) {
21+ require.Equal(t, [4]float32{0xff / 255.0, 0x3b / 255.0, 0x30 / 255.0, 1}, d.pc.color)
22+ out = append(out, image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3])))
23+ }
24+ return out
25+}
26+
27+func TestCaptureIndicatorBorderGeometryLogicalScaled(t *testing.T) {
28+ target := ports.CaptureIndicator{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}}
29+ for _, tc := range []struct {
30+ scale float64
31+ want []image.Rectangle
32+ }{
33+ {1, []image.Rectangle{image.Rect(8, 4, 24, 6), image.Rect(8, 10, 24, 12), image.Rect(8, 6, 10, 10), image.Rect(22, 6, 24, 10)}},
34+ {2, []image.Rectangle{image.Rect(16, 8, 48, 12), image.Rect(16, 20, 48, 24), image.Rect(16, 12, 20, 20), image.Rect(44, 12, 48, 20)}},
35+ } {
36+ r := &Renderer{width: 64, height: 64}
37+ s := indicatorScene(target)
38+ s.Scale = tc.scale
39+ require.Equal(t, tc.want, indicatorDraws(t, r, s), "scale %v", tc.scale)
40+ }
41+ // On the output edge the border is still inside the output.
42+ r := &Renderer{width: 64, height: 64}
43+ got := indicatorDraws(t, r, indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{W: 64, H: 64}}))
44+ require.Equal(t, []image.Rectangle{image.Rect(0, 0, 64, 2), image.Rect(0, 62, 64, 64), image.Rect(0, 2, 2, 62), image.Rect(62, 2, 64, 62)}, got)
45+ // A target thinner than the border is clamped: no overdraw.
46+ got = indicatorDraws(t, r, indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 2}}))
47+ require.Equal(t, []image.Rectangle{image.Rect(4, 4, 14, 5), image.Rect(4, 5, 14, 6)}, got)
48+ // Union of several targets, in order.
49+ got = indicatorDraws(t, r, indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{W: 10, H: 10}}, ports.CaptureIndicator{Rect: ports.Rect{X: 20, W: 10, H: 10}}))
50+ require.Len(t, got, 8)
51+}
52+
53+// A listed mark always draws something: a target 1 logical pixel wide or
54+// tall (an output that small, core inflates the others) is filled whole.
55+func TestCaptureIndicatorThinMarkStillDraws(t *testing.T) {
56+ r := &Renderer{width: 64, height: 64}
57+ got := indicatorDraws(t, r, indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{X: 10, Y: 4, W: 1, H: 20}}))
58+ require.Equal(t, []image.Rectangle{image.Rect(10, 4, 11, 24)}, got, "W=1")
59+ got = indicatorDraws(t, r, indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{X: 4, Y: 10, W: 20, H: 1}}))
60+ require.Equal(t, []image.Rectangle{image.Rect(4, 10, 24, 11)}, got, "H=1")
61+ s := indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{X: 5, Y: 5, W: 1, H: 1}})
62+ s.Scale = 2
63+ require.Equal(t, []image.Rectangle{image.Rect(10, 10, 12, 12)}, indicatorDraws(t, &Renderer{width: 128, height: 128}, s))
64+}
65+
66+func TestCaptureIndicatorPill(t *testing.T) {
67+ r := &Renderer{width: 64, height: 64}
68+ pill := ports.CaptureIndicator{Pill: true, Rect: ports.Rect{X: 44, Y: 8, W: 12, H: 12}}
69+ got := indicatorDraws(t, r, indicatorScene(pill))
70+ // Three bands: the corners are cut, the middle is the full width.
71+ require.Equal(t, []image.Rectangle{image.Rect(46, 8, 54, 11), image.Rect(44, 11, 56, 17), image.Rect(46, 17, 54, 20)}, got)
72+ s := indicatorScene(pill)
73+ s.Scale = 2
74+ got = indicatorDraws(t, &Renderer{width: 128, height: 128}, s)
75+ require.Equal(t, []image.Rectangle{image.Rect(91, 16, 109, 22), image.Rect(88, 22, 112, 34), image.Rect(91, 34, 109, 40)}, got)
76+}
77+
78+// A scene without indicators draws nothing extra, and walking a scene that
79+// has them allocates nothing once warm.
80+func TestCaptureIndicatorIdleAndAllocs(t *testing.T) {
81+ r := &Renderer{width: 64, height: 64}
82+ s := indicatorScene()
83+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
84+ walk := func(s ports.Scene) float64 {
85+ dmg := newDamage(&target{}, s, image.Rect(0, 0, 64, 64))
86+ r.draws(s, nil, dmg)
87+ return testing.AllocsPerRun(50, func() { r.draws(s, nil, dmg) })
88+ }
89+ idle := walk(s)
90+ with := walk(indicatorScene(ports.CaptureIndicator{Rect: ports.Rect{W: 64, H: 64}}, ports.CaptureIndicator{Pill: true, Rect: ports.Rect{X: 44, Y: 8, W: 12, H: 12}}))
91+ t.Logf("r.draws allocations: idle %.0f, with indicators %.0f", idle, with)
92+ require.LessOrEqual(t, with, idle, "indicator draws allocate")
93+}
D internal/adapters/vulkan/capture_session_bench_test.go +0 -88
@@ -1,88 +0,0 @@
1-package vulkan
2-
3-import (
4- "fmt"
5- "image/color"
6- "testing"
7-
8- "github.com/bnema/neferwl/internal/ports"
9-)
10-
11-// BenchmarkCaptureSessionFrame compares what a frame costs (CPU recording plus
12-// GPU completion, waited on the CPU) with no capture, one standard capture, and
13-// a clean capture plus the displayed frame (the second composition of the
14-// exclusion path). Scenes have Seq 0: every frame is a full redraw, as when
15-// the scene changes each frame.
16-//
17-// go test ./internal/adapters/vulkan -run '^$' -bench CaptureSessionFrame -benchmem
18-func BenchmarkCaptureSessionFrame(b *testing.B) {
19- for _, size := range [][2]int{{320, 240}, {1920, 1080}} {
20- w, h := size[0], size[1]
21- r, err := New(w, h)
22- if err != nil {
23- b.Skipf("Vulkan unavailable: %v", err)
24- }
25- if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() {
26- r.Close()
27- b.Skip("Vulkan sync-file export unavailable")
28- }
29- win := solidContent(&testing.T{}, 64, 64, color.RGBA{G: 200, A: 255})
30- hud := solidContent(&testing.T{}, 64, 16, color.RGBA{B: 200, A: 255})
31- win.ID, win.Surface, win.Seq, win.Version = 1, 1, 1, 1
32- hud.ID, hud.Surface, hud.Seq, hud.Version = 10, 2, 1, 1
33- contents := map[ports.WindowID]ports.SurfaceContent{1: win, 10: hud}
34- shown := ports.Scene{
35- Scale: 1, Background: "#101010",
36- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}},
37- Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop, Rect: ports.Rect{X: 8, Y: 8, W: 64, H: 16}}},
38- Capture: &ports.SceneCapture{Session: 1, TargetRect: ports.Rect{W: w, H: h}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{10}},
39- }
40- clean := shown
41- clean.Capture, clean.Layers = nil, nil
42- frame := func(b *testing.B, s ports.Scene, capture bool) {
43- if err := render(r, s, contents); err != nil {
44- b.Fatal(err)
45- }
46- if capture {
47- cf, err := r.BeginCapture()
48- if err != nil {
49- b.Fatal(err)
50- }
51- defer r.EndCapture(cf)
52- }
53- }
54- wait := func(b *testing.B) {
55- if err := r.waitFrame(r.submitted); err != nil {
56- b.Fatal(err)
57- }
58- for _, s := range r.captures {
59- if s.pending {
60- if err := checked("wait", r.dd.WaitForFences(r.device, 1, &s.fence, 1, 1<<62)); err != nil {
61- b.Fatal(err)
62- }
63- }
64- }
65- }
66- for _, tc := range []struct {
67- name string
68- run func(b *testing.B)
69- }{
70- {"render", func(b *testing.B) { frame(b, shown, false) }},
71- {"render+capture", func(b *testing.B) { frame(b, shown, true) }},
72- {"clean+capture+render", func(b *testing.B) { frame(b, clean, true); frame(b, shown, false) }},
73- {"clean+capture+render+capture", func(b *testing.B) { frame(b, clean, true); frame(b, shown, true) }},
74- } {
75- b.Run(fmt.Sprintf("%dx%d/%s", w, h, tc.name), func(b *testing.B) {
76- tc.run(b)
77- wait(b)
78- b.ReportAllocs()
79- b.ResetTimer()
80- for range b.N {
81- tc.run(b)
82- wait(b)
83- }
84- })
85- }
86- r.Close()
87- }
88-}
D internal/adapters/vulkan/capture_session_test.go +0 -127
@@ -1,127 +0,0 @@
1-package vulkan
2-
3-import (
4- "context"
5- "image"
6- "image/color"
7- "math"
8- "testing"
9-
10- "github.com/bnema/neferwl/internal/adapters/capture"
11- "github.com/bnema/neferwl/internal/ports"
12- "github.com/stretchr/testify/require"
13-)
14-
15-var sessionRed = color.RGBA{R: 0xff, G: 0x3b, B: 0x30, A: 255}
16-
17-func sessionScene() ports.Scene {
18- return ports.Scene{
19- Seq: 1, Scale: 1, Background: "#101010",
20- Capture: &ports.SceneCapture{
21- Session: 7, TargetRect: ports.Rect{X: 8, Y: 4, W: 16, H: 8},
22- BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{9},
23- },
24- }
25-}
26-
27-func TestCaptureBorderGeometryLogicalScaled(t *testing.T) {
28- for _, tc := range []struct {
29- scale float64
30- want []image.Rectangle
31- }{
32- {1, []image.Rectangle{image.Rect(8, 4, 24, 6), image.Rect(8, 10, 24, 12), image.Rect(8, 6, 10, 10), image.Rect(22, 6, 24, 10)}},
33- {2, []image.Rectangle{image.Rect(16, 8, 48, 12), image.Rect(16, 20, 48, 24), image.Rect(16, 12, 20, 20), image.Rect(44, 12, 48, 20)}},
34- } {
35- r := &Renderer{width: 64, height: 64}
36- s := sessionScene()
37- s.Scale = tc.scale
38- ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
39- require.Len(t, ds, 4)
40- for i, d := range ds {
41- got := image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))
42- require.Equal(t, tc.want[i], got, "scale %v side %d", tc.scale, i)
43- require.Equal(t, [4]float32{0xff / 255.0, 0x3b / 255.0, 0x30 / 255.0, 1}, d.pc.color)
44- }
45- }
46- // On the output edge the border is still inside the output.
47- r := &Renderer{width: 64, height: 64}
48- s := sessionScene()
49- s.Scale = 1
50- s.Capture.TargetRect = ports.Rect{W: 64, H: 64}
51- ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
52- require.Len(t, ds, 4)
53- require.Equal(t, [4]int32{0, 0, 64, 2}, ds[0].pc.rect)
54- require.Equal(t, [4]int32{62, 2, 64, 62}, ds[3].pc.rect)
55- // A width larger than half the smaller side is clamped: no overdraw.
56- s.Capture.TargetRect, s.Capture.BorderWidth = ports.Rect{X: 4, Y: 4, W: 10, H: 6}, 9
57- ds = r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
58- require.Equal(t, [4]int32{4, 4, 14, 7}, ds[0].pc.rect)
59- require.Equal(t, [4]int32{4, 7, 14, 10}, ds[1].pc.rect)
60- require.Len(t, ds, 2) // the side fills would be empty
61- // No session, or no border: nothing extra is drawn.
62- s = sessionScene()
63- s.Capture.BorderWidth = 0
64- require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
65- s.Capture = nil
66- require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
67-}
68-
69-func pixelAt(t *testing.T, r *Renderer, cf ports.CaptureFrame, x, y int) color.RGBA {
70- t.Helper()
71- dst := make([]byte, 4)
72- require.NoError(t, cf.Read(image.Rect(x, y, x+1, y+1), dst, 4))
73- return color.RGBA{dst[2], dst[1], dst[0], 255}
74-}
75-
76-// The clean frame is rendered and copied first, then the displayed frame:
77-// the first copy has no border, the second one does, and the displayed frame
78-// (Pixels) matches the second.
79-func testCleanThenDisplayed(t *testing.T, r *Renderer, tol int, red color.RGBA) {
80- s := sessionScene()
81- p := capture.NewPipeline(context.Background(), nil)
82- defer p.Close(nil)
83- clean := p.CleanScene(s)
84- require.Nil(t, clean.Capture)
85- require.Zero(t, clean.Seq)
86- require.NoError(t, render(r, clean, nil))
87- cleanCopy := captureWait(t, r)
88- defer r.EndCapture(cleanCopy)
89- require.NoError(t, render(r, s, nil))
90- shownCopy := captureWait(t, r)
91- defer r.EndCapture(shownCopy)
92- near := func(a, b color.RGBA) bool {
93- d := func(x, y uint8) int { return max(int(x)-int(y), int(y)-int(x)) }
94- return max(d(a.R, b.R), d(a.G, b.G), d(a.B, b.B)) <= tol
95- }
96- bg := pixelAt(t, r, shownCopy, 1, 1)
97- // (8, 8) and (23, 8) lie in the 2 px border on the target's edge, (12, 8) inside it.
98- require.True(t, near(pixelAt(t, r, shownCopy, 8, 8), red), "displayed border")
99- require.True(t, near(pixelAt(t, r, shownCopy, 23, 8), red), "displayed border")
100- require.True(t, near(pixelAt(t, r, cleanCopy, 8, 8), bg), "clean copy has no border")
101- require.True(t, near(pixelAt(t, r, cleanCopy, 23, 8), bg), "clean copy has no border")
102- require.True(t, near(pixelAt(t, r, shownCopy, 7, 8), bg), "nothing outside the target")
103- require.True(t, near(pixelAt(t, r, shownCopy, 12, 8), bg), "target interior untouched")
104-}
105-
106-func TestCaptureCleanBeforeDisplayedSDR(t *testing.T) {
107- r, err := New(64, 16)
108- if err != nil {
109- t.Skipf("Vulkan unavailable: %v", err)
110- }
111- defer r.Close()
112- testCleanThenDisplayed(t, r, 0, sessionRed)
113-}
114-
115-func TestCaptureCleanBeforeDisplayedHDR(t *testing.T) {
116- r := hdrTestRenderer(t)
117- // The HDR capture pass tone-maps: the expectation is the same curve.
118- lin := func(v uint8) float64 {
119- f := float64(v) / 255
120- if f <= .04045 {
121- return f / 12.92
122- }
123- return math.Pow((f+.055)/1.055, 2.4)
124- }
125- want := captureSDR([3]float64{lin(sessionRed.R), lin(sessionRed.G), lin(sessionRed.B)})
126- testCleanThenDisplayed(t, r, 3, color.RGBA{want[0], want[1], want[2], 255})
127-}
M internal/adapters/vulkan/scene.go +43 -17
@@ -43,31 +43,57 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
4343 w.bounds = outputBounds
4444 w.layers(true)
4545 w.popups(true)
46- w.captureBorder()
46+ w.captureIndicators()
4747 r.scratchDraws = w.draws
4848 return w.draws
4949 }
5050
51-// captureBorder draws the native border of a recording session over
52-// everything: four fills on the inside edge of Capture.TargetRect, so it stays
53-// visible on a monitor edge. Sizes are logical like the rest of the scene and
54-// scale with it; the width is at most half the smaller side. A scene without
55-// Capture, or without border, draws nothing (clean captures never carry it).
56-func (w *sceneWalk) captureBorder() {
57- c := w.s.Capture
58- if c == nil || c.BorderColor == "" {
51+// captureIndicators draws the compositor's capture indicator over everything
52+// (ports.CaptureIndicator). The border is four fills on the inside edge of the
53+// target, so it stays visible on a monitor edge; the pill is a small rounded
54+// square. Sizes are logical like the rest of the scene and scale with it. A
55+// scene without indicators draws nothing: captures are rendered from scenes
56+// that never carry them.
57+func (w *sceneWalk) captureIndicators() {
58+ if len(w.s.CaptureIndicators) == 0 {
5959 return
6060 }
61- t := c.TargetRect
62- b := min(c.BorderWidth, t.W/2, t.H/2)
63- if b <= 0 {
61+ col := parseColor(ports.CaptureBorderColor)
62+ for _, m := range w.s.CaptureIndicators {
63+ t := m.Rect
64+ if t.W <= 0 || t.H <= 0 {
65+ continue
66+ }
67+ if m.Pill {
68+ w.pill(w.physRect(t.X, t.Y, t.W, t.H), col)
69+ continue
70+ }
71+ b := ports.CaptureBorderOf(t.W, t.H)
72+ if b <= 0 {
73+ // Too thin for a border (core inflates such marks, so this is a
74+ // 1-pixel output): a listed mark always draws something.
75+ w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
76+ continue
77+ }
78+ w.fill(w.physRect(t.X, t.Y, t.W, b), col)
79+ w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
80+ w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
81+ w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
82+ }
83+}
84+
85+// pill fills a rounded square (physical pixels) with three rects: a full
86+// middle and two bands narrowed by the corner radius.
87+func (w *sceneWalk) pill(r image.Rectangle, c [3]uint8) {
88+ rad := min(w.phys(ports.CapturePillRadius), r.Dx()/2, r.Dy()/2)
89+ if rad <= 0 {
90+ w.fill(r, c)
6491 return
6592 }
66- col := parseColor(c.BorderColor)
67- w.fill(w.physRect(t.X, t.Y, t.W, b), col)
68- w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
69- w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
70- w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
93+ cut := (rad + 1) / 2
94+ w.fill(image.Rect(r.Min.X+cut, r.Min.Y, r.Max.X-cut, r.Min.Y+rad), c)
95+ w.fill(image.Rect(r.Min.X, r.Min.Y+rad, r.Max.X, r.Max.Y-rad), c)
96+ w.fill(image.Rect(r.Min.X+cut, r.Max.Y-rad, r.Max.X-cut, r.Max.Y), c)
7197 }
7298
7399 func (w *sceneWalk) phys(v int) int { return int(math.Round(float64(v) * w.scale)) }
M internal/adapters/wayland/capture_admission_test.go +16 -2
@@ -10,6 +10,7 @@ import (
1010 ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
1111 wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
1212 "github.com/bnema/wlturbo"
13+ "github.com/bnema/zerowrap"
1314 "golang.org/x/sys/unix"
1415 )
1516
@@ -41,6 +42,13 @@ type pendingCapture struct {
4142 }
4243
4344 func newAdmissionHarness(t *testing.T, queue int) *admissionHarness {
45+ t.Helper()
46+ return newAdmissionHarnessWith(t, queue, Options{CaptureAllow: allowStore(t, "*\n")}, logging.For(context.Background(), "wayland"))
47+}
48+
49+// newAdmissionHarnessWith runs the server with extra options (RuntimeDir and
50+// Outputs are the harness's) and a logger.
51+func newAdmissionHarnessWith(t *testing.T, queue int, opts Options, log zerowrap.Logger) *admissionHarness {
4452 t.Helper()
4553 h := &admissionHarness{dir: t.TempDir(), requests: make(chan ports.CaptureRequest, queue), replies: make(chan ports.CaptureDone, maxCaptureInflight+2)}
4654 // Registered first so it runs after Run stopped and the clients closed:
@@ -55,7 +63,8 @@ func newAdmissionHarness(t *testing.T, queue int) *admissionHarness {
5563 }
5664 })
5765 out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
58- s, err := New(Options{RuntimeDir: h.dir, Outputs: out}, Channels{Captures: h.requests, Captured: h.replies}, logging.For(context.Background(), "wayland"))
66+ opts.RuntimeDir, opts.Outputs = h.dir, out
67+ s, err := New(opts, Channels{Captures: h.requests, Captured: h.replies}, log)
5968 if err != nil {
6069 t.Fatal(err)
6170 }
@@ -74,7 +83,12 @@ func newAdmissionHarness(t *testing.T, queue int) *admissionHarness {
7483
7584 func (h *admissionHarness) client(t *testing.T) *admissionClient {
7685 t.Helper()
77- c := protocolClient(t, h.s, h.dir)
86+ return h.clientOn(t, protocolClient(t, h.s, h.dir))
87+}
88+
89+// clientOn binds the capture globals on an already connected client.
90+func (h *admissionHarness) clientOn(t *testing.T, c *wlturbo.Display) *admissionClient {
91+ t.Helper()
7892 g, ok := c.Registry().FindGlobal("wl_output")
7993 if !ok {
8094 t.Fatal("output missing")
D internal/adapters/wayland/capture_session.go +0 -423
@@ -1,423 +0,0 @@
1-package wayland
2-
3-import (
4- "crypto/rand"
5- "crypto/subtle"
6- "encoding/hex"
7- "os"
8- "slices"
9- "time"
10-
11- "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
12- "github.com/bnema/neferwl/internal/ports"
13- "github.com/bnema/purego-libwayland/protocol/wayland"
14- "github.com/bnema/purego-libwayland/server"
15-)
16-
17-// Private capture sessions (neferwl_capture_manager_v1, capturesession/neferwl-capture-v1.xml).
18-// Core owns the session state; this file relays requests, tells clients what
19-// core decided and tags the capture requests of the session's own connection.
20-//
21-// Privacy: the session token never reaches a log, and neither does any peer
22-// identity; only session lifecycle states are logged.
23-
24-// privateCapture is the server's private capture state; display goroutine only.
25-type privateCapture struct {
26- ownUID uint32
27- managers []*captureManager
28- next uint64
29- session *privateSession // the one live session
30- // dead are ended sessions whose owner has not destroyed the object yet.
31- dead []*privateSession
32-}
33-
34-// minPingGap drops pings that follow the previous one too closely: the
35-// timeout is seconds, so an owner cannot flood core with them.
36-const minPingGap = ports.CaptureSessionTimeout / 50
37-
38-type privateSession struct {
39- s *Server
40- id uint64
41- token string
42- res *capturesession.NeferwlCaptureSessionV1
43- client server.Client // the owner's connection: its captures are clean
44- output string
45- // layers are the attached layer surfaces; attached their attachment
46- // objects, pending until a core state lists the layer.
47- layers []*layerSurface
48- attached []*layerAttachment
49- lastPing time.Time
50- // confirmed is set by the first core state; active and revision are the
51- // last state's: clean captures are tagged with them.
52- confirmed, active bool
53- revision uint64
54-}
55-
56-// layerAttachment is one neferwl_capture_layer_v1: it reports the result of
57-// an attach_surface and the layer's end.
58-type layerAttachment struct {
59- res *capturesession.NeferwlCaptureLayerV1
60- layer *layerSurface
61- confirmed bool // attached sent
62-}
63-
64-func (a *layerAttachment) fail(reason capturesession.NeferwlCaptureLayerV1Failure) {
65- if a.res.Alive() {
66- a.res.SendFailed(uint32(reason))
67- }
68-}
69-
70-// finish ends an attachment: detached after attached, failed before it.
71-func (a *layerAttachment) finish(failure capturesession.NeferwlCaptureLayerV1Failure, detach capturesession.NeferwlCaptureLayerV1DetachReason) {
72- if !a.res.Alive() {
73- return
74- }
75- if a.confirmed {
76- a.res.SendDetached(uint32(detach))
77- } else {
78- a.res.SendFailed(uint32(failure))
79- }
80-}
81-
82-// trustedPeer reports whether a peer with these credentials may use the
83-// private protocol: its credentials are readable and it runs as the
84-// compositor's own user.
85-func trustedPeer(own uint32, peer server.Credentials, err error) bool {
86- return err == nil && peer.UID == own
87-}
88-
89-func (s *Server) trusted(c server.Client) bool {
90- cr, err := c.Credentials()
91- return trustedPeer(s.private.ownUID, cr, err)
92-}
93-
94-func registerCaptureSession(d *server.Display, s *Server) error {
95- s.private.ownUID = uint32(os.Geteuid())
96- return capturesession.NewNeferwlCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
97- m := &captureManager{s: s, trusted: s.trusted(c), known: map[uint64]privateWorkspace{}}
98- r, err := capturesession.NewNeferwlCaptureManagerV1(c, int32(v), id, m)
99- if err != nil {
100- return
101- }
102- m.res = r
103- s.private.managers = append(s.private.managers, m)
104- r.OnDestroy = func() { s.private.managers = removeItem(s.private.managers, m) }
105- m.update(s.workspaceSnapshot)
106- })
107-}
108-
109-type privateWorkspace struct {
110- output string
111- info ports.WorkspaceInfo
112-}
113-
114-type captureManager struct {
115- s *Server
116- res *capturesession.NeferwlCaptureManagerV1
117- trusted bool
118- known map[uint64]privateWorkspace
119-}
120-
121-// update sends the workspaces that are new or changed, and the ones gone.
122-// Untrusted peers get no inventory.
123-func (m *captureManager) update(snapshot ports.Workspaces) {
124- if m.s.protected() || !m.trusted || !m.res.Alive() {
125- return
126- }
127- now := map[uint64]privateWorkspace{}
128- for _, out := range snapshot.Outputs {
129- for _, w := range out.Workspaces {
130- now[w.ID] = privateWorkspace{out.Name, w}
131- }
132- }
133- for id := range m.known {
134- if _, ok := now[id]; !ok {
135- m.res.SendWorkspaceRemoved(uint32(id>>32), uint32(id))
136- }
137- }
138- for id, w := range now {
139- if m.known[id] == w {
140- continue
141- }
142- f, active := w.info.Frame, uint32(0)
143- if w.info.Active {
144- active = 1
145- }
146- m.res.SendWorkspace(uint32(id>>32), uint32(id), w.output, w.info.Name, int32(f.X), int32(f.Y), int32(f.W), int32(f.H), active)
147- }
148- m.known = now
149-}
150-
151-// updateCaptureWorkspaces relays a core workspace snapshot to the private managers.
152-func (s *Server) updateCaptureWorkspaces(snapshot ports.Workspaces) {
153- for _, m := range s.private.managers {
154- m.update(snapshot)
155- }
156-}
157-
158-func (*captureManager) Destroy(*capturesession.NeferwlCaptureManagerV1) {}
159-
160-func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1, id uint32, out *wayland.Output, x, y, w, h int32, hi, lo, record uint32) {
161- s := m.s
162- if out == nil {
163- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidOutput), "no output")
164- return
165- }
166- if w < 0 || h < 0 {
167- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion), "negative region size")
168- return
169- }
170- ps := &privateSession{s: s, client: r.Client()}
171- res, err := capturesession.NewNeferwlCaptureSessionV1(r.Client(), r.Version(), id, ps)
172- if err != nil {
173- return
174- }
175- ps.res = res
176- stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) }
177- o := s.outputOf(out)
178- switch {
179- case s.protected() || !m.trusted:
180- s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused")
181- stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
182- return
183- case s.private.session != nil:
184- s.log.Info().Str("reason", string(ports.CaptureReasonBusy)).Msg("capture session refused")
185- stop(capturesession.NeferwlCaptureSessionV1StopReasonBusy)
186- return
187- case o == nil:
188- stop(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone)
189- return
190- }
191- var raw [32]byte
192- if _, err := rand.Read(raw[:]); err != nil {
193- s.log.Warn().Msg("capture session refused: no randomness")
194- stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
195- return
196- }
197- s.private.next++
198- ps.id, ps.token, ps.output = s.private.next, hex.EncodeToString(raw[:]), o.name()
199- s.private.session = ps
200- res.OnDestroy = ps.release
201- workspace := uint64(hi)<<32 | uint64(lo)
202- s.log.Info().Uint64("session", ps.id).Str("output", ps.output).Uint64("workspace", workspace).Bool("record", record != 0).Msg("capture session begin")
203- s.emit(ports.CaptureSessionBegin{ID: ps.id, Output: ps.output, Workspace: workspace, Region: ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, Record: record != 0})
204-}
205-
206-func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface) {
207- s := m.s
208- if surface == nil || s.surfaces[surface.Resource] == nil || surface.Resource.Client() != r.Client() {
209- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "surface is not yours")
210- return
211- }
212- state := s.surfaces[surface.Resource]
213- l := state.layer
214- att := &layerAttachment{layer: l}
215- res, err := capturesession.NewNeferwlCaptureLayerV1(r.Client(), r.Version(), id, att)
216- if err != nil {
217- return
218- }
219- att.res = res
220- // Same user as the compositor, and as the session's creator (who passed
221- // the same check): nothing else can attach.
222- ps := s.private.session
223- switch {
224- case s.protected() || !m.trusted: // DISPLAY protection and bind-time UID
225- att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)
226- return
227- case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1:
228- att.fail(capturesession.NeferwlCaptureLayerV1FailureUnknownToken)
229- return
230- case state.kind != roleLayer || l == nil:
231- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorSurfaceRole), "not a layer surface")
232- return
233- case l.pending.layer != ports.LayerTop && l.pending.layer != ports.LayerOverlay:
234- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer), "layer must be top or overlay")
235- return
236- case l.pending.keyboard == 1:
237- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard), "exclusive keyboard")
238- return
239- case l.mapped || l.closed:
240- r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "already mapped")
241- return
242- case l.capture != nil:
243- att.fail(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached)
244- return
245- case len(ps.layers) >= ports.MaxCaptureSessionLayers:
246- att.fail(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers)
247- return
248- }
249- l.capture, l.attachment = ps, att
250- l.pending.keyboard, l.current.keyboard = 0, 0
251- ps.layers = append(ps.layers, l)
252- ps.attached = append(ps.attached, att)
253- // Queued before any LayerChanged of this layer: core excludes it from
254- // the first scene that shows it.
255- s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id, Attached: true})
256-}
257-
258-func (*layerAttachment) Destroy(*capturesession.NeferwlCaptureLayerV1) {}
259-
260-func (ps *privateSession) Destroy(*capturesession.NeferwlCaptureSessionV1) {}
261-
262-func (ps *privateSession) Ping(*capturesession.NeferwlCaptureSessionV1) {
263- if ps.s.private.session != ps {
264- return
265- }
266- if now := time.Now(); now.Sub(ps.lastPing) >= minPingGap {
267- ps.lastPing = now
268- ps.s.emit(ports.CaptureSessionPing{ID: ps.id})
269- }
270-}
271-
272-// release runs when the session object goes: destroy request or client
273-// disconnect. A live session ends and core is told to forget it.
274-func (ps *privateSession) release() {
275- s := ps.s
276- if s.private.session == ps {
277- s.log.Info().Uint64("session", ps.id).Str("reason", "owner-gone").Msg("capture session end")
278- ps.end(true)
279- }
280- s.private.dead = removeItem(s.private.dead, ps)
281-}
282-
283-// end ends the session: every attachment is told (detached, or failed before
284-// it was confirmed), its layer surfaces are closed so none lingers into the
285-// next session, and the owner's captures of the output stay refused until it
286-// destroys the session object. notify tells core.
287-func (ps *privateSession) end(notify bool) {
288- s := ps.s
289- if s.private.session != ps {
290- return
291- }
292- s.private.session = nil
293- if ps.res.Alive() {
294- s.private.dead = append(s.private.dead, ps)
295- }
296- layers, attached := ps.layers, ps.attached
297- ps.layers, ps.attached = nil, nil
298- for _, a := range attached {
299- a.finish(capturesession.NeferwlCaptureLayerV1FailureSessionEnded, capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded)
300- }
301- for _, l := range layers {
302- l.capture, l.attachment = nil, nil
303- // Closing unmaps the surface: the LayerChanged that removes it is
304- // queued before the CaptureSessionEnd below.
305- l.close()
306- }
307- if notify {
308- s.emit(ports.CaptureSessionEnd{ID: ps.id})
309- }
310-}
311-
312-// captureDetach drops a layer surface whose role is gone from its session.
313-// The caller unmapped it first, so the LayerChanged that removes the layer is
314-// already queued: core keeps excluding the layer until it reads that.
315-func (l *layerSurface) captureDetach() {
316- ps, att := l.capture, l.attachment
317- if ps == nil {
318- return
319- }
320- l.capture, l.attachment = nil, nil
321- ps.layers = removeItem(ps.layers, l)
322- ps.attached = removeItem(ps.attached, att)
323- att.finish(capturesession.NeferwlCaptureLayerV1FailureLayerDestroyed, capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed)
324- if ps.s.private.session == ps {
325- ps.s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id})
326- }
327-}
328-
329-// stopReason maps a terminal core reason to the wire.
330-func stopReason(r ports.CaptureReason) capturesession.NeferwlCaptureSessionV1StopReason {
331- switch r {
332- case ports.CaptureReasonOutputGone:
333- return capturesession.NeferwlCaptureSessionV1StopReasonOutputGone
334- case ports.CaptureReasonOutputOff:
335- return capturesession.NeferwlCaptureSessionV1StopReasonOutputOff
336- case ports.CaptureReasonPingTimeout:
337- return capturesession.NeferwlCaptureSessionV1StopReasonPingTimeout
338- case ports.CaptureReasonWorkspaceGone:
339- return capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone
340- case ports.CaptureReasonBusy:
341- return capturesession.NeferwlCaptureSessionV1StopReasonBusy
342- case ports.CaptureReasonInvalidRegion:
343- return capturesession.NeferwlCaptureSessionV1StopReasonInvalidRegion
344- case ports.CaptureReasonUnauthorized:
345- return capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized
346- case ports.CaptureReasonTooManyExcluded:
347- return capturesession.NeferwlCaptureSessionV1StopReasonTooManyExcluded
348- }
349- return capturesession.NeferwlCaptureSessionV1StopReasonRequested
350-}
351-
352-// captureState applies what core decided about the session.
353-func (s *Server) captureState(c ports.CaptureSessionState) {
354- ps := s.private.session
355- if ps == nil || ps.id != c.ID {
356- return
357- }
358- if c.Reason.Terminal() {
359- s.log.Info().Uint64("session", ps.id).Str("reason", string(c.Reason)).Msg("capture session end")
360- if ps.res.Alive() {
361- ps.res.SendStopped(uint32(stopReason(c.Reason)))
362- }
363- ps.end(false)
364- return
365- }
366- ps.confirmed, ps.active, ps.revision = true, c.Active, c.Revision
367- s.log.Debug().Uint64("session", ps.id).Bool("active", c.Active).Uint64("revision", c.Revision).Str("reason", string(c.Reason)).Msg("capture session state")
368- if ps.res.Alive() {
369- active := uint32(0)
370- if c.Active {
371- active = 1
372- }
373- ps.res.SendState(ps.token, active, int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H), uint32(c.Workspace>>32), uint32(c.Workspace), uint32(c.Revision>>32), uint32(c.Revision))
374- }
375- // A layer is confirmed once core lists it: only then is it safe for the
376- // client to show it.
377- for _, a := range ps.attached {
378- if !a.confirmed && slices.Contains(c.Layers, a.layer.id) {
379- a.confirmed = true
380- if a.res.Alive() {
381- a.res.SendAttached()
382- }
383- }
384- }
385-}
386-
387-// stopCaptureOnOutput ends the session of an unplugged output without waiting for core.
388-func (s *Server) stopCaptureOnOutput(name string) {
389- ps := s.private.session
390- if ps == nil || ps.output != name {
391- return
392- }
393- s.log.Info().Uint64("session", ps.id).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("capture session end")
394- if ps.res.Alive() {
395- ps.res.SendStopped(uint32(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone))
396- }
397- ps.end(true)
398-}
399-
400-// captureTag says how a capture of output o requested through life is
401-// served. The owner's connection gets clean captures of the session's output,
402-// fenced by the revision core last confirmed. While its session is unconfirmed,
403-// or after it ended and until the owner destroys the object, those captures
404-// are refused rather than served with the HUD in them. Every other connection,
405-// and the owner's captures of another output, are untouched.
406-func (s *Server) captureTag(life *server.Resource, o *output) (clean bool, session, revision uint64, ok bool) {
407- if life == nil {
408- return false, 0, 0, true
409- }
410- client := life.Client()
411- if ps := s.private.session; ps != nil && client == ps.client && o.name() == ps.output {
412- if !ps.confirmed || !ps.active {
413- return false, 0, 0, false
414- }
415- return true, ps.id, ps.revision, true
416- }
417- for _, ps := range s.private.dead {
418- if client == ps.client && o.name() == ps.output {
419- return false, 0, 0, false
420- }
421- }
422- return false, 0, 0, true
423-}
D internal/adapters/wayland/capture_session_test.go +0 -813
@@ -1,813 +0,0 @@
1-package wayland
2-
3-import (
4- "context"
5- "strings"
6- "testing"
7- "time"
8-
9- "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
10- "github.com/bnema/neferwl/internal/logging"
11- "github.com/bnema/neferwl/internal/ports"
12- "github.com/bnema/purego-libwayland/protocol/wayland"
13- "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
14- wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
15- "github.com/bnema/purego-libwayland/server"
16- "github.com/bnema/wlturbo"
17- "golang.org/x/sys/unix"
18-)
19-
20-// sessionMsg is one decoded event of the private protocol.
21-type sessionMsg struct {
22- op uint16
23- token string
24- name string
25- numbers []int64
26-}
27-
28-// managerEvents and sessionEvents decode the events of the two interfaces.
29-type managerEvents struct {
30- wlturbo.BaseProxy
31- msgs chan sessionMsg
32-}
33-
34-func (p *managerEvents) Dispatch(e *wlturbo.Event) {
35- m := sessionMsg{op: e.Opcode}
36- switch uint32(e.Opcode) {
37- case capturesession.NeferwlCaptureManagerV1EventWorkspace:
38- m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32()))
39- _ = e.String()
40- m.name = e.String()
41- m.numbers = append(m.numbers, int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Uint32()))
42- case capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved:
43- m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32()))
44- }
45- p.msgs <- m
46-}
47-
48-// attachEvents decodes the events of a neferwl_capture_layer_v1.
49-type attachEvents struct {
50- wlturbo.BaseProxy
51- msgs chan sessionMsg
52-}
53-
54-func (p *attachEvents) Dispatch(e *wlturbo.Event) {
55- m := sessionMsg{op: e.Opcode}
56- if uint32(e.Opcode) != capturesession.NeferwlCaptureLayerV1EventAttached {
57- m.numbers = append(m.numbers, int64(e.Uint32()))
58- }
59- p.msgs <- m
60-}
61-
62-type sessionEvents struct {
63- wlturbo.BaseProxy
64- msgs chan sessionMsg
65-}
66-
67-func (p *sessionEvents) Dispatch(e *wlturbo.Event) {
68- m := sessionMsg{op: e.Opcode}
69- switch uint32(e.Opcode) {
70- case capturesession.NeferwlCaptureSessionV1EventState:
71- m.token = e.String()
72- for i := 0; i < 9; i++ {
73- m.numbers = append(m.numbers, int64(e.Int32()))
74- }
75- case capturesession.NeferwlCaptureSessionV1EventStopped:
76- m.numbers = append(m.numbers, int64(e.Uint32()))
77- }
78- p.msgs <- m
79-}
80-
81-type privateHarness struct {
82- s *Server
83- dir string
84- events chan ports.ClientEvent
85- commands chan ports.ClientCommand
86- workspaces chan ports.Workspaces
87- captures chan ports.CaptureRequest
88- captured chan ports.CaptureDone
89-}
90-
91-func newPrivateHarness(t *testing.T) *privateHarness {
92- t.Helper()
93- h := &privateHarness{
94- dir: t.TempDir(),
95- events: make(chan ports.ClientEvent, 64),
96- commands: make(chan ports.ClientCommand, 16),
97- workspaces: make(chan ports.Workspaces, 4),
98- captures: make(chan ports.CaptureRequest, 4),
99- captured: make(chan ports.CaptureDone, 4),
100- }
101- out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
102- s, err := New(Options{RuntimeDir: h.dir, Outputs: out}, Channels{Events: h.events, Commands: h.commands, Workspaces: h.workspaces, Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland"))
103- if err != nil {
104- t.Fatal(err)
105- }
106- h.s = s
107- ctx, cancel := context.WithCancel(context.Background())
108- done := make(chan error, 1)
109- go func() { done <- s.Run(ctx) }()
110- t.Cleanup(func() {
111- cancel()
112- if err := <-done; err != nil {
113- t.Errorf("Run: %v", err)
114- }
115- for len(h.captures) > 0 {
116- _ = (<-h.captures).Dst.File.Close()
117- }
118- })
119- return h
120-}
121-
122-type privateClient struct {
123- c *wlturbo.Display
124- manager uint32
125- output uint32
126- mgr *managerEvents
127-}
128-
129-func (h *privateHarness) client(t *testing.T) *privateClient {
130- t.Helper()
131- c := protocolClient(t, h.s, h.dir)
132- g, ok := c.Registry().FindGlobal("wl_output")
133- if !ok {
134- t.Fatal("output missing")
135- }
136- out, err := bindWireID(c, g.Name, g.Interface, 4)
137- if err != nil {
138- t.Fatal(err)
139- }
140- registerProtocol(t, c, out)
141- pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}}
142- pc.mgr.SetID(pc.manager)
143- registerWireProxy(c, pc.mgr)
144- return pc
145-}
146-
147-// begin opens a session and returns its event stream.
148-func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64, record uint32) (uint32, chan sessionMsg) {
149- t.Helper()
150- id := pc.c.AllocateID()
151- ev := &sessionEvents{msgs: make(chan sessionMsg, 32)}
152- ev.SetID(id)
153- registerWireProxy(pc.c, ev)
154- requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record)
155- if err := pc.c.Roundtrip(); err != nil {
156- t.Fatal(err)
157- }
158- return id, ev.msgs
159-}
160-
161-// waitMsg waits for a message on ch, dispatching the client meanwhile.
162-func waitMsg(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) sessionMsg {
163- t.Helper()
164- deadline := time.Now().Add(2 * time.Second)
165- for time.Now().Before(deadline) {
166- select {
167- case m := <-ch:
168- return m
169- default:
170- }
171- if err := c.Roundtrip(); err != nil {
172- t.Fatal(err)
173- }
174- time.Sleep(time.Millisecond)
175- }
176- t.Fatal("timed out waiting for an event")
177- return sessionMsg{}
178-}
179-
180-func noMessage(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) {
181- t.Helper()
182- if err := c.Roundtrip(); err != nil {
183- t.Fatal(err)
184- }
185- select {
186- case m := <-ch:
187- t.Fatalf("unexpected event %+v", m)
188- default:
189- }
190-}
191-
192-func nextEvent[T ports.ClientEvent](t *testing.T, ch chan ports.ClientEvent) T {
193- t.Helper()
194- for {
195- select {
196- case ev := <-ch:
197- if v, ok := ev.(T); ok {
198- return v
199- }
200- case <-time.After(2 * time.Second):
201- t.Fatal("timed out waiting for a client event")
202- }
203- }
204-}
205-
206-func (h *privateHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
207-
208-func TestTrustedPeer(t *testing.T) {
209- for _, tc := range []struct {
210- name string
211- peer server.Credentials
212- err error
213- want bool
214- }{
215- {"same user", server.Credentials{UID: 1000}, nil, true},
216- {"other user", server.Credentials{UID: 1001}, nil, false},
217- {"root is not the user", server.Credentials{UID: 0}, nil, false},
218- {"overflow id", server.Credentials{UID: 65534}, nil, false},
219- {"unreadable", server.Credentials{UID: 1000}, context.Canceled, false},
220- } {
221- if got := trustedPeer(1000, tc.peer, tc.err); got != tc.want {
222- t.Errorf("%s: %v", tc.name, got)
223- }
224- }
225-}
226-
227-func TestPrivateCaptureBeginAndState(t *testing.T) {
228- h := newPrivateHarness(t)
229- pc := h.client(t)
230- id, msgs := pc.begin(t, 1, 2, 3, 4, 1<<33|7, 1)
231- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
232- if begin.Output != "HEADLESS-1" || begin.Workspace != 1<<33|7 || begin.Region != (ports.Rect{X: 1, Y: 2, W: 3, H: 4}) || !begin.Record || begin.ID == 0 {
233- t.Fatalf("begin %+v", begin)
234- }
235- // Nothing is sent, and above all no token, until core confirmed.
236- noMessage(t, pc.c, msgs)
237- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}, Workspace: 1<<33 | 7, Active: true, Revision: 1<<32 | 5})
238- m := waitMsg(t, pc.c, msgs)
239- if uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventState || len(m.token) != 64 || strings.Trim(m.token, "0123456789abcdef") != "" {
240- t.Fatalf("state %+v", m)
241- }
242- want := []int64{1, 1, 2, 3, 4, 2, 7, 1, 5}
243- for i, v := range want {
244- if m.numbers[i] != v {
245- t.Fatalf("state numbers %v, want %v", m.numbers, want)
246- }
247- }
248- // Ping reaches core, and a flood of them does not.
249- for i := 0; i < 50; i++ {
250- requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestPing)
251- }
252- if err := pc.c.Roundtrip(); err != nil {
253- t.Fatal(err)
254- }
255- if p := nextEvent[ports.CaptureSessionPing](t, h.events); p.ID != begin.ID {
256- t.Fatalf("ping %+v", p)
257- }
258- select {
259- case ev := <-h.events:
260- if _, ok := ev.(ports.CaptureSessionPing); ok {
261- t.Fatalf("ping flood forwarded: %+v", ev)
262- }
263- default:
264- }
265- // Destroy ends the session in core.
266- requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestDestroy)
267- if err := pc.c.Roundtrip(); err != nil {
268- t.Fatal(err)
269- }
270- if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID {
271- t.Fatalf("end %+v", e)
272- }
273-}
274-
275-func TestPrivateCaptureBusyAndTerminal(t *testing.T) {
276- h := newPrivateHarness(t)
277- pc := h.client(t)
278- _, first := pc.begin(t, 0, 0, 0, 0, 0, 0)
279- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
280- other := h.client(t)
281- _, second := other.begin(t, 0, 0, 0, 0, 0, 0)
282- if m := waitMsg(t, other.c, second); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonBusy) {
283- t.Fatalf("second session: %+v", m)
284- }
285- // The refusal neither reaches core nor disturbs the first session.
286- noMessage(t, pc.c, first)
287- select {
288- case ev := <-h.events:
289- t.Fatalf("event for a refused session: %+v", ev)
290- default:
291- }
292- // A terminal state from core stops the session with its reason.
293- h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonWorkspaceGone})
294- if m := waitMsg(t, pc.c, first); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone) {
295- t.Fatalf("stopped: %+v", m)
296- }
297- // The slot is free again.
298- _, third := other.begin(t, 0, 0, 0, 0, 0, 0)
299- noMessage(t, other.c, third)
300- nextEvent[ports.CaptureSessionBegin](t, h.events)
301-}
302-
303-func TestPrivateCaptureInvalidRequests(t *testing.T) {
304- h := newPrivateHarness(t)
305- pc := h.client(t)
306- requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, pc.c.AllocateID(), pc.output, int32(0), int32(0), int32(-1), int32(4), uint32(0), uint32(0), uint32(0))
307- expectProtocolError(t, pc.c, pc.manager, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion))
308-}
309-
310-// hud is a second connection: a layer surface it attaches with the token.
311-type hud struct {
312- *privateClient
313- surface, layer uint32
314-}
315-
316-func (h *privateHarness) hud(t *testing.T, layer ports.Layer, keyboard uint32) *hud {
317- t.Helper()
318- return h.hudVersion(t, layer, keyboard, 1)
319-}
320-
321-func (h *privateHarness) hudVersion(t *testing.T, layer ports.Layer, keyboard, shellVersion uint32) *hud {
322- t.Helper()
323- pc := h.client(t)
324- comp := bindProtocol(t, pc.c, "wl_compositor")
325- shell := bindVersion(t, pc.c, "zwlr_layer_shell_v1", shellVersion)
326- surf, l := pc.c.AllocateID(), pc.c.AllocateID()
327- requestProtocol(t, pc.c, comp, wayland.CompositorRequestCreateSurface, surf)
328- registerProtocol(t, pc.c, surf)
329- requestProtocol(t, pc.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, pc.output, uint32(layer), "hud")
330- registerProtocol(t, pc.c, l)
331- if keyboard != 0 {
332- requestProtocol(t, pc.c, l, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, keyboard)
333- }
334- if err := pc.c.Roundtrip(); err != nil {
335- t.Fatal(err)
336- }
337- return &hud{privateClient: pc, surface: surf, layer: l}
338-}
339-
340-// attach sends attach_surface and returns the attachment's event stream.
341-func (hd *hud) attach(t *testing.T, token string) chan sessionMsg {
342- t.Helper()
343- return hd.attachSurface(t, token, hd.surface)
344-}
345-
346-func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan sessionMsg {
347- t.Helper()
348- id := hd.c.AllocateID()
349- ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
350- ev.SetID(id)
351- registerWireProxy(hd.c, ev)
352- requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface)
353- if err := hd.c.Roundtrip(); err != nil {
354- t.Fatal(err)
355- }
356- return ev.msgs
357-}
358-
359-// expectAttachment waits for one attachment event of the given opcode and first argument.
360-func expectAttachment(t *testing.T, c *wlturbo.Display, ch chan sessionMsg, op uint32, arg ...int64) {
361- t.Helper()
362- m := waitMsg(t, c, ch)
363- if uint32(m.op) != op || (len(arg) > 0 && m.numbers[0] != arg[0]) {
364- t.Fatalf("attachment event %+v, want op %d %v", m, op, arg)
365- }
366-}
367-
368-// Attach works from another connection with the token, reaches core before
369-// any content, and is confirmed to the client only once core lists the layer.
370-func TestPrivateCaptureAttach(t *testing.T) {
371- h := newPrivateHarness(t)
372- owner := h.client(t)
373- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
374- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
375- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Active: true, Revision: 1})
376- token := waitMsg(t, owner.c, msgs).token
377-
378- hd := h.hud(t, ports.LayerOverlay, 2)
379- // A wrong token fails that attachment without touching the session.
380- bad := hd.attach(t, strings.Repeat("0", 64))
381- expectAttachment(t, hd.c, bad, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnknownToken))
382- good := hd.attach(t, token)
383- layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
384- if layer.ID != begin.ID || !layer.Attached || layer.Layer == 0 {
385- t.Fatalf("layer %+v", layer)
386- }
387- noMessage(t, hd.c, good) // not confirmed yet
388- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2})
389- noMessage(t, hd.c, good) // a state that does not list the layer confirms nothing
390- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 3, Layers: []ports.WindowID{layer.Layer}})
391- expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventAttached)
392- // Attaching the same surface again fails on its own object.
393- again := hd.attach(t, token)
394- expectAttachment(t, hd.c, again, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached))
395- // Destroying the layer detaches it: LayerChanged (the unmap) first, then
396- // the detach, on the same ordered channel, so core never drops the
397- // exclusion before the layer is gone.
398- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
399- if err := hd.c.Roundtrip(); err != nil {
400- t.Fatal(err)
401- }
402- expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
403- if l := nextEvent[ports.CaptureSessionLayer](t, h.events); l.Attached || l.Layer != layer.Layer {
404- t.Fatalf("detach %+v", l)
405- }
406-}
407-
408-// Order on the event channel: a mapped attached layer that is destroyed
409-// queues its LayerChanged removal before the detach reaches core.
410-func TestPrivateCaptureDetachOrdering(t *testing.T) {
411- h := newPrivateHarness(t)
412- owner := h.client(t)
413- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
414- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
415- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
416- token := waitMsg(t, owner.c, msgs).token
417- hd := h.hud(t, ports.LayerTop, 0)
418- att := hd.attach(t, token)
419- layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
420- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
421- expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
422- mapLayer(t, hd)
423- if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
424- t.Fatalf("mapped %+v", l)
425- }
426- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
427- if err := hd.c.Roundtrip(); err != nil {
428- t.Fatal(err)
429- }
430- var order []string
431- for len(order) < 2 {
432- switch v := (<-h.events).(type) {
433- case ports.LayerChanged:
434- if len(v.Layers) == 0 {
435- order = append(order, "unmap")
436- }
437- case ports.CaptureSessionLayer:
438- if !v.Attached {
439- order = append(order, "detach")
440- }
441- }
442- }
443- if order[0] != "unmap" || order[1] != "detach" {
444- t.Fatalf("order %v: the exclusion must outlive the layer's last frame", order)
445- }
446-}
447-
448-// mapLayer configures, acks and commits a buffer on the hud's layer.
449-func mapLayer(t *testing.T, hd *hud) {
450- t.Helper()
451- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
452- requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
453- cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
454- cfg.SetID(hd.layer)
455- registerWireProxy(hd.c, cfg)
456- if err := hd.c.Roundtrip(); err != nil {
457- t.Fatal(err)
458- }
459- c := <-cfg.configured
460- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, c[0])
461- _, buf, fd := captureSmallBuffer(t, hd.c)
462- t.Cleanup(func() { _ = unix.Close(fd) })
463- requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
464- requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
465- if err := hd.c.Roundtrip(); err != nil {
466- t.Fatal(err)
467- }
468-}
469-
470-// Stopping the session detaches every attachment, closes the layer surfaces
471-// (so none lingers into the next session) and removes them before core is told.
472-func TestPrivateCaptureStopClosesLayers(t *testing.T) {
473- h := newPrivateHarness(t)
474- owner := h.client(t)
475- sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
476- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
477- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
478- token := waitMsg(t, owner.c, msgs).token
479- hd := h.hud(t, ports.LayerOverlay, 0)
480- closed := make(chan sessionMsg, 4)
481- lp := &layerClosedProxy{msgs: closed}
482- lp.SetID(hd.layer)
483- registerWireProxy(hd.c, lp)
484- confirmed := hd.attach(t, token)
485- layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
486- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
487- expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventAttached)
488- pending := hd.attachSurfaceNew(t, token)
489- _ = pending
490- requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy)
491- if err := owner.c.Roundtrip(); err != nil {
492- t.Fatal(err)
493- }
494- expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded))
495- if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
496- t.Fatalf("layer event %+v", m)
497- }
498- if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID {
499- t.Fatalf("end %+v", e)
500- }
501-}
502-
503-type layerClosedProxy struct {
504- wlturbo.BaseProxy
505- msgs chan sessionMsg
506-}
507-
508-func (p *layerClosedProxy) Dispatch(e *wlturbo.Event) {
509- if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
510- p.msgs <- sessionMsg{op: e.Opcode}
511- }
512-}
513-
514-// attachSurfaceNew attaches a second, fresh layer surface of the hud and returns its stream.
515-func (hd *hud) attachSurfaceNew(t *testing.T, token string) chan sessionMsg {
516- t.Helper()
517- comp := bindProtocol(t, hd.c, "wl_compositor")
518- shell := bindProtocol(t, hd.c, "zwlr_layer_shell_v1")
519- surf, l := hd.c.AllocateID(), hd.c.AllocateID()
520- requestProtocol(t, hd.c, comp, wayland.CompositorRequestCreateSurface, surf)
521- registerProtocol(t, hd.c, surf)
522- requestProtocol(t, hd.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, hd.output, uint32(ports.LayerTop), "hud2")
523- registerProtocol(t, hd.c, l)
524- return hd.attachSurface(t, token, surf)
525-}
526-
527-func TestPrivateCaptureAttachRefusals(t *testing.T) {
528- for _, tc := range []struct {
529- name string
530- layer ports.Layer
531- keyboard uint32
532- code uint32
533- }{
534- {"background layer", ports.LayerBackground, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)},
535- {"bottom layer", ports.LayerBottom, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)},
536- {"exclusive keyboard", ports.LayerTop, 1, uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard)},
537- } {
538- t.Run(tc.name, func(t *testing.T) {
539- h := newPrivateHarness(t)
540- owner := h.client(t)
541- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
542- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
543- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
544- token := waitMsg(t, owner.c, msgs).token
545- hd := h.hud(t, tc.layer, tc.keyboard)
546- requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, hd.c.AllocateID(), token, hd.surface)
547- expectProtocolError(t, hd.c, hd.manager, tc.code)
548- select {
549- case ev := <-h.events:
550- if _, ok := ev.(ports.CaptureSessionLayer); ok {
551- t.Fatalf("a refused attach reached core: %+v", ev)
552- }
553- default:
554- }
555- })
556- }
557-}
558-
559-func TestPrivateCaptureAttachLimit(t *testing.T) {
560- h := newPrivateHarness(t)
561- owner := h.client(t)
562- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
563- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
564- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
565- token := waitMsg(t, owner.c, msgs).token
566- hd := h.hud(t, ports.LayerTop, 0)
567- hd.attach(t, token)
568- for i := 1; i < ports.MaxCaptureSessionLayers; i++ {
569- hd.attachSurfaceNew(t, token)
570- }
571- over := hd.attachSurfaceNew(t, token)
572- expectAttachment(t, hd.c, over, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers))
573- n := 0
574- for len(h.events) > 0 {
575- if l, ok := (<-h.events).(ports.CaptureSessionLayer); ok && l.Attached {
576- n++
577- }
578- }
579- if n != ports.MaxCaptureSessionLayers {
580- t.Fatalf("%d layers reached core, want %d", n, ports.MaxCaptureSessionLayers)
581- }
582-}
583-
584-func TestPrivateCaptureWorkspaceInventory(t *testing.T) {
585- h := newPrivateHarness(t)
586- pc := h.client(t)
587- h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1<<32 | 3, Name: "dev", Active: true, Frame: ports.Rect{X: 5, Y: 6, W: 7, H: 8}}, {ID: 4, Name: "2"}}}}}
588- seen := map[uint64]sessionMsg{}
589- for len(seen) < 2 {
590- m := waitMsg(t, pc.c, pc.mgr.msgs)
591- if uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspace {
592- t.Fatalf("event %+v", m)
593- }
594- seen[uint64(m.numbers[0])<<32|uint64(m.numbers[1])] = m
595- }
596- if m := seen[1<<32|3]; m.name != "dev" || m.numbers[2] != 5 || m.numbers[3] != 6 || m.numbers[4] != 7 || m.numbers[5] != 8 || m.numbers[6] != 1 {
597- t.Fatalf("workspace %+v", m)
598- }
599- h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 4, Name: "2"}}}}}
600- if m := waitMsg(t, pc.c, pc.mgr.msgs); uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved || m.numbers[0] != 1 || m.numbers[1] != 3 {
601- t.Fatalf("removed %+v", m)
602- }
603-}
604-
605-// captureOnce asks a wlr capture of the whole 2x2 output on client a.
606-func captureOnce(t *testing.T, h *privateHarness, pc *privateClient) (*captureEvents, uint32) {
607- t.Helper()
608- _, buf, fd := captureSmallBuffer(t, pc.c)
609- t.Cleanup(func() { _ = unix.Close(fd) })
610- mgr := bindVersion(t, pc.c, "zwlr_screencopy_manager_v1", 3)
611- frame := pc.c.AllocateID()
612- ev := &captureEvents{events: make(chan uint16, 16)}
613- ev.SetID(frame)
614- registerWireProxy(pc.c, ev)
615- requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output)
616- requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
617- if err := pc.c.Roundtrip(); err != nil {
618- t.Fatal(err)
619- }
620- return ev, frame
621-}
622-
623-// The owner's captures are tagged clean and fenced by the confirmed
624-// revision; before confirmation or while paused they are refused, not served
625-// with the HUD in them. Every other connection is untouched.
626-func TestPrivateCaptureCleanTagging(t *testing.T) {
627- h := newPrivateHarness(t)
628- owner, other := h.client(t), h.client(t)
629- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
630- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
631-
632- // Unconfirmed: the owner's capture fails and never reaches the renderer.
633- ev, _ := captureOnce(t, h, owner)
634- if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
635- t.Fatal("the capture of an unconfirmed session did not fail")
636- }
637- select {
638- case r := <-h.captures:
639- _ = r.Dst.File.Close()
640- t.Fatalf("an unconfirmed session's capture reached the renderer: %+v", r)
641- default:
642- }
643-
644- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 9})
645- waitMsg(t, owner.c, msgs)
646- captureOnce(t, h, owner)
647- r := receive1(t, h.captures)
648- if !r.Clean || r.Session != begin.ID || r.CaptureRevision != 9 {
649- t.Fatalf("owner capture %+v", r)
650- }
651- _ = r.Dst.File.Close()
652- h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
653-
654- captureOnce(t, h, other)
655- r = receive1(t, h.captures)
656- if r.Clean || r.Session != 0 || r.CaptureRevision != 0 {
657- t.Fatalf("another connection's capture tagged: %+v", r)
658- }
659- _ = r.Dst.File.Close()
660- h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
661-
662- // Paused: the owner is refused again.
663- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: false, Revision: 10})
664- waitMsg(t, owner.c, msgs)
665- ev, _ = captureOnce(t, h, owner)
666- if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
667- t.Fatal("the capture of a paused session did not fail")
668- }
669- select {
670- case r := <-h.captures:
671- _ = r.Dst.File.Close()
672- t.Fatalf("a paused session's capture reached the renderer: %+v", r)
673- default:
674- }
675-}
676-
677-// eventsInclude drains the frame events received so far and reports op among them.
678-func eventsInclude(ev *captureEvents, op uint16) bool {
679- found := false
680- for len(ev.events) > 0 {
681- if <-ev.events == op {
682- found = true
683- }
684- }
685- return found
686-}
687-
688-func receive1(t *testing.T, ch chan ports.CaptureRequest) ports.CaptureRequest {
689- t.Helper()
690- select {
691- case r := <-ch:
692- return r
693- case <-time.After(2 * time.Second):
694- t.Fatal("no capture request")
695- return ports.CaptureRequest{}
696- }
697-}
698-
699-// After the session ended and until the owner destroys the object, its
700-// captures of the output are refused, never served unfiltered; another
701-// connection is unaffected.
702-func TestPrivateCaptureOwnerRefusedAfterStop(t *testing.T) {
703- h := newPrivateHarness(t)
704- owner, other := h.client(t), h.client(t)
705- sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
706- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
707- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
708- waitMsg(t, owner.c, msgs)
709- h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonOutputOff})
710- if m := waitMsg(t, owner.c, msgs); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped {
711- t.Fatalf("stopped: %+v", m)
712- }
713- ev, _ := captureOnce(t, h, owner)
714- if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
715- t.Fatal("the owner's capture after the stop did not fail")
716- }
717- select {
718- case r := <-h.captures:
719- _ = r.Dst.File.Close()
720- t.Fatalf("a stopped session's capture reached the renderer: %+v", r)
721- default:
722- }
723- captureOnce(t, h, other)
724- r := receive1(t, h.captures)
725- if r.Clean {
726- t.Fatalf("another connection's capture is clean: %+v", r)
727- }
728- _ = r.Dst.File.Close()
729- h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
730- // Destroying the object lifts the refusal.
731- requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy)
732- if err := owner.c.Roundtrip(); err != nil {
733- t.Fatal(err)
734- }
735- captureOnce(t, h, owner)
736- r = receive1(t, h.captures)
737- if r.Clean {
738- t.Fatalf("capture after destroy: %+v", r)
739- }
740- _ = r.Dst.File.Close()
741-}
742-
743-// An attached layer that commits a layer below top is closed and detached
744-// once, and its unmap reaches core before the detach.
745-func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) {
746- h := newPrivateHarness(t)
747- owner := h.client(t)
748- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
749- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
750- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
751- token := waitMsg(t, owner.c, msgs).token
752- hd := h.hudVersion(t, ports.LayerTop, 0, 2)
753- att := hd.attach(t, token)
754- layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
755- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
756- expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
757- mapLayer(t, hd)
758- if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
759- t.Fatalf("mapped %+v", l)
760- }
761- closed := make(chan sessionMsg, 4)
762- lp := &layerClosedProxy{msgs: closed}
763- lp.SetID(hd.layer)
764- registerWireProxy(hd.c, lp)
765- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
766- requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
767- if err := hd.c.Roundtrip(); err != nil {
768- t.Fatal(err)
769- }
770- expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
771- if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
772- t.Fatalf("layer event %+v", m)
773- }
774- noMessage(t, hd.c, att) // detached exactly once
775- var order []string
776- for len(order) < 2 {
777- switch v := (<-h.events).(type) {
778- case ports.LayerChanged:
779- if len(v.Layers) == 0 {
780- order = append(order, "unmap")
781- }
782- case ports.CaptureSessionLayer:
783- if !v.Attached {
784- order = append(order, "detach")
785- }
786- }
787- }
788- if order[0] != "unmap" || order[1] != "detach" {
789- t.Fatalf("order %v", order)
790- }
791-}
792-
793-// An attached layer asking for keyboard interactivity, before or after the
794-// attach, is never reported with it to core.
795-func TestPrivateCaptureLayerKeyboardNone(t *testing.T) {
796- h := newPrivateHarness(t)
797- owner := h.client(t)
798- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
799- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
800- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
801- token := waitMsg(t, owner.c, msgs).token
802- hd := h.hud(t, ports.LayerOverlay, 2) // on demand before the attach
803- att := hd.attach(t, token)
804- layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
805- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
806- expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
807- requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, uint32(2)) // and after
808- mapLayer(t, hd)
809- l := nextEvent[ports.LayerChanged](t, h.events)
810- if len(l.Layers) != 1 || l.Layers[0].Keyboard != 0 {
811- t.Fatalf("layer reported with keyboard: %+v", l.Layers)
812- }
813-}
A internal/adapters/wayland/captureauth.go +177 -0
@@ -0,0 +1,177 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "os"
6+
7+ "github.com/bnema/neferwl/internal/adapters/captureallow"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// mayCapture is the one place that decides whether a client may capture the
12+// screen. Every capture path asks it: wlr-screencopy frames, ext-image-copy-
13+// 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+// refused client gets the failure its protocol defines (failed, or stopped
16+// for a session) and nothing is captured or flashed. In order:
17+//
18+// 1. While the session is protected (ext-session-lock) no client may
19+// capture, so nothing of the locked session reaches a buffer.
20+// 2. A sandboxed client (one that connected through a
21+// wp_security_context_v1 listener, securitycontext.go) never captures,
22+// whatever the allowlist says: it goes through the desktop portal. The
23+// display cannot hide globals per client, so the capture globals stay
24+// visible to it and its requests are refused here.
25+// 3. The executable allowlist (captureallow; /etc/neferwl/capture-allow): no
26+// `*` line lets every client through, an invalid file lets none,
27+// otherwise the executable actually running behind the client's
28+// connection must be listed (in the file, or in the built-in list while
29+// the file is missing). captureexe.go resolves it afresh at every
30+// wlr-screencopy frame request and ext session creation, from a pidfd kept
31+// per client; the frames of an ext session use mayCaptureFrame. Without
32+// SO_PEERPIDFD nobody is identified, so nobody captures.
33+//
34+// Display goroutine only.
35+func (s *Server) mayCapture(c server.Client) bool {
36+ return s.mayCaptureWith(c, s.peerIdentity)
37+}
38+
39+// mayCaptureFrame is mayCapture for the frames of an ext-image-copy-capture
40+// session: a frame does not read /proc again but uses the executable resolved
41+// at the client's latest check (the session's creation, or a later wlr request
42+// or session on the same connection). The current policy and the lock are
43+// still checked each time. Display goroutine only.
44+func (s *Server) mayCaptureFrame(c server.Client) bool {
45+ return s.mayCaptureWith(c, func(c server.Client) *peerID {
46+ id := s.peerEntry(c)
47+ if id.exe == "" && id.err == nil {
48+ // Never resolved: the policy was off when the session began.
49+ id = s.peerIdentity(c)
50+ }
51+ return id
52+ })
53+}
54+
55+// mayCaptureWith is the rule chain of mayCapture; resolve names the client's
56+// executable once the policy needs it.
57+func (s *Server) mayCaptureWith(c server.Client, resolve func(server.Client) *peerID) bool {
58+ if s.protected() {
59+ return false
60+ }
61+ if s.sandboxed[c] != nil {
62+ s.refuseOnce(c, "sandboxed client", "", 0)
63+ return false
64+ }
65+ if s.captureAllow == nil {
66+ // No allowlist store: fail closed, never allow everyone.
67+ s.refuseOnce(c, "allowlist invalid", "", 0)
68+ return false
69+ }
70+ pol := s.captureAllow.Policy()
71+ switch pol.Mode() {
72+ case captureallow.ModeDisabled:
73+ return true
74+ case captureallow.ModeClosed:
75+ s.refuseOnce(c, "allowlist invalid", "", 0)
76+ return false
77+ }
78+ id := resolve(c)
79+ if id.err != nil {
80+ s.refuseOnce(c, "executable unknown: "+id.err.Error(), "", id.pid)
81+ return false
82+ }
83+ if !pol.Allows(id.exe) {
84+ s.refuseOnce(c, "executable not in allowlist", id.exe, id.pid)
85+ return false
86+ }
87+ return true
88+}
89+
90+// peerID is what is known of a client, dropped when it is destroyed. Only the
91+// pidfd is kept across decisions: the executable is read again at each one,
92+// so an exec after connecting is seen.
93+type peerID struct {
94+ pidfd *os.File // pins the peer process; closed with the client
95+ exe string // the last resolution, for mayCaptureFrame
96+ pid int
97+ err error
98+ // refused holds the refusals already logged (reason and executable), so
99+ // a client is logged once per distinct executable; bounded.
100+ refused map[string]struct{}
101+}
102+
103+// maxRefusalsLogged bounds peerID.refused.
104+const maxRefusalsLogged = 8
105+
106+// peerEntry is the entry of c, dropped when the client is destroyed.
107+func (s *Server) peerEntry(c server.Client) *peerID {
108+ if id := s.peers[c]; id != nil {
109+ return id
110+ }
111+ id := &peerID{}
112+ s.peers[c] = id
113+ c.OnDestroy(func() {
114+ if id.pidfd != nil {
115+ _ = id.pidfd.Close()
116+ }
117+ delete(s.peers, c)
118+ })
119+ return id
120+}
121+
122+// peerIdentity resolves the executable behind c now: the pidfd is opened on
123+// the first call and kept, /proc/<pid>/exe and the pidfd's liveness are read
124+// on every call. Failures are not cached.
125+func (s *Server) peerIdentity(c server.Client) *peerID {
126+ id := s.peerEntry(c)
127+ id.exe, id.err = "", nil
128+ cred, _ := c.Credentials()
129+ id.pid = cred.PID
130+ if id.pidfd == nil {
131+ fd, err := c.FD()
132+ if err != nil {
133+ id.err = err
134+ return id
135+ }
136+ id.pidfd, err = s.peer.Pidfd(fd)
137+ if err != nil {
138+ id.pidfd, id.err = nil, err
139+ if errors.Is(err, errNoPidfd) && !s.noPidfdWarned {
140+ 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")
142+ }
143+ return id
144+ }
145+ }
146+ exe, pid, err := s.peer.Exe(id.pidfd)
147+ id.exe, id.err = exe, err
148+ if pid > 0 {
149+ id.pid = pid
150+ }
151+ return id
152+}
153+
154+// refuseOnce logs a refused capture, once per client and executable:
155+// component, pid and executable path, never a token or anything a client
156+// sent.
157+func (s *Server) refuseOnce(c server.Client, reason, exe string, pid int) {
158+ id := s.peerEntry(c)
159+ key := reason + "\x00" + exe
160+ if _, done := id.refused[key]; done || len(id.refused) >= maxRefusalsLogged {
161+ return
162+ }
163+ if id.refused == nil {
164+ id.refused = map[string]struct{}{}
165+ }
166+ id.refused[key] = struct{}{}
167+ if pid == 0 {
168+ if cred, err := c.Credentials(); err == nil {
169+ pid = cred.PID
170+ }
171+ }
172+ e := s.log.Info().Int("pid", pid)
173+ if exe != "" {
174+ e = e.Str("exe", exe)
175+ }
176+ e.Str("reason", reason).Msg("capture refused")
177+}
A internal/adapters/wayland/captureauth_test.go +469 -0
@@ -0,0 +1,469 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferwl/internal/adapters/captureallow"
14+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
15+ "github.com/bnema/neferwl/internal/ports"
16+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
17+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/mock"
20+ "golang.org/x/sys/unix"
21+)
22+
23+func allowStore(t *testing.T, content string) *captureallow.Store {
24+ t.Helper()
25+ path := filepath.Join(t.TempDir(), "capture-allow")
26+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
27+ t.Fatal(err)
28+ }
29+ return captureallow.NewStoreOwnedBy(path, uint32(os.Getuid()), zerowrap.Default())
30+}
31+
32+func authHarness(t *testing.T, store *captureallow.Store, peer peerExe) (*admissionHarness, *lockedBuffer) {
33+ t.Helper()
34+ var out lockedBuffer
35+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland")
36+ return newAdmissionHarnessWith(t, maxCaptureInflight+2, Options{CaptureAllow: store, peer: peer}, log), &out
37+}
38+
39+// The executable behind a real client connection from this very process is
40+// the test binary: a list naming it lets the client capture, one naming
41+// something else does not. No test double for the peer lookup.
42+func TestCaptureAllowlistRealPeer(t *testing.T) {
43+ self, err := os.Executable()
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ h, out := authHarness(t, allowStore(t, "# mine\n"+self+"\n"), nil)
48+ a := h.client(t)
49+ if _, ok := h.accepted(t, a.captureWlr(t)); !ok {
50+ t.Fatal("listed executable refused")
51+ }
52+ if strings.Contains(out.String(), `"message":"capture refused"`) {
53+ t.Fatalf("log = %s", out.String())
54+ }
55+
56+ h2, out2 := authHarness(t, allowStore(t, "/usr/bin/grim\n"), nil)
57+ b := h2.client(t)
58+ for i := 0; i < 3; i++ {
59+ if _, ok := h2.accepted(t, b.captureWlr(t)); ok {
60+ t.Fatal("unlisted executable captured")
61+ }
62+ }
63+ log := out2.String()
64+ if n := strings.Count(log, `"message":"capture refused"`); n != 1 {
65+ t.Fatalf("refusal logged %d times, want once per client: %s", n, log)
66+ }
67+ for _, want := range []string{`"component":"wayland"`, `"exe":"` + self + `"`, `"pid":` + strconv.Itoa(os.Getpid())} {
68+ if !strings.Contains(log, want) {
69+ t.Fatalf("log lacks %s: %s", want, log)
70+ }
71+ }
72+}
73+
74+// Every capture path asks: wlr-screencopy frames and ext sessions. A refused
75+// ext session is stopped at once, before any constraint.
76+func TestCaptureAllowlistEveryPath(t *testing.T) {
77+ h, _ := authHarness(t, allowStore(t, "/usr/bin/grim\n"), nil)
78+ a := h.client(t)
79+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
80+ t.Fatal("wlr capture allowed")
81+ }
82+ if !a.extSessionStopped(t) {
83+ t.Fatal("ext session not refused")
84+ }
85+}
86+
87+// extSessionStopped opens an ext capture session and reports whether the
88+// compositor stopped it at once, as it does for a client that may not capture.
89+func (a *admissionClient) extSessionStopped(t *testing.T) bool {
90+ t.Helper()
91+ c := a.c
92+ sourceManager := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
93+ manager := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
94+ src, session := c.AllocateID(), c.AllocateID()
95+ registerProtocol(t, c, src)
96+ requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, a.output)
97+ events := &captureEvents{events: make(chan uint16, 16)}
98+ events.SetID(session)
99+ registerWireProxy(c, events)
100+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ return <-events.events == uint16(ext.ExtImageCopyCaptureSessionV1EventStopped)
105+}
106+
107+func TestCaptureAllowlistModes(t *testing.T) {
108+ self, _ := os.Executable()
109+ tests := []struct {
110+ name, content string
111+ want bool
112+ }{
113+ {"star", "*\n", true},
114+ {"listed", self + "\n", true},
115+ {"other", "/usr/bin/grim\n", false},
116+ {"empty", "", false},
117+ {"invalid", self + "\nnot-absolute\n", false},
118+ }
119+ for _, tc := range tests {
120+ t.Run(tc.name, func(t *testing.T) {
121+ h, _ := authHarness(t, allowStore(t, tc.content), nil)
122+ a := h.client(t)
123+ _, ok := h.accepted(t, a.captureWlr(t))
124+ if ok != tc.want {
125+ t.Fatalf("accepted = %v, want %v", ok, tc.want)
126+ }
127+ })
128+ }
129+ t.Run("missing file", func(t *testing.T) {
130+ store := captureallow.NewStoreOwnedBy(filepath.Join(t.TempDir(), "absent"), uint32(os.Getuid()), zerowrap.Default())
131+ h, _ := authHarness(t, store, nil)
132+ if _, ok := h.accepted(t, h.client(t).captureWlr(t)); ok {
133+ t.Fatal("missing file: the built-in list must refuse a client it does not list")
134+ }
135+ })
136+ t.Run("no allowlist", func(t *testing.T) {
137+ h, _ := authHarness(t, nil, nil)
138+ if _, ok := h.accepted(t, h.client(t).captureWlr(t)); ok {
139+ t.Fatal("capture allowed without an allowlist: it must fail closed")
140+ }
141+ })
142+}
143+
144+// devNull is a stand-in pidfd the test can see closed.
145+func devNull(t *testing.T) *os.File {
146+ t.Helper()
147+ f, err := os.Open("/dev/null")
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ t.Cleanup(func() { _ = f.Close() })
152+ return f
153+}
154+
155+// With a peer lookup mock: the pidfd is taken once per client and closed with
156+// it; the executable is read again at every wlr frame request and every ext
157+// session creation, but not at the frames of an ext session.
158+func TestCaptureAllowlistResolvesEachRequest(t *testing.T) {
159+ peer := newMockpeerExe(t)
160+ pidfd := devNull(t)
161+ peer.EXPECT().Pidfd(mock.Anything).RunAndReturn(func(fd int) (*os.File, error) {
162+ if fd < 0 {
163+ t.Errorf("Pidfd(%d)", fd)
164+ }
165+ return pidfd, nil
166+ }).Once()
167+ var exes atomic.Int32
168+ peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
169+ exes.Add(1)
170+ return "/usr/bin/grim", 4242, nil
171+ })
172+ h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
173+ a := h.client(t)
174+ for i := 0; i < 3; i++ {
175+ req, ok := h.accepted(t, a.captureWlr(t))
176+ if !ok {
177+ t.Fatal("listed executable refused")
178+ }
179+ h.complete(t, req, h.inflight(t)-1)
180+ }
181+ if n := exes.Load(); n != 3 {
182+ t.Fatalf("resolved %d times for 3 wlr requests", n)
183+ }
184+ // One ext session (one resolution), then frames that do not resolve.
185+ p := a.captureExt(t)
186+ if n := exes.Load(); n != 4 {
187+ t.Fatalf("resolved %d times after the ext session", n)
188+ }
189+ req, ok := h.accepted(t, p)
190+ if !ok {
191+ t.Fatal("ext frame refused")
192+ }
193+ h.complete(t, req, 0)
194+ a.destroy(t, p)
195+ req, ok = h.accepted(t, a.captureExt(t))
196+ if !ok {
197+ t.Fatal("second ext frame refused")
198+ }
199+ h.complete(t, req, 0)
200+ if n := exes.Load(); n != 4 {
201+ t.Fatalf("resolved %d times, ext frames must reuse the session's", n)
202+ }
203+ if strings.Contains(out.String(), `"message":"capture refused"`) {
204+ t.Fatalf("log = %s", out.String())
205+ }
206+ peers := func() int {
207+ n := -1
208+ h.s.display.Do(func() { n = len(h.s.peers) })
209+ return n
210+ }
211+ if peers() != 1 {
212+ t.Fatalf("peers = %d", peers())
213+ }
214+ _ = a.c.Close()
215+ deadline := time.Now().Add(2 * time.Second)
216+ for peers() != 0 {
217+ if time.Now().After(deadline) {
218+ t.Fatal("peer entry kept after the client went")
219+ }
220+ time.Sleep(time.Millisecond)
221+ }
222+ if _, err := pidfd.Stat(); !errors.Is(err, os.ErrClosed) {
223+ t.Fatalf("pidfd not closed with the client: %v", err)
224+ }
225+}
226+
227+// A program that execs another binary after connecting is judged by the new
228+// one at its next request; the refusal is logged once per distinct executable.
229+func TestCaptureAllowlistExecAfterConnect(t *testing.T) {
230+ peer := newMockpeerExe(t)
231+ pidfd := devNull(t)
232+ peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
233+ seq := []string{"/usr/bin/grim", "/usr/bin/evil", "/usr/bin/evil", "/usr/bin/grim", "/usr/bin/evil", "/usr/bin/evil2"}
234+ i := 0
235+ peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
236+ e := seq[i]
237+ i++
238+ return e, 4242, nil
239+ })
240+ h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
241+ a := h.client(t)
242+ for n, want := range []bool{true, false, false, true, false, false} {
243+ req, ok := h.accepted(t, a.captureWlr(t))
244+ if ok != want {
245+ t.Fatalf("request %d (%s): accepted = %v", n, seq[n], ok)
246+ }
247+ if ok {
248+ h.complete(t, req, 0)
249+ }
250+ }
251+ log := out.String()
252+ if c := strings.Count(log, `"message":"capture refused"`); c != 2 || !strings.Contains(log, `"exe":"/usr/bin/evil"`) || !strings.Contains(log, `"exe":"/usr/bin/evil2"`) {
253+ t.Fatalf("want one refusal per distinct executable: %s", log)
254+ }
255+}
256+
257+// Without SO_PEERPIDFD a list policy identifies nobody: fail closed, one Warn.
258+func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) {
259+ h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), linuxPeerExe{noPidfd: true})
260+ a := h.client(t)
261+ for i := 0; i < 3; i++ {
262+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
263+ t.Fatal("captured without a pidfd")
264+ }
265+ }
266+ log := out.String()
267+ if n := strings.Count(log, `"message":"kernel has no SO_PEERPIDFD`); n != 1 || !strings.Contains(log, `"level":"warn"`) {
268+ t.Fatalf("want one warning: %s", log)
269+ }
270+ // `*` needs no identity.
271+ h2, _ := authHarness(t, allowStore(t, "*\n"), linuxPeerExe{noPidfd: true})
272+ if _, ok := h2.accepted(t, h2.client(t).captureWlr(t)); !ok {
273+ t.Fatal("`*` must not need a pidfd")
274+ }
275+}
276+
277+func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {
278+ for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} {
279+ t.Run(name, func(t *testing.T) {
280+ peer := newMockpeerExe(t)
281+ pidfd := devNull(t)
282+ peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
283+ peer.EXPECT().Exe(pidfd).Return("", 77, err).Times(2)
284+ h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
285+ a := h.client(t)
286+ for i := 0; i < 2; i++ {
287+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
288+ t.Fatal("unresolved executable captured")
289+ }
290+ }
291+ log := out.String()
292+ if strings.Count(log, `"message":"capture refused"`) != 1 || !strings.Contains(log, `"pid":77`) || !strings.Contains(log, err.Error()) {
293+ t.Fatalf("log = %s", log)
294+ }
295+ })
296+ }
297+}
298+
299+// The check is off for `*`: the executable is never looked up.
300+func TestCaptureAllowlistDisabledSkipsLookup(t *testing.T) {
301+ peer := newMockpeerExe(t) // any call fails the test
302+ h, _ := authHarness(t, allowStore(t, "*\n"), peer)
303+ if _, ok := h.accepted(t, h.client(t).captureWlr(t)); !ok {
304+ t.Fatal("refused")
305+ }
306+}
307+
308+func TestCaptureAllowlistReloadApplies(t *testing.T) {
309+ self, _ := os.Executable()
310+ path := filepath.Join(t.TempDir(), "capture-allow")
311+ if err := os.WriteFile(path, []byte("/usr/bin/grim\n"), 0o644); err != nil {
312+ t.Fatal(err)
313+ }
314+ store := captureallow.NewStoreOwnedBy(path, uint32(os.Getuid()), zerowrap.Default())
315+ h, _ := authHarness(t, store, nil)
316+ a := h.client(t)
317+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
318+ t.Fatal("captured before listing")
319+ }
320+ // Store.Run is exercised in its own package; here the swap is direct.
321+ if err := os.WriteFile(path, []byte(self+"\n"), 0o644); err != nil {
322+ t.Fatal(err)
323+ }
324+ ctx := t.Context()
325+ go func() { _ = store.Run(ctx) }()
326+ deadline := time.Now().Add(5 * time.Second)
327+ for {
328+ if req, ok := h.accepted(t, a.captureWlr(t)); ok {
329+ h.complete(t, req, 0)
330+ return
331+ }
332+ if time.Now().After(deadline) {
333+ t.Fatal("reload never applied")
334+ }
335+ time.Sleep(20 * time.Millisecond)
336+ }
337+}
338+
339+// The real lookup, against a socket whose peer is this process.
340+func TestLinuxPeerExeSelf(t *testing.T) {
341+ self, err := os.Executable()
342+ if err != nil {
343+ t.Fatal(err)
344+ }
345+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ defer unix.Close(fds[0])
350+ defer unix.Close(fds[1])
351+ var l linuxPeerExe
352+ pidfd, err := l.Pidfd(fds[0])
353+ if errors.Is(err, errNoPidfd) {
354+ t.Skip("kernel without SO_PEERPIDFD")
355+ }
356+ if err != nil {
357+ t.Fatal(err)
358+ }
359+ defer pidfd.Close()
360+ exe, pid, err := l.Exe(pidfd)
361+ if err != nil {
362+ t.Fatal(err)
363+ }
364+ if exe != self || pid != os.Getpid() {
365+ t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
366+ }
367+ if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) {
368+ t.Fatalf("noPidfd: %v", err)
369+ }
370+}
371+
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
417+// `unauthorized` even with the right token, while the recorder that owns the
418+// exclusion (listed) is unaffected.
419+func TestCaptureAllowlistExclusionAttach(t *testing.T) {
420+ peer := newMockpeerExe(t)
421+ // The recorder connects first, the HUD second.
422+ p1, p2 := devNull(t), devNull(t)
423+ peer.EXPECT().Pidfd(mock.Anything).Return(p1, nil).Once()
424+ peer.EXPECT().Pidfd(mock.Anything).Return(p2, nil).Once()
425+ peer.EXPECT().Exe(p1).Return("/usr/bin/nefercap", 11, nil)
426+ peer.EXPECT().Exe(p2).Return("/usr/bin/other", 12, nil)
427+ h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) {
428+ o.CaptureAllow, o.peer = allowStore(t, "/usr/bin/nefercap\n"), peer
429+ })
430+ _, _, _, _, token := h.exclusionOwner(t)
431+ hd := h.hud(t, ports.LayerOverlay, 2)
432+ att := hd.attach(t, token)
433+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnauthorized))
434+}
435+
436+// An ext frame uses the client's latest resolution: after a later wlr request
437+// failed to resolve the executable, the frame is refused as unknown, not as
438+// "not in allowlist" with an empty path.
439+func TestCaptureAllowlistFrameUnknownExecutable(t *testing.T) {
440+ peer := newMockpeerExe(t)
441+ pidfd := devNull(t)
442+ peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
443+ var calls atomic.Int32
444+ peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
445+ if calls.Add(1) == 2 {
446+ return "", 55, errors.New("peer exited")
447+ }
448+ return "/usr/bin/grim", 55, nil
449+ })
450+ h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
451+ a := h.client(t)
452+ a.extMgr, a.session, _ = captureTestExt(t, a.c, a.output) // resolution 1: listed
453+ if _, ok := h.accepted(t, a.captureWlr(t)); ok { // resolution 2: fails
454+ t.Fatal("unresolved wlr request captured")
455+ }
456+ frame, ev := a.newFrame()
457+ requestProtocol(t, a.c, a.session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
458+ requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, a.buf)
459+ requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
460+ if err := a.c.Roundtrip(); err != nil {
461+ t.Fatal(err)
462+ }
463+ if _, ok := h.accepted(t, pendingCapture{frame: frame, events: ev, failed: uint16(ext.ExtImageCopyCaptureFrameV1EventFailed), ext: true}); ok {
464+ t.Fatal("ext frame captured after a failed resolution")
465+ }
466+ if !strings.Contains(out.String(), `"reason":"executable unknown: peer exited"`) {
467+ t.Fatalf("log = %s", out.String())
468+ }
469+}
A internal/adapters/wayland/captureexe.go +117 -0
@@ -0,0 +1,117 @@
1+package wayland
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "runtime"
9+ "strconv"
10+ "strings"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// peerExe names the executable behind a client connection, in two steps so a
16+// caller can keep the pidfd and resolve the executable again at each
17+// decision.
18+type peerExe interface {
19+ // 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
21+ // while the file is open. errNoPidfd means the kernel cannot give one.
22+ Pidfd(sockFD int) (*os.File, error)
23+ // 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
25+ // exited, is an error.
26+ Exe(pidfd *os.File) (exe string, pid int, err error)
27+}
28+
29+var (
30+ errExeDeleted = errors.New("executable deleted")
31+ // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the
32+ // peer cannot be identified safely.
33+ errNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
34+)
35+
36+// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+):
37+//
38+// 1. The pidfd pins the peer process: its pid cannot be recycled while the
39+// descriptor is open. The pid is read from /proc/self/fdinfo/<pidfd>.
40+// 2. /proc/<pid>/exe is read.
41+// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
42+// had exited before the read, the pid could have named someone else, and
43+// the answer is refused.
44+//
45+// There is no fallback to the SO_PEERCRED pid: it has no such proof, so a
46+// kernel without SO_PEERPIDFD identifies nobody.
47+type linuxPeerExe struct {
48+ // noPidfd simulates a kernel without SO_PEERPIDFD (tests).
49+ noPidfd bool
50+}
51+
52+func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {
53+ if l.noPidfd {
54+ return nil, errNoPidfd
55+ }
56+ pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
57+ switch {
58+ case err == nil:
59+ return os.NewFile(uintptr(pidfd), "pidfd"), nil
60+ case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
61+ return nil, errNoPidfd
62+ default:
63+ return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
64+ }
65+}
66+
67+func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {
68+ defer runtime.KeepAlive(pidfd)
69+ return exeOfPidfd(int(pidfd.Fd()))
70+}
71+
72+// exeOfPidfd runs the pidfd procedure above.
73+func exeOfPidfd(pidfd int) (string, int, error) {
74+ pid, err := pidOfPidfd(pidfd)
75+ if err != nil {
76+ return "", 0, err
77+ }
78+ exe, err := readExe(pid)
79+ // After the read: a process that is gone may have let its pid be reused.
80+ if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
81+ return "", pid, fmt.Errorf("peer exited: %w", alive)
82+ }
83+ return exe, pid, err
84+}
85+
86+// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
87+// process has exited, 0 when it is outside this pid namespace.
88+func pidOfPidfd(pidfd int) (int, error) {
89+ f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
90+ if err != nil {
91+ return 0, err
92+ }
93+ defer f.Close()
94+ sc := bufio.NewScanner(f)
95+ for sc.Scan() {
96+ if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
97+ pid, err := strconv.Atoi(strings.TrimSpace(v))
98+ if err != nil || pid <= 0 {
99+ return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
100+ }
101+ return pid, nil
102+ }
103+ }
104+ return 0, errors.New("pidfd fdinfo has no Pid")
105+}
106+
107+// readExe reads /proc/<pid>/exe and refuses a deleted executable.
108+func readExe(pid int) (string, error) {
109+ exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
110+ if err != nil {
111+ return "", err
112+ }
113+ if strings.HasSuffix(exe, " (deleted)") {
114+ return "", errExeDeleted
115+ }
116+ return exe, nil
117+}
D internal/adapters/wayland/capturesession/capturesession.go +0 -296
@@ -1,296 +0,0 @@
1-// Code generated by wlgen; DO NOT EDIT.
2-package capturesession
3-
4-import (
5- "fmt"
6- wayland "github.com/bnema/purego-libwayland/protocol/wayland"
7- "github.com/bnema/purego-libwayland/server"
8- "runtime"
9-)
10-
11-var NeferwlCaptureManagerV1Interface *server.Interface
12-var NeferwlCaptureLayerV1Interface *server.Interface
13-var NeferwlCaptureSessionV1Interface *server.Interface
14-
15-func init() {
16- NeferwlCaptureManagerV1Interface = &server.Interface{Name: "neferwl_capture_manager_v1", Version: 1}
17- NeferwlCaptureLayerV1Interface = &server.Interface{Name: "neferwl_capture_layer_v1", Version: 1}
18- NeferwlCaptureSessionV1Interface = &server.Interface{Name: "neferwl_capture_session_v1", Version: 1}
19- NeferwlCaptureManagerV1Interface.Requests = []server.Message{
20- {Name: "destroy", Signature: "", Types: []*server.Interface{}},
21- {Name: "begin_session", Signature: "noiiiiuuu", Types: []*server.Interface{NeferwlCaptureSessionV1Interface, wayland.OutputInterface, nil, nil, nil, nil, nil, nil, nil}},
22- {Name: "attach_surface", Signature: "nso", Types: []*server.Interface{NeferwlCaptureLayerV1Interface, nil, wayland.SurfaceInterface}},
23- }
24- NeferwlCaptureManagerV1Interface.Events = []server.Message{
25- {Name: "workspace", Signature: "uussiiiiu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil}},
26- {Name: "workspace_removed", Signature: "uu", Types: []*server.Interface{nil, nil}},
27- }
28- NeferwlCaptureLayerV1Interface.Requests = []server.Message{
29- {Name: "destroy", Signature: "", Types: []*server.Interface{}},
30- }
31- NeferwlCaptureLayerV1Interface.Events = []server.Message{
32- {Name: "attached", Signature: "", Types: []*server.Interface{}},
33- {Name: "failed", Signature: "u", Types: []*server.Interface{nil}},
34- {Name: "detached", Signature: "u", Types: []*server.Interface{nil}},
35- }
36- NeferwlCaptureSessionV1Interface.Requests = []server.Message{
37- {Name: "destroy", Signature: "", Types: []*server.Interface{}},
38- {Name: "ping", Signature: "", Types: []*server.Interface{}},
39- }
40- NeferwlCaptureSessionV1Interface.Events = []server.Message{
41- {Name: "state", Signature: "suiiiiuuuu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil, nil}},
42- {Name: "stopped", Signature: "u", Types: []*server.Interface{nil}},
43- }
44- if err := server.NewInterfaces(NeferwlCaptureManagerV1Interface, NeferwlCaptureLayerV1Interface, NeferwlCaptureSessionV1Interface); err != nil {
45- panic(err)
46- }
47-}
48-
49-// NeferwlCaptureManagerV1 private capture manager.
50-type NeferwlCaptureManagerV1 struct{ *server.Resource }
51-
52-func WrapNeferwlCaptureManagerV1(r *server.Resource) *NeferwlCaptureManagerV1 {
53- if r == nil {
54- return nil
55- }
56- return &NeferwlCaptureManagerV1{r}
57-}
58-
59-const NeferwlCaptureManagerV1RequestDestroy uint32 = 0
60-const NeferwlCaptureManagerV1RequestBeginSession uint32 = 1
61-const NeferwlCaptureManagerV1RequestAttachSurface uint32 = 2
62-const NeferwlCaptureManagerV1EventWorkspace uint32 = 0
63-const NeferwlCaptureManagerV1EventWorkspaceRemoved uint32 = 1
64-
65-// NeferwlCaptureManagerV1Error .
66-type NeferwlCaptureManagerV1Error uint32
67-
68-const (
69- NeferwlCaptureManagerV1ErrorInvalidOutput NeferwlCaptureManagerV1Error = 0
70- NeferwlCaptureManagerV1ErrorInvalidRegion NeferwlCaptureManagerV1Error = 1
71- NeferwlCaptureManagerV1ErrorSurfaceRole NeferwlCaptureManagerV1Error = 2
72- NeferwlCaptureManagerV1ErrorInvalidLayer NeferwlCaptureManagerV1Error = 3
73- NeferwlCaptureManagerV1ErrorExclusiveKeyboard NeferwlCaptureManagerV1Error = 4
74- NeferwlCaptureManagerV1ErrorInvalidSurface NeferwlCaptureManagerV1Error = 5
75-)
76-
77-// NeferwlCaptureManagerV1Handler handles neferwl_capture_manager_v1 requests.
78-type NeferwlCaptureManagerV1Handler interface {
79- Destroy(self *NeferwlCaptureManagerV1)
80- BeginSession(self *NeferwlCaptureManagerV1, id uint32, output *wayland.Output, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, record uint32)
81- AttachSurface(self *NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface)
82-}
83-
84-func NewNeferwlCaptureManagerV1(c server.Client, version int32, id uint32, h NeferwlCaptureManagerV1Handler) (*NeferwlCaptureManagerV1, error) {
85- if h == nil {
86- return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_manager_v1")
87- }
88- var self *NeferwlCaptureManagerV1
89- r, err := c.CreateResource(NeferwlCaptureManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
90- switch opcode {
91- case 0:
92- h.Destroy(self)
93- self.Destroy()
94- case 1:
95- h.BeginSession(self, args[0].NewID(), wayland.WrapOutput(args[1].Resource()), args[2].Int(), args[3].Int(), args[4].Int(), args[5].Int(), args[6].Uint(), args[7].Uint(), args[8].Uint())
96- case 2:
97- h.AttachSurface(self, args[0].NewID(), args[1].String(), wayland.WrapSurface(args[2].Resource()))
98- }
99- })
100- if err != nil {
101- return nil, err
102- }
103- self = WrapNeferwlCaptureManagerV1(r)
104- r.Data = self
105- return self, nil
106-}
107-
108-// SendWorkspace a workspace and its geometry.
109-func (self *NeferwlCaptureManagerV1) SendWorkspace(workspaceHi uint32, workspaceLo uint32, outputName string, name string, x int32, y int32, width int32, height int32, active uint32) {
110- var p runtime.Pinner
111- defer p.Unpin()
112- self.PostEvent(NeferwlCaptureManagerV1EventWorkspace, server.Uint(workspaceHi), server.Uint(workspaceLo), server.String(&p, outputName, false), server.String(&p, name, false), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(active))
113-}
114-
115-// SendWorkspaceRemoved a workspace is gone.
116-func (self *NeferwlCaptureManagerV1) SendWorkspaceRemoved(workspaceHi uint32, workspaceLo uint32) {
117- self.PostEvent(NeferwlCaptureManagerV1EventWorkspaceRemoved, server.Uint(workspaceHi), server.Uint(workspaceLo))
118-}
119-func NewNeferwlCaptureManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
120- return d.CreateGlobal(NeferwlCaptureManagerV1Interface, version, bind)
121-}
122-
123-// NeferwlCaptureLayerV1 a layer surface attached to a capture session.
124-type NeferwlCaptureLayerV1 struct{ *server.Resource }
125-
126-func WrapNeferwlCaptureLayerV1(r *server.Resource) *NeferwlCaptureLayerV1 {
127- if r == nil {
128- return nil
129- }
130- return &NeferwlCaptureLayerV1{r}
131-}
132-
133-const NeferwlCaptureLayerV1RequestDestroy uint32 = 0
134-const NeferwlCaptureLayerV1EventAttached uint32 = 0
135-const NeferwlCaptureLayerV1EventFailed uint32 = 1
136-const NeferwlCaptureLayerV1EventDetached uint32 = 2
137-
138-// NeferwlCaptureLayerV1Failure .
139-type NeferwlCaptureLayerV1Failure uint32
140-
141-const (
142- NeferwlCaptureLayerV1FailureUnknownToken NeferwlCaptureLayerV1Failure = 0
143- NeferwlCaptureLayerV1FailureUnauthorized NeferwlCaptureLayerV1Failure = 1
144- NeferwlCaptureLayerV1FailureTooManyLayers NeferwlCaptureLayerV1Failure = 2
145- NeferwlCaptureLayerV1FailureAlreadyAttached NeferwlCaptureLayerV1Failure = 3
146- NeferwlCaptureLayerV1FailureSessionEnded NeferwlCaptureLayerV1Failure = 4
147- NeferwlCaptureLayerV1FailureLayerDestroyed NeferwlCaptureLayerV1Failure = 5
148-)
149-
150-// NeferwlCaptureLayerV1DetachReason .
151-type NeferwlCaptureLayerV1DetachReason uint32
152-
153-const (
154- NeferwlCaptureLayerV1DetachReasonSessionEnded NeferwlCaptureLayerV1DetachReason = 0
155- NeferwlCaptureLayerV1DetachReasonLayerDestroyed NeferwlCaptureLayerV1DetachReason = 1
156-)
157-
158-// NeferwlCaptureLayerV1Handler handles neferwl_capture_layer_v1 requests.
159-type NeferwlCaptureLayerV1Handler interface {
160- Destroy(self *NeferwlCaptureLayerV1)
161-}
162-
163-func NewNeferwlCaptureLayerV1(c server.Client, version int32, id uint32, h NeferwlCaptureLayerV1Handler) (*NeferwlCaptureLayerV1, error) {
164- if h == nil {
165- return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_layer_v1")
166- }
167- var self *NeferwlCaptureLayerV1
168- r, err := c.CreateResource(NeferwlCaptureLayerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
169- switch opcode {
170- case 0:
171- h.Destroy(self)
172- self.Destroy()
173- }
174- })
175- if err != nil {
176- return nil, err
177- }
178- self = WrapNeferwlCaptureLayerV1(r)
179- r.Data = self
180- return self, nil
181-}
182-
183-// SendAttached the layer is part of the session.
184-func (self *NeferwlCaptureLayerV1) SendAttached() {
185- self.PostEvent(NeferwlCaptureLayerV1EventAttached)
186-}
187-
188-// SendFailed the attachment was refused.
189-func (self *NeferwlCaptureLayerV1) SendFailed(reason uint32) {
190- self.PostEvent(NeferwlCaptureLayerV1EventFailed, server.Uint(reason))
191-}
192-
193-// SendDetached the layer left the session.
194-func (self *NeferwlCaptureLayerV1) SendDetached(reason uint32) {
195- self.PostEvent(NeferwlCaptureLayerV1EventDetached, server.Uint(reason))
196-}
197-func NewNeferwlCaptureLayerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
198- return d.CreateGlobal(NeferwlCaptureLayerV1Interface, version, bind)
199-}
200-
201-// NeferwlCaptureSessionV1 one private capture session.
202-type NeferwlCaptureSessionV1 struct{ *server.Resource }
203-
204-func WrapNeferwlCaptureSessionV1(r *server.Resource) *NeferwlCaptureSessionV1 {
205- if r == nil {
206- return nil
207- }
208- return &NeferwlCaptureSessionV1{r}
209-}
210-
211-const NeferwlCaptureSessionV1RequestDestroy uint32 = 0
212-const NeferwlCaptureSessionV1RequestPing uint32 = 1
213-const NeferwlCaptureSessionV1EventState uint32 = 0
214-const NeferwlCaptureSessionV1EventStopped uint32 = 1
215-
216-// NeferwlCaptureSessionV1StopReason .
217-type NeferwlCaptureSessionV1StopReason uint32
218-
219-const (
220- NeferwlCaptureSessionV1StopReasonRequested NeferwlCaptureSessionV1StopReason = 0
221- NeferwlCaptureSessionV1StopReasonOutputGone NeferwlCaptureSessionV1StopReason = 1
222- NeferwlCaptureSessionV1StopReasonOutputOff NeferwlCaptureSessionV1StopReason = 2
223- NeferwlCaptureSessionV1StopReasonPingTimeout NeferwlCaptureSessionV1StopReason = 3
224- NeferwlCaptureSessionV1StopReasonWorkspaceGone NeferwlCaptureSessionV1StopReason = 5
225- NeferwlCaptureSessionV1StopReasonBusy NeferwlCaptureSessionV1StopReason = 6
226- NeferwlCaptureSessionV1StopReasonInvalidRegion NeferwlCaptureSessionV1StopReason = 7
227- NeferwlCaptureSessionV1StopReasonUnauthorized NeferwlCaptureSessionV1StopReason = 8
228- NeferwlCaptureSessionV1StopReasonTooManyExcluded NeferwlCaptureSessionV1StopReason = 9
229-)
230-
231-// NeferwlCaptureSessionV1Handler handles neferwl_capture_session_v1 requests.
232-type NeferwlCaptureSessionV1Handler interface {
233- Destroy(self *NeferwlCaptureSessionV1)
234- Ping(self *NeferwlCaptureSessionV1)
235-}
236-
237-func NewNeferwlCaptureSessionV1(c server.Client, version int32, id uint32, h NeferwlCaptureSessionV1Handler) (*NeferwlCaptureSessionV1, error) {
238- if h == nil {
239- return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_session_v1")
240- }
241- var self *NeferwlCaptureSessionV1
242- r, err := c.CreateResource(NeferwlCaptureSessionV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
243- switch opcode {
244- case 0:
245- h.Destroy(self)
246- self.Destroy()
247- case 1:
248- h.Ping(self)
249- }
250- })
251- if err != nil {
252- return nil, err
253- }
254- self = WrapNeferwlCaptureSessionV1(r)
255- r.Data = self
256- return self, nil
257-}
258-
259-// SendState what the compositor decided.
260-func (self *NeferwlCaptureSessionV1) SendState(token string, active uint32, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, revisionHi uint32, revisionLo uint32) {
261- var p runtime.Pinner
262- defer p.Unpin()
263- self.PostEvent(NeferwlCaptureSessionV1EventState, server.String(&p, token, false), server.Uint(active), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(workspaceHi), server.Uint(workspaceLo), server.Uint(revisionHi), server.Uint(revisionLo))
264-}
265-
266-// SendStopped the session ended.
267-func (self *NeferwlCaptureSessionV1) SendStopped(reason uint32) {
268- self.PostEvent(NeferwlCaptureSessionV1EventStopped, server.Uint(reason))
269-}
270-func NewNeferwlCaptureSessionV1Global(d *server.Display, version int32, bind server.BindFunc) error {
271- return d.CreateGlobal(NeferwlCaptureSessionV1Interface, version, bind)
272-}
273-func resourceOf(v interface{ GetResource() *server.Resource }) *server.Resource {
274- if v == nil {
275- return nil
276- }
277- return v.GetResource()
278-}
279-func (self *NeferwlCaptureManagerV1) GetResource() *server.Resource {
280- if self == nil {
281- return nil
282- }
283- return self.Resource
284-}
285-func (self *NeferwlCaptureLayerV1) GetResource() *server.Resource {
286- if self == nil {
287- return nil
288- }
289- return self.Resource
290-}
291-func (self *NeferwlCaptureSessionV1) GetResource() *server.Resource {
292- if self == nil {
293- return nil
294- }
295- return self.Resource
296-}
D internal/adapters/wayland/capturesession/gen.sh +0 -9
@@ -1,9 +0,0 @@
1-#!/bin/sh
2-# Regenerates capturesession.go from neferwl-capture-v1.xml with the wlgen of
3-# the purego-libwayland module in use (offline: go.work or the module cache).
4-set -eu
5-cd "$(dirname "$0")"
6-mod=$(go list -m -f '{{.Dir}}' github.com/bnema/purego-libwayland)
7-go run github.com/bnema/purego-libwayland/cmd/wlgen -package capturesession -out capturesession.go \
8- -import wayland=github.com/bnema/purego-libwayland/protocol/wayland -import-xml wayland="$mod/protocols/wayland.xml" \
9- neferwl-capture-v1.xml
D internal/adapters/wayland/capturesession/neferwl-capture-v1.xml +0 -236
@@ -1,236 +0,0 @@
1-<?xml version="1.0" encoding="UTF-8"?>
2-<protocol name="neferwl_capture_v1">
3- <copyright>
4- neferwl private protocol. Not for other compositors; no stability promise
5- beyond the version of the interface.
6- </copyright>
7-
8- <description summary="private capture sessions for the neferwl screen recorder">
9- Lets one client (the capture connection) open a session on a workspace and
10- lets a second connection of the same user (the HUD) attach layer surfaces
11- to it. While the session is active, the attached surfaces stay visible
12- over a fullscreen window and are left out of the captures the capture
13- connection takes (ext-image-copy-capture / wlr-screencopy on the same
14- connection). Every other client's captures still show them.
15-
16- Only one session is live in the compositor. The session token is a secret:
17- it is sent only to the session's owner and must never be logged.
18- Only the compositor's own user may use this protocol.
19- </description>
20-
21- <interface name="neferwl_capture_manager_v1" version="1">
22- <description summary="private capture manager">
23- Global. Binding it starts the workspace inventory: one workspace event
24- per workspace, then workspace events again for every change and
25- workspace_removed for each workspace that disappears.
26- </description>
27-
28- <enum name="error">
29- <entry name="invalid_output" value="0" summary="the wl_output is unknown or gone"/>
30- <entry name="invalid_region" value="1" summary="negative width or height"/>
31- <entry name="surface_role" value="2" summary="the surface is not a wlr-layer-shell surface"/>
32- <entry name="invalid_layer" value="3" summary="the layer is neither top nor overlay"/>
33- <entry name="exclusive_keyboard" value="4" summary="the layer takes the keyboard exclusively"/>
34- <entry name="invalid_surface" value="5" summary="the surface belongs to another client or is already mapped"/>
35- </enum>
36-
37- <request name="destroy" type="destructor">
38- <description summary="destroy the manager">
39- Sessions created through it stay alive.
40- </description>
41- </request>
42-
43- <request name="begin_session">
44- <description summary="open a capture session">
45- Asks for a session. Workspace 0 (both halves) targets the output:
46- whatever it shows, bars and margins included, and the overview too.
47- A non-zero workspace ID (from a workspace event) targets that
48- workspace's frame, without bars, whether or not it is on screen and
49- whatever the overview shows. The session is stopped with
50- workspace_gone when the workspace is removed.
51-
52- The region is in output-local logical pixels, clipped to the output
53- (workspace 0) or to the workspace frame. A zero width or height means
54- all of it. Record non-zero asks for the native recording border, drawn
55- inside the target's edge and never in this connection's clean captures.
56-
57- A served session answers with state, which carries the token. A
58- session that cannot be served gets stopped with the reason, without a
59- state. Only one session is live: a second one is stopped with reason
60- busy.
61- </description>
62- <arg name="id" type="new_id" interface="neferwl_capture_session_v1"/>
63- <arg name="output" type="object" interface="wl_output"/>
64- <arg name="x" type="int"/>
65- <arg name="y" type="int"/>
66- <arg name="width" type="int"/>
67- <arg name="height" type="int"/>
68- <arg name="workspace_hi" type="uint"/>
69- <arg name="workspace_lo" type="uint"/>
70- <arg name="record" type="uint"/>
71- </request>
72-
73- <request name="attach_surface">
74- <description summary="attach a layer surface to a session by token">
75- Marks a layer surface of this client as part of the session that owns
76- the token, and creates the attachment object that reports the result.
77- The client is usually not the session's owner but has the same user
78- ID; both are checked with the peer credentials. The surface must
79- already have the wlr-layer-shell role, on the top or overlay layer,
80- must not have mapped yet and must not have asked for exclusive keyboard
81- interactivity. At most 4 layers per session. Once attached, a layer
82- never gets keyboard interactivity, whatever it asks for, and if it later moves to another layer the compositor closes it
83- and sends detached.
84-
85- The attachment answers with exactly one of attached or failed. The
86- surface is excluded from clean captures from this request on, before
87- it can map; attached only says that the compositor core confirmed it,
88- and the client must not show the surface before it. A refusal is
89- failed, not a protocol error, except the errors above.
90-
91- The attachment ends with detached when the session stops or the layer
92- surface is destroyed. Destroying the attachment object does not
93- release the surface: it stays excluded until its layer role is
94- destroyed or the session stops.
95- </description>
96- <arg name="id" type="new_id" interface="neferwl_capture_layer_v1"/>
97- <arg name="token" type="string"/>
98- <arg name="surface" type="object" interface="wl_surface"/>
99- </request>
100-
101- <event name="workspace">
102- <description summary="a workspace and its geometry">
103- A workspace of the inventory, new or changed. The rectangle is the
104- workspace frame in output-local logical pixels. Active is non-zero for
105- the workspace on screen. Name is the workspace name or its number.
106- </description>
107- <arg name="workspace_hi" type="uint"/>
108- <arg name="workspace_lo" type="uint"/>
109- <arg name="output_name" type="string"/>
110- <arg name="name" type="string"/>
111- <arg name="x" type="int"/>
112- <arg name="y" type="int"/>
113- <arg name="width" type="int"/>
114- <arg name="height" type="int"/>
115- <arg name="active" type="uint"/>
116- </event>
117-
118- <event name="workspace_removed">
119- <description summary="a workspace is gone"/>
120- <arg name="workspace_hi" type="uint"/>
121- <arg name="workspace_lo" type="uint"/>
122- </event>
123- </interface>
124-
125- <interface name="neferwl_capture_layer_v1" version="1">
126- <description summary="a layer surface attached to a capture session">
127- Created by attach_surface. Exactly one of attached or failed comes
128- first; detached comes at most once, after attached.
129- </description>
130-
131- <enum name="failure">
132- <entry name="unknown_token" value="0" summary="no live session has this token"/>
133- <entry name="unauthorized" value="1" summary="peer credentials differ from the compositor user's or are unreadable"/>
134- <entry name="too_many_layers" value="2" summary="the session already has the maximum of 4 attached layers"/>
135- <entry name="already_attached" value="3" summary="the surface is already attached"/>
136- <entry name="session_ended" value="4" summary="the session stopped before core confirmed the layer"/>
137- <entry name="layer_destroyed" value="5" summary="the layer role was destroyed before core confirmed it"/>
138- </enum>
139-
140- <enum name="detach_reason">
141- <entry name="session_ended" value="0" summary="the session stopped; the compositor closed the layer surface"/>
142- <entry name="layer_destroyed" value="1" summary="the layer role was destroyed"/>
143- </enum>
144-
145- <request name="destroy" type="destructor">
146- <description summary="destroy the attachment object">
147- The surface stays excluded from clean captures until its layer role
148- is destroyed or the session stops.
149- </description>
150- </request>
151-
152- <event name="attached">
153- <description summary="the layer is part of the session">
154- The compositor core confirmed the attachment: the layer is excluded
155- from the session's clean captures, and was already before it could map.
156- Safe to show.
157- </description>
158- </event>
159-
160- <event name="failed">
161- <description summary="the attachment was refused"/>
162- <arg name="reason" type="uint" enum="failure"/>
163- </event>
164-
165- <event name="detached">
166- <description summary="the layer left the session">
167- Sent once after attached. When the session stopped, the compositor
168- also closed the layer surface (zwlr_layer_surface_v1.closed): the
169- client destroys it.
170- </description>
171- <arg name="reason" type="uint" enum="detach_reason"/>
172- </event>
173- </interface>
174-
175- <interface name="neferwl_capture_session_v1" version="1">
176- <description summary="one private capture session">
177- Destroying the session stops it. A session whose owner sends no ping for
178- 5 seconds is stopped by the compositor.
179- </description>
180-
181- <enum name="stop_reason">
182- <entry name="requested" value="0" summary="the client stopped it"/>
183- <entry name="output_gone" value="1"/>
184- <entry name="output_off" value="2"/>
185- <entry name="ping_timeout" value="3"/>
186- <!-- 4 is reserved: it stays unused so the values of the other reasons do not move -->
187- <entry name="workspace_gone" value="5"/>
188- <entry name="busy" value="6" summary="another session is live"/>
189- <entry name="invalid_region" value="7"/>
190- <entry name="unauthorized" value="8"/>
191- <entry name="too_many_excluded" value="9" summary="attached layers and their popups exceed 128 surfaces"/>
192- </enum>
193-
194- <request name="destroy" type="destructor">
195- <description summary="stop and destroy the session"/>
196- </request>
197-
198- <request name="ping">
199- <description summary="keep the session alive">
200- Must arrive at least every 5 seconds.
201- </description>
202- </request>
203-
204- <event name="state">
205- <description summary="what the compositor decided">
206- Sent once the session exists and on every change. The token is 64
207- lowercase hexadecimal characters (32 random bytes): a secret for the
208- owner only, never logged, valid until stopped. The rectangle is the
209- clipped target in output-local logical pixels and the workspace is
210- the real one (0 for an output target). Active non-zero means captures of this connection are
211- served clean. Active is 1 while the session lives: a session that is
212- lost is stopped, never paused. The revision (64 bits, from 1) grows
213- whenever the session's configuration changes: layers, popups, target.
214- </description>
215- <arg name="token" type="string"/>
216- <arg name="active" type="uint"/>
217- <arg name="x" type="int"/>
218- <arg name="y" type="int"/>
219- <arg name="width" type="int"/>
220- <arg name="height" type="int"/>
221- <arg name="workspace_hi" type="uint"/>
222- <arg name="workspace_lo" type="uint"/>
223- <arg name="revision_hi" type="uint"/>
224- <arg name="revision_lo" type="uint"/>
225- </event>
226-
227- <event name="stopped">
228- <description summary="the session ended">
229- Final. The token is void. The compositor closes the attached layer
230- surfaces (zwlr_layer_surface_v1.closed) and ends their attachments
231- with detached. Destroy the object.
232- </description>
233- <arg name="reason" type="uint" enum="stop_reason"/>
234- </event>
235- </interface>
236-</protocol>
A internal/adapters/wayland/exclusion.go +273 -0
@@ -0,0 +1,273 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "crypto/subtle"
6+ "encoding/hex"
7+ "os"
8+ "slices"
9+
10+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
11+ "github.com/bnema/neferwl/internal/ports"
12+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
13+ "github.com/bnema/purego-libwayland/protocol/wayland"
14+ "github.com/bnema/purego-libwayland/server"
15+)
16+
17+// Capture exclusion (neferwl_capture_exclusion_manager_v1). A recorder asks
18+// for the exclusion of its ext-image-copy-capture session and gets a token;
19+// its HUD, usually on another connection of the same user, attaches layer
20+// surfaces with it. Core owns the decisions (ports/capture.go): this file
21+// relays requests and tells clients what core decided. Only one exclusion is
22+// live in the compositor, and it ends with its session.
23+//
24+// Privacy: the token never reaches a log, and neither does any peer
25+// identity; only lifecycle events are logged.
26+
27+// exclusion is the state behind one neferwl_capture_exclusion_v1.
28+type exclusion struct {
29+ s *Server
30+ res *imagecapture.NeferwlCaptureExclusionV1
31+ session *captureSession
32+ token string
33+ // layers are the attached layer surfaces; attached their attachment
34+ // objects, pending until a core state lists the layer.
35+ layers []*layerSurface
36+ attached []*layerAttachment
37+ ended bool
38+}
39+
40+// layerAttachment is one neferwl_capture_layer_v1: it reports the result of
41+// an attach_surface and the layer's end.
42+type layerAttachment struct {
43+ res *imagecapture.NeferwlCaptureLayerV1
44+ layer *layerSurface
45+ confirmed bool // attached sent
46+}
47+
48+func (a *layerAttachment) fail(reason imagecapture.NeferwlCaptureLayerV1Failure) {
49+ if a.res.Alive() {
50+ a.res.SendFailed(uint32(reason))
51+ }
52+}
53+
54+// finish ends an attachment: detached after attached, failed before it.
55+func (a *layerAttachment) finish(failure imagecapture.NeferwlCaptureLayerV1Failure, detach imagecapture.NeferwlCaptureLayerV1DetachReason) {
56+ if !a.res.Alive() {
57+ return
58+ }
59+ if a.confirmed {
60+ a.res.SendDetached(uint32(detach))
61+ } else {
62+ a.res.SendFailed(uint32(failure))
63+ }
64+}
65+
66+// trustedPeer reports whether a peer with these credentials may attach
67+// surfaces: its credentials are readable and it runs as the compositor's own
68+// user.
69+func trustedPeer(own uint32, peer server.Credentials, err error) bool {
70+ return err == nil && peer.UID == own
71+}
72+
73+func (s *Server) trusted(c server.Client) bool {
74+ cr, err := c.Credentials()
75+ return trustedPeer(s.ownUID, cr, err)
76+}
77+
78+func registerExclusion(d *server.Display, s *Server) error {
79+ s.ownUID = uint32(os.Geteuid())
80+ return imagecapture.NewNeferwlCaptureExclusionManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
81+ m := &exclusionManager{s: s, trusted: s.trusted(c)}
82+ _, _ = imagecapture.NewNeferwlCaptureExclusionManagerV1(c, int32(v), id, m)
83+ })
84+}
85+
86+type exclusionManager struct {
87+ s *Server
88+ trusted bool
89+}
90+
91+func (*exclusionManager) Destroy(*imagecapture.NeferwlCaptureExclusionManagerV1) {}
92+
93+// sessionOf finds the registered session behind an ext session object.
94+func (s *Server) sessionOf(res *ext.ExtImageCopyCaptureSessionV1) *captureSession {
95+ if res == nil {
96+ return nil
97+ }
98+ for c := range s.captureSessions {
99+ if c.res != nil && c.res.Resource == res.Resource {
100+ return c
101+ }
102+ }
103+ return nil
104+}
105+
106+func (m *exclusionManager) GetExclusion(r *imagecapture.NeferwlCaptureExclusionManagerV1, id uint32, session *ext.ExtImageCopyCaptureSessionV1) {
107+ s := m.s
108+ c := s.sessionOf(session)
109+ ex := &exclusion{s: s, session: c}
110+ res, err := imagecapture.NewNeferwlCaptureExclusionV1(r.Client(), r.Version(), id, ex)
111+ if err != nil {
112+ return
113+ }
114+ ex.res = res
115+ if c != nil && c.excl != nil {
116+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorAlreadyExcluded), "the capture session already has an exclusion")
117+ return
118+ }
119+ switch {
120+ case c == nil || c.stopped || c.id == 0:
121+ ex.session = nil
122+ ex.ended = true
123+ res.SendFailed(uint32(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped))
124+ return
125+ case s.excl != nil:
126+ ex.session = nil
127+ ex.ended = true
128+ s.log.Info().Str("reason", "busy").Msg("capture exclusion refused")
129+ res.SendFailed(uint32(imagecapture.NeferwlCaptureExclusionV1FailureBusy))
130+ return
131+ }
132+ var raw [32]byte
133+ if _, err := rand.Read(raw[:]); err != nil {
134+ s.log.Warn().Msg("capture exclusion refused: no randomness")
135+ ex.session, ex.ended = nil, true
136+ res.SendFailed(uint32(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped))
137+ return
138+ }
139+ ex.token = hex.EncodeToString(raw[:])
140+ s.excl, c.excl = ex, ex
141+ s.log.Info().Uint64("session", c.id).Msg("capture exclusion begin")
142+ s.emit(ports.CaptureExclusionBegin{Session: c.id})
143+ res.SendToken(ex.token)
144+}
145+
146+// Destroy: the owner let go of the exclusion; the session lives on.
147+func (ex *exclusion) Destroy(*imagecapture.NeferwlCaptureExclusionV1) {
148+ if !ex.ended {
149+ ex.end(true)
150+ }
151+}
152+
153+func (m *exclusionManager) AttachSurface(r *imagecapture.NeferwlCaptureExclusionManagerV1, id uint32, token string, surface *wayland.Surface) {
154+ s := m.s
155+ if surface == nil || s.surfaces[surface.Resource] == nil || surface.Resource.Client() != r.Client() {
156+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidSurface), "surface is not yours")
157+ return
158+ }
159+ state := s.surfaces[surface.Resource]
160+ l := state.layer
161+ att := &layerAttachment{layer: l}
162+ res, err := imagecapture.NewNeferwlCaptureLayerV1(r.Client(), r.Version(), id, att)
163+ if err != nil {
164+ return
165+ }
166+ att.res = res
167+ // Same user as the compositor, and the owner of the exclusion passed the
168+ // same check by holding the token.
169+ ex := s.excl
170+ switch {
171+ case s.protected() || !m.trusted || !s.mayCapture(r.Client()): // DISPLAY protection; the UID of this connection, checked once at bind; the capture allowlist
172+ att.fail(imagecapture.NeferwlCaptureLayerV1FailureUnauthorized)
173+ return
174+ case ex == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ex.token)) != 1:
175+ att.fail(imagecapture.NeferwlCaptureLayerV1FailureUnknownToken)
176+ return
177+ case state.kind != roleLayer || l == nil:
178+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorSurfaceRole), "not a layer surface")
179+ return
180+ case l.pending.layer != ports.LayerTop && l.pending.layer != ports.LayerOverlay:
181+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidLayer), "layer must be top or overlay")
182+ return
183+ case l.pending.keyboard == 1:
184+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorExclusiveKeyboard), "exclusive keyboard")
185+ return
186+ case l.mapped || l.closed:
187+ r.PostError(uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidSurface), "already mapped")
188+ return
189+ case l.capture != nil:
190+ att.fail(imagecapture.NeferwlCaptureLayerV1FailureAlreadyAttached)
191+ return
192+ case len(ex.layers) >= ports.MaxExclusionLayers:
193+ att.fail(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers)
194+ return
195+ }
196+ l.capture, l.attachment = ex, att
197+ l.pending.keyboard, l.current.keyboard = 0, 0
198+ ex.layers = append(ex.layers, l)
199+ ex.attached = append(ex.attached, att)
200+ // Queued before any LayerChanged of this layer: core excludes it from
201+ // the first scene that shows it.
202+ s.emit(ports.CaptureExclusionLayer{Session: ex.session.id, Layer: l.id, Attached: true})
203+}
204+
205+func (*layerAttachment) Destroy(*imagecapture.NeferwlCaptureLayerV1) {}
206+
207+// end ends the exclusion: every attachment is told (detached, or failed
208+// before it was confirmed) and its layer surface is closed so none lingers
209+// into the next exclusion. notify tells core; it is false when the session
210+// goes (core forgets the exclusion with it, or decided the end itself).
211+func (ex *exclusion) end(notify bool) {
212+ if ex.ended {
213+ return
214+ }
215+ s := ex.s
216+ ex.ended = true
217+ if s.excl == ex {
218+ s.excl = nil
219+ }
220+ sess := ex.session
221+ if sess != nil {
222+ // exclSeen stays as core's last state says: core keeps the ended
223+ // exclusion's layers excluded while a screen lists them, and frames
224+ // keep asking for that exclusion (fenced by its revision) until a
225+ // state says it is gone.
226+ sess.excl = nil
227+ }
228+ layers, attached := ex.layers, ex.attached
229+ ex.layers, ex.attached = nil, nil
230+ for _, a := range attached {
231+ a.finish(imagecapture.NeferwlCaptureLayerV1FailureExclusionEnded, imagecapture.NeferwlCaptureLayerV1DetachReasonExclusionEnded)
232+ }
233+ for _, l := range layers {
234+ l.capture, l.attachment = nil, nil
235+ // Closing unmaps the surface: the LayerChanged that removes it is
236+ // queued before the CaptureExclusionEnd below.
237+ l.close()
238+ }
239+ if notify && sess != nil {
240+ s.log.Info().Uint64("session", sess.id).Msg("capture exclusion end")
241+ s.emit(ports.CaptureExclusionEnd{Session: sess.id})
242+ }
243+}
244+
245+// confirm sends attached to the attachments core now lists: only then is it
246+// safe for the client to show the layer.
247+func (ex *exclusion) confirm(listed []ports.WindowID) {
248+ for _, a := range ex.attached {
249+ if !a.confirmed && slices.Contains(listed, a.layer.id) {
250+ a.confirmed = true
251+ if a.res.Alive() {
252+ a.res.SendAttached()
253+ }
254+ }
255+ }
256+}
257+
258+// captureDetach drops a layer surface whose role is gone from its exclusion.
259+// The caller unmapped it first, so the LayerChanged that removes the layer is
260+// already queued: core keeps excluding the layer until it reads that.
261+func (l *layerSurface) captureDetach() {
262+ ex, att := l.capture, l.attachment
263+ if ex == nil {
264+ return
265+ }
266+ l.capture, l.attachment = nil, nil
267+ ex.layers = removeItem(ex.layers, l)
268+ ex.attached = removeItem(ex.attached, att)
269+ att.finish(imagecapture.NeferwlCaptureLayerV1FailureLayerDestroyed, imagecapture.NeferwlCaptureLayerV1DetachReasonLayerDestroyed)
270+ if !ex.ended && ex.session != nil {
271+ ex.s.emit(ports.CaptureExclusionLayer{Session: ex.session.id, Layer: l.id})
272+ }
273+}
M internal/adapters/wayland/globals.go +2 -1
@@ -39,7 +39,8 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3939 func() error { return registerInhibit(d, s) },
4040 func() error { return registerIdle(d, s) },
4141 func() error { return registerCapture(d, s) },
42- func() error { return registerCaptureSession(d, s) },
42+ func() error { return registerExclusion(d, s) },
43+ func() error { return registerSecurityContext(d, s) },
4344 func() error { return registerActivation(d, s) },
4445 func() error { return registerVirtualKeyboard(d, s) },
4546 func() error { return registerTextInput(d, s) },
A internal/adapters/wayland/imagecapture/gen.sh +12 -0
@@ -0,0 +1,12 @@
1+#!/bin/sh
2+# Regenerates imagecapture.go from neferwl-image-capture-v1.xml with the wlgen
3+# of the purego-libwayland module in use (offline: go.work or the module cache).
4+set -eu
5+cd "$(dirname "$0")"
6+mod=$(go list -m -f '{{.Dir}}' github.com/bnema/purego-libwayland)
7+go run github.com/bnema/purego-libwayland/cmd/wlgen -package imagecapture -out imagecapture.go \
8+ -import wayland=github.com/bnema/purego-libwayland/protocol/wayland -import-xml wayland="$mod/protocols/wayland.xml" \
9+ -import extworkspace=github.com/bnema/purego-libwayland/protocol/extworkspace -import-xml extworkspace="$mod/protocols/ext-workspace-v1.xml" \
10+ -import extimagecapturesource=github.com/bnema/purego-libwayland/protocol/extimagecapturesource -import-xml extimagecapturesource="$mod/protocols/ext-image-capture-source-v1.xml" \
11+ -import extimagecopycapture=github.com/bnema/purego-libwayland/protocol/extimagecopycapture -import-xml extimagecopycapture="$mod/protocols/ext-image-copy-capture-v1.xml" \
12+ neferwl-image-capture-v1.xml
A internal/adapters/wayland/imagecapture/imagecapture.go +403 -0
@@ -0,0 +1,403 @@
1+// Code generated by wlgen; DO NOT EDIT.
2+package imagecapture
3+
4+import (
5+ "fmt"
6+ extimagecapturesource "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
7+ extimagecopycapture "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
8+ extworkspace "github.com/bnema/purego-libwayland/protocol/extworkspace"
9+ wayland "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/server"
11+ "runtime"
12+)
13+
14+var NeferwlImageCaptureSourceManagerV1Interface *server.Interface
15+var NeferwlWorkspaceFrameV1Interface *server.Interface
16+var NeferwlCaptureExclusionManagerV1Interface *server.Interface
17+var NeferwlCaptureExclusionV1Interface *server.Interface
18+var NeferwlCaptureLayerV1Interface *server.Interface
19+
20+func init() {
21+ NeferwlImageCaptureSourceManagerV1Interface = &server.Interface{Name: "neferwl_image_capture_source_manager_v1", Version: 1}
22+ NeferwlWorkspaceFrameV1Interface = &server.Interface{Name: "neferwl_workspace_frame_v1", Version: 1}
23+ NeferwlCaptureExclusionManagerV1Interface = &server.Interface{Name: "neferwl_capture_exclusion_manager_v1", Version: 1}
24+ NeferwlCaptureExclusionV1Interface = &server.Interface{Name: "neferwl_capture_exclusion_v1", Version: 1}
25+ NeferwlCaptureLayerV1Interface = &server.Interface{Name: "neferwl_capture_layer_v1", Version: 1}
26+ NeferwlImageCaptureSourceManagerV1Interface.Requests = []server.Message{
27+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
28+ {Name: "create_workspace_source", Signature: "no", Types: []*server.Interface{extimagecapturesource.ExtImageCaptureSourceV1Interface, extworkspace.ExtWorkspaceHandleV1Interface}},
29+ {Name: "create_output_region_source", Signature: "noiiii", Types: []*server.Interface{extimagecapturesource.ExtImageCaptureSourceV1Interface, wayland.OutputInterface, nil, nil, nil, nil}},
30+ {Name: "get_workspace_frame", Signature: "no", Types: []*server.Interface{NeferwlWorkspaceFrameV1Interface, extworkspace.ExtWorkspaceHandleV1Interface}},
31+ }
32+ NeferwlImageCaptureSourceManagerV1Interface.Events = []server.Message{}
33+ NeferwlWorkspaceFrameV1Interface.Requests = []server.Message{
34+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
35+ }
36+ NeferwlWorkspaceFrameV1Interface.Events = []server.Message{
37+ {Name: "frame", Signature: "iiii", Types: []*server.Interface{nil, nil, nil, nil}},
38+ }
39+ NeferwlCaptureExclusionManagerV1Interface.Requests = []server.Message{
40+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
41+ {Name: "get_exclusion", Signature: "no", Types: []*server.Interface{NeferwlCaptureExclusionV1Interface, extimagecopycapture.ExtImageCopyCaptureSessionV1Interface}},
42+ {Name: "attach_surface", Signature: "nso", Types: []*server.Interface{NeferwlCaptureLayerV1Interface, nil, wayland.SurfaceInterface}},
43+ }
44+ NeferwlCaptureExclusionManagerV1Interface.Events = []server.Message{}
45+ NeferwlCaptureExclusionV1Interface.Requests = []server.Message{
46+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
47+ }
48+ NeferwlCaptureExclusionV1Interface.Events = []server.Message{
49+ {Name: "token", Signature: "s", Types: []*server.Interface{nil}},
50+ {Name: "failed", Signature: "u", Types: []*server.Interface{nil}},
51+ }
52+ NeferwlCaptureLayerV1Interface.Requests = []server.Message{
53+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
54+ }
55+ NeferwlCaptureLayerV1Interface.Events = []server.Message{
56+ {Name: "attached", Signature: "", Types: []*server.Interface{}},
57+ {Name: "failed", Signature: "u", Types: []*server.Interface{nil}},
58+ {Name: "detached", Signature: "u", Types: []*server.Interface{nil}},
59+ }
60+ if err := server.NewInterfaces(NeferwlImageCaptureSourceManagerV1Interface, NeferwlWorkspaceFrameV1Interface, NeferwlCaptureExclusionManagerV1Interface, NeferwlCaptureExclusionV1Interface, NeferwlCaptureLayerV1Interface); err != nil {
61+ panic(err)
62+ }
63+}
64+
65+// NeferwlImageCaptureSourceManagerV1 workspace and output-region capture sources.
66+type NeferwlImageCaptureSourceManagerV1 struct{ *server.Resource }
67+
68+func WrapNeferwlImageCaptureSourceManagerV1(r *server.Resource) *NeferwlImageCaptureSourceManagerV1 {
69+ if r == nil {
70+ return nil
71+ }
72+ return &NeferwlImageCaptureSourceManagerV1{r}
73+}
74+
75+const NeferwlImageCaptureSourceManagerV1RequestDestroy uint32 = 0
76+const NeferwlImageCaptureSourceManagerV1RequestCreateWorkspaceSource uint32 = 1
77+const NeferwlImageCaptureSourceManagerV1RequestCreateOutputRegionSource uint32 = 2
78+const NeferwlImageCaptureSourceManagerV1RequestGetWorkspaceFrame uint32 = 3
79+
80+// NeferwlImageCaptureSourceManagerV1Error .
81+type NeferwlImageCaptureSourceManagerV1Error uint32
82+
83+const (
84+ NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion NeferwlImageCaptureSourceManagerV1Error = 0
85+)
86+
87+// NeferwlImageCaptureSourceManagerV1Handler handles neferwl_image_capture_source_manager_v1 requests.
88+type NeferwlImageCaptureSourceManagerV1Handler interface {
89+ Destroy(self *NeferwlImageCaptureSourceManagerV1)
90+ CreateWorkspaceSource(self *NeferwlImageCaptureSourceManagerV1, source uint32, workspace *extworkspace.ExtWorkspaceHandleV1)
91+ CreateOutputRegionSource(self *NeferwlImageCaptureSourceManagerV1, source uint32, output *wayland.Output, x int32, y int32, width int32, height int32)
92+ GetWorkspaceFrame(self *NeferwlImageCaptureSourceManagerV1, id uint32, workspace *extworkspace.ExtWorkspaceHandleV1)
93+}
94+
95+func NewNeferwlImageCaptureSourceManagerV1(c server.Client, version int32, id uint32, h NeferwlImageCaptureSourceManagerV1Handler) (*NeferwlImageCaptureSourceManagerV1, error) {
96+ if h == nil {
97+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_image_capture_source_manager_v1")
98+ }
99+ var self *NeferwlImageCaptureSourceManagerV1
100+ r, err := c.CreateResource(NeferwlImageCaptureSourceManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
101+ switch opcode {
102+ case 0:
103+ h.Destroy(self)
104+ self.Destroy()
105+ case 1:
106+ h.CreateWorkspaceSource(self, args[0].NewID(), extworkspace.WrapExtWorkspaceHandleV1(args[1].Resource()))
107+ case 2:
108+ h.CreateOutputRegionSource(self, args[0].NewID(), wayland.WrapOutput(args[1].Resource()), args[2].Int(), args[3].Int(), args[4].Int(), args[5].Int())
109+ case 3:
110+ h.GetWorkspaceFrame(self, args[0].NewID(), extworkspace.WrapExtWorkspaceHandleV1(args[1].Resource()))
111+ }
112+ })
113+ if err != nil {
114+ return nil, err
115+ }
116+ self = WrapNeferwlImageCaptureSourceManagerV1(r)
117+ r.Data = self
118+ return self, nil
119+}
120+func NewNeferwlImageCaptureSourceManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
121+ return d.CreateGlobal(NeferwlImageCaptureSourceManagerV1Interface, version, bind)
122+}
123+
124+// NeferwlWorkspaceFrameV1 the frame of one workspace.
125+type NeferwlWorkspaceFrameV1 struct{ *server.Resource }
126+
127+func WrapNeferwlWorkspaceFrameV1(r *server.Resource) *NeferwlWorkspaceFrameV1 {
128+ if r == nil {
129+ return nil
130+ }
131+ return &NeferwlWorkspaceFrameV1{r}
132+}
133+
134+const NeferwlWorkspaceFrameV1RequestDestroy uint32 = 0
135+const NeferwlWorkspaceFrameV1EventFrame uint32 = 0
136+
137+// NeferwlWorkspaceFrameV1Handler handles neferwl_workspace_frame_v1 requests.
138+type NeferwlWorkspaceFrameV1Handler interface {
139+ Destroy(self *NeferwlWorkspaceFrameV1)
140+}
141+
142+func NewNeferwlWorkspaceFrameV1(c server.Client, version int32, id uint32, h NeferwlWorkspaceFrameV1Handler) (*NeferwlWorkspaceFrameV1, error) {
143+ if h == nil {
144+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_workspace_frame_v1")
145+ }
146+ var self *NeferwlWorkspaceFrameV1
147+ r, err := c.CreateResource(NeferwlWorkspaceFrameV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
148+ switch opcode {
149+ case 0:
150+ h.Destroy(self)
151+ self.Destroy()
152+ }
153+ })
154+ if err != nil {
155+ return nil, err
156+ }
157+ self = WrapNeferwlWorkspaceFrameV1(r)
158+ r.Data = self
159+ return self, nil
160+}
161+
162+// SendFrame the workspace frame.
163+func (self *NeferwlWorkspaceFrameV1) SendFrame(x int32, y int32, width int32, height int32) {
164+ self.PostEvent(NeferwlWorkspaceFrameV1EventFrame, server.Int(x), server.Int(y), server.Int(width), server.Int(height))
165+}
166+func NewNeferwlWorkspaceFrameV1Global(d *server.Display, version int32, bind server.BindFunc) error {
167+ return d.CreateGlobal(NeferwlWorkspaceFrameV1Interface, version, bind)
168+}
169+
170+// NeferwlCaptureExclusionManagerV1 leave a client's own surfaces out of its captures.
171+type NeferwlCaptureExclusionManagerV1 struct{ *server.Resource }
172+
173+func WrapNeferwlCaptureExclusionManagerV1(r *server.Resource) *NeferwlCaptureExclusionManagerV1 {
174+ if r == nil {
175+ return nil
176+ }
177+ return &NeferwlCaptureExclusionManagerV1{r}
178+}
179+
180+const NeferwlCaptureExclusionManagerV1RequestDestroy uint32 = 0
181+const NeferwlCaptureExclusionManagerV1RequestGetExclusion uint32 = 1
182+const NeferwlCaptureExclusionManagerV1RequestAttachSurface uint32 = 2
183+
184+// NeferwlCaptureExclusionManagerV1Error .
185+type NeferwlCaptureExclusionManagerV1Error uint32
186+
187+const (
188+ NeferwlCaptureExclusionManagerV1ErrorSurfaceRole NeferwlCaptureExclusionManagerV1Error = 0
189+ NeferwlCaptureExclusionManagerV1ErrorInvalidLayer NeferwlCaptureExclusionManagerV1Error = 1
190+ NeferwlCaptureExclusionManagerV1ErrorExclusiveKeyboard NeferwlCaptureExclusionManagerV1Error = 2
191+ NeferwlCaptureExclusionManagerV1ErrorInvalidSurface NeferwlCaptureExclusionManagerV1Error = 3
192+ NeferwlCaptureExclusionManagerV1ErrorAlreadyExcluded NeferwlCaptureExclusionManagerV1Error = 4
193+)
194+
195+// NeferwlCaptureExclusionManagerV1Handler handles neferwl_capture_exclusion_manager_v1 requests.
196+type NeferwlCaptureExclusionManagerV1Handler interface {
197+ Destroy(self *NeferwlCaptureExclusionManagerV1)
198+ GetExclusion(self *NeferwlCaptureExclusionManagerV1, id uint32, session *extimagecopycapture.ExtImageCopyCaptureSessionV1)
199+ AttachSurface(self *NeferwlCaptureExclusionManagerV1, id uint32, token string, surface *wayland.Surface)
200+}
201+
202+func NewNeferwlCaptureExclusionManagerV1(c server.Client, version int32, id uint32, h NeferwlCaptureExclusionManagerV1Handler) (*NeferwlCaptureExclusionManagerV1, error) {
203+ if h == nil {
204+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_exclusion_manager_v1")
205+ }
206+ var self *NeferwlCaptureExclusionManagerV1
207+ r, err := c.CreateResource(NeferwlCaptureExclusionManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
208+ switch opcode {
209+ case 0:
210+ h.Destroy(self)
211+ self.Destroy()
212+ case 1:
213+ h.GetExclusion(self, args[0].NewID(), extimagecopycapture.WrapExtImageCopyCaptureSessionV1(args[1].Resource()))
214+ case 2:
215+ h.AttachSurface(self, args[0].NewID(), args[1].String(), wayland.WrapSurface(args[2].Resource()))
216+ }
217+ })
218+ if err != nil {
219+ return nil, err
220+ }
221+ self = WrapNeferwlCaptureExclusionManagerV1(r)
222+ r.Data = self
223+ return self, nil
224+}
225+func NewNeferwlCaptureExclusionManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
226+ return d.CreateGlobal(NeferwlCaptureExclusionManagerV1Interface, version, bind)
227+}
228+
229+// NeferwlCaptureExclusionV1 the exclusion of one capture session.
230+type NeferwlCaptureExclusionV1 struct{ *server.Resource }
231+
232+func WrapNeferwlCaptureExclusionV1(r *server.Resource) *NeferwlCaptureExclusionV1 {
233+ if r == nil {
234+ return nil
235+ }
236+ return &NeferwlCaptureExclusionV1{r}
237+}
238+
239+const NeferwlCaptureExclusionV1RequestDestroy uint32 = 0
240+const NeferwlCaptureExclusionV1EventToken uint32 = 0
241+const NeferwlCaptureExclusionV1EventFailed uint32 = 1
242+
243+// NeferwlCaptureExclusionV1Failure .
244+type NeferwlCaptureExclusionV1Failure uint32
245+
246+const (
247+ NeferwlCaptureExclusionV1FailureBusy NeferwlCaptureExclusionV1Failure = 0
248+ NeferwlCaptureExclusionV1FailureSessionStopped NeferwlCaptureExclusionV1Failure = 1
249+)
250+
251+// NeferwlCaptureExclusionV1Handler handles neferwl_capture_exclusion_v1 requests.
252+type NeferwlCaptureExclusionV1Handler interface {
253+ Destroy(self *NeferwlCaptureExclusionV1)
254+}
255+
256+func NewNeferwlCaptureExclusionV1(c server.Client, version int32, id uint32, h NeferwlCaptureExclusionV1Handler) (*NeferwlCaptureExclusionV1, error) {
257+ if h == nil {
258+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_exclusion_v1")
259+ }
260+ var self *NeferwlCaptureExclusionV1
261+ r, err := c.CreateResource(NeferwlCaptureExclusionV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
262+ switch opcode {
263+ case 0:
264+ h.Destroy(self)
265+ self.Destroy()
266+ }
267+ })
268+ if err != nil {
269+ return nil, err
270+ }
271+ self = WrapNeferwlCaptureExclusionV1(r)
272+ r.Data = self
273+ return self, nil
274+}
275+
276+// SendToken the attachment token.
277+func (self *NeferwlCaptureExclusionV1) SendToken(token string) {
278+ var p runtime.Pinner
279+ defer p.Unpin()
280+ self.PostEvent(NeferwlCaptureExclusionV1EventToken, server.String(&p, token, false))
281+}
282+
283+// SendFailed no exclusion.
284+func (self *NeferwlCaptureExclusionV1) SendFailed(reason uint32) {
285+ self.PostEvent(NeferwlCaptureExclusionV1EventFailed, server.Uint(reason))
286+}
287+func NewNeferwlCaptureExclusionV1Global(d *server.Display, version int32, bind server.BindFunc) error {
288+ return d.CreateGlobal(NeferwlCaptureExclusionV1Interface, version, bind)
289+}
290+
291+// NeferwlCaptureLayerV1 a layer surface attached to an exclusion.
292+type NeferwlCaptureLayerV1 struct{ *server.Resource }
293+
294+func WrapNeferwlCaptureLayerV1(r *server.Resource) *NeferwlCaptureLayerV1 {
295+ if r == nil {
296+ return nil
297+ }
298+ return &NeferwlCaptureLayerV1{r}
299+}
300+
301+const NeferwlCaptureLayerV1RequestDestroy uint32 = 0
302+const NeferwlCaptureLayerV1EventAttached uint32 = 0
303+const NeferwlCaptureLayerV1EventFailed uint32 = 1
304+const NeferwlCaptureLayerV1EventDetached uint32 = 2
305+
306+// NeferwlCaptureLayerV1Failure .
307+type NeferwlCaptureLayerV1Failure uint32
308+
309+const (
310+ NeferwlCaptureLayerV1FailureUnknownToken NeferwlCaptureLayerV1Failure = 0
311+ NeferwlCaptureLayerV1FailureUnauthorized NeferwlCaptureLayerV1Failure = 1
312+ NeferwlCaptureLayerV1FailureTooManyLayers NeferwlCaptureLayerV1Failure = 2
313+ NeferwlCaptureLayerV1FailureAlreadyAttached NeferwlCaptureLayerV1Failure = 3
314+ NeferwlCaptureLayerV1FailureExclusionEnded NeferwlCaptureLayerV1Failure = 4
315+ NeferwlCaptureLayerV1FailureLayerDestroyed NeferwlCaptureLayerV1Failure = 5
316+)
317+
318+// NeferwlCaptureLayerV1DetachReason .
319+type NeferwlCaptureLayerV1DetachReason uint32
320+
321+const (
322+ NeferwlCaptureLayerV1DetachReasonExclusionEnded NeferwlCaptureLayerV1DetachReason = 0
323+ NeferwlCaptureLayerV1DetachReasonLayerDestroyed NeferwlCaptureLayerV1DetachReason = 1
324+)
325+
326+// NeferwlCaptureLayerV1Handler handles neferwl_capture_layer_v1 requests.
327+type NeferwlCaptureLayerV1Handler interface {
328+ Destroy(self *NeferwlCaptureLayerV1)
329+}
330+
331+func NewNeferwlCaptureLayerV1(c server.Client, version int32, id uint32, h NeferwlCaptureLayerV1Handler) (*NeferwlCaptureLayerV1, error) {
332+ if h == nil {
333+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_layer_v1")
334+ }
335+ var self *NeferwlCaptureLayerV1
336+ r, err := c.CreateResource(NeferwlCaptureLayerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
337+ switch opcode {
338+ case 0:
339+ h.Destroy(self)
340+ self.Destroy()
341+ }
342+ })
343+ if err != nil {
344+ return nil, err
345+ }
346+ self = WrapNeferwlCaptureLayerV1(r)
347+ r.Data = self
348+ return self, nil
349+}
350+
351+// SendAttached the layer is excluded.
352+func (self *NeferwlCaptureLayerV1) SendAttached() {
353+ self.PostEvent(NeferwlCaptureLayerV1EventAttached)
354+}
355+
356+// SendFailed the attachment was refused.
357+func (self *NeferwlCaptureLayerV1) SendFailed(reason uint32) {
358+ self.PostEvent(NeferwlCaptureLayerV1EventFailed, server.Uint(reason))
359+}
360+
361+// SendDetached the layer left the exclusion.
362+func (self *NeferwlCaptureLayerV1) SendDetached(reason uint32) {
363+ self.PostEvent(NeferwlCaptureLayerV1EventDetached, server.Uint(reason))
364+}
365+func NewNeferwlCaptureLayerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
366+ return d.CreateGlobal(NeferwlCaptureLayerV1Interface, version, bind)
367+}
368+func resourceOf(v interface{ GetResource() *server.Resource }) *server.Resource {
369+ if v == nil {
370+ return nil
371+ }
372+ return v.GetResource()
373+}
374+func (self *NeferwlImageCaptureSourceManagerV1) GetResource() *server.Resource {
375+ if self == nil {
376+ return nil
377+ }
378+ return self.Resource
379+}
380+func (self *NeferwlWorkspaceFrameV1) GetResource() *server.Resource {
381+ if self == nil {
382+ return nil
383+ }
384+ return self.Resource
385+}
386+func (self *NeferwlCaptureExclusionManagerV1) GetResource() *server.Resource {
387+ if self == nil {
388+ return nil
389+ }
390+ return self.Resource
391+}
392+func (self *NeferwlCaptureExclusionV1) GetResource() *server.Resource {
393+ if self == nil {
394+ return nil
395+ }
396+ return self.Resource
397+}
398+func (self *NeferwlCaptureLayerV1) GetResource() *server.Resource {
399+ if self == nil {
400+ return nil
401+ }
402+ return self.Resource
403+}
A internal/adapters/wayland/imagecapture/neferwl-image-capture-v1.xml +233 -0
@@ -0,0 +1,233 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<protocol name="neferwl_image_capture_v1">
3+ <copyright>
4+ neferwl private protocol. No stability promise beyond the version of the
5+ interface.
6+ </copyright>
7+
8+ <description summary="neferwl additions to ext-image-copy-capture">
9+ Optional additions to ext-image-capture-source-v1, ext-workspace-v1 and
10+ ext-image-copy-capture-v1 for what those protocols do not cover:
11+ workspace and output-region capture sources, the frame of a workspace,
12+ and leaving a client's own surfaces (a recording HUD) out of its
13+ captures.
14+
15+ Capture always goes through ext-image-copy-capture. The compositor applies
16+ the same rules to every capture, whatever its source: it may refuse a
17+ client (ext_image_copy_capture_session_v1.stopped), and it draws its own
18+ capture indicator on screen, never in a captured image, which no client
19+ can turn off.
20+ </description>
21+
22+ <interface name="neferwl_image_capture_source_manager_v1" version="1">
23+ <description summary="workspace and output-region capture sources">
24+ Global. Sources created here are ext_image_capture_source_v1 objects,
25+ used with ext_image_copy_capture_manager_v1.create_session like any
26+ other source.
27+ </description>
28+
29+ <enum name="error">
30+ <entry name="invalid_region" value="0" summary="width or height is not positive"/>
31+ </enum>
32+
33+ <request name="destroy" type="destructor">
34+ <description summary="destroy the manager">
35+ Sources created through it stay valid.
36+ </description>
37+ </request>
38+
39+ <request name="create_workspace_source">
40+ <description summary="capture a workspace">
41+ The source is the workspace frame, without bars, whether or not the
42+ workspace is on screen: a workspace that is not on screen is rendered
43+ for capture only. Its buffer size follows the workspace frame. A
44+ session on it is stopped when the workspace is removed. A handle
45+ already removed gives a source whose sessions stop at once.
46+ </description>
47+ <arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
48+ <arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
49+ </request>
50+
51+ <request name="create_output_region_source">
52+ <description summary="capture a rectangle of an output">
53+ The rectangle is in output-local logical pixels and is clipped to the
54+ output. The buffer covers only that rectangle, in physical pixels. A
55+ session on it is stopped when the output goes away or when nothing of
56+ the rectangle is left on the output.
57+ </description>
58+ <arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
59+ <arg name="output" type="object" interface="wl_output"/>
60+ <arg name="x" type="int"/>
61+ <arg name="y" type="int"/>
62+ <arg name="width" type="int"/>
63+ <arg name="height" type="int"/>
64+ </request>
65+
66+ <request name="get_workspace_frame">
67+ <description summary="learn the frame of a workspace">
68+ ext-workspace-v1 carries no geometry. The frame object reports the
69+ rectangle that a workspace source of the same handle captures. For a
70+ handle already removed, the object never sends frame. It needs no
71+ capture authorization: the frame is layout information, not pixels.
72+ </description>
73+ <arg name="id" type="new_id" interface="neferwl_workspace_frame_v1"/>
74+ <arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
75+ </request>
76+ </interface>
77+
78+ <interface name="neferwl_workspace_frame_v1" version="1">
79+ <description summary="the frame of one workspace">
80+ The frame is the area of its output that the workspace draws in,
81+ without bars: the whole output, or the smaller size set for the
82+ workspace.
83+ </description>
84+
85+ <request name="destroy" type="destructor">
86+ <description summary="destroy the frame object"/>
87+ </request>
88+
89+ <event name="frame">
90+ <description summary="the workspace frame">
91+ The frame in output-local logical pixels. Sent when the object is
92+ created and again whenever the frame changes. Not sent once the
93+ workspace is removed. Not sent while the session is locked; the
94+ current frame is sent after unlock if it changed.
95+ </description>
96+ <arg name="x" type="int"/>
97+ <arg name="y" type="int"/>
98+ <arg name="width" type="int"/>
99+ <arg name="height" type="int"/>
100+ </event>
101+ </interface>
102+
103+ <interface name="neferwl_capture_exclusion_manager_v1" version="1">
104+ <description summary="leave a client's own surfaces out of its captures">
105+ Global. A recorder asks for an exclusion on its capture session and
106+ gets a token; its HUD, usually on another connection of the same user,
107+ attaches layer surfaces with that token. Frames of that session leave
108+ the attached surfaces out. Every other capture shows them.
109+ </description>
110+
111+ <enum name="error">
112+ <entry name="surface_role" value="0" summary="the surface is not a wlr-layer-shell surface"/>
113+ <entry name="invalid_layer" value="1" summary="the layer is neither top nor overlay"/>
114+ <entry name="exclusive_keyboard" value="2" summary="the layer takes the keyboard exclusively"/>
115+ <entry name="invalid_surface" value="3" summary="the surface is already mapped"/>
116+ <entry name="already_excluded" value="4" summary="the capture session already has an exclusion"/>
117+ </enum>
118+
119+ <request name="destroy" type="destructor">
120+ <description summary="destroy the manager">
121+ Exclusions and attachments created through it stay alive.
122+ </description>
123+ </request>
124+
125+ <request name="get_exclusion">
126+ <description summary="create the exclusion of a capture session">
127+ The exclusion answers with exactly one of token or failed. It ends
128+ with the capture session.
129+ </description>
130+ <arg name="id" type="new_id" interface="neferwl_capture_exclusion_v1"/>
131+ <arg name="session" type="object" interface="ext_image_copy_capture_session_v1"/>
132+ </request>
133+
134+ <request name="attach_surface">
135+ <description summary="attach a layer surface to an exclusion by token">
136+ Marks a layer surface of this client as excluded from the frames of
137+ the session that owns the token. The client must have the same user
138+ ID as the compositor and must also be allowed to capture: its
139+ executable is on the capture allowlist (or the allowlist is off), it
140+ is not sandboxed, and the session is not locked. The surface must already have the
141+ wlr-layer-shell role, on the top or overlay layer, must not have
142+ mapped yet and must not ask for exclusive keyboard interactivity. At
143+ most 4 layers per exclusion. An attached layer never gets keyboard
144+ interactivity; if it moves to another layer the compositor closes it
145+ and sends detached.
146+
147+ The attachment answers with exactly one of attached or failed. The
148+ surface is excluded from this request on, before it can map; the
149+ client must not show it before attached.
150+ </description>
151+ <arg name="id" type="new_id" interface="neferwl_capture_layer_v1"/>
152+ <arg name="token" type="string"/>
153+ <arg name="surface" type="object" interface="wl_surface"/>
154+ </request>
155+ </interface>
156+
157+ <interface name="neferwl_capture_exclusion_v1" version="1">
158+ <description summary="the exclusion of one capture session">
159+ Destroying it detaches every attached layer.
160+ </description>
161+
162+ <enum name="failure">
163+ <entry name="busy" value="0" summary="another exclusion is live in the compositor"/>
164+ <entry name="session_stopped" value="1" summary="the capture session is stopped"/>
165+ </enum>
166+
167+ <request name="destroy" type="destructor">
168+ <description summary="destroy the exclusion"/>
169+ </request>
170+
171+ <event name="token">
172+ <description summary="the attachment token">
173+ 64 lowercase hexadecimal characters (32 random bytes). A secret for
174+ the owner and its HUD, never logged, valid until the exclusion ends.
175+ </description>
176+ <arg name="token" type="string"/>
177+ </event>
178+
179+ <event name="failed">
180+ <description summary="no exclusion">
181+ Final. Destroy the object.
182+ </description>
183+ <arg name="reason" type="uint" enum="failure"/>
184+ </event>
185+ </interface>
186+
187+ <interface name="neferwl_capture_layer_v1" version="1">
188+ <description summary="a layer surface attached to an exclusion">
189+ Created by attach_surface. Exactly one of attached or failed comes
190+ first; detached comes at most once, after attached.
191+ </description>
192+
193+ <enum name="failure">
194+ <entry name="unknown_token" value="0" summary="no live exclusion has this token"/>
195+ <entry name="unauthorized" value="1" summary="peer credentials differ from the compositor user's or are unreadable, or the client may not capture (not on the allowlist, sandboxed, or session locked)"/>
196+ <entry name="too_many_layers" value="2" summary="the exclusion already has 4 attached layers"/>
197+ <entry name="already_attached" value="3" summary="the surface is already attached"/>
198+ <entry name="exclusion_ended" value="4" summary="the exclusion ended before the compositor confirmed the layer"/>
199+ <entry name="layer_destroyed" value="5" summary="the layer role was destroyed before the compositor confirmed it"/>
200+ </enum>
201+
202+ <enum name="detach_reason">
203+ <entry name="exclusion_ended" value="0" summary="the exclusion ended; the compositor closed the layer surface"/>
204+ <entry name="layer_destroyed" value="1" summary="the layer role was destroyed"/>
205+ </enum>
206+
207+ <request name="destroy" type="destructor">
208+ <description summary="destroy the attachment object">
209+ The surface stays excluded until its layer role is destroyed or the
210+ exclusion ends.
211+ </description>
212+ </request>
213+
214+ <event name="attached">
215+ <description summary="the layer is excluded">
216+ The compositor confirmed the attachment. Safe to show.
217+ </description>
218+ </event>
219+
220+ <event name="failed">
221+ <description summary="the attachment was refused"/>
222+ <arg name="reason" type="uint" enum="failure"/>
223+ </event>
224+
225+ <event name="detached">
226+ <description summary="the layer left the exclusion">
227+ Sent once after attached. When the exclusion ended, the compositor
228+ also closed the layer surface (zwlr_layer_surface_v1.closed).
229+ </description>
230+ <arg name="reason" type="uint" enum="detach_reason"/>
231+ </event>
232+ </interface>
233+</protocol>
A internal/adapters/wayland/imagecapture_test.go +1459 -0
@@ -0,0 +1,1459 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "image"
6+ "strings"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
12+ "github.com/bnema/neferwl/internal/logging"
13+ "github.com/bnema/neferwl/internal/ports"
14+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
15+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
16+ extws "github.com/bnema/purego-libwayland/protocol/extworkspace"
17+ "github.com/bnema/purego-libwayland/protocol/wayland"
18+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
19+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
20+ "github.com/bnema/purego-libwayland/server"
21+ "github.com/bnema/wlturbo"
22+ "golang.org/x/sys/unix"
23+)
24+
25+// sessionMsg is one decoded event of the neferwl extension.
26+type sessionMsg struct {
27+ op uint16
28+ token string
29+ numbers []int64
30+}
31+
32+// attachEvents decodes the events of a neferwl_capture_layer_v1.
33+type attachEvents struct {
34+ wlturbo.BaseProxy
35+ msgs chan sessionMsg
36+}
37+
38+func (p *attachEvents) Dispatch(e *wlturbo.Event) {
39+ m := sessionMsg{op: e.Opcode}
40+ if uint32(e.Opcode) != imagecapture.NeferwlCaptureLayerV1EventAttached {
41+ m.numbers = append(m.numbers, int64(e.Uint32()))
42+ }
43+ p.msgs <- m
44+}
45+
46+// exclusionEvents decodes the events of a neferwl_capture_exclusion_v1.
47+type exclusionEvents struct {
48+ wlturbo.BaseProxy
49+ msgs chan sessionMsg
50+}
51+
52+func (p *exclusionEvents) Dispatch(e *wlturbo.Event) {
53+ m := sessionMsg{op: e.Opcode}
54+ switch uint32(e.Opcode) {
55+ case imagecapture.NeferwlCaptureExclusionV1EventToken:
56+ m.token = e.String()
57+ case imagecapture.NeferwlCaptureExclusionV1EventFailed:
58+ m.numbers = append(m.numbers, int64(e.Uint32()))
59+ }
60+ p.msgs <- m
61+}
62+
63+// captureHarness is a server with one 4x4 output and every capture channel.
64+type captureHarness struct {
65+ s *Server
66+ dir string
67+ events chan ports.ClientEvent
68+ commands chan ports.ClientCommand
69+ workspaces chan ports.Workspaces
70+ captures chan ports.CaptureRequest
71+ captured chan ports.CaptureDone
72+}
73+
74+var captureOutput = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
75+
76+func newCaptureHarness(t *testing.T) *captureHarness {
77+ t.Helper()
78+ return newCaptureHarnessWith(t, nil)
79+}
80+
81+// newCaptureHarnessWith lets a test adjust the options and channels (for
82+// instance to wire a session-lock gate) before the server starts.
83+func newCaptureHarnessWith(t *testing.T, tune func(*Options, *Channels)) *captureHarness {
84+ t.Helper()
85+ h := &captureHarness{
86+ dir: t.TempDir(),
87+ events: make(chan ports.ClientEvent, 64),
88+ commands: make(chan ports.ClientCommand, 16),
89+ workspaces: make(chan ports.Workspaces, 4),
90+ captures: make(chan ports.CaptureRequest, 8),
91+ captured: make(chan ports.CaptureDone, 8),
92+ }
93+ opts := Options{RuntimeDir: h.dir, Outputs: ports.Layout{captureOutput}, CaptureAllow: allowStore(t, "*\n")}
94+ ch := Channels{Events: h.events, Commands: h.commands, Workspaces: h.workspaces, Captures: h.captures, Captured: h.captured}
95+ if tune != nil {
96+ tune(&opts, &ch)
97+ }
98+ s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ h.s = s
103+ ctx, cancel := context.WithCancel(context.Background())
104+ done := make(chan error, 1)
105+ go func() { done <- s.Run(ctx) }()
106+ t.Cleanup(func() {
107+ cancel()
108+ if err := <-done; err != nil {
109+ t.Errorf("Run: %v", err)
110+ }
111+ for len(h.captures) > 0 {
112+ _ = (<-h.captures).Dst.File.Close()
113+ }
114+ })
115+ return h
116+}
117+
118+// cclient is a client with every capture global bound.
119+type cclient struct {
120+ c *wlturbo.Display
121+ output uint32
122+ outSrc uint32 // ext_output_image_capture_source_manager_v1
123+ extra uint32 // neferwl_image_capture_source_manager_v1
124+ copyMgr uint32 // ext_image_copy_capture_manager_v1
125+ excl uint32 // neferwl_capture_exclusion_manager_v1
126+}
127+
128+func (h *captureHarness) client(t *testing.T) *cclient {
129+ t.Helper()
130+ c := protocolClient(t, h.s, h.dir)
131+ out := bindVersion(t, c, "wl_output", 4)
132+ registerProtocol(t, c, out)
133+ return &cclient{c: c, output: out,
134+ outSrc: bindProtocol(t, c, "ext_output_image_capture_source_manager_v1"),
135+ extra: bindProtocol(t, c, "neferwl_image_capture_source_manager_v1"),
136+ copyMgr: bindProtocol(t, c, "ext_image_copy_capture_manager_v1"),
137+ excl: bindProtocol(t, c, "neferwl_capture_exclusion_manager_v1"),
138+ }
139+}
140+
141+func (cc *cclient) roundtrip(t *testing.T) {
142+ t.Helper()
143+ if err := cc.c.Roundtrip(); err != nil {
144+ t.Fatal(err)
145+ }
146+}
147+
148+// outputSource, regionSource and workspaceSource make a capture source.
149+func (cc *cclient) outputSource(t *testing.T) uint32 {
150+ t.Helper()
151+ src := cc.c.AllocateID()
152+ registerProtocol(t, cc.c, src)
153+ requestProtocol(t, cc.c, cc.outSrc, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, cc.output)
154+ return src
155+}
156+
157+func (cc *cclient) regionSource(t *testing.T, x, y, w, h int32) uint32 {
158+ t.Helper()
159+ src := cc.c.AllocateID()
160+ registerProtocol(t, cc.c, src)
161+ requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestCreateOutputRegionSource, src, cc.output, x, y, w, h)
162+ return src
163+}
164+
165+func (cc *cclient) workspaceSource(t *testing.T, handle uint32) uint32 {
166+ t.Helper()
167+ src := cc.c.AllocateID()
168+ registerProtocol(t, cc.c, src)
169+ requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestCreateWorkspaceSource, src, handle)
170+ return src
171+}
172+
173+// session opens an ext-image-copy-capture session on a source and returns its
174+// object and the decoded events (see captureDetails).
175+func (cc *cclient) session(t *testing.T, src uint32) (uint32, chan []uint32) {
176+ t.Helper()
177+ id := cc.c.AllocateID()
178+ p := &captureDetails{events: make(chan []uint32, 32)}
179+ p.SetID(id)
180+ registerWireProxy(cc.c, p)
181+ requestProtocol(t, cc.c, cc.copyMgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, id, src, uint32(0))
182+ cc.roundtrip(t)
183+ return id, p.events
184+}
185+
186+// Session event opcodes as captureDetails reports them.
187+const (
188+ evBufferSize = uint32(ext.ExtImageCopyCaptureSessionV1EventBufferSize)
189+ evDone = uint32(ext.ExtImageCopyCaptureSessionV1EventDone)
190+ evStopped = uint32(ext.ExtImageCopyCaptureSessionV1EventStopped)
191+)
192+
193+// constraints reads one complete batch (buffer_size, two formats, done) and
194+// returns the size.
195+func constraints(t *testing.T, cc *cclient, ev chan []uint32) (w, h uint32) {
196+ t.Helper()
197+ got := nextSession(t, cc, ev)
198+ if got[0] != evBufferSize || len(got) != 3 {
199+ t.Fatalf("expected buffer_size, got %v", got)
200+ }
201+ w, h = got[1], got[2]
202+ for _, want := range []uint32{uint32(ext.ExtImageCopyCaptureSessionV1EventShmFormat), uint32(ext.ExtImageCopyCaptureSessionV1EventShmFormat), evDone} {
203+ if g := nextSession(t, cc, ev); g[0] != want {
204+ t.Fatalf("batch event %v, want opcode %d", g, want)
205+ }
206+ }
207+ return w, h
208+}
209+
210+// nextSession waits for the next decoded session event, dispatching the client.
211+func nextSession(t *testing.T, cc *cclient, ev chan []uint32) []uint32 {
212+ t.Helper()
213+ deadline := time.Now().Add(2 * time.Second)
214+ for time.Now().Before(deadline) {
215+ select {
216+ case v := <-ev:
217+ return v
218+ default:
219+ }
220+ cc.roundtrip(t)
221+ time.Sleep(time.Millisecond)
222+ }
223+ t.Fatal("timed out waiting for a session event")
224+ return nil
225+}
226+
227+func noSession(t *testing.T, cc *cclient, ev chan []uint32) {
228+ t.Helper()
229+ cc.roundtrip(t)
230+ select {
231+ case v := <-ev:
232+ t.Fatalf("unexpected session event %v", v)
233+ default:
234+ }
235+}
236+
237+// settle waits until the server applied the commands sent so far and the
238+// client received what followed.
239+func (h *captureHarness) settle(t *testing.T, cc *cclient) {
240+ t.Helper()
241+ deadline := time.Now().Add(2 * time.Second)
242+ for len(h.commands) > 0 && time.Now().Before(deadline) {
243+ time.Sleep(time.Millisecond)
244+ }
245+ if !h.s.display.Do(func() {}) {
246+ t.Fatal("display stopped")
247+ }
248+ cc.roundtrip(t)
249+}
250+
251+func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
252+
253+// captureShm makes a w x h XRGB8888 wl_buffer.
254+func captureShm(t *testing.T, c *wlturbo.Display, w, h int32) (buf uint32, fd int) {
255+ t.Helper()
256+ shm := bindProtocol(t, c, "wl_shm")
257+ registerProtocol(t, c, shm)
258+ fd, err := unix.MemfdCreate("ext-capture", 0)
259+ if err != nil {
260+ t.Fatal(err)
261+ }
262+ size := w * h * 4
263+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
264+ t.Fatal(err)
265+ }
266+ pool := c.AllocateID()
267+ buf = c.AllocateID()
268+ registerProtocol(t, c, pool)
269+ registerProtocol(t, c, buf)
270+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
271+ t.Fatal(err)
272+ }
273+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), w, h, w*4, uint32(wayland.ShmFormatXrgb8888))
274+ t.Cleanup(func() { _ = unix.Close(fd) })
275+ return buf, fd
276+}
277+
278+// extFrame asks one frame of a session into a fresh w x h buffer and returns
279+// its events; the frame object is destroyed so the next one may follow.
280+func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
281+ t.Helper()
282+ buf, _ := captureShm(t, cc.c, w, h)
283+ frame := cc.c.AllocateID()
284+ ev := &captureEvents{events: make(chan uint16, 16)}
285+ ev.SetID(frame)
286+ registerWireProxy(cc.c, ev)
287+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
288+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
289+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
290+ cc.roundtrip(t)
291+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestDestroy)
292+ cc.roundtrip(t)
293+ return ev
294+}
295+
296+// wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
297+func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
298+ t.Helper()
299+ buf, _ := captureShm(t, cc.c, 4, 4)
300+ mgr := bindVersion(t, cc.c, "zwlr_screencopy_manager_v1", 3)
301+ frame := cc.c.AllocateID()
302+ ev := &captureEvents{events: make(chan uint16, 16)}
303+ ev.SetID(frame)
304+ registerWireProxy(cc.c, ev)
305+ requestProtocol(t, cc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), cc.output)
306+ requestProtocol(t, cc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
307+ cc.roundtrip(t)
308+ return ev
309+}
310+
311+func receiveCapture(t *testing.T, ch chan ports.CaptureRequest) ports.CaptureRequest {
312+ t.Helper()
313+ select {
314+ case r := <-ch:
315+ _ = r.Dst.File.Close()
316+ return r
317+ case <-time.After(2 * time.Second):
318+ t.Fatal("no capture request")
319+ return ports.CaptureRequest{}
320+ }
321+}
322+
323+func noCapture(t *testing.T, ch chan ports.CaptureRequest) {
324+ t.Helper()
325+ select {
326+ case r := <-ch:
327+ _ = r.Dst.File.Close()
328+ t.Fatalf("a capture reached the renderer: %+v", r)
329+ default:
330+ }
331+}
332+
333+// eventsInclude drains the frame events received so far and reports op among them.
334+func eventsInclude(ev *captureEvents, op uint16) bool {
335+ found := false
336+ for len(ev.events) > 0 {
337+ if <-ev.events == op {
338+ found = true
339+ }
340+ }
341+ return found
342+}
343+
344+const (
345+ frameReady = uint16(ext.ExtImageCopyCaptureFrameV1EventReady)
346+ frameFailed = uint16(ext.ExtImageCopyCaptureFrameV1EventFailed)
347+)
348+
349+func (h *captureHarness) open(t *testing.T) ports.CaptureSessionOpen {
350+ t.Helper()
351+ return nextEvent[ports.CaptureSessionOpen](t, h.events)
352+}
353+
354+// active is the state core sends for a live session on the whole output.
355+func active(id uint64) ports.CaptureSessionState {
356+ return ports.CaptureSessionState{ID: id, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true}
357+}
358+
359+func TestTrustedPeer(t *testing.T) {
360+ for _, tc := range []struct {
361+ name string
362+ peer server.Credentials
363+ err error
364+ want bool
365+ }{
366+ {"same user", server.Credentials{UID: 1000}, nil, true},
367+ {"other user", server.Credentials{UID: 1001}, nil, false},
368+ {"root is not the user", server.Credentials{UID: 0}, nil, false},
369+ {"overflow id", server.Credentials{UID: 65534}, nil, false},
370+ {"unreadable", server.Credentials{UID: 1000}, context.Canceled, false},
371+ } {
372+ if got := trustedPeer(1000, tc.peer, tc.err); got != tc.want {
373+ t.Errorf("%s: %v", tc.name, got)
374+ }
375+ }
376+}
377+
378+// A session on an output source registers with core and gets the output size.
379+func TestOutputSessionRegistersAndClose(t *testing.T) {
380+ h := newCaptureHarness(t)
381+ cc := h.client(t)
382+ session, ev := cc.session(t, cc.outputSource(t))
383+ if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
384+ t.Fatalf("size %dx%d", w, hh)
385+ }
386+ open := h.open(t)
387+ if open.ID == 0 || open.Output != "HEADLESS-1" || open.Workspace != 0 || open.Region != (ports.Rect{}) {
388+ t.Fatalf("open %+v", open)
389+ }
390+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
391+ cc.roundtrip(t)
392+ if c := nextEvent[ports.CaptureSessionClose](t, h.events); c.ID != open.ID {
393+ t.Fatalf("close %+v", c)
394+ }
395+}
396+
397+// A region source is clipped to the output, sized in physical pixels, and its
398+// buffer size is sent again when the scale changes.
399+func TestRegionSourceConstraintsFollowScale(t *testing.T) {
400+ h := newCaptureHarness(t)
401+ cc := h.client(t)
402+ session, ev := cc.session(t, cc.regionSource(t, 1, 1, 2, 2))
403+ if w, hh := constraints(t, cc, ev); w != 2 || hh != 2 {
404+ t.Fatalf("size %dx%d", w, hh)
405+ }
406+ open := h.open(t)
407+ if open.Output != "HEADLESS-1" || open.Region != (ports.Rect{X: 1, Y: 1, W: 2, H: 2}) {
408+ t.Fatalf("open %+v", open)
409+ }
410+ // Clipped: a rectangle past the output edge.
411+ _, ev2 := cc.session(t, cc.regionSource(t, 3, 3, 10, 10))
412+ if w, hh := constraints(t, cc, ev2); w != 1 || hh != 1 {
413+ t.Fatalf("clipped size %dx%d", w, hh)
414+ }
415+ h.open(t)
416+ // Doubling the scale of a bigger mode doubles the buffer.
417+ bigger := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 8, Height: 8}, Width: 4, Height: 4, Scale: 2}
418+ applyOutputs(t, h.s, h.commands, ports.SetOutputs{Outputs: ports.Layout{bigger}})
419+ if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
420+ t.Fatalf("size after the scale change %dx%d", w, hh)
421+ }
422+ noSession(t, cc, ev) // one batch only
423+ _ = session
424+ // The frame asked with the new size is served for the new region.
425+ r := func() ports.CaptureRequest {
426+ cc.extFrame(t, session, 4, 4)
427+ return receiveCapture(t, h.captures)
428+ }()
429+ if r.Region.Dx() != 4 || r.Region.Dy() != 4 || r.Workspace != 0 || r.Exclude {
430+ t.Fatalf("request %+v", r)
431+ }
432+}
433+
434+// A frame whose buffer does not fit the geometry computed now, while the last
435+// constraints the client got are stale (the refresh has not run), fails with
436+// buffer_constraints after a new constraints batch: the client can retry.
437+func TestStaleConstraintsAreResentBeforeBufferConstraintsFailure(t *testing.T) {
438+ h := newCaptureHarness(t)
439+ cc := h.client(t)
440+ session, ev := cc.session(t, cc.outputSource(t))
441+ constraints(t, cc, ev)
442+ h.open(t)
443+ // The client's last batch said 1x1 while the output is 4x4 now.
444+ if !h.s.display.Do(func() {
445+ for c := range h.s.captureSessions {
446+ c.sent = image.Pt(1, 1)
447+ }
448+ }) {
449+ t.Fatal("display stopped")
450+ }
451+ frame := cc.extFrame(t, session, 1, 1)
452+ if !eventsInclude(frame, frameFailed) {
453+ t.Fatal("a buffer of the stale size was served")
454+ }
455+ noCapture(t, h.captures)
456+ if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
457+ t.Fatalf("resent constraints %dx%d", w, hh)
458+ }
459+ // Up to date: a wrong buffer fails without another batch.
460+ frame = cc.extFrame(t, session, 1, 1)
461+ if !eventsInclude(frame, frameFailed) {
462+ t.Fatal("a buffer of the wrong size was served")
463+ }
464+ noSession(t, cc, ev)
465+}
466+
467+func TestRegionSourceInvalidSize(t *testing.T) {
468+ for _, size := range [][2]int32{{0, 4}, {4, 0}, {-1, 4}} {
469+ h := newCaptureHarness(t)
470+ cc := h.client(t)
471+ cc.regionSource(t, 0, 0, size[0], size[1])
472+ expectProtocolError(t, cc.c, cc.extra, uint32(imagecapture.NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion))
473+ }
474+}
475+
476+// A region with nothing left on the output stops at once and is not
477+// registered with core; so does a session on an output that is gone.
478+func TestRegionAndOutputSourcesThatAreEmptyStop(t *testing.T) {
479+ h := newCaptureHarness(t)
480+ cc := h.client(t)
481+ _, ev := cc.session(t, cc.regionSource(t, 100, 100, 5, 5))
482+ if g := nextSession(t, cc, ev); g[0] != evStopped {
483+ t.Fatalf("event %v", g)
484+ }
485+ select {
486+ case e := <-h.events:
487+ t.Fatalf("event for a stopped session: %+v", e)
488+ default:
489+ }
490+}
491+
492+// Removing the output stops its sessions at once and tells core.
493+func TestOutputRemovalStopsRegionAndOutputSessions(t *testing.T) {
494+ h := newCaptureHarness(t)
495+ cc := h.client(t)
496+ _, ev1 := cc.session(t, cc.outputSource(t))
497+ _, ev2 := cc.session(t, cc.regionSource(t, 0, 0, 2, 2))
498+ constraints(t, cc, ev1)
499+ constraints(t, cc, ev2)
500+ a, b := h.open(t), h.open(t)
501+ applyOutputs(t, h.s, h.commands, ports.SetOutputs{})
502+ for _, ev := range []chan []uint32{ev1, ev2} {
503+ if g := nextSession(t, cc, ev); g[0] != evStopped {
504+ t.Fatalf("event %v", g)
505+ }
506+ }
507+ closed := map[uint64]bool{}
508+ for len(closed) < 2 {
509+ closed[nextEvent[ports.CaptureSessionClose](t, h.events).ID] = true
510+ }
511+ if !closed[a.ID] || !closed[b.ID] {
512+ t.Fatalf("closed %v", closed)
513+ }
514+}
515+
516+// workspaceClient binds ext_workspace_manager_v1 on cc and returns the handle
517+// of each workspace announced, by order.
518+func (h *captureHarness) handles(t *testing.T, cc *cclient, want int) []uint32 {
519+ t.Helper()
520+ mgr := bindProtocol(t, cc.c, "ext_workspace_manager_v1")
521+ p := &workspaceEvents{client: cc.c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
522+ p.SetID(mgr)
523+ registerWireProxy(cc.c, p)
524+ var out []uint32
525+ for i := 0; i < 16 && len(out) < want; i++ {
526+ cc.roundtrip(t)
527+ for len(p.events) > 0 {
528+ if v := <-p.events; v[0] == uint32(extws.ExtWorkspaceManagerV1EventWorkspace) {
529+ out = append(out, v[1])
530+ }
531+ }
532+ }
533+ if len(out) != want {
534+ t.Fatalf("%d workspace handles, want %d", len(out), want)
535+ }
536+ return out
537+}
538+
539+func (h *captureHarness) snapshot(t *testing.T, ws ...ports.WorkspaceInfo) {
540+ t.Helper()
541+ h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: ws}}}
542+ deadline := time.Now().Add(2 * time.Second)
543+ for time.Now().Before(deadline) {
544+ got := 0
545+ h.s.display.Do(func() {
546+ for _, o := range h.s.workspaceSnapshot.Outputs {
547+ got += len(o.Workspaces)
548+ }
549+ })
550+ if got == len(ws) {
551+ return
552+ }
553+ time.Sleep(time.Millisecond)
554+ }
555+ t.Fatal("snapshot not applied")
556+}
557+
558+// A workspace source registers the workspace ID, sizes buffers from the
559+// frame (off screen: the whole frame at the output scale), sends new
560+// constraints when core moves or resizes it, and stops at once when the
561+// handle is removed.
562+func TestWorkspaceSourceSessionLifecycle(t *testing.T) {
563+ h := newCaptureHarness(t)
564+ h.snapshot(t,
565+ ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}},
566+ ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{W: 3, H: 2}},
567+ )
568+ cc := h.client(t)
569+ handles := h.handles(t, cc, 2)
570+ // On screen: its frame on the output.
571+ _, evOn := cc.session(t, cc.workspaceSource(t, handles[0]))
572+ if w, hh := constraints(t, cc, evOn); w != 4 || hh != 4 {
573+ t.Fatalf("on-screen size %dx%d", w, hh)
574+ }
575+ if o := h.open(t); o.Workspace != 42 || o.Output != "" {
576+ t.Fatalf("open %+v", o)
577+ }
578+ // Off screen: the whole frame.
579+ sess, ev := cc.session(t, cc.workspaceSource(t, handles[1]))
580+ if w, hh := constraints(t, cc, ev); w != 3 || hh != 2 {
581+ t.Fatalf("off-screen size %dx%d", w, hh)
582+ }
583+ open := h.open(t)
584+ if open.Workspace != 43 {
585+ t.Fatalf("open %+v", open)
586+ }
587+ // Core resizes the frame: new constraints, once.
588+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Workspace: 43, Hidden: true, Active: true})
589+ if w, hh := constraints(t, cc, ev); w != 2 || hh != 2 {
590+ t.Fatalf("resized %dx%d", w, hh)
591+ }
592+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Workspace: 43, Hidden: true, Active: true, Revision: 0})
593+ h.settle(t, cc)
594+ noSession(t, cc, ev)
595+ // A frame asks for the off-screen workspace, whole.
596+ cc.extFrame(t, sess, 2, 2)
597+ r := receiveCapture(t, h.captures)
598+ if r.Workspace != 43 || !r.OffScreen || r.Session != open.ID || r.Region.Dx() != 2 || r.Region.Dy() != 2 {
599+ t.Fatalf("request %+v", r)
600+ }
601+ // Removing workspace 43 stops its session at once; the other lives on.
602+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
603+ if g := nextSession(t, cc, ev); g[0] != evStopped {
604+ t.Fatalf("event %v", g)
605+ }
606+ if c := nextEvent[ports.CaptureSessionClose](t, h.events); c.ID != open.ID {
607+ t.Fatalf("close %+v", c)
608+ }
609+ noSession(t, cc, evOn)
610+}
611+
612+// A handle that is already removed gives a source whose sessions stop at once.
613+func TestWorkspaceSourceOfRemovedHandleStops(t *testing.T) {
614+ h := newCaptureHarness(t)
615+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
616+ cc := h.client(t)
617+ handles := h.handles(t, cc, 1)
618+ h.snapshot(t)
619+ cc.roundtrip(t)
620+ _, ev := cc.session(t, cc.workspaceSource(t, handles[0]))
621+ if g := nextSession(t, cc, ev); g[0] != evStopped {
622+ t.Fatalf("event %v", g)
623+ }
624+}
625+
626+// The workspace ID survives a configured-name change: same handle, same
627+// decimal id, so a source made before stays valid.
628+func TestWorkspaceSourceSurvivesConfiguredRename(t *testing.T) {
629+ h := newCaptureHarness(t)
630+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
631+ cc := h.client(t)
632+ handles := h.handles(t, cc, 1)
633+ _, ev := cc.session(t, cc.workspaceSource(t, handles[0]))
634+ constraints(t, cc, ev)
635+ h.open(t)
636+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "dev", Configured: "dev", Active: true, Frame: ports.Rect{W: 4, H: 4}})
637+ h.settle(t, cc)
638+ noSession(t, cc, ev)
639+ select {
640+ case e := <-h.events:
641+ t.Fatalf("event %+v after a rename", e)
642+ default:
643+ }
644+}
645+
646+// frameEvents decodes the events of a neferwl_workspace_frame_v1.
647+type frameEvents struct {
648+ wlturbo.BaseProxy
649+ rects chan [4]int32
650+}
651+
652+func (p *frameEvents) Dispatch(e *wlturbo.Event) {
653+ p.rects <- [4]int32{e.Int32(), e.Int32(), e.Int32(), e.Int32()}
654+}
655+
656+// workspaceFrame asks get_workspace_frame for a workspace handle.
657+func (cc *cclient) workspaceFrame(t *testing.T, handle uint32) (uint32, chan [4]int32) {
658+ t.Helper()
659+ id := cc.c.AllocateID()
660+ ev := &frameEvents{rects: make(chan [4]int32, 8)}
661+ ev.SetID(id)
662+ registerWireProxy(cc.c, ev)
663+ requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestGetWorkspaceFrame, id, handle)
664+ cc.roundtrip(t)
665+ return id, ev.rects
666+}
667+
668+func wantFrame(t *testing.T, ch chan [4]int32, want [4]int32) {
669+ t.Helper()
670+ select {
671+ case got := <-ch:
672+ if got != want {
673+ t.Fatalf("frame %v, want %v", got, want)
674+ }
675+ default:
676+ t.Fatalf("no frame, want %v", want)
677+ }
678+}
679+
680+func noFrame(t *testing.T, ch chan [4]int32) {
681+ t.Helper()
682+ select {
683+ case got := <-ch:
684+ t.Fatalf("unexpected frame %v", got)
685+ default:
686+ }
687+}
688+
689+// A workspace frame object reports the frame when it is created and each time
690+// it changes, for every workspace, whether or not it is on screen; it reports
691+// nothing for a removed handle and stops after a rename replaced the handle.
692+func TestWorkspaceFrameEvents(t *testing.T) {
693+ h := newCaptureHarness(t)
694+ h.snapshot(t,
695+ ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}},
696+ ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{W: 3, H: 2}},
697+ )
698+ cc := h.client(t)
699+ handles := h.handles(t, cc, 2)
700+ _, on := cc.workspaceFrame(t, handles[0])
701+ wantFrame(t, on, [4]int32{1, 0, 2, 4})
702+ _, off := cc.workspaceFrame(t, handles[1])
703+ wantFrame(t, off, [4]int32{0, 0, 3, 2})
704+ // Unchanged snapshot: nothing. A change: once, to the right object.
705+ h.snapshot(t,
706+ ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}},
707+ ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{X: 1, Y: 1, W: 2, H: 2}},
708+ )
709+ h.settle(t, cc)
710+ noFrame(t, on)
711+ wantFrame(t, off, [4]int32{1, 1, 2, 2})
712+ noFrame(t, off)
713+ // Removal sends nothing; a handle removed before the request sends nothing.
714+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}})
715+ h.settle(t, cc)
716+ noFrame(t, off)
717+ _, gone := cc.workspaceFrame(t, handles[1])
718+ noFrame(t, gone)
719+ // A configured rename replaces the handle: the old object goes quiet.
720+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "dev", Configured: "dev", Active: true, Frame: ports.Rect{W: 4, H: 4}})
721+ h.settle(t, cc)
722+ noFrame(t, on)
723+}
724+
725+// While the session is locked a frame object sends nothing, whether it is
726+// created before the lock or during it; the unlock sends the current frame
727+// once, and only if it changed.
728+func TestWorkspaceFrameSessionLock(t *testing.T) {
729+ // unlock leaves protection the way the session-lock unlock does.
730+ unlock := func(t *testing.T, h *captureHarness, state *atomic.Pointer[ports.SecurityState]) {
731+ t.Helper()
732+ protect(t, h.s, state, 2, false)
733+ if !h.s.display.Do(func() { h.s.updateWorkspaceManagers(h.s.workspaceSnapshot) }) {
734+ t.Fatal("display stopped")
735+ }
736+ }
737+ info := func(r ports.Rect) ports.WorkspaceInfo {
738+ return ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: r}
739+ }
740+ t.Run("change while locked", func(t *testing.T) {
741+ h := newCaptureHarness(t)
742+ h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
743+ cc := h.client(t)
744+ handles := h.handles(t, cc, 1)
745+ _, ev := cc.workspaceFrame(t, handles[0])
746+ wantFrame(t, ev, [4]int32{0, 0, 2, 2})
747+ state := installSecurity(t, h.s)
748+ protect(t, h.s, state, 1, true)
749+ h.snapshot(t, info(ports.Rect{X: 1, Y: 1, W: 3, H: 3}))
750+ h.settle(t, cc)
751+ noFrame(t, ev)
752+ unlock(t, h, state)
753+ cc.roundtrip(t)
754+ wantFrame(t, ev, [4]int32{1, 1, 3, 3})
755+ noFrame(t, ev)
756+ })
757+ t.Run("no change while locked", func(t *testing.T) {
758+ h := newCaptureHarness(t)
759+ h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
760+ cc := h.client(t)
761+ handles := h.handles(t, cc, 1)
762+ _, ev := cc.workspaceFrame(t, handles[0])
763+ wantFrame(t, ev, [4]int32{0, 0, 2, 2})
764+ state := installSecurity(t, h.s)
765+ protect(t, h.s, state, 1, true)
766+ h.snapshot(t, info(ports.Rect{X: 1, Y: 1, W: 3, H: 3}))
767+ h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
768+ h.settle(t, cc)
769+ unlock(t, h, state)
770+ cc.roundtrip(t)
771+ noFrame(t, ev)
772+ })
773+ t.Run("get while locked", func(t *testing.T) {
774+ h := newCaptureHarness(t)
775+ h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
776+ cc := h.client(t)
777+ handles := h.handles(t, cc, 1)
778+ state := installSecurity(t, h.s)
779+ protect(t, h.s, state, 1, true)
780+ _, ev := cc.workspaceFrame(t, handles[0])
781+ h.settle(t, cc)
782+ noFrame(t, ev)
783+ unlock(t, h, state)
784+ cc.roundtrip(t)
785+ wantFrame(t, ev, [4]int32{0, 0, 2, 2})
786+ noFrame(t, ev)
787+ })
788+}
789+
790+// Destroying a frame object drops it from the server's list, and nothing is
791+// sent to it afterwards.
792+func TestWorkspaceFrameDestroyRemovesObject(t *testing.T) {
793+ h := newCaptureHarness(t)
794+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 2, H: 2}})
795+ cc := h.client(t)
796+ handles := h.handles(t, cc, 1)
797+ count := func() (n int) {
798+ h.s.display.Do(func() { n = len(h.s.workspaceFrames) })
799+ return n
800+ }
801+ id, ev := cc.workspaceFrame(t, handles[0])
802+ wantFrame(t, ev, [4]int32{0, 0, 2, 2})
803+ if n := count(); n != 1 {
804+ t.Fatalf("%d frame objects, want 1", n)
805+ }
806+ requestProtocol(t, cc.c, id, imagecapture.NeferwlWorkspaceFrameV1RequestDestroy)
807+ cc.roundtrip(t)
808+ if n := count(); n != 0 {
809+ t.Fatalf("%d frame objects after destroy, want 0", n)
810+ }
811+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 3, H: 3}})
812+ h.settle(t, cc)
813+ noFrame(t, ev)
814+}
815+
816+// exclusion asks get_exclusion on a session and returns the event stream.
817+func (cc *cclient) exclusion(t *testing.T, session uint32) (uint32, chan sessionMsg) {
818+ t.Helper()
819+ id := cc.c.AllocateID()
820+ ev := &exclusionEvents{msgs: make(chan sessionMsg, 8)}
821+ ev.SetID(id)
822+ registerWireProxy(cc.c, ev)
823+ requestProtocol(t, cc.c, cc.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestGetExclusion, id, session)
824+ cc.roundtrip(t)
825+ return id, ev.msgs
826+}
827+
828+// exclusionOwner opens a session and its exclusion, confirmed by core.
829+func (h *captureHarness) exclusionOwner(t *testing.T) (*cclient, uint32, ports.CaptureSessionOpen, chan sessionMsg, string) {
830+ t.Helper()
831+ cc := h.client(t)
832+ session, sev := cc.session(t, cc.outputSource(t))
833+ constraints(t, cc, sev)
834+ open := h.open(t)
835+ _, msgs := cc.exclusion(t, session)
836+ m := waitMsg(t, cc.c, msgs)
837+ if uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventToken || len(m.token) != 64 || strings.Trim(m.token, "0123456789abcdef") != "" {
838+ t.Fatalf("token %+v", m)
839+ }
840+ if b := nextEvent[ports.CaptureExclusionBegin](t, h.events); b.Session != open.ID {
841+ t.Fatalf("begin %+v", b)
842+ }
843+ return cc, session, open, msgs, m.token
844+}
845+
846+// The exclusion answers with a token; a second one is busy, even from
847+// another connection, and is not reported to core.
848+func TestExclusionTokenAndBusy(t *testing.T) {
849+ h := newCaptureHarness(t)
850+ _, _, open, _, _ := h.exclusionOwner(t)
851+ other := h.client(t)
852+ session2, sev2 := other.session(t, other.outputSource(t))
853+ constraints(t, other, sev2)
854+ h.open(t)
855+ _, msgs := other.exclusion(t, session2)
856+ if m := waitMsg(t, other.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureBusy) {
857+ t.Fatalf("second exclusion %+v", m)
858+ }
859+ for len(h.events) > 0 {
860+ if b, ok := (<-h.events).(ports.CaptureExclusionBegin); ok {
861+ t.Fatalf("a refused exclusion reached core: %+v (owner %d)", b, open.ID)
862+ }
863+ }
864+}
865+
866+// get_exclusion on a session that already stopped answers
867+// failed(session_stopped), whatever the reason it stopped.
868+func TestExclusionOnStoppedSession(t *testing.T) {
869+ h := newCaptureHarness(t)
870+ cc := h.client(t)
871+ // Stopped at creation (a region with nothing on the output).
872+ session, ev := cc.session(t, cc.regionSource(t, 100, 100, 5, 5))
873+ if g := nextSession(t, cc, ev); g[0] != evStopped {
874+ t.Fatalf("event %v", g)
875+ }
876+ _, msgs := cc.exclusion(t, session)
877+ if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped) {
878+ t.Fatalf("exclusion %+v", m)
879+ }
880+ // Stopped by core after it was registered.
881+ session2, sev := cc.session(t, cc.outputSource(t))
882+ constraints(t, cc, sev)
883+ open := h.open(t)
884+ h.send(ports.CaptureSessionState{ID: open.ID, Reason: ports.CaptureReasonOutputGone})
885+ if g := nextSession(t, cc, sev); g[0] != evStopped {
886+ t.Fatalf("event %v", g)
887+ }
888+ _, msgs = cc.exclusion(t, session2)
889+ if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped) {
890+ t.Fatalf("exclusion %+v", m)
891+ }
892+ for len(h.events) > 0 {
893+ if b, ok := (<-h.events).(ports.CaptureExclusionBegin); ok {
894+ t.Fatalf("a refused exclusion reached core: %+v", b)
895+ }
896+ }
897+}
898+
899+// One session gets one live exclusion object.
900+func TestExclusionAlreadyExcluded(t *testing.T) {
901+ h := newCaptureHarness(t)
902+ cc, session, _, _, _ := h.exclusionOwner(t)
903+ requestProtocol(t, cc.c, cc.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestGetExclusion, cc.c.AllocateID(), session)
904+ expectProtocolError(t, cc.c, cc.excl, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorAlreadyExcluded))
905+}
906+
907+// Destroying the exclusion ends it in core, detaches its layers and frees
908+// the slot; the session lives on.
909+func TestExclusionDestroyFreesTheSlot(t *testing.T) {
910+ h := newCaptureHarness(t)
911+ cc, session, open, _, _ := h.exclusionOwner(t)
912+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 1})
913+ h.settle(t, cc)
914+ var excl uint32
915+ h.s.display.Do(func() { excl = h.s.excl.res.ID() })
916+ requestProtocol(t, cc.c, excl, imagecapture.NeferwlCaptureExclusionV1RequestDestroy)
917+ cc.roundtrip(t)
918+ if e := nextEvent[ports.CaptureExclusionEnd](t, h.events); e.Session != open.ID {
919+ t.Fatalf("end %+v", e)
920+ }
921+ _, msgs := cc.exclusion(t, session)
922+ if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventToken {
923+ t.Fatalf("new exclusion %+v", m)
924+ }
925+}
926+
927+// Frames of the session that owns the exclusion are fenced: refused until
928+// core knows the exclusion, then tagged with its revision. Every other
929+// capture, wlr-screencopy included, carries no tag.
930+func TestExclusionFrameTagging(t *testing.T) {
931+ h := newCaptureHarness(t)
932+ owner, session, open, _, _ := h.exclusionOwner(t)
933+ // Core has not confirmed the exclusion: the frame fails.
934+ ev := owner.extFrame(t, session, 4, 4)
935+ if !eventsInclude(ev, frameFailed) {
936+ t.Fatal("a frame before core confirmed the exclusion was served")
937+ }
938+ noCapture(t, h.captures)
939+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
940+ h.settle(t, owner)
941+ owner.extFrame(t, session, 4, 4)
942+ r := receiveCapture(t, h.captures)
943+ if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
944+ t.Fatalf("owner frame %+v", r)
945+ }
946+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
947+ // A wlr-screencopy capture, even of the owner's connection, is untagged.
948+ owner.wlrFrame(t)
949+ r = receiveCapture(t, h.captures)
950+ if r.Exclude || r.Session != 0 || r.CaptureRevision != 0 || r.Workspace != 0 {
951+ t.Fatalf("wlr capture tagged: %+v", r)
952+ }
953+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
954+ // So is a plain ext session of another client.
955+ other := h.client(t)
956+ session2, sev := other.session(t, other.outputSource(t))
957+ constraints(t, other, sev)
958+ h.open(t)
959+ other.extFrame(t, session2, 4, 4)
960+ r = receiveCapture(t, h.captures)
961+ if r.Exclude || r.CaptureRevision != 0 {
962+ t.Fatalf("another session's frame tagged: %+v", r)
963+ }
964+}
965+
966+// hud is a second connection: a layer surface it attaches with the token.
967+type hud struct {
968+ *cclient
969+ surface, layer uint32
970+}
971+
972+func (h *captureHarness) hud(t *testing.T, layer ports.Layer, keyboard uint32) *hud {
973+ t.Helper()
974+ return h.hudVersion(t, layer, keyboard, 1)
975+}
976+
977+func (h *captureHarness) hudVersion(t *testing.T, layer ports.Layer, keyboard, shellVersion uint32) *hud {
978+ t.Helper()
979+ pc := h.client(t)
980+ comp := bindProtocol(t, pc.c, "wl_compositor")
981+ shell := bindVersion(t, pc.c, "zwlr_layer_shell_v1", shellVersion)
982+ surf, l := pc.c.AllocateID(), pc.c.AllocateID()
983+ requestProtocol(t, pc.c, comp, wayland.CompositorRequestCreateSurface, surf)
984+ registerProtocol(t, pc.c, surf)
985+ requestProtocol(t, pc.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, pc.output, uint32(layer), "hud")
986+ registerProtocol(t, pc.c, l)
987+ if keyboard != 0 {
988+ requestProtocol(t, pc.c, l, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, keyboard)
989+ }
990+ if err := pc.c.Roundtrip(); err != nil {
991+ t.Fatal(err)
992+ }
993+ return &hud{cclient: pc, surface: surf, layer: l}
994+}
995+
996+// attach sends attach_surface and returns the attachment's event stream.
997+func (hd *hud) attach(t *testing.T, token string) chan sessionMsg {
998+ t.Helper()
999+ return hd.attachSurface(t, token, hd.surface)
1000+}
1001+
1002+func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan sessionMsg {
1003+ t.Helper()
1004+ id := hd.c.AllocateID()
1005+ ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
1006+ ev.SetID(id)
1007+ registerWireProxy(hd.c, ev)
1008+ requestProtocol(t, hd.c, hd.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestAttachSurface, id, token, surface)
1009+ if err := hd.c.Roundtrip(); err != nil {
1010+ t.Fatal(err)
1011+ }
1012+ return ev.msgs
1013+}
1014+
1015+// expectAttachment waits for one attachment event of the given opcode and first argument.
1016+func expectAttachment(t *testing.T, c *wlturbo.Display, ch chan sessionMsg, op uint32, arg ...int64) {
1017+ t.Helper()
1018+ m := waitMsg(t, c, ch)
1019+ if uint32(m.op) != op || (len(arg) > 0 && m.numbers[0] != arg[0]) {
1020+ t.Fatalf("attachment event %+v, want op %d %v", m, op, arg)
1021+ }
1022+}
1023+
1024+// waitMsg waits for a message on ch, dispatching the client meanwhile.
1025+func waitMsg(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) sessionMsg {
1026+ t.Helper()
1027+ deadline := time.Now().Add(2 * time.Second)
1028+ for time.Now().Before(deadline) {
1029+ select {
1030+ case m := <-ch:
1031+ return m
1032+ default:
1033+ }
1034+ if err := c.Roundtrip(); err != nil {
1035+ t.Fatal(err)
1036+ }
1037+ time.Sleep(time.Millisecond)
1038+ }
1039+ t.Fatal("timed out waiting for an event")
1040+ return sessionMsg{}
1041+}
1042+
1043+func noMessage(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) {
1044+ t.Helper()
1045+ if err := c.Roundtrip(); err != nil {
1046+ t.Fatal(err)
1047+ }
1048+ select {
1049+ case m := <-ch:
1050+ t.Fatalf("unexpected event %+v", m)
1051+ default:
1052+ }
1053+}
1054+
1055+func nextEvent[T ports.ClientEvent](t *testing.T, ch chan ports.ClientEvent) T {
1056+ t.Helper()
1057+ for {
1058+ select {
1059+ case ev := <-ch:
1060+ if v, ok := ev.(T); ok {
1061+ return v
1062+ }
1063+ case <-time.After(2 * time.Second):
1064+ t.Fatal("timed out waiting for a client event")
1065+ }
1066+ }
1067+}
1068+
1069+// Attach works from another connection with the token, reaches core before
1070+// any content, and is confirmed to the client only once core lists the layer.
1071+func TestExclusionAttach(t *testing.T) {
1072+ h := newCaptureHarness(t)
1073+ _, _, open, _, token := h.exclusionOwner(t)
1074+ hd := h.hud(t, ports.LayerOverlay, 2)
1075+ // A wrong token fails that attachment without touching the exclusion.
1076+ bad := hd.attach(t, strings.Repeat("0", 64))
1077+ expectAttachment(t, hd.c, bad, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnknownToken))
1078+ good := hd.attach(t, token)
1079+ layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1080+ if layer.Session != open.ID || !layer.Attached || layer.Layer == 0 {
1081+ t.Fatalf("layer %+v", layer)
1082+ }
1083+ noMessage(t, hd.c, good) // not confirmed yet
1084+ st := active(open.ID)
1085+ st.Exclusion, st.Revision = true, 2
1086+ h.send(st)
1087+ noMessage(t, hd.c, good) // a state that does not list the layer confirms nothing
1088+ st.Revision, st.Layers = 3, []ports.WindowID{layer.Layer}
1089+ h.send(st)
1090+ expectAttachment(t, hd.c, good, imagecapture.NeferwlCaptureLayerV1EventAttached)
1091+ // Attaching the same surface again fails on its own object.
1092+ again := hd.attach(t, token)
1093+ expectAttachment(t, hd.c, again, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureAlreadyAttached))
1094+ // Destroying the layer detaches it: LayerChanged (the unmap) first, then
1095+ // the detach, on the same ordered channel.
1096+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
1097+ if err := hd.c.Roundtrip(); err != nil {
1098+ t.Fatal(err)
1099+ }
1100+ expectAttachment(t, hd.c, good, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
1101+ if l := nextEvent[ports.CaptureExclusionLayer](t, h.events); l.Attached || l.Layer != layer.Layer {
1102+ t.Fatalf("detach %+v", l)
1103+ }
1104+}
1105+
1106+// Once the exclusion ended the token is dead.
1107+func TestExclusionTokenDeadAfterEnd(t *testing.T) {
1108+ h := newCaptureHarness(t)
1109+ owner, session, _, _, token := h.exclusionOwner(t)
1110+ requestProtocol(t, owner.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
1111+ owner.roundtrip(t)
1112+ hd := h.hud(t, ports.LayerOverlay, 0)
1113+ att := hd.attach(t, token)
1114+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnknownToken))
1115+}
1116+
1117+// Order on the event channel: a mapped attached layer that is destroyed
1118+// queues its LayerChanged removal before the detach reaches core.
1119+func TestExclusionDetachOrdering(t *testing.T) {
1120+ h := newCaptureHarness(t)
1121+ _, _, open, _, token := h.exclusionOwner(t)
1122+ hd := h.hud(t, ports.LayerTop, 0)
1123+ att := hd.attach(t, token)
1124+ layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1125+ st := active(open.ID)
1126+ st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1127+ h.send(st)
1128+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1129+ mapLayer(t, hd)
1130+ if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
1131+ t.Fatalf("mapped %+v", l)
1132+ }
1133+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
1134+ if err := hd.c.Roundtrip(); err != nil {
1135+ t.Fatal(err)
1136+ }
1137+ var order []string
1138+ for len(order) < 2 {
1139+ switch v := (<-h.events).(type) {
1140+ case ports.LayerChanged:
1141+ if len(v.Layers) == 0 {
1142+ order = append(order, "unmap")
1143+ }
1144+ case ports.CaptureExclusionLayer:
1145+ if !v.Attached {
1146+ order = append(order, "detach")
1147+ }
1148+ }
1149+ }
1150+ if order[0] != "unmap" || order[1] != "detach" {
1151+ t.Fatalf("order %v: the exclusion must outlive the layer's last frame", order)
1152+ }
1153+}
1154+
1155+// mapLayer configures, acks and commits a buffer on the hud's layer.
1156+func mapLayer(t *testing.T, hd *hud) {
1157+ t.Helper()
1158+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
1159+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1160+ cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
1161+ cfg.SetID(hd.layer)
1162+ registerWireProxy(hd.c, cfg)
1163+ if err := hd.c.Roundtrip(); err != nil {
1164+ t.Fatal(err)
1165+ }
1166+ c := <-cfg.configured
1167+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, c[0])
1168+ _, buf, fd := captureSmallBuffer(t, hd.c)
1169+ t.Cleanup(func() { _ = unix.Close(fd) })
1170+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
1171+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1172+ if err := hd.c.Roundtrip(); err != nil {
1173+ t.Fatal(err)
1174+ }
1175+}
1176+
1177+// Ending the exclusion (here with its session) detaches every attachment,
1178+// closes the layer surfaces (so none lingers into the next exclusion) and
1179+// removes them before core is told the session closed.
1180+func TestExclusionEndClosesLayers(t *testing.T) {
1181+ h := newCaptureHarness(t)
1182+ owner, session, open, _, token := h.exclusionOwner(t)
1183+ hd := h.hud(t, ports.LayerOverlay, 0)
1184+ closed := make(chan sessionMsg, 4)
1185+ lp := &layerClosedProxy{msgs: closed}
1186+ lp.SetID(hd.layer)
1187+ registerWireProxy(hd.c, lp)
1188+ confirmed := hd.attach(t, token)
1189+ layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1190+ st := active(open.ID)
1191+ st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1192+ h.send(st)
1193+ expectAttachment(t, hd.c, confirmed, imagecapture.NeferwlCaptureLayerV1EventAttached)
1194+ pending := hd.attachSurfaceNew(t, token)
1195+ requestProtocol(t, owner.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
1196+ owner.roundtrip(t)
1197+ expectAttachment(t, hd.c, confirmed, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonExclusionEnded))
1198+ expectAttachment(t, hd.c, pending, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureExclusionEnded))
1199+ if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1200+ t.Fatalf("layer event %+v", m)
1201+ }
1202+ if e := nextEvent[ports.CaptureSessionClose](t, h.events); e.ID != open.ID {
1203+ t.Fatalf("close %+v", e)
1204+ }
1205+}
1206+
1207+type layerClosedProxy struct {
1208+ wlturbo.BaseProxy
1209+ msgs chan sessionMsg
1210+}
1211+
1212+func (p *layerClosedProxy) Dispatch(e *wlturbo.Event) {
1213+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1214+ p.msgs <- sessionMsg{op: e.Opcode}
1215+ }
1216+}
1217+
1218+// attachSurfaceNew attaches a second, fresh layer surface of the hud and returns its stream.
1219+func (hd *hud) attachSurfaceNew(t *testing.T, token string) chan sessionMsg {
1220+ t.Helper()
1221+ comp := bindProtocol(t, hd.c, "wl_compositor")
1222+ shell := bindProtocol(t, hd.c, "zwlr_layer_shell_v1")
1223+ surf, l := hd.c.AllocateID(), hd.c.AllocateID()
1224+ requestProtocol(t, hd.c, comp, wayland.CompositorRequestCreateSurface, surf)
1225+ registerProtocol(t, hd.c, surf)
1226+ requestProtocol(t, hd.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, hd.output, uint32(ports.LayerTop), "hud2")
1227+ registerProtocol(t, hd.c, l)
1228+ return hd.attachSurface(t, token, surf)
1229+}
1230+
1231+func TestExclusionAttachRefusals(t *testing.T) {
1232+ for _, tc := range []struct {
1233+ name string
1234+ layer ports.Layer
1235+ keyboard uint32
1236+ code uint32
1237+ }{
1238+ {"background layer", ports.LayerBackground, 0, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidLayer)},
1239+ {"bottom layer", ports.LayerBottom, 0, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidLayer)},
1240+ {"exclusive keyboard", ports.LayerTop, 1, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorExclusiveKeyboard)},
1241+ } {
1242+ t.Run(tc.name, func(t *testing.T) {
1243+ h := newCaptureHarness(t)
1244+ _, _, _, _, token := h.exclusionOwner(t)
1245+ hd := h.hud(t, tc.layer, tc.keyboard)
1246+ requestProtocol(t, hd.c, hd.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestAttachSurface, hd.c.AllocateID(), token, hd.surface)
1247+ expectProtocolError(t, hd.c, hd.excl, tc.code)
1248+ for len(h.events) > 0 {
1249+ if l, ok := (<-h.events).(ports.CaptureExclusionLayer); ok {
1250+ t.Fatalf("a refused attach reached core: %+v", l)
1251+ }
1252+ }
1253+ })
1254+ }
1255+}
1256+
1257+func TestExclusionAttachLimit(t *testing.T) {
1258+ h := newCaptureHarness(t)
1259+ _, _, _, _, token := h.exclusionOwner(t)
1260+ hd := h.hud(t, ports.LayerTop, 0)
1261+ hd.attach(t, token)
1262+ for i := 1; i < ports.MaxExclusionLayers; i++ {
1263+ hd.attachSurfaceNew(t, token)
1264+ }
1265+ over := hd.attachSurfaceNew(t, token)
1266+ expectAttachment(t, hd.c, over, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers))
1267+ n := 0
1268+ for len(h.events) > 0 {
1269+ if l, ok := (<-h.events).(ports.CaptureExclusionLayer); ok && l.Attached {
1270+ n++
1271+ }
1272+ }
1273+ if n != ports.MaxExclusionLayers {
1274+ t.Fatalf("%d layers reached core, want %d", n, ports.MaxExclusionLayers)
1275+ }
1276+}
1277+
1278+// An attached layer that commits a layer below top is closed and detached
1279+// once, and its unmap reaches core before the detach.
1280+func TestExclusionLayerMovedBelowTop(t *testing.T) {
1281+ h := newCaptureHarness(t)
1282+ _, _, open, _, token := h.exclusionOwner(t)
1283+ hd := h.hudVersion(t, ports.LayerTop, 0, 2)
1284+ att := hd.attach(t, token)
1285+ layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1286+ st := active(open.ID)
1287+ st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1288+ h.send(st)
1289+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1290+ mapLayer(t, hd)
1291+ if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
1292+ t.Fatalf("mapped %+v", l)
1293+ }
1294+ closed := make(chan sessionMsg, 4)
1295+ lp := &layerClosedProxy{msgs: closed}
1296+ lp.SetID(hd.layer)
1297+ registerWireProxy(hd.c, lp)
1298+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
1299+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1300+ if err := hd.c.Roundtrip(); err != nil {
1301+ t.Fatal(err)
1302+ }
1303+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
1304+ if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1305+ t.Fatalf("layer event %+v", m)
1306+ }
1307+ noMessage(t, hd.c, att) // detached exactly once
1308+ var order []string
1309+ for len(order) < 2 {
1310+ switch v := (<-h.events).(type) {
1311+ case ports.LayerChanged:
1312+ if len(v.Layers) == 0 {
1313+ order = append(order, "unmap")
1314+ }
1315+ case ports.CaptureExclusionLayer:
1316+ if !v.Attached {
1317+ order = append(order, "detach")
1318+ }
1319+ }
1320+ }
1321+ if order[0] != "unmap" || order[1] != "detach" {
1322+ t.Fatalf("order %v", order)
1323+ }
1324+}
1325+
1326+// An attached layer asking for keyboard interactivity, before or after the
1327+// attach, is never reported with it to core.
1328+func TestExclusionLayerKeyboardNone(t *testing.T) {
1329+ h := newCaptureHarness(t)
1330+ _, _, open, _, token := h.exclusionOwner(t)
1331+ hd := h.hud(t, ports.LayerOverlay, 2) // on demand before the attach
1332+ att := hd.attach(t, token)
1333+ layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1334+ st := active(open.ID)
1335+ st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1336+ h.send(st)
1337+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1338+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, uint32(2)) // and after
1339+ mapLayer(t, hd)
1340+ l := nextEvent[ports.LayerChanged](t, h.events)
1341+ if len(l.Layers) != 1 || l.Layers[0].Keyboard != 0 {
1342+ t.Fatalf("layer reported with keyboard: %+v", l.Layers)
1343+ }
1344+}
1345+
1346+// frameTaken waits for the next CaptureFrameTaken core is told of.
1347+func (h *captureHarness) frameTaken(t *testing.T) ports.CaptureFrameTaken {
1348+ t.Helper()
1349+ return nextEvent[ports.CaptureFrameTaken](t, h.events)
1350+}
1351+
1352+// Every capture is reported to core, which shows the indicator for it: a
1353+// wlr-screencopy frame (no session) and a frame of each kind of ext source,
1354+// with the target the client asked for.
1355+func TestEveryCaptureFrameIsReportedToCore(t *testing.T) {
1356+ h := newCaptureHarness(t)
1357+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
1358+ cc := h.client(t)
1359+ // wlr-screencopy, whole output.
1360+ cc.wlrFrame(t)
1361+ r := receiveCapture(t, h.captures)
1362+ if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Output: "HEADLESS-1"}) {
1363+ t.Fatalf("wlr frame reported %+v", got)
1364+ }
1365+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1366+ if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Output: "HEADLESS-1"}) {
1367+ t.Fatalf("wlr completion reported %+v", got)
1368+ }
1369+ // ext output source.
1370+ session, ev := cc.session(t, cc.outputSource(t))
1371+ constraints(t, cc, ev)
1372+ open := h.open(t)
1373+ cc.extFrame(t, session, 4, 4)
1374+ receiveCapture(t, h.captures)
1375+ if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Output: "HEADLESS-1"}) {
1376+ t.Fatalf("ext output frame reported %+v", got)
1377+ }
1378+ // ext region source: the logical rectangle as asked.
1379+ session, ev = cc.session(t, cc.regionSource(t, 1, 1, 2, 2))
1380+ constraints(t, cc, ev)
1381+ open = h.open(t)
1382+ cc.extFrame(t, session, 2, 2)
1383+ receiveCapture(t, h.captures)
1384+ if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Output: "HEADLESS-1", Region: ports.Rect{X: 1, Y: 1, W: 2, H: 2}}) {
1385+ t.Fatalf("ext region frame reported %+v", got)
1386+ }
1387+ // ext workspace source.
1388+ handles := h.handles(t, cc, 1)
1389+ session, ev = cc.session(t, cc.workspaceSource(t, handles[0]))
1390+ constraints(t, cc, ev)
1391+ open = h.open(t)
1392+ cc.extFrame(t, session, 4, 4)
1393+ receiveCapture(t, h.captures)
1394+ if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Workspace: 42}) {
1395+ t.Fatalf("ext workspace frame reported %+v", got)
1396+ }
1397+}
1398+
1399+// Every capture request asks the output owner to hold it until the indicator
1400+// is on screen, whatever protocol or source made it.
1401+func TestEveryCaptureRequestAsksForItsIndicator(t *testing.T) {
1402+ h := newCaptureHarness(t)
1403+ h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
1404+ cc := h.client(t)
1405+ cc.wlrFrame(t)
1406+ if r := receiveCapture(t, h.captures); !r.Indicate {
1407+ t.Fatalf("wlr request %+v", r)
1408+ }
1409+ session, ev := cc.session(t, cc.outputSource(t))
1410+ constraints(t, cc, ev)
1411+ h.open(t)
1412+ cc.extFrame(t, session, 4, 4)
1413+ if r := receiveCapture(t, h.captures); !r.Indicate {
1414+ t.Fatalf("ext output request %+v", r)
1415+ }
1416+ handles := h.handles(t, cc, 1)
1417+ session, ev = cc.session(t, cc.workspaceSource(t, handles[0]))
1418+ constraints(t, cc, ev)
1419+ h.open(t)
1420+ cc.extFrame(t, session, 4, 4)
1421+ if r := receiveCapture(t, h.captures); !r.Indicate || r.Workspace != 42 {
1422+ t.Fatalf("ext workspace request %+v", r)
1423+ }
1424+}
1425+
1426+// A capture the compositor refuses is not reported: nothing was captured.
1427+func TestRefusedCaptureIsNotReported(t *testing.T) {
1428+ h := newCaptureHarness(t)
1429+ cc := h.client(t)
1430+ session, ev := cc.session(t, cc.outputSource(t))
1431+ constraints(t, cc, ev)
1432+ h.open(t)
1433+ // A wrong-sized buffer is refused before it reaches the renderer.
1434+ cc.extFrame(t, session, 2, 2)
1435+ noCapture(t, h.captures)
1436+ h.settle(t, cc)
1437+ for len(h.events) > 0 {
1438+ if v, ok := (<-h.events).(ports.CaptureFrameTaken); ok {
1439+ t.Fatalf("refused capture reported: %+v", v)
1440+ }
1441+ }
1442+}
1443+
1444+// logicalRegion gives core the logical rectangle of a physical region.
1445+func TestLogicalRegion(t *testing.T) {
1446+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 8, Height: 8}, Scale: 2}}
1447+ for _, tc := range []struct {
1448+ phys image.Rectangle
1449+ want ports.Rect
1450+ }{
1451+ {image.Rect(0, 0, 8, 8), ports.Rect{}},
1452+ {image.Rect(2, 2, 6, 4), ports.Rect{X: 1, Y: 1, W: 2, H: 1}},
1453+ {image.Rect(1, 1, 5, 3), ports.Rect{X: 0, Y: 0, W: 3, H: 2}}, // rounded outwards
1454+ } {
1455+ if got := logicalRegion(o, tc.phys); got != tc.want {
1456+ t.Errorf("%v: %+v, want %+v", tc.phys, got, tc.want)
1457+ }
1458+ }
1459+}
A internal/adapters/wayland/imagecopy.go +525 -0
@@ -0,0 +1,525 @@
1+package wayland
2+
3+import (
4+ "image"
5+ "math"
6+
7+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
8+ "github.com/bnema/neferwl/internal/ports"
9+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
10+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
11+ extworkspace "github.com/bnema/purego-libwayland/protocol/extworkspace"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/purego-libwayland/server"
14+)
15+
16+// ext-image-copy-capture with three kinds of source: an output (the standard
17+// ext-image-capture-source), and the workspace and output-region sources of
18+// neferwl_image_capture_source_manager_v1. Every session is registered with
19+// core (ports.CaptureSessionOpen), which decides its target, tells when it
20+// ends and draws the capture indicator while it lives; see ports/capture.go.
21+
22+type sourceKind int
23+
24+const (
25+ srcDead sourceKind = iota // output or workspace gone: sessions stop at once
26+ srcOutput // a whole output
27+ srcRegion // a logical rectangle of an output
28+ srcWorkspace // a workspace frame
29+)
30+
31+// captureSource is the state behind one ext_image_capture_source_v1.
32+type captureSource struct {
33+ kind sourceKind
34+ output *output
35+ region image.Rectangle // output-local logical, srcRegion
36+ workspace uint64
37+}
38+
39+func registerImageCopy(d *server.Display, s *Server) error {
40+ if err := source.NewExtOutputImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
41+ _, _ = source.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputCaptureSource{s})
42+ }); err != nil {
43+ return err
44+ }
45+ if err := imagecapture.NewNeferwlImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
46+ _, _ = imagecapture.NewNeferwlImageCaptureSourceManagerV1(c, int32(v), id, extraSources{s})
47+ }); err != nil {
48+ return err
49+ }
50+ return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
51+ _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
52+ })
53+}
54+
55+type captureSourceHandler struct{}
56+
57+func (captureSourceHandler) Destroy(*source.ExtImageCaptureSourceV1) {}
58+
59+// newSource makes the ext_image_capture_source_v1 of a source.
60+func (s *Server) newSource(c server.Client, id uint32, src *captureSource) {
61+ res, err := source.NewExtImageCaptureSourceV1(c, 1, id, captureSourceHandler{})
62+ if err != nil {
63+ return
64+ }
65+ s.captureSources[res.Resource] = src
66+ res.OnDestroy = func() { delete(s.captureSources, res.Resource) }
67+}
68+
69+type outputCaptureSource struct{ s *Server }
70+
71+func (outputCaptureSource) Destroy(*source.ExtOutputImageCaptureSourceManagerV1) {}
72+func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) {
73+ src := &captureSource{kind: srcDead}
74+ if o := m.s.outputOf(out); o != nil {
75+ src = &captureSource{kind: srcOutput, output: o}
76+ }
77+ m.s.newSource(r.Client(), id, src)
78+}
79+
80+type extraSources struct{ s *Server }
81+
82+func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {}
83+func (m extraSources) CreateWorkspaceSource(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, ws *extworkspace.ExtWorkspaceHandleV1) {
84+ src := &captureSource{kind: srcDead}
85+ if ws != nil {
86+ if wid, ok := m.s.workspaceIDOf(ws.Resource); ok {
87+ src = &captureSource{kind: srcWorkspace, workspace: wid}
88+ }
89+ }
90+ m.s.newSource(r.Client(), id, src)
91+}
92+func (m extraSources) CreateOutputRegionSource(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, out *wayland.Output, x, y, w, h int32) {
93+ if w <= 0 || h <= 0 {
94+ r.PostError(uint32(imagecapture.NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion), "width or height is not positive")
95+ return
96+ }
97+ src := &captureSource{kind: srcDead}
98+ if o := m.s.outputOf(out); o != nil {
99+ src = &captureSource{kind: srcRegion, output: o, region: image.Rect(int(x), int(y), int64Clamp(int64(x)+int64(w)), int64Clamp(int64(y)+int64(h)))}
100+ }
101+ m.s.newSource(r.Client(), id, src)
102+}
103+
104+func (m extraSources) GetWorkspaceFrame(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, ws *extworkspace.ExtWorkspaceHandleV1) {
105+ m.s.newWorkspaceFrame(r.Client(), id, ws)
106+}
107+
108+// workspaceIDOf is the workspace behind a handle of any ext-workspace manager.
109+func (s *Server) workspaceIDOf(res *server.Resource) (uint64, bool) {
110+ for _, m := range s.workspaceManagers {
111+ for id, h := range m.handles {
112+ if h.res.Resource == res {
113+ return id, true
114+ }
115+ }
116+ }
117+ return 0, false
118+}
119+
120+// workspaceInfo finds a workspace of the last snapshot core sent.
121+func (s *Server) workspaceInfo(id uint64) (output string, w ports.WorkspaceInfo, ok bool) {
122+ for _, out := range s.workspaceSnapshot.Outputs {
123+ for _, w := range out.Workspaces {
124+ if w.ID == id {
125+ return out.Name, w, true
126+ }
127+ }
128+ }
129+ return "", ports.WorkspaceInfo{}, false
130+}
131+
132+type copyCaptureManager struct{ s *Server }
133+
134+func (copyCaptureManager) Destroy(*ext.ExtImageCopyCaptureManagerV1) {}
135+func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, src *source.ExtImageCaptureSourceV1, options uint32) {
136+ s := m.s
137+ if options&^1 != 0 {
138+ r.PostError(uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption), "invalid capture option")
139+ return
140+ }
141+ var cs *captureSource
142+ if src != nil {
143+ cs = s.captureSources[src.Resource]
144+ }
145+ state := &captureSession{s: s, src: cs, cursor: options&1 != 0}
146+ res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
147+ if err != nil {
148+ return
149+ }
150+ state.res = res
151+ s.captureSessions[state] = struct{}{}
152+ // Frames outlive their session (the spec): each frame drops its own reply.
153+ res.OnDestroy = func() { state.end(false, true) }
154+ if cs == nil || cs.kind == srcDead || !s.mayCapture(r.Client()) {
155+ state.end(true, false)
156+ return
157+ }
158+ if _, _, _, ok := state.geometry(); !ok {
159+ state.end(true, false)
160+ return
161+ }
162+ s.nextSession++
163+ state.id = s.nextSession
164+ s.sessionsByID[state.id] = state
165+ open := ports.CaptureSessionOpen{ID: state.id}
166+ switch cs.kind {
167+ case srcOutput:
168+ open.Output = cs.output.name()
169+ case srcRegion:
170+ open.Output, open.Region = cs.output.name(), ports.Rect{X: cs.region.Min.X, Y: cs.region.Min.Y, W: cs.region.Dx(), H: cs.region.Dy()}
171+ case srcWorkspace:
172+ open.Workspace = cs.workspace
173+ }
174+ s.emit(open)
175+ state.sendConstraints()
176+}
177+
178+func (m copyCaptureManager) CreatePointerCursorSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, _ *source.ExtImageCaptureSourceV1, _ *wayland.Pointer) {
179+ state := &cursorCaptureSession{}
180+ res, err := ext.NewExtImageCopyCaptureCursorSessionV1(r.Client(), 1, id, state)
181+ if err == nil {
182+ state.res = res
183+ }
184+}
185+
186+type cursorCaptureSession struct {
187+ res *ext.ExtImageCopyCaptureCursorSessionV1
188+ used bool
189+}
190+
191+func (*cursorCaptureSession) Destroy(*ext.ExtImageCopyCaptureCursorSessionV1) {}
192+func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCursorSessionV1, id uint32) {
193+ if c.used {
194+ c.res.PostError(uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession), "session already created")
195+ return
196+ }
197+ c.used = true
198+ state := &captureSession{stopped: true}
199+ res, err := ext.NewExtImageCopyCaptureSessionV1(c.res.Client(), 1, id, state)
200+ if err == nil {
201+ state.res = res
202+ res.SendStopped()
203+ }
204+}
205+
206+// captureSession is one ext_image_copy_capture_session_v1.
207+type captureSession struct {
208+ s *Server
209+ res *ext.ExtImageCopyCaptureSessionV1
210+ src *captureSource
211+ id uint64 // registered with core; 0 when never
212+ cursor, stopped bool
213+ frame *captureExtFrame
214+ sent image.Point // buffer size of the last constraints
215+ // st is the last decision of core; haveState tells there is one.
216+ st ports.CaptureSessionState
217+ haveState bool
218+ excl *exclusion // the live exclusion object of this session
219+ // exclSeen: the last state of core knew an exclusion. Frames of an
220+ // excluded session leave the HUD out while it is (see tag).
221+ exclSeen bool
222+}
223+
224+func (*captureSession) Destroy(*ext.ExtImageCopyCaptureSessionV1) {}
225+
226+// end stops the session once. send tells the client (stopped), notify core
227+// (which has to forget it unless it decided the end itself).
228+func (c *captureSession) end(send, notify bool) {
229+ if c.stopped {
230+ return
231+ }
232+ c.stopped = true
233+ if send && c.res.Alive() {
234+ c.res.SendStopped()
235+ }
236+ if c.excl != nil {
237+ c.excl.end(false)
238+ }
239+ if c.id != 0 {
240+ delete(c.s.sessionsByID, c.id)
241+ if notify {
242+ c.s.emit(ports.CaptureSessionClose{ID: c.id})
243+ }
244+ }
245+ delete(c.s.captureSessions, c)
246+}
247+
248+// geometry locates the session's frames now: the output, the physical region
249+// of the output they cover (for a workspace that is not on screen, the whole
250+// image of its off-screen render, from the origin) and whether the workspace
251+// is off screen. Core's last state decides where a workspace is; before it,
252+// the last workspace snapshot does.
253+func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok bool) {
254+ src := c.src
255+ if src == nil {
256+ return nil, image.Rectangle{}, false, false
257+ }
258+ switch src.kind {
259+ case srcOutput:
260+ o = src.output
261+ if o == nil || c.s.outputByNameExact(o.name()) != o {
262+ return nil, image.Rectangle{}, false, false
263+ }
264+ return o, image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height), false, true
265+ case srcRegion:
266+ o = src.output
267+ if o == nil || c.s.outputByNameExact(o.name()) != o {
268+ return nil, image.Rectangle{}, false, false
269+ }
270+ rect = captureRegion(o, src.region)
271+ return o, rect, false, !rect.Empty()
272+ case srcWorkspace:
273+ var name string
274+ var frame ports.Rect
275+ if c.haveState && c.st.Reason == ports.CaptureReasonNone {
276+ name, frame, hidden = c.st.Output, c.st.Rect, c.st.Hidden
277+ } else {
278+ out, w, found := c.s.workspaceInfo(src.workspace)
279+ if !found {
280+ return nil, image.Rectangle{}, false, false
281+ }
282+ name, frame, hidden = out, w.Frame, !w.Active
283+ }
284+ o = c.s.outputByNameExact(name)
285+ if o == nil || frame.W <= 0 || frame.H <= 0 {
286+ return nil, image.Rectangle{}, false, false
287+ }
288+ if hidden {
289+ scale := o.place.Scale
290+ if scale <= 0 {
291+ scale = 1
292+ }
293+ // The size of the off-screen child image (capture.childSize).
294+ return o, image.Rect(0, 0, int(math.Ceil(float64(frame.W)*scale)), int(math.Ceil(float64(frame.H)*scale))), true, true
295+ }
296+ rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H))
297+ return o, rect, false, !rect.Empty()
298+ }
299+ return nil, image.Rectangle{}, false, false
300+}
301+
302+// sendConstraints sends one complete constraint batch: size, formats, done.
303+func (c *captureSession) sendConstraints() {
304+ _, rect, _, ok := c.geometry()
305+ if !ok || !c.res.Alive() || c.s.protected() {
306+ return
307+ }
308+ c.sent = image.Pt(rect.Dx(), rect.Dy())
309+ c.res.SendBufferSize(uint32(rect.Dx()), uint32(rect.Dy()))
310+ c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
311+ c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
312+ c.res.SendDone()
313+}
314+
315+// refresh follows the target: new constraints when the buffer size changed,
316+// the end when nothing is left of it.
317+func (c *captureSession) refresh() {
318+ if c.stopped || c.id == 0 {
319+ return
320+ }
321+ if c.src.kind == srcWorkspace {
322+ // The workspace left the snapshot: its handle is removed, stop at once
323+ // without waiting for core's state.
324+ if _, _, found := c.s.workspaceInfo(c.src.workspace); !found {
325+ c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end")
326+ c.end(true, true)
327+ return
328+ }
329+ }
330+ _, rect, _, ok := c.geometry()
331+ switch {
332+ case !ok && c.src.kind != srcWorkspace:
333+ c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")
334+ c.end(true, true)
335+ case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:
336+ c.sendConstraints()
337+ }
338+}
339+
340+// refreshSessions runs when outputs, scales or workspaces changed.
341+func (s *Server) refreshSessions() {
342+ for c := range s.captureSessions {
343+ c.refresh()
344+ }
345+}
346+
347+// endCaptureSessions stops every session, and its exclusion with it, when
348+// the session gets protected: a capture session never outlives the lock, and
349+// its client must ask for a new one after the unlock.
350+func (s *Server) endCaptureSessions() {
351+ for c := range s.captureSessions {
352+ if c.id != 0 {
353+ s.log.Info().Uint64("session", c.id).Str("reason", "session-protected").Msg("capture session end")
354+ }
355+ c.end(true, true)
356+ }
357+}
358+
359+// outputGone ends the sessions of an unplugged output without waiting for
360+// core. Workspace sessions follow their workspace to its new output.
361+func (s *Server) outputGone(o *output) {
362+ for c := range s.captureSessions {
363+ if c.src != nil && c.src.output == o && (c.src.kind == srcOutput || c.src.kind == srcRegion) {
364+ s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("capture session end")
365+ c.end(true, true)
366+ }
367+ }
368+}
369+
370+// captureState applies what core decided about a session.
371+func (s *Server) captureState(st ports.CaptureSessionState) {
372+ c := s.sessionsByID[st.ID]
373+ if c == nil {
374+ return
375+ }
376+ if st.Reason.Terminal() {
377+ s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end")
378+ c.end(true, false)
379+ return
380+ }
381+ c.st, c.haveState, c.exclSeen = st, true, st.Exclusion
382+ s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state")
383+ if ex := c.excl; ex != nil {
384+ ex.confirm(st.Layers)
385+ }
386+ c.refresh()
387+}
388+
389+// tag says how a frame of this session is served. A session with an
390+// exclusion gets frames without its HUD, fenced by the revision core last
391+// confirmed; until core knows the exclusion, and while a finished one is
392+// still in the last state, a frame is not served rather than shown with the
393+// HUD in it (ok false).
394+func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
395+ t = captureTag{session: c.id}
396+ if c.src.kind == srcWorkspace {
397+ t.workspace, t.offscreen = c.src.workspace, hidden
398+ }
399+ switch c.src.kind {
400+ case srcOutput:
401+ t.taken = ports.CaptureFrameTaken{Output: c.src.output.name()}
402+ case srcRegion:
403+ r := c.src.region
404+ t.taken = ports.CaptureFrameTaken{Output: c.src.output.name(), Region: ports.Rect{X: r.Min.X, Y: r.Min.Y, W: r.Dx(), H: r.Dy()}}
405+ case srcWorkspace:
406+ t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace}
407+ }
408+ if c.excl != nil && !c.exclSeen {
409+ return t, false
410+ }
411+ if c.exclSeen {
412+ t.exclude, t.revision = true, c.st.Revision
413+ }
414+ return t, true
415+}
416+
417+func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
418+ if c.frame != nil {
419+ r.PostError(uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "frame still alive")
420+ return
421+ }
422+ state := &captureExtFrame{session: c}
423+ res, err := ext.NewExtImageCopyCaptureFrameV1(r.Client(), 1, id, state)
424+ if err == nil {
425+ state.res = res
426+ c.frame = state
427+ res.OnDestroy = func() {
428+ if c.frame == state {
429+ c.frame = nil
430+ }
431+ if c.s != nil {
432+ delete(c.s.captureReplies, state.replyID)
433+ }
434+ }
435+ }
436+}
437+
438+type captureExtFrame struct {
439+ session *captureSession
440+ res *ext.ExtImageCopyCaptureFrameV1
441+ buf *wayland.Buffer
442+ used bool
443+ replyID uint64
444+}
445+
446+func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
447+func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
448+ if f.used {
449+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
450+ return
451+ }
452+ f.buf = b
453+}
454+func (f *captureExtFrame) DamageBuffer(_ *ext.ExtImageCopyCaptureFrameV1, x, y, w, h int32) {
455+ if f.used {
456+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
457+ return
458+ }
459+ if x < 0 || y < 0 || w <= 0 || h <= 0 {
460+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage), "invalid damage")
461+ }
462+}
463+
464+// constraintsFailed fails the frame with buffer_constraints. The buffer was
465+// checked against the geometry computed now; when that differs from the last
466+// constraints the client got (the target moved or rescaled and the refresh has
467+// not run yet), a new batch goes first so the client can retry with the size
468+// the compositor expects.
469+func (f *captureExtFrame) constraintsFailed(region image.Rectangle) {
470+ if c := f.session; image.Pt(region.Dx(), region.Dy()) != c.sent {
471+ c.sendConstraints()
472+ }
473+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
474+}
475+
476+func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
477+ if f.used {
478+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
479+ return
480+ }
481+ f.used = true
482+ c := f.session
483+ if c.stopped || c.src == nil || !c.s.mayCaptureFrame(f.res.Client()) {
484+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
485+ return
486+ }
487+ if f.buf == nil {
488+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
489+ return
490+ }
491+ o, region, hidden, ok := c.geometry()
492+ if !ok || c.haveState && !c.st.Active {
493+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
494+ return
495+ }
496+ tag, ok := c.tag(hidden)
497+ buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
498+ switch {
499+ case !isBuf:
500+ f.constraintsFailed(region)
501+ return
502+ case !ok:
503+ // The exclusion is not confirmed by core yet: nothing is served.
504+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
505+ return
506+ }
507+ f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
508+ if done.Err != nil {
509+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
510+ return
511+ }
512+ f.res.SendTransform(0)
513+ f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
514+ hi, lo, ns := captureTime(done.Time)
515+ f.res.SendPresentationTime(hi, lo, ns)
516+ f.res.SendReady()
517+ }, f.res.Resource, tag)
518+ if !ok {
519+ if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
520+ f.constraintsFailed(region)
521+ } else {
522+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
523+ }
524+ }
525+}
M internal/adapters/wayland/layer.go +2 -2
@@ -31,8 +31,8 @@ type layerSurface struct {
3131 // creation. The surface is closed when it is unplugged.
3232 output *output
3333 closed bool // output gone: commits are ignored until destroy
34- // capture is the private capture session this surface is attached to.
35- capture *privateSession
34+ // capture is the exclusion this surface is attached to.
35+ capture *exclusion
3636 attachment *layerAttachment
3737 }
3838
M internal/adapters/wayland/outputs.go +4 -15
@@ -180,13 +180,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
180180 o.global.Remove()
181181 delete(s.hdrOutputs, o.name())
182182 delete(s.colorIdentity, o.name())
183- for session := range s.captureSessions {
184- if session.o == o && !session.stopped {
185- session.stopped = true
186- session.res.SendStopped()
187- }
188- }
189- s.stopCaptureOnOutput(o.name())
183+ s.outputGone(o)
190184 s.log.Info().Str("output", o.name()).Msg("output removed")
191185 for _, l := range s.layers {
192186 if l.output == o {
@@ -215,7 +209,6 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
215209 continue
216210 }
217211 scaleChanged := o.place.Scale != p.Scale
218- sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height
219212 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
220213 o.place = p
221214 if logicalChanged {
@@ -226,13 +219,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
226219 }
227220 s.lockSurfaceChanged()
228221 }
229- if sizeChanged {
230- for session := range s.captureSessions {
231- if session.o == o && !session.stopped {
232- session.sendConstraints(p.Info.Width, p.Info.Height)
233- }
234- }
235- }
222+ // Buffer sizes follow the mode and the scale (region and workspace
223+ // sources too): sessions send new constraints when theirs changed.
224+ s.refreshSessions()
236225 for _, r := range o.resources {
237226 o.sendAll(r)
238227 }
A internal/adapters/wayland/peer_exe_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 wayland
6+
7+import (
8+ "os"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockpeerExe creates a new instance of mockpeerExe. 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 newMockpeerExe(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockpeerExe {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockpeerExe{}
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+// mockpeerExe is an autogenerated mock type for the peerExe type
37+type mockpeerExe struct {
38+ mock.Mock
39+}
40+
41+type mockpeerExe_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockpeerExe) EXPECT() *mockpeerExe_Expecter {
46+ return &mockpeerExe_Expecter{mock: &_m.Mock}
47+}
48+
49+// Exe provides a mock function for the type mockpeerExe
50+func (_mock *mockpeerExe) 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+// mockpeerExe_Exe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Exe'
82+type mockpeerExe_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 *mockpeerExe_Expecter) Exe(pidfd any) *mockpeerExe_Exe_Call {
89+ return &mockpeerExe_Exe_Call{Call: _e.mock.On("Exe", pidfd)}
90+}
91+
92+func (_c *mockpeerExe_Exe_Call) Run(run func(pidfd *os.File)) *mockpeerExe_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 *mockpeerExe_Exe_Call) Return(exe string, pid int, err error) *mockpeerExe_Exe_Call {
106+ _c.Call.Return(exe, pid, err)
107+ return _c
108+}
109+
110+func (_c *mockpeerExe_Exe_Call) RunAndReturn(run func(pidfd *os.File) (string, int, error)) *mockpeerExe_Exe_Call {
111+ _c.Call.Return(run)
112+ return _c
113+}
114+
115+// Pidfd provides a mock function for the type mockpeerExe
116+func (_mock *mockpeerExe) Pidfd(sockFD int) (*os.File, error) {
117+ ret := _mock.Called(sockFD)
118+
119+ if len(ret) == 0 {
120+ panic("no return value specified for Pidfd")
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(sockFD)
127+ }
128+ if returnFunc, ok := ret.Get(0).(func(int) *os.File); ok {
129+ r0 = returnFunc(sockFD)
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(sockFD)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// mockpeerExe_Pidfd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pidfd'
144+type mockpeerExe_Pidfd_Call struct {
145+ *mock.Call
146+}
147+
148+// Pidfd is a helper method to define mock.On call
149+// - sockFD int
150+func (_e *mockpeerExe_Expecter) Pidfd(sockFD any) *mockpeerExe_Pidfd_Call {
151+ return &mockpeerExe_Pidfd_Call{Call: _e.mock.On("Pidfd", sockFD)}
152+}
153+
154+func (_c *mockpeerExe_Pidfd_Call) Run(run func(sockFD int)) *mockpeerExe_Pidfd_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 *mockpeerExe_Pidfd_Call) Return(file *os.File, err error) *mockpeerExe_Pidfd_Call {
168+ _c.Call.Return(file, err)
169+ return _c
170+}
171+
172+func (_c *mockpeerExe_Pidfd_Call) RunAndReturn(run func(sockFD int) (*os.File, error)) *mockpeerExe_Pidfd_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
M internal/adapters/wayland/release_test.go +2 -2
@@ -144,11 +144,11 @@ func TestKeepHeldUntilChildFenceCompletes(t *testing.T) {
144144 defer p.Close(nil)
145145 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
146146 s := ports.Scene{
147- Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}},
147+ Capture: &ports.SceneCapture{Workspace: 9},
148148 CaptureScene: &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 1}}},
149149 }
150150 p.SubmitHidden(s, map[ports.WindowID]ports.SurfaceContent{1: {Seq: 5}}, []ports.CaptureRequest{{
151- ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2),
151+ ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2),
152152 }})
153153 now := time.Unix(100, 0)
154154 h := heldBuffer{window: 1, after: 5, at: now}
M internal/adapters/wayland/screencopy.go +45 -203
@@ -7,8 +7,6 @@ import (
77 "time"
88
99 "github.com/bnema/neferwl/internal/ports"
10- source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
11- ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
1210 "github.com/bnema/purego-libwayland/protocol/wayland"
1311 wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
1412 "github.com/bnema/purego-libwayland/server"
@@ -43,14 +41,7 @@ func registerCapture(d *server.Display, s *Server) error {
4341 }); err != nil {
4442 return err
4543 }
46- if err := source.NewExtOutputImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
47- _, _ = source.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputCaptureSource{s})
48- }); err != nil {
49- return err
50- }
51- return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
52- _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
53- })
44+ return registerImageCopy(d, s)
5445 }
5546
5647 type screencopyManager struct{ s *Server }
@@ -135,7 +126,7 @@ func (f *screencopyFrame) copy(b *wayland.Buffer, damage bool) {
135126 }
136127 hi, lo, ns := captureTime(done.Time)
137128 f.res.SendReady(hi, lo, ns)
138- }, f.res.Resource)
129+ }, f.res.Resource, captureTag{taken: ports.CaptureFrameTaken{Output: f.o.name(), Region: logicalRegion(f.o, f.region)}})
139130 f.replyID = id
140131 if !ok {
141132 f.res.SendFailed()
@@ -151,13 +142,38 @@ func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, for
151142 return ok && (format == uint32(wayland.ShmFormatArgb8888) || format == uint32(wayland.ShmFormatXrgb8888)) && buf.width == rect.Dx() && buf.height == rect.Dy() && buf.stride >= rect.Dx()*4 && buf.format == format && fileHolds(buf.pool.file, int64(buf.offset)+int64(buf.height-1)*int64(buf.stride)+int64(buf.width)*4)
152143 }
153144
145+// logicalRegion is a physical region of an output as the logical rectangle
146+// core marks: zero for the whole output, else the region's bounds rounded
147+// outwards to logical pixels.
148+func logicalRegion(o *output, phys image.Rectangle) ports.Rect {
149+ scale := o.place.Scale
150+ if scale <= 0 {
151+ scale = 1
152+ }
153+ if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {
154+ return ports.Rect{}
155+ }
156+ x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale))
157+ x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale))
158+ return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
159+}
160+
161+// captureTag says what a capture is for the renderer: the registered session
162+// (0 for wlr-screencopy), the exclusion fence and the workspace. taken is
163+// what core is told of the frame (ports.CaptureFrameTaken): the compositor
164+// shows an indicator for every capture, whatever the client or protocol.
165+type captureTag struct {
166+ session uint64
167+ exclude bool
168+ revision uint64
169+ workspace uint64
170+ offscreen bool
171+ taken ports.CaptureFrameTaken
172+}
173+
154174 // requestCapture duplicates the pool descriptor, so destroying the wl_buffer
155175 // cannot invalidate a capture in flight.
156-func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) {
157- if s.protected() {
158- s.log.Debug().Str("component", "wayland").Msg("capture refused: session protected")
159- return 0, false
160- }
176+func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource, tag captureTag) (uint64, bool) {
161177 if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {
162178 return 0, false
163179 }
@@ -169,9 +185,9 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
169185 s.log.Debug().Str("output", o.name()).Int("inflight", len(s.captureInflight)).Msg("capture refused: too many captures in flight")
170186 return 0, false
171187 }
172- clean, session, revision, allowed := s.captureTag(life, o)
173- if !allowed {
174- s.log.Debug().Str("output", o.name()).Msg("capture refused: private session not active")
188+ // An ext frame (tag.session set) rides on the session's resolution.
189+ if life == nil || (tag.session != 0 && !s.mayCaptureFrame(life.Client())) || (tag.session == 0 && !s.mayCapture(life.Client())) {
190+ s.log.Debug().Str("output", o.name()).Msg("capture refused: client may not capture")
175191 return 0, false
176192 }
177193 buf := s.buffers[b.Resource].(*buffer)
@@ -184,7 +200,14 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
184200 id := s.nextCapture
185201 s.captureInflight[id] = struct{}{}
186202 start := time.Now()
203+ taken := tag.taken
204+ taken.Session = tag.session
187205 s.captureReplies[id] = func(done ports.CaptureDone) {
206+ if done.Err == nil {
207+ // Whatever became of the client's frame object, its buffer holds
208+ // an image now: the indicator lasts CaptureFlash from here.
209+ s.emit(taken)
210+ }
188211 ev := s.log.Debug()
189212 if done.Err != nil {
190213 ev = s.log.Info().Err(done.Err)
@@ -194,10 +217,12 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
194217 reply(done)
195218 }
196219 }
197- req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Clean: clean, Session: session, CaptureRevision: revision}
220+ req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Session: tag.session, Exclude: tag.exclude, CaptureRevision: tag.revision, Workspace: tag.workspace, OffScreen: tag.offscreen, Indicate: true}
198221 select {
199222 case s.channels.Captures <- req:
200223 s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
224+ // The indicator is on screen while the frame is rendered.
225+ s.emit(taken)
201226 return id, true
202227 default:
203228 delete(s.captureReplies, id)
@@ -231,186 +256,3 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
231256 }
232257 }
233258 }
234-
235-type outputCaptureSource struct{ s *Server }
236-
237-func (outputCaptureSource) Destroy(*source.ExtOutputImageCaptureSourceManagerV1) {}
238-func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) {
239- src, err := source.NewExtImageCaptureSourceV1(r.Client(), 1, id, captureSourceHandler{})
240- if err != nil {
241- return
242- }
243- m.s.captureSources[src.Resource] = m.s.outputOf(out)
244- src.OnDestroy = func() { delete(m.s.captureSources, src.Resource) }
245-}
246-
247-type captureSourceHandler struct{}
248-
249-func (captureSourceHandler) Destroy(*source.ExtImageCaptureSourceV1) {}
250-
251-type copyCaptureManager struct{ s *Server }
252-
253-func (copyCaptureManager) Destroy(*ext.ExtImageCopyCaptureManagerV1) {}
254-func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, src *source.ExtImageCaptureSourceV1, options uint32) {
255- var o *output
256- if options&^1 != 0 {
257- r.PostError(uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption), "invalid capture option")
258- return
259- }
260- if src != nil {
261- o = m.s.captureSources[src.Resource]
262- }
263- state := &captureSession{s: m.s, o: o, cursor: options&1 != 0}
264- res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
265- if err != nil {
266- return
267- }
268- state.res = res
269- m.s.captureSessions[state] = struct{}{}
270- // Frames outlive their session (the spec): each frame drops its own reply.
271- res.OnDestroy = func() { delete(m.s.captureSessions, state) }
272- if m.s.protected() || o == nil {
273- res.SendStopped()
274- state.stopped = true
275- return
276- }
277- state.sendConstraints(o.place.Info.Width, o.place.Info.Height)
278-}
279-
280-// sendConstraints sends one complete constraint batch: formats, size, done.
281-func (c *captureSession) sendConstraints(w, h int) {
282- if c.s.protected() {
283- if !c.stopped {
284- c.stopped = true
285- c.res.SendStopped()
286- }
287- return
288- }
289- c.res.SendBufferSize(uint32(w), uint32(h))
290- c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
291- c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
292- c.res.SendDone()
293-}
294-func (m copyCaptureManager) CreatePointerCursorSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, _ *source.ExtImageCaptureSourceV1, _ *wayland.Pointer) {
295- state := &cursorCaptureSession{}
296- res, err := ext.NewExtImageCopyCaptureCursorSessionV1(r.Client(), 1, id, state)
297- if err == nil {
298- state.res = res
299- }
300-}
301-
302-type cursorCaptureSession struct {
303- res *ext.ExtImageCopyCaptureCursorSessionV1
304- used bool
305-}
306-
307-func (*cursorCaptureSession) Destroy(*ext.ExtImageCopyCaptureCursorSessionV1) {}
308-func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCursorSessionV1, id uint32) {
309- if c.used {
310- c.res.PostError(uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession), "session already created")
311- return
312- }
313- c.used = true
314- state := &captureSession{stopped: true}
315- res, err := ext.NewExtImageCopyCaptureSessionV1(c.res.Client(), 1, id, state)
316- if err == nil {
317- state.res = res
318- res.SendStopped()
319- }
320-}
321-
322-type captureSession struct {
323- s *Server
324- o *output
325- res *ext.ExtImageCopyCaptureSessionV1
326- cursor, stopped bool
327- frame *captureExtFrame
328-}
329-
330-func (*captureSession) Destroy(*ext.ExtImageCopyCaptureSessionV1) {}
331-func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
332- if c.frame != nil {
333- r.PostError(uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "frame still alive")
334- return
335- }
336- state := &captureExtFrame{session: c}
337- res, err := ext.NewExtImageCopyCaptureFrameV1(r.Client(), 1, id, state)
338- if err == nil {
339- state.res = res
340- c.frame = state
341- res.OnDestroy = func() {
342- if c.frame == state {
343- c.frame = nil
344- }
345- if c.s != nil {
346- delete(c.s.captureReplies, state.replyID)
347- }
348- }
349- }
350-}
351-
352-type captureExtFrame struct {
353- session *captureSession
354- res *ext.ExtImageCopyCaptureFrameV1
355- buf *wayland.Buffer
356- used bool
357- replyID uint64
358-}
359-
360-func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
361-func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
362- if f.used {
363- f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
364- return
365- }
366- f.buf = b
367-}
368-func (f *captureExtFrame) DamageBuffer(_ *ext.ExtImageCopyCaptureFrameV1, x, y, w, h int32) {
369- if f.used {
370- f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
371- return
372- }
373- if x < 0 || y < 0 || w <= 0 || h <= 0 {
374- f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage), "invalid damage")
375- }
376-}
377-func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
378- if f.used {
379- f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
380- return
381- }
382- f.used = true
383- c := f.session
384- if c.stopped || c.o == nil || c.s.protected() || c.s.outputByNameExact(c.o.name()) != c.o {
385- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
386- return
387- }
388- region := image.Rect(0, 0, c.o.place.Info.Width, c.o.place.Info.Height)
389- if f.buf == nil {
390- f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
391- return
392- }
393- buf, ok := c.s.buffers[f.buf.GetResource()].(*buffer)
394- if !ok {
395- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
396- return
397- }
398- f.replyID, ok = c.s.requestCapture(c.o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
399- if done.Err != nil {
400- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
401- return
402- }
403- f.res.SendTransform(0)
404- f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
405- hi, lo, ns := captureTime(done.Time)
406- f.res.SendPresentationTime(hi, lo, ns)
407- f.res.SendReady()
408- }, f.res.Resource)
409- if !ok {
410- if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
411- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
412- } else {
413- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
414- }
415- }
416-}
M internal/adapters/wayland/screencopy_test.go +4 -4
@@ -43,7 +43,7 @@ func TestScreencopyProtocol(t *testing.T) {
4343 dir := t.TempDir()
4444 requests := make(chan ports.CaptureRequest, 1)
4545 replies := make(chan ports.CaptureDone, 1)
46- s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}}}, Channels{Captures: requests, Captured: replies}, logging.For(context.Background(), "wayland"))
46+ s, err := New(Options{RuntimeDir: dir, CaptureAllow: allowStore(t, "*\n"), Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}}}, Channels{Captures: requests, Captured: replies}, logging.For(context.Background(), "wayland"))
4747 if err != nil {
4848 t.Fatal(err)
4949 }
@@ -418,7 +418,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
418418 replies := make(chan ports.CaptureDone, 1)
419419 commands := make(chan ports.ClientCommand, 4)
420420 initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
421- s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
421+ s, err := New(Options{RuntimeDir: dir, CaptureAllow: allowStore(t, "*\n"), Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
422422 if err != nil {
423423 t.Fatal(err)
424424 }
@@ -574,7 +574,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
574574 dir := t.TempDir()
575575 commands := make(chan ports.ClientCommand, 4)
576576 initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
577- s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: make(chan ports.CaptureRequest, 1), Captured: make(chan ports.CaptureDone, 1), Commands: commands}, logging.For(context.Background(), "wayland"))
577+ s, err := New(Options{RuntimeDir: dir, CaptureAllow: allowStore(t, "*\n"), Outputs: ports.Layout{initial}}, Channels{Captures: make(chan ports.CaptureRequest, 1), Captured: make(chan ports.CaptureDone, 1), Commands: commands}, logging.For(context.Background(), "wayland"))
578578 if err != nil {
579579 t.Fatal(err)
580580 }
@@ -629,7 +629,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
629629 replies := make(chan ports.CaptureDone, 1)
630630 commands := make(chan ports.ClientCommand, 2)
631631 initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}
632- s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
632+ s, err := New(Options{RuntimeDir: dir, CaptureAllow: allowStore(t, "*\n"), Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
633633 if err != nil {
634634 t.Fatal(err)
635635 }
A internal/adapters/wayland/securitycontext.go +263 -0
@@ -0,0 +1,263 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/purego-libwayland/protocol/securitycontext"
9+ "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// wp_security_context_v1: a sandbox engine (Flatpak) asks the compositor to
14+// serve a listening socket it made, and everything that connects to it is a
15+// sandboxed client. Such a client is tagged when it is adopted and cannot
16+// capture the screen (mayCapture): it asks the desktop portal, whose own
17+// connection is not sandboxed. Sandboxed clients cannot nest a context.
18+//
19+// The display has no per-client global filter, so the capture globals stay
20+// advertised to sandboxed clients and their requests are refused; so does
21+// the manager global, which a sandboxed client may bind (binding every global
22+// is common) but not use: create_listener is the nested protocol error. Only capture is
23+// restricted for sandboxed clients so far: other privileged protocols
24+// (virtual keyboard, output management, data control, foreign toplevels...)
25+// are not.
26+//
27+// Each committed context runs one goroutine that polls its listening socket
28+// (the event loop of the display is not reachable from Go), accepts, and hands
29+// each connection to the display goroutine, which adopts it. The goroutine
30+// stops when close_fd hangs up or when the compositor stops, and then closes
31+// both descriptors. It keeps serving when the client that committed the
32+// context goes away (the protocol says so).
33+
34+// sandbox is what a sandboxed client was committed with. Engine, app and
35+// instance ids are the sandbox's own claims; they label logs and nothing else.
36+type sandbox struct {
37+ engine, appID, instanceID string
38+}
39+
40+const (
41+ maxSecurityMetadata = 1024 // bytes per metadata string
42+ acceptRetry = 50 * time.Millisecond
43+)
44+
45+func registerSecurityContext(d *server.Display, s *Server) error {
46+ return securitycontext.NewWpSecurityContextManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
47+ _, _ = securitycontext.NewWpSecurityContextManagerV1(c, int32(v), id, &securityManager{s: s})
48+ })
49+}
50+
51+type securityManager struct{ s *Server }
52+
53+func (*securityManager) Destroy(*securitycontext.WpSecurityContextManagerV1) {}
54+
55+func (m *securityManager) CreateListener(r *securitycontext.WpSecurityContextManagerV1, id uint32, listenFD, closeFD int) {
56+ s := m.s
57+ switch {
58+ case s.sandboxed[r.Client()] != nil:
59+ r.PostError(uint32(securitycontext.WpSecurityContextManagerV1ErrorNested), "nested security contexts are forbidden")
60+ case !listeningUnixSocket(listenFD):
61+ r.PostError(uint32(securitycontext.WpSecurityContextManagerV1ErrorInvalidListenFd), "listen_fd is not a listening unix stream socket")
62+ default:
63+ ctx := &securityContext{s: s, listenFD: listenFD, closeFD: closeFD}
64+ res, err := securitycontext.NewWpSecurityContextV1(r.Client(), r.Version(), id, ctx)
65+ if err == nil {
66+ // The client went away, or destroyed the object, before commit.
67+ res.OnDestroy = ctx.release
68+ return
69+ }
70+ }
71+ _ = unix.Close(listenFD)
72+ _ = unix.Close(closeFD)
73+}
74+
75+// listeningUnixSocket reports whether fd is a unix stream socket that listens.
76+func listeningUnixSocket(fd int) bool {
77+ if fd < 0 {
78+ return false
79+ }
80+ if v, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ACCEPTCONN); err != nil || v == 0 {
81+ return false
82+ }
83+ if v, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_DOMAIN); err != nil || v != unix.AF_UNIX {
84+ return false
85+ }
86+ v, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TYPE)
87+ return err == nil && v == unix.SOCK_STREAM
88+}
89+
90+// securityContext is one wp_security_context_v1. It owns the descriptors
91+// until commit hands them to the listener.
92+type securityContext struct {
93+ s *Server
94+ listenFD, closeFD int
95+ info sandbox
96+ engine, app, inst bool // set once
97+ committed bool
98+}
99+
100+// release closes the descriptors of a context that was never committed.
101+func (c *securityContext) release() {
102+ if c.committed {
103+ return
104+ }
105+ c.committed = true
106+ _ = unix.Close(c.listenFD)
107+ _ = unix.Close(c.closeFD)
108+}
109+
110+func (c *securityContext) Destroy(*securitycontext.WpSecurityContextV1) {}
111+
112+// usable rejects any request after commit.
113+func (c *securityContext) usable(r *securitycontext.WpSecurityContextV1) bool {
114+ if c.committed {
115+ r.PostError(uint32(securitycontext.WpSecurityContextV1ErrorAlreadyUsed), "security context already committed")
116+ return false
117+ }
118+ return true
119+}
120+
121+// validMetadata: non-empty, bounded, printable-enough (no NUL or control).
122+func validMetadata(v string) bool {
123+ return v != "" && len(v) <= maxSecurityMetadata && !strings.ContainsFunc(v, func(r rune) bool { return r < 0x20 || r == 0x7f })
124+}
125+
126+func (c *securityContext) set(r *securitycontext.WpSecurityContextV1, done *bool, dst *string, v string) {
127+ switch {
128+ case !c.usable(r):
129+ case *done:
130+ r.PostError(uint32(securitycontext.WpSecurityContextV1ErrorAlreadySet), "metadata already set")
131+ case !validMetadata(v):
132+ r.PostError(uint32(securitycontext.WpSecurityContextV1ErrorInvalidMetadata), "metadata must be a non-empty printable string of at most 1024 bytes")
133+ default:
134+ *done, *dst = true, v
135+ }
136+}
137+
138+func (c *securityContext) SetSandboxEngine(r *securitycontext.WpSecurityContextV1, name string) {
139+ c.set(r, &c.engine, &c.info.engine, name)
140+}
141+func (c *securityContext) SetAppId(r *securitycontext.WpSecurityContextV1, id string) {
142+ c.set(r, &c.app, &c.info.appID, id)
143+}
144+func (c *securityContext) SetInstanceId(r *securitycontext.WpSecurityContextV1, id string) {
145+ c.set(r, &c.inst, &c.info.instanceID, id)
146+}
147+
148+// Commit starts serving the listening socket.
149+func (c *securityContext) Commit(r *securitycontext.WpSecurityContextV1) {
150+ if !c.usable(r) {
151+ return
152+ }
153+ // The engine name is the one thing every sandbox has; without it a
154+ // context says nothing about who is behind it.
155+ if !c.engine {
156+ r.PostError(uint32(securitycontext.WpSecurityContextV1ErrorInvalidMetadata), "sandbox engine not set")
157+ return
158+ }
159+ c.committed = true
160+ l := &securityListener{s: c.s, listenFD: c.listenFD, closeFD: c.closeFD, info: c.info}
161+ c.s.securityLive.Add(1)
162+ c.s.securityWG.Add(1)
163+ go l.run()
164+ c.s.log.Info().Str("engine", c.info.engine).Str("app_id", c.info.appID).Msg("security context listener started")
165+}
166+
167+// securityListener accepts the connections of one committed context.
168+type securityListener struct {
169+ s *Server
170+ listenFD, closeFD int
171+ info sandbox
172+}
173+
174+func (l *securityListener) run() {
175+ s := l.s
176+ defer s.securityWG.Done()
177+ defer s.securityLive.Add(-1)
178+ defer unix.Close(l.closeFD)
179+ defer unix.Close(l.listenFD)
180+ // Only this listener accepts on the socket: a blocked accept must not
181+ // outlast a stop request.
182+ if err := unix.SetNonblock(l.listenFD, true); err != nil {
183+ s.log.Warn().Err(err).Str("engine", l.info.engine).Msg("security context listener: socket not usable")
184+ return
185+ }
186+ for {
187+ fds := []unix.PollFd{{Fd: int32(l.listenFD), Events: unix.POLLIN}, {Fd: int32(l.closeFD), Events: unix.POLLIN}, {Fd: int32(s.securityStop), Events: unix.POLLIN}}
188+ if _, err := unix.Poll(fds, -1); err != nil {
189+ if err == unix.EINTR {
190+ continue
191+ }
192+ s.log.Warn().Err(err).Str("engine", l.info.engine).Msg("security context listener: poll")
193+ return
194+ }
195+ switch {
196+ case fds[2].Revents != 0:
197+ return // the compositor stops
198+ case fds[1].Revents != 0: // readable or hung up
199+ s.log.Info().Str("engine", l.info.engine).Str("app_id", l.info.appID).Msg("security context listener closed")
200+ return
201+ case fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0:
202+ s.log.Warn().Str("engine", l.info.engine).Msg("security context listener: socket failed")
203+ return
204+ }
205+ if fds[0].Revents&unix.POLLIN != 0 && !l.acceptAll() {
206+ return
207+ }
208+ }
209+}
210+
211+// acceptAll adopts every pending connection; it returns false to stop.
212+func (l *securityListener) acceptAll() bool {
213+ s := l.s
214+ for {
215+ fd, _, err := unix.Accept4(l.listenFD, unix.SOCK_CLOEXEC)
216+ switch {
217+ case err == nil:
218+ l.adopt(fd)
219+ case errors.Is(err, unix.EAGAIN), errors.Is(err, unix.ECONNABORTED):
220+ return true
221+ case errors.Is(err, unix.EINTR):
222+ case errors.Is(err, unix.EMFILE), errors.Is(err, unix.ENFILE), errors.Is(err, unix.ENOMEM), errors.Is(err, unix.ENOBUFS):
223+ // Out of descriptors: wait for some, unless told to stop.
224+ s.log.Warn().Err(err).Msg("security context listener: accept")
225+ fds := []unix.PollFd{{Fd: int32(s.securityStop), Events: unix.POLLIN}}
226+ if _, perr := unix.Poll(fds, int(acceptRetry.Milliseconds())); perr == nil && fds[0].Revents != 0 {
227+ return false
228+ }
229+ return true
230+ default:
231+ s.log.Warn().Err(err).Msg("security context listener: accept")
232+ return false
233+ }
234+ }
235+}
236+
237+// adopt hands a connection to the display goroutine as a sandboxed client.
238+func (l *securityListener) adopt(fd int) {
239+ s := l.s
240+ ran := s.display.Do(func() {
241+ c, err := s.display.CreateClient(fd)
242+ if err != nil {
243+ s.log.Warn().Err(err).Msg("security context: connection not adopted")
244+ _ = unix.Close(fd)
245+ return
246+ }
247+ info := l.info
248+ s.sandboxed[c] = &info
249+ c.OnDestroy(func() { delete(s.sandboxed, c) })
250+ s.log.Debug().Str("engine", info.engine).Str("app_id", info.appID).Msg("sandboxed client connected")
251+ })
252+ if !ran {
253+ _ = unix.Close(fd) // the display stopped first
254+ }
255+}
256+
257+// stopSecurityContexts ends every listener and waits for them. Called once
258+// the display has stopped.
259+func (s *Server) stopSecurityContexts() {
260+ var one = [8]byte{1}
261+ _, _ = unix.Write(s.securityStop, one[:])
262+ s.securityWG.Wait()
263+}
A internal/adapters/wayland/securitycontext_test.go +405 -0
@@ -0,0 +1,405 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/logging"
13+ "github.com/bnema/purego-libwayland/protocol/securitycontext"
14+ "github.com/bnema/wlturbo"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// sandboxListener is what a sandbox engine prepares: a listening socket and
19+// the pipe whose hangup ends the context.
20+type sandboxListener struct {
21+ path string
22+ listen int // engine's copy; the compositor gets a dup
23+ closeR int // goes to the compositor
24+ closeW int // the engine keeps it; closing it hangs up closeR
25+ released bool
26+}
27+
28+func newSandboxListener(t *testing.T) *sandboxListener {
29+ t.Helper()
30+ // Unix socket paths are short; t.TempDir can be long.
31+ dir, err := os.MkdirTemp("", "sbx")
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
36+ l := &sandboxListener{path: filepath.Join(dir, "s")}
37+ if l.listen, err = unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0); err != nil {
38+ t.Fatal(err)
39+ }
40+ if err = unix.Bind(l.listen, &unix.SockaddrUnix{Name: l.path}); err != nil {
41+ t.Fatal(err)
42+ }
43+ if err = unix.Listen(l.listen, 8); err != nil {
44+ t.Fatal(err)
45+ }
46+ var p [2]int
47+ if err = unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
48+ t.Fatal(err)
49+ }
50+ l.closeR, l.closeW = p[0], p[1]
51+ t.Cleanup(func() {
52+ if !l.released {
53+ _ = unix.Close(l.listen)
54+ }
55+ _ = unix.Close(l.closeR)
56+ if l.closeW >= 0 {
57+ _ = unix.Close(l.closeW)
58+ }
59+ })
60+ return l
61+}
62+
63+// handOver drops the engine's descriptors the compositor has its own copies
64+// of (the protocol: closing them is the only valid operation afterwards). The
65+// write end of the pipe stays with the engine.
66+func (l *sandboxListener) handOver() {
67+ _ = unix.Close(l.listen)
68+ _ = unix.Close(l.closeR)
69+ l.released, l.closeR = true, -1
70+}
71+
72+func (l *sandboxListener) hangUp() {
73+ _ = unix.Close(l.closeW)
74+ l.closeW = -1
75+}
76+
77+func (l *sandboxListener) connect(t *testing.T) *wlturbo.Display {
78+ t.Helper()
79+ c, err := wlturbo.Connect(l.path)
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) })
84+ if err := c.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ return c
88+}
89+
90+// refused reports whether nothing listens on the socket any more.
91+func (l *sandboxListener) refused() bool {
92+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
93+ if err != nil {
94+ return false
95+ }
96+ defer unix.Close(fd)
97+ return errors.Is(unix.Connect(fd, &unix.SockaddrUnix{Name: l.path}), unix.ECONNREFUSED)
98+}
99+
100+func waitTrue(t *testing.T, what string, cond func() bool) {
101+ t.Helper()
102+ deadline := time.Now().Add(5 * time.Second)
103+ for !cond() {
104+ if time.Now().After(deadline) {
105+ t.Fatal("timeout: " + what)
106+ }
107+ time.Sleep(2 * time.Millisecond)
108+ }
109+}
110+
111+// contextClient is a client of the main socket with a security context being built.
112+type contextClient struct {
113+ c *wlturbo.Display
114+ manager uint32
115+ ctx uint32
116+}
117+
118+func newContextClient(t *testing.T, h *admissionHarness) *contextClient {
119+ t.Helper()
120+ c := protocolClient(t, h.s, h.dir)
121+ return &contextClient{c: c, manager: bindProtocol(t, c, "wp_security_context_manager_v1")}
122+}
123+
124+// create sends create_listener with the engine's descriptors.
125+func (cc *contextClient) create(t *testing.T, listen, closeFD int) {
126+ t.Helper()
127+ cc.ctx = cc.c.AllocateID()
128+ if err := wireRequest(cc.c, cc.manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{listen, closeFD}, cc.ctx); err != nil {
129+ t.Fatal(err)
130+ }
131+}
132+
133+func (cc *contextClient) request(t *testing.T, op uint32, args ...any) {
134+ t.Helper()
135+ requestProtocol(t, cc.c, cc.ctx, op, args...)
136+}
137+
138+// commit makes a context with an engine, like a Flatpak would, and waits for
139+// the compositor to have processed it.
140+func (cc *contextClient) commit(t *testing.T, l *sandboxListener, engine, app string) {
141+ t.Helper()
142+ cc.create(t, l.listen, l.closeR)
143+ cc.request(t, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, engine)
144+ if app != "" {
145+ cc.request(t, securitycontext.WpSecurityContextV1RequestSetAppId, app)
146+ }
147+ cc.request(t, securitycontext.WpSecurityContextV1RequestCommit)
148+ if err := cc.c.Roundtrip(); err != nil {
149+ t.Fatal(err)
150+ }
151+ l.handOver()
152+}
153+
154+func (h *admissionHarness) sandboxedCount() int {
155+ n := -1
156+ h.s.display.Do(func() { n = len(h.s.sandboxed) })
157+ return n
158+}
159+
160+// A client that connects through a committed context is sandboxed: it cannot
161+// capture, while a client of the main socket still can (the allowlist is `*` here).
162+func TestSecurityContextSandboxedClientCannotCapture(t *testing.T) {
163+ h := newAdmissionHarnessWith(t, maxCaptureInflight+2, Options{CaptureAllow: allowStore(t, "*\n")}, logging.For(context.Background(), "wayland"))
164+ plain := h.client(t)
165+ if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
166+ t.Fatal("plain client refused")
167+ }
168+
169+ l := newSandboxListener(t)
170+ cc := newContextClient(t, h)
171+ cc.commit(t, l, "org.flatpak", "org.example.App")
172+
173+ sandboxed := h.clientOn(t, l.connect(t))
174+ waitTrue(t, "client tagged", func() bool { return h.sandboxedCount() == 1 })
175+ for i := 0; i < 2; i++ {
176+ if _, ok := h.accepted(t, sandboxed.captureWlr(t)); ok {
177+ t.Fatal("sandboxed wlr capture allowed")
178+ }
179+ }
180+ if !sandboxed.extSessionStopped(t) {
181+ t.Fatal("sandboxed ext capture allowed")
182+ }
183+ // The committing client is not sandboxed by it, and is unaffected.
184+ if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
185+ t.Fatal("plain client refused after the context")
186+ }
187+ // The tag goes with the client.
188+ _ = sandboxed.c.Close()
189+ waitTrue(t, "tag dropped", func() bool { return h.sandboxedCount() == 0 })
190+}
191+
192+// Sandboxed clients refuse even when the allowlist is off or lists them.
193+func TestSecurityContextOverridesAllowlist(t *testing.T) {
194+ self, _ := os.Executable()
195+ for _, content := range []string{"*\n", self + "\n"} {
196+ h, out := authHarness(t, allowStore(t, content), nil)
197+ l := newSandboxListener(t)
198+ newContextClient(t, h).commit(t, l, "org.flatpak", "")
199+ c := h.clientOn(t, l.connect(t))
200+ if _, ok := h.accepted(t, c.captureWlr(t)); ok {
201+ t.Fatalf("sandboxed client captured with allowlist %q", content)
202+ }
203+ if !strings.Contains(out.String(), `"reason":"sandboxed client"`) {
204+ t.Fatalf("log = %s", out.String())
205+ }
206+ }
207+}
208+
209+// The listener keeps serving after the client that created it is gone, takes
210+// several connections, and stops when close_fd hangs up.
211+func TestSecurityContextListenerLifecycle(t *testing.T) {
212+ h := newAdmissionHarness(t, maxCaptureInflight+2)
213+ l := newSandboxListener(t)
214+ cc := newContextClient(t, h)
215+ cc.commit(t, l, "org.flatpak", "org.example.App")
216+ _ = cc.c.Close() // the protocol: the listener outlives the client that made it
217+ waitTrue(t, "creator gone", func() bool {
218+ n := 0
219+ h.s.display.Do(func() { n = len(h.s.peers) })
220+ return n == 0
221+ })
222+ a, b := h.clientOn(t, l.connect(t)), h.clientOn(t, l.connect(t))
223+ waitTrue(t, "two tagged", func() bool { return h.sandboxedCount() == 2 })
224+ for _, c := range []*admissionClient{a, b} {
225+ if _, ok := h.accepted(t, c.captureWlr(t)); ok {
226+ t.Fatal("sandboxed client captured")
227+ }
228+ }
229+ if h.s.securityLive.Load() != 1 {
230+ t.Fatalf("listeners = %d", h.s.securityLive.Load())
231+ }
232+ l.hangUp()
233+ waitTrue(t, "listener stopped", func() bool { return h.s.securityLive.Load() == 0 && l.refused() })
234+ // Clients already connected are untouched.
235+ if err := a.c.Roundtrip(); err != nil {
236+ t.Fatal(err)
237+ }
238+}
239+
240+// Compositor shutdown ends the listeners and closes their sockets.
241+func TestSecurityContextStopsWithTheCompositor(t *testing.T) {
242+ dir := t.TempDir()
243+ s, err := New(Options{RuntimeDir: dir}, Channels{}, logging.For(context.Background(), "wayland"))
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ ctx, cancel := context.WithCancel(context.Background())
248+ done := make(chan error, 1)
249+ go func() { done <- s.Run(ctx) }()
250+ defer cancel()
251+ c := protocolClient(t, s, dir)
252+ manager := bindProtocol(t, c, "wp_security_context_manager_v1")
253+ l := newSandboxListener(t)
254+ id := c.AllocateID()
255+ if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{l.listen, l.closeR}, id); err != nil {
256+ t.Fatal(err)
257+ }
258+ requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, "org.flatpak")
259+ requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestCommit)
260+ if err := c.Roundtrip(); err != nil {
261+ t.Fatal(err)
262+ }
263+ l.handOver()
264+ waitTrue(t, "listening", func() bool { return s.securityLive.Load() == 1 })
265+ cancel()
266+ select {
267+ case err := <-done:
268+ if err != nil {
269+ t.Fatal(err)
270+ }
271+ case <-time.After(5 * time.Second):
272+ t.Fatal("Run did not return")
273+ }
274+ if s.securityLive.Load() != 0 || !l.refused() {
275+ t.Fatalf("listener left behind: live=%d refused=%v", s.securityLive.Load(), l.refused())
276+ }
277+}
278+
279+// Destroying a context that was never committed closes the descriptors: the
280+// socket stops listening.
281+func TestSecurityContextDestroyBeforeCommit(t *testing.T) {
282+ h := newAdmissionHarness(t, 2)
283+ l := newSandboxListener(t)
284+ cc := newContextClient(t, h)
285+ cc.create(t, l.listen, l.closeR)
286+ if err := cc.c.Roundtrip(); err != nil {
287+ t.Fatal(err)
288+ }
289+ l.handOver()
290+ if l.refused() {
291+ t.Fatal("socket stopped listening before the context was destroyed")
292+ }
293+ cc.request(t, securitycontext.WpSecurityContextV1RequestDestroy)
294+ if err := cc.c.Roundtrip(); err != nil {
295+ t.Fatal(err)
296+ }
297+ waitTrue(t, "descriptors closed", l.refused)
298+ if h.s.securityLive.Load() != 0 {
299+ t.Fatal("an uncommitted context listens")
300+ }
301+}
302+
303+func TestSecurityContextErrors(t *testing.T) {
304+ const (
305+ ctxAlreadyUsed = uint32(securitycontext.WpSecurityContextV1ErrorAlreadyUsed)
306+ ctxAlreadySet = uint32(securitycontext.WpSecurityContextV1ErrorAlreadySet)
307+ ctxInvalidMeta = uint32(securitycontext.WpSecurityContextV1ErrorInvalidMetadata)
308+ )
309+ engine := uint32(securitycontext.WpSecurityContextV1RequestSetSandboxEngine)
310+ app := uint32(securitycontext.WpSecurityContextV1RequestSetAppId)
311+ inst := uint32(securitycontext.WpSecurityContextV1RequestSetInstanceId)
312+ commit := uint32(securitycontext.WpSecurityContextV1RequestCommit)
313+ type step struct {
314+ op uint32
315+ arg []any
316+ }
317+ tests := []struct {
318+ name string
319+ steps []step
320+ code uint32
321+ }{
322+ {"engine twice", []step{{engine, []any{"a"}}, {engine, []any{"b"}}}, ctxAlreadySet},
323+ {"app id twice", []step{{app, []any{"a"}}, {app, []any{"b"}}}, ctxAlreadySet},
324+ {"instance id twice", []step{{inst, []any{"a"}}, {inst, []any{"b"}}}, ctxAlreadySet},
325+ {"empty engine", []step{{engine, []any{""}}}, ctxInvalidMeta},
326+ {"control characters", []step{{app, []any{"a\x01b"}}}, ctxInvalidMeta},
327+ {"oversized", []step{{app, []any{strings.Repeat("x", maxSecurityMetadata+1)}}}, ctxInvalidMeta},
328+ {"commit without engine", []step{{commit, nil}}, ctxInvalidMeta},
329+ {"set after commit", []step{{engine, []any{"a"}}, {commit, nil}, {app, []any{"a"}}}, ctxAlreadyUsed},
330+ {"commit twice", []step{{engine, []any{"a"}}, {commit, nil}, {commit, nil}}, ctxAlreadyUsed},
331+ }
332+ for _, tc := range tests {
333+ t.Run(tc.name, func(t *testing.T) {
334+ h := newAdmissionHarness(t, 2)
335+ l := newSandboxListener(t)
336+ cc := newContextClient(t, h)
337+ cc.create(t, l.listen, l.closeR)
338+ for _, s := range tc.steps {
339+ cc.request(t, s.op, s.arg...)
340+ }
341+ expectProtocolError(t, cc.c, cc.ctx, tc.code)
342+ l.handOver()
343+ })
344+ }
345+}
346+
347+func TestSecurityContextInvalidListenFD(t *testing.T) {
348+ h := newAdmissionHarness(t, 2)
349+ // A connected socket, a regular pipe and a datagram socket listen on nothing.
350+ pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ defer unix.Close(pair[1])
355+ dgram, err := unix.Socket(unix.AF_UNIX, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
356+ if err != nil {
357+ t.Fatal(err)
358+ }
359+ defer unix.Close(dgram)
360+ pipe := make([]int, 2)
361+ if err := unix.Pipe2(pipe, unix.O_CLOEXEC); err != nil {
362+ t.Fatal(err)
363+ }
364+ defer unix.Close(pipe[1])
365+ for name, fd := range map[string]int{"connected": pair[0], "datagram": dgram, "pipe": pipe[0]} {
366+ t.Run(name, func(t *testing.T) {
367+ cc := newContextClient(t, h)
368+ r, w := mustPipe(t)
369+ defer unix.Close(w)
370+ cc.create(t, fd, r)
371+ expectProtocolError(t, cc.c, cc.manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorInvalidListenFd))
372+ _ = unix.Close(r)
373+ })
374+ }
375+}
376+
377+func mustPipe(t *testing.T) (r, w int) {
378+ t.Helper()
379+ var p [2]int
380+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
381+ t.Fatal(err)
382+ }
383+ return p[0], p[1]
384+}
385+
386+// A sandboxed client may bind the manager (binding every global is common)
387+// but create_listener is the nested error.
388+func TestSecurityContextNestedForbidden(t *testing.T) {
389+ h := newAdmissionHarness(t, 2)
390+ l := newSandboxListener(t)
391+ newContextClient(t, h).commit(t, l, "org.flatpak", "")
392+ c := l.connect(t)
393+ manager := bindProtocol(t, c, "wp_security_context_manager_v1")
394+ waitTrue(t, "tagged", func() bool { return h.sandboxedCount() == 1 })
395+ inner := newSandboxListener(t)
396+ id := c.AllocateID()
397+ if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{inner.listen, inner.closeR}, id); err != nil {
398+ t.Fatal(err)
399+ }
400+ expectProtocolError(t, c, manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorNested))
401+ // The compositor closed its copies: nothing listens on the inner socket
402+ // once ours goes too.
403+ inner.handOver()
404+ waitTrue(t, "inner closed", inner.refused)
405+}
M internal/adapters/wayland/sensitive_test.go +77 -36
@@ -5,7 +5,8 @@ import (
55 "testing"
66 "time"
77
8- "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
8+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
9+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
910 "github.com/bnema/purego-libwayland/protocol/cursorshape"
1011 wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel"
1112 screencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
@@ -227,54 +228,94 @@ func TestProtectedInputMethod(t *testing.T) {
227228 }
228229 }
229230
230-func TestProtectedPrivateCapture(t *testing.T) {
231- h := newPrivateHarness(t)
231+// While the session is protected nothing of it is captured: ext sessions stop
232+// or fail their frames, wlr-screencopy fails, a new session is stopped, an
233+// exclusion attach is refused, and none of it reaches core or the renderer.
234+func TestProtectedCapture(t *testing.T) {
235+ h := newCaptureHarness(t)
232236 state := installSecurity(t, h.s)
233- owner := h.client(t)
234- _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
235- begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
236- h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
237- token := waitMsg(t, owner.c, msgs).token
238- // Establish that this active owner's capture is admitted and clean before
239- // protection, then refuse the same connection without ending its session.
240- captureOnce(t, h, owner)
241- accepted := receive1(t, h.captures)
242- if !accepted.Clean || accepted.Session != begin.ID {
243- t.Fatalf("owner capture not clean: %+v", accepted)
244- }
245- accepted.Dst.File.Close()
237+ owner, session, open, _, token := h.exclusionOwner(t)
238+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 1})
239+ h.settle(t, owner)
240+ // Unprotected, the same frame is admitted (positive control).
241+ owner.extFrame(t, session, 4, 4)
242+ accepted := receiveCapture(t, h.captures)
243+ if !accepted.Exclude || accepted.Session != open.ID {
244+ t.Fatalf("owner capture not excluded: %+v", accepted)
245+ }
246246 h.captured <- ports.CaptureDone{ID: accepted.ID, Output: accepted.Output, Time: time.Now()}
247247 hd := h.hud(t, ports.LayerTop, 0)
248248 protect(t, h.s, state, 1, true)
249- failed, _ := captureOnce(t, h, owner)
250- if !eventsInclude(failed, uint16(screencopy.ZwlrScreencopyFrameV1EventFailed)) {
251- t.Fatal("protected active owner's clean capture not failed")
249+ if !eventsInclude(owner.extFrame(t, session, 4, 4), frameFailed) {
250+ t.Fatal("protected ext frame not failed")
252251 }
253- select {
254- case req := <-h.captures:
255- req.Dst.File.Close()
256- t.Fatal("protected active owner's capture admitted")
257- default:
252+ if !eventsInclude(owner.wlrFrame(t), uint16(screencopy.ZwlrScreencopyFrameV1EventFailed)) {
253+ t.Fatal("protected wlr frame not failed")
258254 }
255+ noCapture(t, h.captures)
259256 att := hd.attach(t, token)
260- expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnauthorized))
257+ expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnauthorized))
258+ // A session made while protected is stopped, for every kind of source.
261259 other := h.client(t)
262- _, refused := other.begin(t, 0, 0, 0, 0, 0, 0)
263- expectAttachment(t, other.c, refused, capturesession.NeferwlCaptureSessionV1EventStopped, int64(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized))
264- snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1, Name: "sensitive"}}}}}
265- if !h.s.display.Do(func() { h.s.workspaceSnapshot = snapshot; h.s.updateCaptureWorkspaces(snapshot) }) {
266- t.Fatal("display stopped")
260+ for name, src := range map[string]uint32{"output": other.outputSource(t), "region": other.regionSource(t, 0, 0, 2, 2)} {
261+ _, ev := other.session(t, src)
262+ if got := nextSession(t, other, ev); got[0] != evStopped {
263+ t.Fatalf("%s session while protected: %v", name, got)
264+ }
267265 }
268- noMessage(t, owner.c, owner.mgr.msgs)
269- noMessage(t, other.c, other.mgr.msgs)
270- late := h.client(t)
271- noMessage(t, late.c, late.mgr.msgs)
272266 for len(h.events) > 0 {
273267 switch ev := (<-h.events).(type) {
274- case ports.CaptureSessionBegin, ports.CaptureSessionLayer:
275- t.Fatalf("protected private request reached core: %+v", ev)
268+ case ports.CaptureSessionOpen, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer:
269+ t.Fatalf("protected capture request reached core: %+v", ev)
276270 }
277271 }
272+ // Unlocked again, capture works for a new session.
273+ protect(t, h.s, state, 2, false)
274+ cc := h.client(t)
275+ again, ev := cc.session(t, cc.outputSource(t))
276+ constraints(t, cc, ev)
277+ h.open(t)
278+ cc.extFrame(t, again, 4, 4)
279+ receiveCapture(t, h.captures)
280+}
281+
282+// Engaging the lock ends every capture session and its exclusion and tells
283+// core, so a session never outlives the lock and no hidden-workspace or
284+// region session keeps rendering.
285+func TestSessionLockEndsCaptureSessions(t *testing.T) {
286+ gate := &sessionsecurity.Gate{}
287+ h := newCaptureHarnessWith(t, func(o *Options, c *Channels) {
288+ o.Security = gate
289+ c.SecurityChanges = make(chan ports.SecurityState, 1)
290+ c.SecurityEvents = make(chan ports.SecurityBackendEvent, 16)
291+ })
292+ owner, session, open, msgs, _ := h.exclusionOwner(t)
293+ region, rev := owner.session(t, owner.regionSource(t, 0, 0, 2, 2))
294+ constraints(t, owner, rev)
295+ regionOpen := h.open(t)
296+ manager := bindLockManager(t, owner.c)
297+ if _, err := manager.Lock(); err != nil {
298+ t.Fatal(err)
299+ }
300+ if err := owner.c.Roundtrip(); err != nil {
301+ t.Fatal(err)
302+ }
303+ if !gate.Snapshot().Protected {
304+ t.Fatal("lock did not engage")
305+ }
306+ closed := map[uint64]bool{}
307+ for len(closed) < 2 {
308+ closed[nextEvent[ports.CaptureSessionClose](t, h.events).ID] = true
309+ }
310+ if !closed[open.ID] || !closed[regionOpen.ID] {
311+ t.Fatalf("core not told: %v", closed)
312+ }
313+ // Both sessions are stopped and the exclusion failed with its session.
314+ if !eventsInclude(owner.extFrame(t, session, 4, 4), frameFailed) || !eventsInclude(owner.extFrame(t, region, 2, 2), frameFailed) {
315+ t.Fatal("frame of a locked session not failed")
316+ }
317+ noCapture(t, h.captures)
318+ _ = msgs
278319 }
279320
280321 func TestProtectedActivationAndForeignInventory(t *testing.T) {
M internal/adapters/wayland/server.go +56 -4
@@ -5,11 +5,14 @@ import (
55 "fmt"
66 "os"
77 "sync"
8+ "sync/atomic"
89 "time"
910
1011 "github.com/bnema/purego-libwayland/protocol/relativepointer"
1112
13+ "github.com/bnema/neferwl/internal/adapters/captureallow"
1214 "github.com/bnema/neferwl/internal/adapters/clock"
15+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
1316 "github.com/bnema/neferwl/internal/ports"
1417 "github.com/bnema/neferwl/internal/sessionlock"
1518 "github.com/bnema/purego-libwayland/protocol/fractionalscale"
@@ -37,8 +40,17 @@ type Options struct {
3740 // Security is display-owned protection control. Nil leaves session-lock
3841 // unadvertised; independent protocol tests remain unlocked.
3942 Security ports.SessionSecurityController
43+ // WorkspaceIDs names workspaces (ext-workspace id); nil draws a fresh
44+ // launch prefix. The state file must share the one of the session.
45+ WorkspaceIDs *workspaceid.IDs
46+ // CaptureAllow is the executable allowlist for screen capture (captureauth.go);
47+ // nil lets every client capture, unless the session is protected. The
48+ // caller keeps it current (captureallow.Store.Run).
49+ CaptureAllow *captureallow.Store
4050 // syncDev replaces the render node's syncobj interface (tests).
4151 syncDev syncobjDevice
52+ // peer replaces the peer executable lookup (tests).
53+ peer peerExe
4254 }
4355
4456 // Channels carries client notifications and commands. Events may be unbuffered.
@@ -95,10 +107,29 @@ type Server struct {
95107 // captureInflight counts accepted captures until the backend completes
96108 // them, independent of client resources; only the display loop touches it.
97109 captureInflight map[uint64]struct{}
98- captureSources map[*server.Resource]*output
110+ captureSources map[*server.Resource]*captureSource
99111 captureSessions map[*captureSession]struct{}
100- // private is the private capture session state (capture_session.go).
101- private privateCapture
112+ // sessionsByID are the sessions registered with core (imagecopy.go);
113+ // excl is the one live exclusion (exclusion.go); ownUID the compositor's.
114+ sessionsByID map[uint64]*captureSession
115+ nextSession uint64
116+ excl *exclusion
117+ ownUID uint32
118+ // captureAllow, peer and peers decide mayCapture (captureauth.go):
119+ // peers keeps each client's pidfd until it is destroyed.
120+ captureAllow *captureallow.Store
121+ peer peerExe
122+ peers map[server.Client]*peerID
123+ // noPidfdWarned: the missing SO_PEERPIDFD is logged once.
124+ noPidfdWarned bool
125+ // sandboxed are the clients that connected through a security context
126+ // (securitycontext.go); display goroutine only. securityStop is
127+ // written once at shutdown to end the listeners, securityWG waits for
128+ // them, securityLive counts them.
129+ sandboxed map[server.Client]*sandbox
130+ securityStop int
131+ securityWG sync.WaitGroup
132+ securityLive atomic.Int32
102133 display *server.Display
103134 env procEnv
104135 slotsPending bool // core waits for a slot window
@@ -191,6 +222,8 @@ type Server struct {
191222 workspaceManagers []*workspaceManager
192223 toplevelManagers []*toplevelManager
193224 workspaceSnapshot ports.Workspaces
225+ workspaceFrames []*workspaceFrame
226+ workspaceIDs *workspaceid.IDs
194227 outputHeads ports.OutputHeads
195228 outputPlaces ports.Layout
196229 managementSerial uint32
@@ -259,15 +292,25 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
259292 s.pendingLeaseCards = map[uint64]string{}
260293 s.activeLeases = map[leaseKey]*leaseObject{}
261294 s.leaseReady = make(chan struct{}, 1)
295+ s.workspaceIDs = opts.WorkspaceIDs
296+ if s.workspaceIDs == nil {
297+ s.workspaceIDs = workspaceid.New()
298+ }
262299 s.clock = opts.Clock
263300 if s.clock == nil {
264301 s.clock = clock.System{}
265302 }
303+ s.captureAllow, s.peer, s.peers = opts.CaptureAllow, opts.peer, map[server.Client]*peerID{}
304+ if s.peer == nil {
305+ s.peer = linuxPeerExe{}
306+ }
307+ s.sandboxed, s.securityStop = map[server.Client]*sandbox{}, -1
266308 s.captureReplies = map[uint64]func(ports.CaptureDone){}
267309 s.captureInflight = map[uint64]struct{}{}
268310 s.outputReplies = map[uint64]*outputConfiguration{}
269311 s.managementSerial = 1
270- s.captureSources = map[*server.Resource]*output{}
312+ s.captureSources = map[*server.Resource]*captureSource{}
313+ s.sessionsByID = map[uint64]*captureSession{}
271314 s.captureSessions = map[*captureSession]struct{}{}
272315 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
273316 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
@@ -316,6 +359,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
316359 }
317360 s.syncWait.log = log
318361 s.cleanup = func() {
362+ if s.securityStop >= 0 {
363+ unix.Close(s.securityStop)
364+ }
319365 if s.seat.keymapFD >= 0 {
320366 unix.Close(s.seat.keymapFD)
321367 }
@@ -328,6 +374,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
328374 }
329375 cleanup()
330376 }
377+ if s.securityStop, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
378+ s.cleanup()
379+ d.Close()
380+ return nil, err
381+ }
331382 if err = registerGlobals(d, opts, s); err != nil {
332383 s.cleanup()
333384 d.Close()
@@ -403,6 +454,7 @@ func (s *Server) Run(ctx context.Context) error {
403454 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
404455 }
405456 err := s.display.Run(ctx)
457+ s.stopSecurityContexts()
406458 wg.Wait()
407459 return err
408460 }
M internal/adapters/wayland/server_test.go +1 -1
@@ -127,7 +127,7 @@ func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.
127127 dir := t.TempDir()
128128 events := make(chan ports.ClientEvent, 16)
129129 commands := make(chan ports.ClientCommand, 16)
130- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
130+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, CaptureAllow: allowStore(t, "*\n")}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
131131 if err != nil {
132132 t.Fatal(err)
133133 }
M internal/adapters/wayland/session_lock.go +1 -4
@@ -54,9 +54,7 @@ func (m sessionLockManager) Lock(r *extsessionlock.ExtSessionLockManagerV1, id u
5454 s.lockReadiness.Begin(state.Generation)
5555 res.OnDestroy = l.orphan
5656 s.resetProtectionInput()
57- if ps := s.private.session; ps != nil {
58- ps.end(true)
59- }
57+ s.endCaptureSessions()
6058 for _, lease := range s.activeLeases {
6159 lease.destroy()
6260 }
@@ -94,7 +92,6 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) {
9492 s.sessionLock = nil
9593 s.refreshToplevels()
9694 s.updateWorkspaceManagers(s.workspaceSnapshot)
97- s.updateCaptureWorkspaces(s.workspaceSnapshot)
9895 for _, surface := range l.surfaces {
9996 surface.unmap()
10097 }
M internal/adapters/wayland/session_lock_barrier_test.go +1 -1
@@ -35,7 +35,7 @@ func newBarrierServer(t *testing.T) *barrierServer {
3535 h := &barrierServer{gate: &sessionsecurity.Gate{}, changes: make(chan ports.SecurityState, 1), captures: make(chan ports.CaptureRequest, 4), captured: make(chan ports.CaptureDone, 4), stopped: make(chan struct{}), dir: t.TempDir()}
3636 layout := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
3737 var err error
38- h.s, err = New(Options{RuntimeDir: h.dir, Outputs: layout, Security: h.gate}, Channels{SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland"))
38+ h.s, err = New(Options{RuntimeDir: h.dir, Outputs: layout, Security: h.gate, CaptureAllow: allowStore(t, "*\n")}, Channels{SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland"))
3939 if err != nil {
4040 t.Fatal(err)
4141 }
M internal/adapters/wayland/session_lock_surface_test.go +7 -0
@@ -102,9 +102,16 @@ func TestLockSurfaceInitialConfigureAndCommitErrors(t *testing.T) {
102102 if scenario == "queued before ack" {
103103 s.display.Do(func() { surf.barrier = true; surf.next.wait = true })
104104 }
105+ // Hold the display goroutine while both requests are written: the
106+ // server reads neither before the release, so the fatal commit
107+ // cannot close the connection under the ACK write.
108+ entered, release := make(chan struct{}), make(chan struct{})
109+ go s.display.Do(func() { close(entered); <-release })
110+ <-entered
105111 requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
106112 // This later ACK must not legalize an earlier FIFO commit.
107113 requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
114+ close(release)
108115 code = extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck
109116 case "null":
110117 requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
M internal/adapters/wayland/wire_client_test.go +9 -4
@@ -10,7 +10,7 @@ import (
1010 "sync"
1111 "testing"
1212
13- wire_capturesession "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
13+ wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
1414 wire_alphamodifier "github.com/bnema/purego-libwayland/protocol/alphamodifier"
1515 wire_colormanagement "github.com/bnema/purego-libwayland/protocol/colormanagement"
1616 wire_colorrepresentation "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
@@ -36,6 +36,7 @@ import (
3636 wire_presentationtime "github.com/bnema/purego-libwayland/protocol/presentationtime"
3737 wire_primaryselection "github.com/bnema/purego-libwayland/protocol/primaryselection"
3838 wire_relativepointer "github.com/bnema/purego-libwayland/protocol/relativepointer"
39+ wire_securitycontext "github.com/bnema/purego-libwayland/protocol/securitycontext"
3940 wire_tearingcontrol "github.com/bnema/purego-libwayland/protocol/tearingcontrol"
4041 wire_textinput "github.com/bnema/purego-libwayland/protocol/textinput"
4142 wire_viewporter "github.com/bnema/purego-libwayland/protocol/viewporter"
@@ -119,9 +120,11 @@ var wireSchemas = map[string]wireSchema{
119120 "ext_workspace_group_handle_v1": {wire_extworkspace.ExtWorkspaceGroupHandleV1Interface, &client_workspace.ExtWorkspaceGroupHandle{}, map[uint16]bool{1: true}, nil},
120121 "ext_workspace_handle_v1": {wire_extworkspace.ExtWorkspaceHandleV1Interface, &client_workspace.ExtWorkspaceHandle{}, map[uint16]bool{0: true}, nil},
121122 "ext_workspace_manager_v1": {wire_extworkspace.ExtWorkspaceManagerV1Interface, &client_workspace.ExtWorkspaceManager{}, nil, map[uint16]bool{3: true}},
122- "neferwl_capture_layer_v1": {wire_capturesession.NeferwlCaptureLayerV1Interface, nil, map[uint16]bool{0: true}, nil},
123- "neferwl_capture_manager_v1": {wire_capturesession.NeferwlCaptureManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
124- "neferwl_capture_session_v1": {wire_capturesession.NeferwlCaptureSessionV1Interface, nil, map[uint16]bool{0: true}, nil},
123+ "neferwl_capture_exclusion_manager_v1": {wire_imagecapture.NeferwlCaptureExclusionManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
124+ "neferwl_capture_exclusion_v1": {wire_imagecapture.NeferwlCaptureExclusionV1Interface, nil, map[uint16]bool{0: true}, nil},
125+ "neferwl_capture_layer_v1": {wire_imagecapture.NeferwlCaptureLayerV1Interface, nil, map[uint16]bool{0: true}, nil},
126+ "neferwl_image_capture_source_manager_v1": {wire_imagecapture.NeferwlImageCaptureSourceManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
127+ "neferwl_workspace_frame_v1": {wire_imagecapture.NeferwlWorkspaceFrameV1Interface, nil, map[uint16]bool{0: true}, nil},
125128 "org_kde_kwin_server_decoration": {wire_kdedecoration.OrgKdeKwinServerDecorationInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecoration{}, map[uint16]bool{0: true}, nil},
126129 "org_kde_kwin_server_decoration_manager": {wire_kdedecoration.OrgKdeKwinServerDecorationManagerInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecorationManager{}, nil, nil},
127130 "wl_buffer": {wire_wayland.BufferInterface, &client_core.Buffer{}, map[uint16]bool{0: true}, nil},
@@ -178,6 +181,8 @@ var wireSchemas = map[string]wireSchema{
178181 "wp_pointer_warp_v1": {wire_pointerwarp.WpPointerWarpV1Interface, &client_pointerwarp.WpPointerWarp{}, map[uint16]bool{0: true}, nil},
179182 "wp_presentation": {wire_presentationtime.WpPresentationInterface, &client_presentation.WpPresentation{}, map[uint16]bool{0: true}, nil},
180183 "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}},
184+ "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
185+ "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil},
181186 "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil},
182187 "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil},
183188 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/workspaces.go +70 -6
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/binary"
66
7+ "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
78 "github.com/bnema/neferwl/internal/ports"
89 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
910 "github.com/bnema/purego-libwayland/protocol/wayland"
@@ -139,7 +140,6 @@ func (s *Server) forwardWorkspaces(ctx context.Context) {
139140 func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
140141 s.workspaceSnapshot = snapshot
141142 s.updateWorkspaceManagers(snapshot)
142- s.updateCaptureWorkspaces(snapshot)
143143 }
144144
145145 // updateWorkspaceManagers also refreshes inventory on protection transitions.
@@ -149,7 +149,71 @@ func (s *Server) updateWorkspaceManagers(snapshot ports.Workspaces) {
149149 for _, m := range s.workspaceManagers {
150150 m.update(snapshot)
151151 }
152+ s.updateWorkspaceFrames()
153+ s.refreshSessions()
152154 }
155+
156+// workspaceFrame is a neferwl_workspace_frame_v1: the frame of one workspace,
157+// output-local logical, sent when it is created and when it changes. Nothing
158+// is sent for a workspace that is gone, nor while the session is protected.
159+type workspaceFrame struct {
160+ res *imagecapture.NeferwlWorkspaceFrameV1
161+ handle *server.Resource
162+ id uint64
163+ sent bool
164+ last ports.Rect
165+}
166+
167+func (*workspaceFrame) Destroy(*imagecapture.NeferwlWorkspaceFrameV1) {}
168+
169+func (s *Server) newWorkspaceFrame(c server.Client, id uint32, ws *ext.ExtWorkspaceHandleV1) {
170+ f := &workspaceFrame{}
171+ known := false
172+ if ws != nil {
173+ f.handle = ws.Resource
174+ f.id, known = s.workspaceIDOf(ws.Resource)
175+ }
176+ r, err := imagecapture.NewNeferwlWorkspaceFrameV1(c, 1, id, f)
177+ if err != nil {
178+ return
179+ }
180+ f.res = r
181+ if !known {
182+ return
183+ }
184+ s.workspaceFrames = append(s.workspaceFrames, f)
185+ r.OnDestroy = func() { s.workspaceFrames = removeItem(s.workspaceFrames, f) }
186+ s.updateWorkspaceFrames()
187+}
188+
189+// updateWorkspaceFrames sends the frame of each workspace whose last sent one
190+// differs. A removed workspace gets nothing and its entry is dropped.
191+func (s *Server) updateWorkspaceFrames() {
192+ if s.protected() {
193+ return
194+ }
195+ kept := s.workspaceFrames[:0]
196+ for _, f := range s.workspaceFrames {
197+ // The handle must still exist: a removed or renamed handle never
198+ // returns, so its frame object is dropped.
199+ if id, ok := s.workspaceIDOf(f.handle); !ok || id != f.id {
200+ continue
201+ }
202+ kept = append(kept, f)
203+ if !f.res.Alive() {
204+ continue
205+ }
206+ _, w, ok := s.workspaceInfo(f.id)
207+ if !ok || (f.sent && f.last == w.Frame) {
208+ continue
209+ }
210+ f.sent, f.last = true, w.Frame
211+ f.res.SendFrame(int32(w.Frame.X), int32(w.Frame.Y), int32(w.Frame.W), int32(w.Frame.H))
212+ }
213+ clear(s.workspaceFrames[len(kept):])
214+ s.workspaceFrames = kept
215+}
216+
153217 func workspaceState(w ports.WorkspaceInfo) uint32 {
154218 var state uint32
155219 if w.Active {
@@ -183,8 +247,10 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
183247 }
184248 // Move existing workspaces before retiring their old output group.
185249 for id, h := range m.handles {
186- info, exists := infos[id]
187- if !exists || info.Configured != h.info.Configured {
250+ // A handle's id is fixed: a workspace that became configured or
251+ // unconfigured (a rename in the config) is a removed handle and a
252+ // new one.
253+ if info, exists := infos[id]; !exists || info.Configured != h.info.Configured {
188254 if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() {
189255 g.res.SendWorkspaceLeave(h.res)
190256 }
@@ -220,9 +286,7 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
220286 h = &workspaceHandle{res: r}
221287 m.handles[w.ID] = h
222288 m.res.SendWorkspace(r)
223- if w.Configured != "" {
224- r.SendId(w.Configured)
225- }
289+ r.SendId(m.s.workspaceIDs.ID(w.ID, w.Configured))
226290 r.SendName(w.Name)
227291 m.sendCoordinates(r, w.Index)
228292 r.SendState(workspaceState(w))
M internal/adapters/wayland/workspaces_sensitive_test.go +2 -1
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/neferwl/internal/ports"
78 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
@@ -128,7 +129,7 @@ func TestProtectedWorkspaceInventoryAndActivation(t *testing.T) {
128129 if !ok || len(activation.IDs) != 1 || activation.IDs[0] != 42 {
129130 t.Fatalf("unlocked activation: %v", ev)
130131 }
131- default:
132+ case <-time.After(2 * time.Second):
132133 t.Fatal("unlocked activation not emitted")
133134 }
134135 }
M internal/adapters/wayland/workspaces_test.go +27 -17
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
1011 "github.com/bnema/neferwl/internal/logging"
1112 "github.com/bnema/neferwl/internal/ports"
1213 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
@@ -86,7 +87,7 @@ func TestWorkspaceProtocol(t *testing.T) {
8687 dir := t.TempDir()
8788 events := make(chan ports.ClientEvent, 16)
8889 snapshots := make(chan ports.Workspaces, 1)
89- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
90+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
9091 if err != nil {
9192 t.Fatal(err)
9293 }
@@ -127,10 +128,14 @@ func TestWorkspaceProtocol(t *testing.T) {
127128 if handle == 0 {
128129 t.Fatal("workspace not announced")
129130 }
131+ // A dynamic workspace sends "<launch prefix>-<core ID>".
130132 select {
131133 case id := <-p.idEvents:
132- t.Fatalf("dynamic workspace sent stable id %q", id)
134+ if id != "0a1b2c3d-42" {
135+ t.Fatalf("workspace id %q, want 0a1b2c3d-42", id)
136+ }
133137 default:
138+ t.Fatal("workspace sent no id")
134139 }
135140 out := bindProtocol(t, c, "wl_output")
136141 registerProtocol(t, c, out)
@@ -161,8 +166,8 @@ func TestWorkspaceProtocol(t *testing.T) {
161166 if !entered || doneCount != 1 {
162167 t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
163168 }
164- if len(p.idEvents) != 0 {
165- t.Fatal("dynamic workspace sent id")
169+ if len(p.idEvents) != 1 { // the second workspace's; the first was read above
170+ t.Fatalf("id events left = %d, want 1", len(p.idEvents))
166171 }
167172 requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
168173 requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate)
@@ -228,7 +233,7 @@ func TestWorkspaceProtocol(t *testing.T) {
228233 func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
229234 dir := t.TempDir()
230235 snapshots := make(chan ports.Workspaces, 1)
231- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
236+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
232237 if err != nil {
233238 t.Fatal(err)
234239 }
@@ -284,25 +289,29 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
284289 // initialized; workspace_enter follows all initial workspace details.
285290 wantOrder := []uint32{
286291 ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
287- ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
292+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
288293 ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
289294 ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
290- ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
295+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
291296 ext.ExtWorkspaceManagerV1EventDone,
292297 }
293298 if !slices.Equal(order, wantOrder) {
294299 t.Fatalf("creation order = %v, want %v", order, wantOrder)
295300 }
296- select {
297- case id := <-p.idEvents:
298- if id != "dev" {
299- t.Fatalf("id=%q", id)
301+ // A configured workspace sends "name:<name>", the others the launch
302+ // prefix and their core ID, in creation order.
303+ for _, want := range []string{"0a1b2c3d-42", "name:dev", "0a1b2c3d-44"} {
304+ select {
305+ case id := <-p.idEvents:
306+ if id != want {
307+ t.Fatalf("id=%q, want %q", id, want)
308+ }
309+ default:
310+ t.Fatalf("workspace %s has no id", want)
300311 }
301- default:
302- t.Fatal("configured workspace has no id")
303312 }
304313 if len(p.idEvents) != 0 {
305- t.Fatal("dynamic workspace received id")
314+ t.Fatal("extra id")
306315 }
307316 out := bindProtocol(t, c, "wl_output")
308317 registerProtocol(t, c, out)
@@ -375,8 +384,9 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
375384 t.Fatalf("recreated client-destroyed handle: %v", v)
376385 }
377386 }
378- // A configured rename replaces the handle on the still-live second group.
379- // The destroyed first group remains a tombstone until it disappears.
387+ // A configured rename is a removed handle and a new one (the id is the
388+ // configured name), on the still-live second group. The destroyed first
389+ // group remains a tombstone until it disappears.
380390 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "code", Configured: "code", Active: true}}}}}
381391 replaced, removed := 0, 0
382392 for i := 0; i < 8 && replaced == 0; i++ {
@@ -401,7 +411,7 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
401411 }
402412 select {
403413 case id := <-p.idEvents:
404- if id != "code" {
414+ if id != "name:code" {
405415 t.Fatalf("renamed id=%q", id)
406416 }
407417 default:
A internal/adapters/workspaceid/workspaceid.go +41 -0
@@ -0,0 +1,41 @@
1+// Package workspaceid names workspaces for programs outside the compositor:
2+// the `id` of ext_workspace_handle_v1 and the `workspace_id` of the state
3+// file are the same string.
4+//
5+// Core keeps numeric workspace IDs, unique for the life of the process. A
6+// workspace declared in the configuration is named after its configured name
7+// ("name:<configured name>"), so its ID survives restarts; any other
8+// workspace is "<prefix>-<core ID>", where the prefix is drawn once per
9+// compositor launch, so such an ID is unique within a launch and never
10+// repeats across launches.
11+package workspaceid
12+
13+import (
14+ "crypto/rand"
15+ "encoding/hex"
16+ "strconv"
17+)
18+
19+// IDs builds workspace ID strings for one compositor launch.
20+type IDs struct{ prefix string }
21+
22+// New draws the launch prefix: 8 lowercase hex characters from crypto/rand.
23+func New() *IDs {
24+ var b [4]byte
25+ // rand.Read never returns an error (Go 1.24+): it stops the program
26+ // instead of returning weak randomness.
27+ _, _ = rand.Read(b[:])
28+ return &IDs{prefix: hex.EncodeToString(b[:])}
29+}
30+
31+// WithPrefix uses a fixed launch prefix (tests).
32+func WithPrefix(prefix string) *IDs { return &IDs{prefix: prefix} }
33+
34+// ID is the string of the workspace with this core ID and configured name
35+// (empty for a numbered or dynamic workspace).
36+func (i *IDs) ID(id uint64, configured string) string {
37+ if configured != "" {
38+ return "name:" + configured
39+ }
40+ return i.prefix + "-" + strconv.FormatUint(id, 10)
41+}
A internal/adapters/workspaceid/workspaceid_test.go +29 -0
@@ -0,0 +1,29 @@
1+package workspaceid
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestConfiguredWorkspaceIDIsItsNameWhateverTheCoreID(t *testing.T) {
11+ ids := WithPrefix("0a1b2c3d")
12+ require.Equal(t, "name:dev", ids.ID(7, "dev"))
13+ require.Equal(t, "name:dev", ids.ID(99, "dev"), "stable across launches and core IDs")
14+ require.NotEqual(t, ids.ID(7, "dev"), ids.ID(7, "web"))
15+}
16+
17+func TestNumberedWorkspaceIDIsLaunchPrefixAndCoreID(t *testing.T) {
18+ ids := WithPrefix("0a1b2c3d")
19+ require.Equal(t, "0a1b2c3d-7", ids.ID(7, ""))
20+ require.NotEqual(t, ids.ID(7, ""), ids.ID(8, ""))
21+}
22+
23+func TestLaunchPrefixIsEightLowercaseHexAndDiffersPerLaunch(t *testing.T) {
24+ re := regexp.MustCompile(`^[0-9a-f]{8}-1$`)
25+ a, b := New().ID(1, ""), New().ID(1, "")
26+ require.Regexp(t, re, a)
27+ require.Regexp(t, re, b)
28+ require.NotEqual(t, a, b, "two launches draw two prefixes")
29+}
M internal/app/app.go +30 -3
@@ -12,6 +12,7 @@ import (
1212 "sync"
1313 "time"
1414
15+ "github.com/bnema/neferwl/internal/adapters/captureallow"
1516 "github.com/bnema/neferwl/internal/adapters/clock"
1617 "github.com/bnema/neferwl/internal/adapters/config"
1718 "github.com/bnema/neferwl/internal/adapters/drm"
@@ -23,6 +24,7 @@ import (
2324 "github.com/bnema/neferwl/internal/adapters/statefile"
2425 "github.com/bnema/neferwl/internal/adapters/vulkan"
2526 "github.com/bnema/neferwl/internal/adapters/wayland"
27+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
2628 "github.com/bnema/neferwl/internal/adapters/xkb"
2729 "github.com/bnema/neferwl/internal/adapters/xwayland"
2830 "github.com/bnema/neferwl/internal/core"
@@ -53,6 +55,11 @@ type Options struct {
5355 // Run closes Script on shutdown; the reader goroutine exits after Close.
5456 Script io.ReadCloser
5557 testScenes chan<- []ports.Scene
58+ // captureAllowPath replaces the fixed allowlist path (tests).
59+ captureAllowPath string
60+ // captureAllowOwner is the uid that must own that file (tests use their
61+ // own; root, 0, otherwise).
62+ captureAllowOwner uint32
5663 }
5764
5865 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -131,7 +138,20 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
131138 // Every output renders on the same GPU: its formats are the clients'.
132139 dmabuf := vulkan.Probe()
133140 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
134- server, err := wayland.New(wayland.Options{Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
141+ // One launch prefix for ext-workspace ids and the state file.
142+ wsIDs := workspaceid.New()
143+ // The path is fixed: a config key would let a program of the same user
144+ // widen who may capture.
145+ // Production: the fixed path, a root-owned file. Only tests set a path,
146+ // and an owner for it.
147+ captureAllowLog := logging.For(ctx, "captureallow")
148+ var captureAllow *captureallow.Store
149+ if opts.captureAllowPath != "" {
150+ captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
151+ } else {
152+ captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
153+ }
154+ server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
135155 if err != nil {
136156 km.Close()
137157 return err
@@ -160,7 +180,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
160180 inject(input)
161181 }
162182 var workers sync.WaitGroup
163- workers.Add(7)
183+ workers.Add(8)
164184 done := make(chan error, 8)
165185 path := opts.ConfigPath
166186 if path == "" {
@@ -238,6 +258,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
238258 close(script)
239259 }
240260 go func() { defer workers.Done(); done <- server.Run(ctx) }()
261+ go func() {
262+ defer workers.Done()
263+ // Without a watch the allowlist stays as loaded: restart to apply.
264+ if err := captureAllow.Run(ctx); err != nil {
265+ log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
266+ }
267+ }()
241268 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
242269 go func() { defer workers.Done(); done <- c.Run(ctx) }()
243270 if xdisplay != nil {
@@ -249,7 +276,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
249276 workers.Add(1)
250277 go func() {
251278 defer workers.Done()
252- if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil {
279+ if err := statefile.Run(ctx, statePath, states, wsIDs, logging.For(ctx, "statefile")); err != nil {
253280 // Scripts lose their state; the session goes on.
254281 log.Warn().Err(err).Msg("state file disabled")
255282 }
M internal/app/session_lock_test.go +6 -1
@@ -28,13 +28,18 @@ func TestHeadlessSessionLockEndToEnd(t *testing.T) {
2828 t.Setenv("XDG_RUNTIME_DIR", runtime)
2929 t.Setenv("XDG_STATE_HOME", t.TempDir())
3030 shots := t.TempDir()
31+ // The test's own capture client is not on the built-in list: disable the check.
32+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
33+ if err := os.WriteFile(allowPath, []byte("*\n"), 0o644); err != nil {
34+ t.Fatal(err)
35+ }
3136 scenes := make(chan []ports.Scene, 128)
3237 script, writer := io.Pipe()
3338 defer writer.Close()
3439 ctx, cancel := context.WithCancel(context.Background())
3540 done := make(chan error, 1)
3641 go func() {
37- done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{160, 120}, {128, 96}}, ScreenshotDir: shots, Script: script, testScenes: scenes})
42+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{160, 120}, {128, 96}}, ScreenshotDir: shots, Script: script, testScenes: scenes, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())})
3843 }()
3944 // Cancellation closes clients' sockets too, bounding blocked Roundtrip.
4045 watchdog := time.AfterFunc(20*time.Second, cancel)
A internal/core/capindicator.go +228 -0
@@ -0,0 +1,228 @@
1+package core
2+
3+import (
4+ "slices"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// The capture indicator: the compositor shows that something is captured,
11+// for every capture of every client and protocol, and no client can turn it
12+// off. Core owns what is shown and until when; the renderer draws it over the
13+// physical output only (Scene.CaptureIndicators) and never in a capture.
14+//
15+// - a session that produced a frame (CaptureFrameTaken with a Session)
16+// marks its target for as long as it lives;
17+// - every captured frame marks its target for ports.CaptureFlash after it
18+// (a flash), so a one-shot screenshot shows for that long and periodic
19+// captures stay visible;
20+// - a target drawn on screen (output, region, workspace frame) gets a
21+// border; a workspace rendered for capture only has no place on screen, so
22+// its output gets a pill.
23+//
24+// Marks are the union of all live sessions and flashes. Expiry follows the
25+// core clock: one timer, armed at the earliest flash, is the only time
26+// source. Only the core goroutine touches this state; nothing here runs, or
27+// allocates, while no session produced a frame and no flash is live.
28+
29+// maxCaptureFlashes bounds the live flashes. Past it the flashes of regions
30+// of an output collapse into one flash of the whole output (a larger mark,
31+// never a missing one); targets that are not regions are bounded by the
32+// outputs and workspaces that exist.
33+const maxCaptureFlashes = 256
34+
35+// capFlash is a captured frame's target and when its mark ends.
36+type capFlash struct {
37+ target capTarget
38+ until time.Time
39+}
40+
41+// captureFrame records a captured frame. It reports whether what is shown
42+// changed, so a scene must be published; extending a flash that shows
43+// already does not.
44+func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
45+ changed := false
46+ if s := c.capSession(v.Session); v.Session != 0 && s != nil && !s.recording {
47+ s.recording, changed = true, true
48+ }
49+ t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}
50+ if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {
51+ // Nothing of the target is left on screen to mark.
52+ return changed
53+ }
54+ until := c.now().Add(ports.CaptureFlash)
55+ for i := range c.capFlashes {
56+ if c.capFlashes[i].target == t {
57+ c.capFlashes[i].until = until
58+ c.armCaptureTimer()
59+ return changed
60+ }
61+ }
62+ if len(c.capFlashes) >= maxCaptureFlashes {
63+ c.collapseFlashes(t)
64+ }
65+ c.capFlashes = append(c.capFlashes, capFlash{target: t, until: until})
66+ c.armCaptureTimer()
67+ return true
68+}
69+
70+// collapseFlashes makes room: the flashes of regions of t's output become one
71+// whole-output flash lasting as long as the longest of them; when that is
72+// not enough the oldest flash goes.
73+func (c *Core) collapseFlashes(t capTarget) {
74+ if t.workspace == 0 && t.output != "" {
75+ var until time.Time
76+ n := 0
77+ c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool {
78+ if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {
79+ return false
80+ }
81+ if f.until.After(until) {
82+ until = f.until
83+ }
84+ n++
85+ return true
86+ })
87+ if n > 0 {
88+ c.capFlashes = append(c.capFlashes, capFlash{target: capTarget{output: t.output}, until: until})
89+ }
90+ }
91+ if len(c.capFlashes) >= maxCaptureFlashes {
92+ old := 0
93+ for i, f := range c.capFlashes {
94+ if f.until.Before(c.capFlashes[old].until) {
95+ old = i
96+ }
97+ }
98+ c.capFlashes = slices.Delete(c.capFlashes, old, old+1)
99+ }
100+}
101+
102+// captureExpire forgets the flashes that ended.
103+func (c *Core) captureExpire() {
104+ if len(c.capFlashes) == 0 {
105+ return
106+ }
107+ now := c.now()
108+ c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool { return !f.until.After(now) })
109+}
110+
111+// captureFlashTick runs when the flash timer fired: it forgets the flashes
112+// that ended and re-arms for the next. It reports whether one ended.
113+func (c *Core) captureFlashTick() bool {
114+ n := len(c.capFlashes)
115+ c.captureExpire()
116+ c.armCaptureTimer()
117+ return len(c.capFlashes) != n
118+}
119+
120+// armCaptureTimer arms the timer at the earliest flash when none runs. A
121+// flash that was extended leaves it: the timer fires early, finds nothing to
122+// forget and arms again. No flash stops it.
123+func (c *Core) armCaptureTimer() {
124+ if len(c.capFlashes) == 0 {
125+ c.stopCaptureTimer()
126+ return
127+ }
128+ if c.capStop != nil {
129+ return
130+ }
131+ first := c.capFlashes[0].until
132+ for _, f := range c.capFlashes[1:] {
133+ if f.until.Before(first) {
134+ first = f.until
135+ }
136+ }
137+ d := max(first.Sub(c.now()), time.Millisecond)
138+ if c.ch.Clock != nil {
139+ t := c.ch.Clock.NewTimer(d)
140+ c.capC, c.capStop = t.C(), t.Stop
141+ return
142+ }
143+ t := time.NewTimer(d)
144+ c.capC, c.capStop = t.C, t.Stop
145+}
146+
147+func (c *Core) stopCaptureTimer() {
148+ if c.capStop != nil {
149+ c.capStop()
150+ }
151+ c.capC, c.capStop = nil, nil
152+}
153+
154+// captureIndicators is the marks of one output's scene; nil when there are
155+// none. The slice is immutable once published and shared with the next scene
156+// of the screen while the marks are the same; it is cloned only when they
157+// change, so a steady recording allocates nothing.
158+func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
159+ if len(c.capSessions) == 0 && len(c.capFlashes) == 0 {
160+ sc.capMarks = nil
161+ return nil
162+ }
163+ out := c.capMarks[:0]
164+ add := func(t capTarget) {
165+ r, reason := c.capResolve(t)
166+ if reason != ports.CaptureReasonNone || r.sc != sc {
167+ return
168+ }
169+ o := sc.mon.Output()
170+ var m ports.CaptureIndicator
171+ if r.hidden {
172+ m = ports.CaptureIndicator{Pill: true, Rect: pillRect(o)}
173+ } else {
174+ m = ports.CaptureIndicator{Rect: thickenMark(r.rect, o)}
175+ }
176+ if !slices.Contains(out, m) {
177+ out = append(out, m)
178+ }
179+ }
180+ for _, s := range c.capSessions {
181+ if s.recording {
182+ add(s.target())
183+ }
184+ }
185+ for _, f := range c.capFlashes {
186+ add(f.target)
187+ }
188+ c.capMarks = out
189+ switch {
190+ case len(out) == 0:
191+ sc.capMarks = nil
192+ case !slices.Equal(sc.capMarks, out):
193+ sc.capMarks = slices.Clone(out)
194+ }
195+ return sc.capMarks
196+}
197+
198+// thickenMark inflates a mark to at least ports.MinCaptureMark on each side,
199+// around the target and clamped to the output (a target one logical pixel
200+// wide gets a border with something to draw; the border then covers the
201+// target whole). An output smaller than that is marked whole.
202+func thickenMark(r, out Rect) Rect {
203+ grow := func(pos, size, lo, span int) (int, int) {
204+ if size >= ports.MinCaptureMark {
205+ return pos, size
206+ }
207+ if span <= ports.MinCaptureMark {
208+ return lo, span
209+ }
210+ pos -= (ports.MinCaptureMark - size) / 2
211+ pos = min(max(pos, lo), lo+span-ports.MinCaptureMark)
212+ return pos, ports.MinCaptureMark
213+ }
214+ r.X, r.W = grow(r.X, r.W, 0, out.W)
215+ r.Y, r.H = grow(r.Y, r.H, 0, out.H)
216+ return r
217+}
218+
219+// pillRect is the pill in the top-right corner of an output, inset, and always
220+// whole inside it: an output too small for the pill (clamped to the output it
221+// would be cut) is filled whole instead, so the mark is never empty.
222+func pillRect(o Rect) Rect {
223+ r := Rect{X: max(o.W-ports.CapturePillInset-ports.CapturePillSize, 0), Y: ports.CapturePillInset, W: ports.CapturePillSize, H: ports.CapturePillSize}
224+ if in := intersect(r, Rect{W: o.W, H: o.H}); in != r {
225+ return Rect{W: o.W, H: o.H}
226+ }
227+ return r
228+}
A internal/core/capindicator_test.go +369 -0
@@ -0,0 +1,369 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// indicatorClock is a clock the test moves; its timers never fire by
14+// themselves: the test calls captureFlashTick, and the durations asked for
15+// are recorded.
16+type indicatorClock struct {
17+ now time.Time
18+ timers []time.Duration
19+}
20+
21+func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
22+ t.Helper()
23+ ic := &indicatorClock{now: time.Unix(1000, 0)}
24+ clock := portsmocks.NewMockClock(t)
25+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return ic.now }).Maybe()
26+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
27+ ic.timers = append(ic.timers, d)
28+ timer := portsmocks.NewMockTimer(t)
29+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30+ timer.EXPECT().Stop().Return(true).Maybe()
31+ return timer
32+ }).Maybe()
33+ var cfg ports.Config
34+ cfg.Keyboard.CmdKey = "super"
35+ cfg.Layout.MaxColumns = 2
36+ cfg.Border.Width = 0
37+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64), Clock: clock})
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
42+ return c, ic
43+}
44+
45+func indicatorScene(t *testing.T, c *Core) ports.Scene {
46+ t.Helper()
47+ if err := c.publish(context.Background()); err != nil {
48+ t.Fatal(err)
49+ }
50+ return (<-c.ch.Scenes)[0]
51+}
52+
53+func TestCaptureIndicatorFlashLastsOneSecond(t *testing.T) {
54+ c, ic := indicatorCore(t)
55+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
56+ t.Fatalf("indicator without capture: %+v", s.CaptureIndicators)
57+ }
58+ // A one-shot screenshot of the output, by any protocol.
59+ if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
60+ t.Fatal("first frame shows nothing new")
61+ }
62+ want := []ports.CaptureIndicator{{Rect: Rect{W: 300, H: 200}}}
63+ if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0] != want[0] {
64+ t.Fatalf("indicators %+v", s.CaptureIndicators)
65+ }
66+ if len(ic.timers) != 1 || ic.timers[0] != ports.CaptureFlash {
67+ t.Fatalf("timers %v, want one of %v", ic.timers, ports.CaptureFlash)
68+ }
69+ // A frame later extends the flash without a new scene.
70+ ic.now = ic.now.Add(600 * time.Millisecond)
71+ if c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
72+ t.Fatal("extending a flash asks for a scene")
73+ }
74+ // The first timer fires at its due time: the flash was extended, it stays.
75+ ic.now = ic.now.Add(400 * time.Millisecond)
76+ if c.captureFlashTick() {
77+ t.Fatal("flash ended early")
78+ }
79+ if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
80+ t.Fatalf("indicators %+v", s.CaptureIndicators)
81+ }
82+ // Just before the end it stays; at the end it goes.
83+ ic.now = ic.now.Add(599 * time.Millisecond)
84+ if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
85+ t.Fatal("flash shorter than one second after the last frame")
86+ }
87+ ic.now = ic.now.Add(time.Millisecond)
88+ if !c.captureFlashTick() {
89+ t.Fatal("flash did not end")
90+ }
91+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
92+ t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
93+ }
94+ if c.capStop != nil || len(c.capFlashes) != 0 {
95+ t.Fatal("timer or flash left")
96+ }
97+}
98+
99+// Engaging protection clears a live flash and its timer, so no indicator is
100+// shown for a capture that can no longer happen, and none comes back after the
101+// unlock; capture flashes then work again.
102+func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.T) {
103+ c, ic := indicatorCore(t)
104+ state := ports.SecurityState{Generation: 1}
105+ gate := portsmocks.NewMockSessionSecurity(t)
106+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
107+ c.ch.Security = gate
108+ if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
109+ t.Fatal("first frame shows nothing new")
110+ }
111+ if len(c.capFlashes) != 1 || c.capStop == nil {
112+ t.Fatalf("flash %d, timer armed %v", len(c.capFlashes), c.capStop != nil)
113+ }
114+ state = ports.SecurityState{Generation: 2, Protected: true}
115+ if !c.syncSecurity() {
116+ t.Fatal("protection not engaged")
117+ }
118+ if len(c.capFlashes) != 0 || c.capStop != nil || c.capC != nil {
119+ t.Fatalf("flash %d, timer %v left under protection", len(c.capFlashes), c.capStop != nil)
120+ }
121+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
122+ t.Fatalf("indicators %+v while protected", s.CaptureIndicators)
123+ }
124+ // The clock passes the old deadline: nothing comes back after the unlock.
125+ ic.now = ic.now.Add(2 * time.Second)
126+ state = ports.SecurityState{Generation: 3}
127+ if !c.syncSecurity() {
128+ t.Fatal("protection not released")
129+ }
130+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
131+ t.Fatalf("stale indicators %+v after the unlock", s.CaptureIndicators)
132+ }
133+ timers := len(ic.timers)
134+ if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
135+ t.Fatal("first frame after the unlock shows nothing new")
136+ }
137+ if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
138+ t.Fatalf("indicators %+v after the unlock", s.CaptureIndicators)
139+ }
140+ if len(ic.timers) != timers+1 || c.capStop == nil {
141+ t.Fatalf("timer not re-armed after the unlock: %v", ic.timers)
142+ }
143+ ic.now = ic.now.Add(ports.CaptureFlash)
144+ if !c.captureFlashTick() {
145+ t.Fatal("flash did not end after the unlock")
146+ }
147+}
148+
149+func TestCaptureIndicatorRegionAndUnion(t *testing.T) {
150+ c, _ := indicatorCore(t)
151+ c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 250, Y: 150, W: 100, H: 100}})
152+ c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 250, Y: 150, W: 100, H: 100}})
153+ c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
154+ s := indicatorScene(t, c)
155+ if len(s.CaptureIndicators) != 2 || s.CaptureIndicators[0].Rect != (Rect{X: 250, Y: 150, W: 50, H: 50}) || s.CaptureIndicators[1].Rect != (Rect{W: 300, H: 200}) {
156+ t.Fatalf("indicators %+v", s.CaptureIndicators)
157+ }
158+ // A target with nothing on screen marks nothing and stores nothing.
159+ c.captureFrame(ports.CaptureFrameTaken{Output: "gone"})
160+ c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 900, Y: 900, W: 5, H: 5}})
161+ if len(c.capFlashes) != 2 {
162+ t.Fatalf("flashes %+v", c.capFlashes)
163+ }
164+}
165+
166+func TestCaptureIndicatorRecordingLivesWithItsSession(t *testing.T) {
167+ c, ic := indicatorCore(t)
168+ c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
169+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
170+ t.Fatalf("indicator of a session that captured nothing: %+v", s.CaptureIndicators)
171+ }
172+ if !c.captureFrame(ports.CaptureFrameTaken{Session: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}}) {
173+ t.Fatal("first frame of a session shows nothing new")
174+ }
175+ // Long after the flash, the session still shows.
176+ ic.now = ic.now.Add(time.Hour)
177+ c.captureFlashTick()
178+ s := indicatorScene(t, c)
179+ if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != (Rect{X: 10, Y: 10, W: 50, H: 40}) {
180+ t.Fatalf("indicators %+v", s.CaptureIndicators)
181+ }
182+ c.captureClose(1)
183+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
184+ t.Fatalf("indicators %+v after the session", s.CaptureIndicators)
185+ }
186+}
187+
188+func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
189+ c, ws, _ := hiddenCaptureCommands(t)
190+ ic := &indicatorClock{now: time.Unix(5, 0)}
191+ clock := portsmocks.NewMockClock(t)
192+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return ic.now }).Maybe()
193+ timer := portsmocks.NewMockTimer(t)
194+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
195+ timer.EXPECT().Stop().Return(true).Maybe()
196+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
197+ c.ch.Clock = clock
198+ c.configures.cw.reset()
199+ c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
200+ c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
201+ s := indicatorScene(t, c)
202+ pill := ports.CaptureIndicator{Pill: true, Rect: Rect{X: 300 - ports.CapturePillInset - ports.CapturePillSize, Y: ports.CapturePillInset, W: ports.CapturePillSize, H: ports.CapturePillSize}}
203+ if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0] != pill {
204+ t.Fatalf("indicators %+v, want %+v", s.CaptureIndicators, pill)
205+ }
206+ if s.CaptureScene == nil || s.CaptureScene.CaptureIndicators != nil || s.CaptureScene.Capture != nil {
207+ t.Fatalf("the off-screen workspace scene carries an indicator: %+v", s.CaptureScene)
208+ }
209+ // On screen, the same session shows a border on its frame instead.
210+ c.screens[0].mon.show(ws)
211+ if c.screens[0].mon.Current() != ws {
212+ t.Fatal("workspace not on screen")
213+ }
214+ s = indicatorScene(t, c)
215+ if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Pill || s.CaptureIndicators[0].Rect != ws.Output {
216+ t.Fatalf("indicators %+v", s.CaptureIndicators)
217+ }
218+}
219+
220+// With nothing captured the indicator costs nothing in core.
221+func TestCaptureIndicatorNoAllocationWhenIdle(t *testing.T) {
222+ c, _ := indicatorCore(t)
223+ sc := c.screens[0]
224+ if n := testing.AllocsPerRun(100, func() {
225+ c.captureExpire()
226+ c.armCaptureTimer()
227+ if c.captureIndicators(sc) != nil {
228+ t.Fatal("indicators")
229+ }
230+ }); n != 0 {
231+ t.Fatalf("%v allocations with no capture", n)
232+ }
233+ // A live flash, steady state, allocates nothing either (measured on the
234+ // system clock: the generated clock mock allocates on every call).
235+ c.ch.Clock = nil
236+ c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
237+ if n := testing.AllocsPerRun(100, func() { c.captureExpire(); c.armCaptureTimer() }); n != 0 {
238+ t.Fatalf("%v allocations keeping a flash", n)
239+ }
240+}
241+
242+// A target core cannot locate (the workspace or output is gone) has no mark
243+// to show: core marks nothing, and the output owner fails such a capture
244+// after its bounded wait rather than serving it without an indicator.
245+func TestCaptureIndicatorMissingForAnUnresolvableTarget(t *testing.T) {
246+ c, _ := indicatorCore(t)
247+ for _, v := range []ports.CaptureFrameTaken{{Output: "GONE"}, {Workspace: 999}, {Output: "A", Region: Rect{X: 900, Y: 900, W: 10, H: 10}}} {
248+ if c.captureFrame(v) {
249+ t.Fatalf("%+v marked", v)
250+ }
251+ }
252+ if s := indicatorScene(t, c); s.CaptureIndicators != nil {
253+ t.Fatalf("indicators %+v for a target that is not there", s.CaptureIndicators)
254+ }
255+}
256+
257+// A target thinner than the border would have nothing to draw: core lists a
258+// mark at least ports.MinCaptureMark wide and tall around it, inside the
259+// output; an output smaller than that is marked whole.
260+func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
261+ c, _ := indicatorCore(t)
262+ for _, tc := range []struct {
263+ region, want Rect
264+ }{
265+ {Rect{X: 100, Y: 50, W: 1, H: 100}, Rect{X: 98, Y: 50, W: 5, H: 100}},
266+ {Rect{X: 100, Y: 50, W: 100, H: 1}, Rect{X: 100, Y: 48, W: 100, H: 5}},
267+ {Rect{X: 100, Y: 50, W: 1, H: 1}, Rect{X: 98, Y: 48, W: 5, H: 5}},
268+ {Rect{X: 0, Y: 0, W: 1, H: 1}, Rect{X: 0, Y: 0, W: 5, H: 5}},
269+ {Rect{X: 299, Y: 199, W: 1, H: 1}, Rect{X: 295, Y: 195, W: 5, H: 5}},
270+ {Rect{X: 100, Y: 50, W: 4, H: 4}, Rect{X: 100, Y: 50, W: 5, H: 5}},
271+ {Rect{X: 100, Y: 50, W: 5, H: 5}, Rect{X: 100, Y: 50, W: 5, H: 5}},
272+ } {
273+ c.capFlashes = nil
274+ c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: tc.region})
275+ s := indicatorScene(t, c)
276+ if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != tc.want {
277+ t.Fatalf("region %+v: marks %+v, want %+v", tc.region, s.CaptureIndicators, tc.want)
278+ }
279+ m := s.CaptureIndicators[0]
280+ if !m.Drawable() {
281+ t.Fatalf("region %+v: mark %+v draws nothing", tc.region, m)
282+ }
283+ if m.Rect.X > tc.region.X || m.Rect.Y > tc.region.Y || m.Rect.X+m.Rect.W < tc.region.X+tc.region.W || m.Rect.Y+m.Rect.H < tc.region.Y+tc.region.H {
284+ t.Fatalf("region %+v not inside its mark %+v", tc.region, m.Rect)
285+ }
286+ }
287+ // An output smaller than the minimum is marked whole.
288+ if got := thickenMark(Rect{W: 1, H: 1}, Rect{W: 3, H: 4}); got != (Rect{W: 3, H: 4}) {
289+ t.Fatalf("tiny output mark %+v", got)
290+ }
291+}
292+
293+// A recording session that keeps being published allocates nothing for its
294+// capture state: the marks, the scene capture and the session states are
295+// scratch or shared while unchanged.
296+func TestCaptureRecordingPublishAllocations(t *testing.T) {
297+ c, _ := indicatorCore(t)
298+ c.ch.Clock = nil
299+ cmds := make(chan ports.ClientCommand, 64)
300+ c.ch.Commands = cmds
301+ c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
302+ c.captureOpen(ports.CaptureSessionOpen{ID: 2, Output: "A"})
303+ for id := uint64(1); id <= 2; id++ {
304+ c.captureFrame(ports.CaptureFrameTaken{Session: id, Output: "A"})
305+ }
306+ ctx := context.Background()
307+ sc := c.screens[0]
308+ step := func() {
309+ v, err := c.captureEvaluate(ctx)
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ if c.captureSceneFor(sc, v) == nil || len(c.captureIndicators(sc)) == 0 {
314+ t.Fatal("no capture state")
315+ }
316+ }
317+ step()
318+ for len(cmds) > 0 {
319+ <-cmds
320+ }
321+ first := sc.capMarks
322+ n := testing.AllocsPerRun(100, step)
323+ t.Logf("capture state allocations per recording publish: %v", n)
324+ if n != 0 {
325+ t.Fatalf("%v allocations per recording publish", n)
326+ }
327+ if len(cmds) != 0 {
328+ t.Fatal("steady publish sent a command")
329+ }
330+ if &first[0] != &sc.capMarks[0] {
331+ t.Fatal("unchanged marks were cloned")
332+ }
333+}
334+
335+// The pill of a workspace rendered for capture only lies whole inside its
336+// output; an output too small for it (<= 8 logical px tall, < 12 wide) is
337+// filled whole instead, never cut to nothing.
338+func TestCaptureIndicatorPillFitsTheOutput(t *testing.T) {
339+ for _, tc := range []struct {
340+ name string
341+ out Rect
342+ want Rect
343+ }{
344+ {"regular", Rect{W: 300, H: 200}, Rect{X: 280, Y: 8, W: 12, H: 12}},
345+ {"short", Rect{W: 300, H: 8}, Rect{W: 300, H: 8}},
346+ {"narrow", Rect{W: 11, H: 200}, Rect{W: 11, H: 200}},
347+ {"tiny", Rect{W: 5, H: 6}, Rect{W: 5, H: 6}},
348+ {"just fits", Rect{W: 20, H: 20}, Rect{X: 0, Y: 8, W: 12, H: 12}},
349+ } {
350+ got := pillRect(tc.out)
351+ if got != tc.want {
352+ t.Fatalf("%s: pill %+v, want %+v", tc.name, got, tc.want)
353+ }
354+ m := ports.CaptureIndicator{Pill: true, Rect: got}
355+ if !m.DrawableIn(tc.out.W, tc.out.H) {
356+ t.Fatalf("%s: pill %+v not drawable in %+v", tc.name, got, tc.out)
357+ }
358+ }
359+ // A hidden workspace on a short output lists the clamped pill.
360+ c, ws, _ := hiddenCaptureCommands(t)
361+ c.screens[0].mon.SetOutput(300, 8)
362+ c.configures.cw.reset()
363+ c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
364+ c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
365+ s := indicatorScene(t, c)
366+ if len(s.CaptureIndicators) != 1 || !s.CaptureIndicators[0].Pill || s.CaptureIndicators[0].Rect != (Rect{W: 300, H: 8}) {
367+ t.Fatalf("indicators %+v", s.CaptureIndicators)
368+ }
369+}
A internal/core/capture.go +444 -0
@@ -0,0 +1,444 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Capture state (ports/capture.go). Only the core goroutine touches it.
11+//
12+// Things that live here:
13+// - the registered ext-image-copy-capture sessions, decided on every publish
14+// (target, hidden workspace, end);
15+// - the one exclusion, whose HUD layers stay visible over a fullscreen
16+// window and are left out of its session's frames;
17+// - the workspace rendered off screen for a session (Scene.CaptureScene);
18+// - the capture indicator (capindicator below): what shows on screen that
19+// something is captured, whoever captures.
20+//
21+// With no session, no exclusion and no flash none of this allocates: the
22+// scenes carry no Capture and no CaptureIndicators.
23+
24+// capTarget is what a capture covers, as asked.
25+type capTarget struct {
26+ output string
27+ workspace uint64
28+ rect Rect // output-local logical; zero is the whole output or frame
29+}
30+
31+type capSession struct {
32+ open ports.CaptureSessionOpen
33+ last ports.CaptureSessionState
34+ hasLast bool
35+ // recording: the session produced a frame. Its target shows the
36+ // indicator for as long as the session lives.
37+ recording bool
38+}
39+
40+func (s *capSession) target() capTarget {
41+ return capTarget{output: s.open.Output, workspace: s.open.Workspace, rect: s.open.Region}
42+}
43+
44+// capExclusion is the one live exclusion.
45+type capExclusion struct {
46+ session uint64
47+ layers []WindowID // attached layer surfaces, at most ports.MaxExclusionLayers
48+ // retained are detached layer surfaces still listed by a screen: they
49+ // stay excluded until a LayerChanged stops listing them, so a HUD that
50+ // is destroyed is never captured during its last frame.
51+ retained []WindowID
52+ // ended: the exclusion object was destroyed and the session lives on.
53+ // Its layers are retained only: still excluded and fenced (frames of the
54+ // session keep leaving them out) until no screen lists them, then the
55+ // exclusion is dropped. It never shows layers over a fullscreen window.
56+ ended bool
57+ // keep is the set of attached layers, shared by every screen
58+ // (screen.capture) and rebuilt only when the attached set changes.
59+ keep map[WindowID]bool
60+ rev uint64 // revision fence, bumped when what a frame must leave out changes
61+ key capExclusionKey
62+ hasKey bool
63+ // scratch is the list captureExcluded builds.
64+ scratch []WindowID
65+}
66+
67+// capExclusionKey is what the revision fence follows.
68+type capExclusionKey struct {
69+ excluded []WindowID
70+ layers []WindowID
71+ target ports.CaptureSessionState
72+}
73+
74+// capView is what one evaluation decided, for the scenes of this publish.
75+type capView struct {
76+ hidden *Workspace // captured off screen, nil when none
77+ hiddenScr *screen
78+ hiddenID uint64
79+ excluded []WindowID
80+ exclusion *capExclusion
81+}
82+
83+// capResolved is a target located on the current screens.
84+type capResolved struct {
85+ sc *screen
86+ ws *Workspace
87+ rect Rect // clipped, output-local logical
88+ hidden bool
89+}
90+
91+// capResolve locates a target. A zero reason is success.
92+func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
93+ var r capResolved
94+ if t.workspace != 0 {
95+ sc, w := c.workspaceByID(t.workspace)
96+ if w == nil || sc.name() == "" {
97+ return r, ports.CaptureReasonWorkspaceGone
98+ }
99+ r.sc, r.ws, r.rect = sc, w, w.Output
100+ r.hidden = sc.mon.Current() != w || sc.mon.ov.open
101+ return r, ports.CaptureReasonNone
102+ }
103+ i := c.screenIndex(t.output)
104+ if i < 0 || t.output == "" {
105+ return r, ports.CaptureReasonOutputGone
106+ }
107+ r.sc = c.screens[i]
108+ r.rect = r.sc.mon.Output()
109+ if t.rect.W != 0 || t.rect.H != 0 {
110+ r.rect = intersect(r.rect, t.rect)
111+ }
112+ if r.rect.W <= 0 || r.rect.H <= 0 {
113+ return r, ports.CaptureReasonInvalidRegion
114+ }
115+ return r, ports.CaptureReasonNone
116+}
117+
118+// captureOpen registers a session.
119+func (c *Core) captureOpen(v ports.CaptureSessionOpen) {
120+ c.capSessions = append(c.capSessions, &capSession{open: v})
121+}
122+
123+func (c *Core) capSession(id uint64) *capSession {
124+ for _, s := range c.capSessions {
125+ if s.open.ID == id {
126+ return s
127+ }
128+ }
129+ return nil
130+}
131+
132+// captureClose forgets a session and its exclusion. Its flashes stay.
133+func (c *Core) captureClose(id uint64) {
134+ c.capSessions = slices.DeleteFunc(c.capSessions, func(s *capSession) bool { return s.open.ID == id })
135+ if c.capExcl != nil && c.capExcl.session == id {
136+ c.dropExclusion()
137+ }
138+}
139+
140+// captureExclusionBegin starts the exclusion of a session. Wayland refused
141+// it already when another one was live.
142+func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {
143+ if c.capSession(v.Session) == nil {
144+ return
145+ }
146+ if old := c.capExcl; old != nil {
147+ if !old.ended {
148+ return
149+ }
150+ // An ended exclusion still excludes its listed HUD layers: a new one
151+ // inherits them, so they are left out until no screen lists them.
152+ c.capExcl = &capExclusion{session: v.Session, retained: old.retained, rev: old.rev}
153+ return
154+ }
155+ c.capExcl = &capExclusion{session: v.Session}
156+}
157+
158+// captureExclusionLayer attaches or detaches a layer surface.
159+func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
160+ e := c.capExcl
161+ if e == nil || e.ended || e.session != v.Session {
162+ return
163+ }
164+ i := slices.Index(e.layers, v.Layer)
165+ switch {
166+ case v.Attached && i < 0 && len(e.layers) < ports.MaxExclusionLayers:
167+ e.layers = append(e.layers, v.Layer)
168+ e.keep = nil
169+ c.clampCaptureKeyboard()
170+ case !v.Attached && i >= 0:
171+ e.layers = slices.Delete(e.layers, i, i+1)
172+ e.keep = nil
173+ if c.layerListed(v.Layer) {
174+ e.retained = append(e.retained, v.Layer)
175+ }
176+ }
177+}
178+
179+// captureExclusionEnd ends the exclusion of a session that lives on. Its
180+// layers that a screen still lists stay excluded until none does (the
181+// retained set): the recorder's frames never show a HUD that just ended.
182+func (c *Core) captureExclusionEnd(session uint64) {
183+ e := c.capExcl
184+ if e == nil || e.ended || e.session != session {
185+ return
186+ }
187+ for _, id := range e.layers {
188+ if c.layerListed(id) && !slices.Contains(e.retained, id) {
189+ e.retained = append(e.retained, id)
190+ }
191+ }
192+ e.layers, e.keep, e.ended = nil, nil, true
193+ c.pruneRetained()
194+ if c.capExcl != nil {
195+ // Attached layers no longer stay over a fullscreen window.
196+ for _, sc := range c.screens {
197+ sc.capture = nil
198+ }
199+ }
200+}
201+
202+// dropCaptureSessions forgets every session and the exclusion: the session is
203+// protected, no capture is served, and wayland has stopped them all.
204+func (c *Core) dropCaptureSessions() {
205+ c.capSessions = nil
206+ // No capture indicator outlives the lock: nothing is drawn for a capture
207+ // that can no longer happen, and none is left to show after the unlock.
208+ c.capFlashes = nil
209+ c.stopCaptureTimer()
210+ c.dropExclusion()
211+}
212+
213+func (c *Core) dropExclusion() {
214+ c.capExcl = nil
215+ c.configures.cw.reset()
216+ for _, sc := range c.screens {
217+ sc.capture = nil
218+ }
219+}
220+
221+// layerListed reports whether a screen still lists the layer surface.
222+func (c *Core) layerListed(id WindowID) bool {
223+ for _, sc := range c.screens {
224+ for _, l := range sc.layers {
225+ if l.ID == id {
226+ return true
227+ }
228+ }
229+ }
230+ return false
231+}
232+
233+// pruneRetained forgets the detached layers no screen lists any more, and
234+// the ended exclusion with the last of them.
235+func (c *Core) pruneRetained() {
236+ e := c.capExcl
237+ if e == nil {
238+ return
239+ }
240+ e.retained = slices.DeleteFunc(e.retained, func(id WindowID) bool { return !c.layerListed(id) })
241+ if e.ended && len(e.retained) == 0 {
242+ c.dropExclusion()
243+ }
244+}
245+
246+// exclusionKeep is the layers of the exclusion that stay visible over a
247+// fullscreen window. It is built once per change of the attached set and
248+// shared, read-only, by every screen.
249+func (c *Core) exclusionKeep(e *capExclusion) map[WindowID]bool {
250+ if e.ended {
251+ return nil
252+ }
253+ if e.keep == nil {
254+ e.keep = make(map[WindowID]bool, len(e.layers))
255+ for _, id := range e.layers {
256+ e.keep[id] = true
257+ }
258+ }
259+ return e.keep
260+}
261+
262+// clampCaptureKeyboard gives every attached layer surface no keyboard
263+// interactivity at all, whatever it asks for (exclusive or on demand): a
264+// capture HUD never takes or steals the keyboard. Run whenever the layer
265+// list or the attached set changes.
266+func (c *Core) clampCaptureKeyboard() {
267+ e := c.capExcl
268+ if e == nil {
269+ return
270+ }
271+ for _, sc := range c.screens {
272+ for i := range sc.layers {
273+ if sc.layers[i].Keyboard != 0 && (slices.Contains(e.layers, sc.layers[i].ID) || slices.Contains(e.retained, sc.layers[i].ID)) {
274+ sc.layers[i].Keyboard = 0
275+ }
276+ }
277+ }
278+}
279+
280+// excluded lists the attached layers and every popup hanging from one of
281+// them, sorted. Layers not mapped yet are listed too: they are excluded from
282+// their first frame.
283+// The result is scratch of the exclusion, valid until the next call.
284+func (c *Core) captureExcluded(e *capExclusion) []WindowID {
285+ out := append(append(e.scratch[:0], e.layers...), e.retained...)
286+ for id := range c.popups {
287+ if root := c.popupRoot(id); slices.Contains(e.layers, root) || slices.Contains(e.retained, root) {
288+ out = append(out, id)
289+ }
290+ }
291+ slices.Sort(out)
292+ e.scratch = out
293+ return out
294+}
295+
296+// captureEvaluate decides every session against the current screens, sends
297+// the states that changed and returns the view of this publish; nil when
298+// there is no session and no exclusion. A terminal state
299+// is sent once and the session is forgotten.
300+func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
301+ if len(c.capSessions) == 0 && c.capExcl == nil {
302+ return nil, nil
303+ }
304+ c.pruneRetained()
305+ // The scratch is the view and the states of this evaluation; nothing the
306+ // scenes or the commands keep points into it.
307+ v := &c.capScratch
308+ *v = capView{}
309+ // Decide each session. The live ones are kept in place.
310+ states := c.capStates[:0]
311+ alive := c.capSessions[:0]
312+ for i, s := range c.capSessions {
313+ st := ports.CaptureSessionState{ID: s.open.ID}
314+ r, reason := c.capResolve(s.target())
315+ if reason == ports.CaptureReasonNone {
316+ st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden
317+ if r.hidden && v.hiddenID == 0 {
318+ v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace
319+ }
320+ // One hidden workspace is rendered at a time.
321+ st.Active = !r.hidden || v.hiddenID == s.open.Workspace
322+ }
323+ st.Reason = reason
324+ states = append(states, st)
325+ if reason != ports.CaptureReasonNone {
326+ if err := c.command(ctx, st); err != nil {
327+ c.capSessions = append(alive, c.capSessions[i:]...)
328+ return nil, err
329+ }
330+ if c.capExcl != nil && c.capExcl.session == s.open.ID {
331+ c.dropExclusion()
332+ }
333+ continue
334+ }
335+ alive = append(alive, s)
336+ }
337+ clear(c.capSessions[len(alive):])
338+ c.capSessions = alive
339+ c.capStates = states
340+ // Exclusion.
341+ if e := c.capExcl; e != nil {
342+ ex := c.captureExcluded(e)
343+ if len(ex) > ports.MaxCaptureExcluded {
344+ // Fail closed: the session cannot leave them all out.
345+ st := ports.CaptureSessionState{ID: e.session, Reason: ports.CaptureReasonTooManyExcluded}
346+ if err := c.command(ctx, st); err != nil {
347+ return nil, err
348+ }
349+ c.capSessions = slices.DeleteFunc(c.capSessions, func(s *capSession) bool { return s.open.ID == e.session })
350+ c.dropExclusion()
351+ } else {
352+ v.exclusion = e
353+ keep := c.exclusionKeep(e)
354+ for _, sc := range c.screens {
355+ sc.capture = keep
356+ }
357+ var st ports.CaptureSessionState
358+ for _, x := range states {
359+ if x.ID == e.session {
360+ st = x
361+ }
362+ }
363+ if !e.hasKey || !slices.Equal(e.key.excluded, ex) || !slices.Equal(e.key.layers, e.layers) || !sameCaptureState(e.key.target, st) {
364+ e.rev++
365+ e.key = capExclusionKey{excluded: slices.Clone(ex), layers: slices.Clone(e.layers), target: st}
366+ e.hasKey = true
367+ }
368+ // What the scenes carry is the key's list, shared while it holds.
369+ v.excluded = e.key.excluded
370+ }
371+ }
372+ // Tell wayland what changed.
373+ for _, s := range c.capSessions {
374+ var st ports.CaptureSessionState
375+ for _, x := range states {
376+ if x.ID == s.open.ID {
377+ st = x
378+ }
379+ }
380+ if e := c.capExcl; e != nil && e.session == s.open.ID {
381+ st.Exclusion, st.Revision = true, e.rev
382+ // The list is sent, read by another goroutine: shared, never
383+ // rebuilt, while the attached set is the same.
384+ if s.hasLast && slices.Equal(s.last.Layers, e.layers) {
385+ st.Layers = s.last.Layers
386+ } else {
387+ st.Layers = slices.Clone(e.layers)
388+ }
389+ }
390+ if !s.hasLast || !sameCaptureState(s.last, st) {
391+ s.last, s.hasLast = st, true
392+ if err := c.command(ctx, st); err != nil {
393+ return nil, err
394+ }
395+ }
396+ }
397+ return v, nil
398+}
399+
400+// sameCaptureState reports whether two session states say the same.
401+func sameCaptureState(a, b ports.CaptureSessionState) bool {
402+ return a.ID == b.ID && a.Output == b.Output && a.Rect == b.Rect && a.Workspace == b.Workspace &&
403+ a.Hidden == b.Hidden && a.Active == b.Active && a.Reason == b.Reason &&
404+ a.Exclusion == b.Exclusion && a.Revision == b.Revision && slices.Equal(a.Layers, b.Layers)
405+}
406+
407+// captureSceneFor is the capture state of one screen's scene; nil when
408+// there is no session. Shown is the workspace drawn as itself on screen.
409+func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
410+ if v == nil || len(c.capSessions) == 0 && v.exclusion == nil {
411+ return nil
412+ }
413+ want := ports.SceneCapture{}
414+ if !sc.mon.ov.open {
415+ want.Shown = sc.mon.Current().ID
416+ }
417+ if e := v.exclusion; e != nil {
418+ want.Session, want.Revision, want.Excluded = e.session, e.rev, v.excluded
419+ }
420+ if v.hiddenScr == sc {
421+ want.Workspace = v.hiddenID
422+ }
423+ // The scene is read by the output owner and never changed: the last one
424+ // is shared while it says the same.
425+ if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&
426+ p.Workspace == want.Workspace && slices.Equal(p.Excluded, want.Excluded) {
427+ return p
428+ }
429+ shared := want
430+ sc.capScene = &shared
431+ return sc.capScene
432+}
433+
434+// workspaceByID finds a workspace, numbered or hidden, on any screen.
435+func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
436+ for _, sc := range c.screens {
437+ for _, w := range sc.mon.all() {
438+ if w.ID == id {
439+ return sc, w
440+ }
441+ }
442+ }
443+ return nil, nil
444+}
A internal/core/capture_internal_test.go +76 -0
@@ -0,0 +1,76 @@
1+package core
2+
3+import (
4+ "context"
5+ "reflect"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// While an exclusion is live, the set of layers kept over a fullscreen window
13+// is built once per change of the attached set and shared by every screen: a
14+// publish allocates nothing for it.
15+func TestExclusionKeepIsSharedAndBuiltOncePerChange(t *testing.T) {
16+ c, _, _ := hiddenCaptureCommands(t)
17+ c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 100})
18+ c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A"})
19+ c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 1})
20+ c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true})
21+ c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true})
22+ e := c.capExcl
23+ keep := c.exclusionKeep(e)
24+ if len(keep) != 2 || !keep[10] || !keep[11] {
25+ t.Fatalf("keep %v", keep)
26+ }
27+ if n := testing.AllocsPerRun(100, func() { _ = c.exclusionKeep(e) }); n != 0 {
28+ t.Fatalf("%v allocations for an unchanged exclusion", n)
29+ }
30+ if err := c.publish(context.Background()); err != nil {
31+ t.Fatal(err)
32+ }
33+ for _, sc := range c.screens {
34+ if reflect.ValueOf(sc.capture).Pointer() != reflect.ValueOf(keep).Pointer() {
35+ t.Fatalf("screen %s has its own keep set", sc.name())
36+ }
37+ }
38+ // A change of the attached set builds a new one, once.
39+ c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 11})
40+ if k := c.exclusionKeep(e); len(k) != 1 || !k[10] {
41+ t.Fatalf("keep after detach %v", k)
42+ }
43+ // An ended exclusion keeps nothing over a fullscreen window.
44+ c.screens[0].layers = []ports.LayerSurface{{ID: 10}}
45+ c.captureExclusionEnd(1)
46+ if c.capExcl == nil || !c.capExcl.ended || !slices.Equal(c.capExcl.retained, []WindowID{10}) {
47+ t.Fatalf("ended exclusion %+v", c.capExcl)
48+ }
49+ if k := c.exclusionKeep(c.capExcl); k != nil {
50+ t.Fatalf("keep of an ended exclusion %v", k)
51+ }
52+ // With no layer listed any more it is gone.
53+ c.screens[0].layers = nil
54+ c.pruneRetained()
55+ if c.capExcl != nil {
56+ t.Fatalf("exclusion survived its last listed layer: %+v", c.capExcl)
57+ }
58+}
59+
60+// sameCaptureState compares every field of the session state, by hand. A new
61+// field must be compared there, or a change of it is never sent to wayland.
62+func TestSameCaptureStateComparesEveryField(t *testing.T) {
63+ const compared = 10 // ID Output Rect Workspace Hidden Active Reason Exclusion Revision Layers
64+ if n := reflect.TypeFor[ports.CaptureSessionState]().NumField(); n != compared {
65+ t.Fatalf("ports.CaptureSessionState has %d fields, sameCaptureState compares %d: compare the new field in sameCaptureState (capture.go), then update this count", n, compared)
66+ }
67+ base := ports.CaptureSessionState{}
68+ for _, v := range []ports.CaptureSessionState{
69+ {ID: 1}, {Output: "A"}, {Rect: Rect{W: 1}}, {Workspace: 1}, {Hidden: true}, {Active: true},
70+ {Reason: ports.CaptureReasonOutputGone}, {Exclusion: true}, {Revision: 1}, {Layers: []WindowID{1}},
71+ } {
72+ if sameCaptureState(base, v) {
73+ t.Fatalf("a change of %+v is not seen", v)
74+ }
75+ }
76+}
D internal/core/capture_session.go +0 -301
@@ -1,301 +0,0 @@
1-package core
2-
3-import (
4- "context"
5- "reflect"
6- "slices"
7- "time"
8-
9- "github.com/bnema/neferwl/internal/ports"
10-)
11-
12-// captureSession is the one live private capture session (ports/capture_session.go).
13-// Core owns its state; wayland only relays requests and decisions.
14-type captureSession struct {
15- begin ports.CaptureSessionBegin
16- layers []WindowID // attached layer surfaces, at most ports.MaxCaptureSessionLayers
17- // retained are detached layer surfaces still listed by a screen: they
18- // stay excluded until a LayerChanged stops listing them, so a HUD that
19- // is destroyed is never captured during its last frame.
20- retained []WindowID
21- expired bool // no ping within ports.CaptureSessionTimeout
22- rev uint64 // revision fence, bumped when the decision changes
23- last captureView
24- hasLast bool
25-}
26-
27-// captureView is what one evaluation of the session decided.
28-type captureView struct {
29- state ports.CaptureSessionState
30- output string
31- scene *ports.SceneCapture // nil unless Active
32- // hidden is set when the targeted workspace is not drawn as itself on
33- // screen (another one is, or the overview previews it): it is rendered
34- // for capture only (Scene.CaptureScene) and the physical scene carries no
35- // border for it.
36- hidden bool
37-}
38-
39-// captureBegin registers a session, or refuses it as busy: the live session
40-// is not disturbed.
41-func (c *Core) captureBegin(ctx context.Context, v ports.CaptureSessionBegin) error {
42- if c.capture != nil {
43- return c.command(ctx, ports.CaptureSessionState{ID: v.ID, Output: v.Output, Workspace: v.Workspace, Reason: ports.CaptureReasonBusy})
44- }
45- c.capture = &captureSession{begin: v}
46- c.armCapture()
47- return nil
48-}
49-
50-// captureLayer attaches or detaches a layer surface of the live session.
51-func (c *Core) captureLayer(v ports.CaptureSessionLayer) {
52- s := c.capture
53- if s == nil || s.begin.ID != v.ID {
54- return
55- }
56- i := slices.Index(s.layers, v.Layer)
57- switch {
58- case v.Attached && i < 0 && len(s.layers) < ports.MaxCaptureSessionLayers:
59- s.layers = append(s.layers, v.Layer)
60- c.clampCaptureKeyboard()
61- case !v.Attached && i >= 0:
62- s.layers = slices.Delete(s.layers, i, i+1)
63- if c.layerListed(v.Layer) {
64- s.retained = append(s.retained, v.Layer)
65- }
66- }
67-}
68-
69-// layerListed reports whether a screen still lists the layer surface.
70-func (c *Core) layerListed(id WindowID) bool {
71- for _, sc := range c.screens {
72- for _, l := range sc.layers {
73- if l.ID == id {
74- return true
75- }
76- }
77- }
78- return false
79-}
80-
81-// pruneRetained forgets the detached layers no screen lists any more.
82-func (c *Core) pruneRetained() {
83- if c.capture != nil {
84- c.capture.retained = slices.DeleteFunc(c.capture.retained, func(id WindowID) bool { return !c.layerListed(id) })
85- }
86-}
87-
88-// clampCaptureKeyboard gives every attached layer surface no keyboard
89-// interactivity at all, whatever it asks for (exclusive or on demand): a
90-// capture HUD never takes or steals the keyboard. Run whenever the layer
91-// list or the attached set changes.
92-func (c *Core) clampCaptureKeyboard() {
93- if c.capture == nil {
94- return
95- }
96- for _, sc := range c.screens {
97- for i := range sc.layers {
98- if sc.layers[i].Keyboard != 0 && (slices.Contains(c.capture.layers, sc.layers[i].ID) || slices.Contains(c.capture.retained, sc.layers[i].ID)) {
99- sc.layers[i].Keyboard = 0
100- }
101- }
102- }
103-}
104-
105-// captureEnd forgets the session on an explicit stop or its owner's disconnect.
106-func (c *Core) captureEnd(id uint64) {
107- if c.capture != nil && c.capture.begin.ID == id {
108- c.dropCapture()
109- }
110-}
111-
112-// capturePing restarts the keep-alive timer.
113-func (c *Core) capturePing(id uint64) {
114- if c.capture != nil && c.capture.begin.ID == id {
115- c.armCapture()
116- }
117-}
118-
119-func (c *Core) dropCapture() {
120- c.capture = nil
121- c.stopCapture()
122- c.configures.cw.reset()
123- for _, sc := range c.screens {
124- sc.capture = nil
125- }
126-}
127-
128-// armCapture (re)starts the keep-alive timer; it exists only while a session does.
129-func (c *Core) armCapture() {
130- c.stopCapture()
131- if c.ch.Clock != nil {
132- t := c.ch.Clock.NewTimer(ports.CaptureSessionTimeout)
133- c.captureC, c.captureStop = t.C(), t.Stop
134- return
135- }
136- t := time.NewTimer(ports.CaptureSessionTimeout)
137- c.captureC, c.captureStop = t.C, t.Stop
138-}
139-
140-func (c *Core) stopCapture() {
141- if c.captureStop != nil {
142- c.captureStop()
143- }
144- c.captureC, c.captureStop = nil, nil
145-}
146-
147-// captureExpired marks the session for the next publish to end it.
148-func (c *Core) captureExpired() {
149- c.captureC, c.captureStop = nil, nil
150- if c.capture != nil {
151- c.capture.expired = true
152- }
153-}
154-
155-// captureEvaluate decides the state of the session against the current
156-// screens, sends it when it changed and returns the view; nil without a
157-// session. A terminal state is sent once and the session is forgotten.
158-func (c *Core) captureEvaluate(ctx context.Context) (*captureView, error) {
159- s := c.capture
160- if s == nil {
161- return nil, nil
162- }
163- c.pruneRetained()
164- view := c.captureDecide(s)
165- if view.state.Reason.Terminal() {
166- c.dropCapture()
167- return nil, c.command(ctx, view.state)
168- }
169- // Screens keep the attached layers visible over a fullscreen window
170- // only while the session serves captures.
171- for _, sc := range c.screens {
172- sc.capture = nil
173- }
174- if view.state.Active {
175- if i := c.screenIndex(view.output); i >= 0 {
176- keep := make(map[WindowID]bool, len(s.layers))
177- for _, id := range s.layers {
178- keep[id] = true
179- }
180- c.screens[i].capture = keep
181- }
182- }
183- // The revision grows whenever what a clean capture must do changes; the
184- // scene of this publish carries it, as does the state sent now.
185- view.state.Layers = slices.Clone(s.layers)
186- changed := !s.hasLast || !sameCapture(s.last, view)
187- if changed {
188- s.rev++
189- }
190- view.state.Revision = s.rev
191- if view.scene != nil {
192- view.scene.Revision = s.rev
193- }
194- s.last, s.hasLast = view, true
195- if changed {
196- if err := c.command(ctx, view.state); err != nil {
197- return nil, err
198- }
199- }
200- return &view, nil
201-}
202-
203-// sameCapture compares two decisions, revision aside.
204-func sameCapture(a, b captureView) bool {
205- a.state.Revision, b.state.Revision = 0, 0
206- if a.scene != nil {
207- s := *a.scene
208- s.Revision = 0
209- a.scene = &s
210- }
211- if b.scene != nil {
212- s := *b.scene
213- s.Revision = 0
214- b.scene = &s
215- }
216- return reflect.DeepEqual(a, b)
217-}
218-
219-// captureDecide is the pure part of captureEvaluate.
220-func (c *Core) captureDecide(s *captureSession) captureView {
221- b := s.begin
222- st := ports.CaptureSessionState{ID: b.ID, Output: b.Output, Workspace: b.Workspace}
223- v := captureView{state: st, output: b.Output}
224- stop := func(r ports.CaptureReason) captureView {
225- v.state.Reason = r
226- return v
227- }
228- if s.expired {
229- return stop(ports.CaptureReasonPingTimeout)
230- }
231- i := c.screenIndex(b.Output)
232- if i < 0 || b.Output == "" {
233- return stop(ports.CaptureReasonOutputGone)
234- }
235- sc := c.screens[i]
236- if sc.off {
237- return stop(ports.CaptureReasonOutputOff)
238- }
239- m := sc.mon
240- // target is the area captures cover, before the session's own region.
241- target := m.Output()
242- var origin Rect // the viewport origin of a hidden workspace's CaptureScene
243- if b.Workspace != 0 {
244- owner, w := c.workspaceByID(b.Workspace)
245- if w == nil || owner != sc {
246- return stop(ports.CaptureReasonWorkspaceGone)
247- }
248- target, origin = w.Output, Rect{X: w.Output.X, Y: w.Output.Y}
249- v.hidden = m.Current() != w || m.ov.open
250- }
251- r := b.Region
252- if r.W > 0 && r.H > 0 {
253- target = intersect(target, r)
254- }
255- if target.W <= 0 || target.H <= 0 {
256- return stop(ports.CaptureReasonInvalidRegion)
257- }
258- v.state.Rect = target
259- excluded := c.captureExcluded(s)
260- if len(excluded) > ports.MaxCaptureExcluded {
261- return stop(ports.CaptureReasonTooManyExcluded)
262- }
263- v.state.Active = true
264- scene := &ports.SceneCapture{Session: b.ID, TargetRect: target, Excluded: excluded, Workspace: b.Workspace}
265- if v.hidden {
266- // Rendered from Scene.CaptureScene, whose origin is the workspace
267- // viewport's: TargetRect follows it.
268- scene.TargetRect.X -= origin.X
269- scene.TargetRect.Y -= origin.Y
270- } else if b.Record {
271- scene.BorderWidth, scene.BorderColor = ports.CaptureBorderWidth, ports.CaptureBorderColor
272- }
273- v.scene = scene
274- return v
275-}
276-
277-// captureExcluded lists the attached layers and every popup hanging from
278-// one of them, sorted. Layers not mapped yet are listed too: they are
279-// excluded from their first frame.
280-func (c *Core) captureExcluded(s *captureSession) []WindowID {
281- out := append(slices.Clone(s.layers), s.retained...)
282- for id := range c.popups {
283- if root := c.popupRoot(id); slices.Contains(s.layers, root) || slices.Contains(s.retained, root) {
284- out = append(out, id)
285- }
286- }
287- slices.Sort(out)
288- return out
289-}
290-
291-// workspaceByID finds a workspace, numbered or hidden, on any screen.
292-func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
293- for _, sc := range c.screens {
294- for _, w := range sc.mon.all() {
295- if w.ID == id {
296- return sc, w
297- }
298- }
299- }
300- return nil, nil
301-}
D internal/core/capture_session_test.go +0 -420
@@ -1,420 +0,0 @@
1-package core_test
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/bnema/neferwl/internal/adapters/config"
9- "github.com/bnema/neferwl/internal/core"
10- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11- "github.com/bnema/neferwl/internal/ports"
12-)
13-
14-type captureRig struct {
15- client chan ports.ClientEvent
16- output chan ports.OutputEvent
17- commands chan ports.ClientCommand
18- scenes chan []ports.Scene
19- workspaces chan ports.Workspaces
20-}
21-
22-// captureCore runs a core with one 100x80 output OUT-1; a nil clock gets a
23-// keep-alive timer that never fires.
24-func captureCore(t *testing.T, clock ports.Clock) *captureRig {
25- t.Helper()
26- cfg := config.Defaults()
27- cfg.Border.Width = 0
28- r := &captureRig{
29- client: make(chan ports.ClientEvent, 16),
30- output: make(chan ports.OutputEvent, 8),
31- commands: make(chan ports.ClientCommand, 64),
32- scenes: make(chan []ports.Scene, 1),
33- workspaces: make(chan ports.Workspaces, 1),
34- }
35- if clock == nil {
36- // The keep-alive timer never fires.
37- timer := portsmocks.NewMockTimer(t)
38- timer.EXPECT().Stop().Return(true).Maybe()
39- timer.EXPECT().C().Return((<-chan time.Time)(nil)).Maybe()
40- mc := portsmocks.NewMockClock(t)
41- mc.EXPECT().Now().RunAndReturn(time.Now).Maybe()
42- mc.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Maybe()
43- clock = mc
44- }
45- c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces, Clock: clock})
46- if err != nil {
47- t.Fatal(err)
48- }
49- ctx, cancel := context.WithCancel(context.Background())
50- t.Cleanup(cancel)
51- go c.Run(ctx)
52- r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
53- scene(t, r.scenes)
54- return r
55-}
56-
57-// state returns the next CaptureSessionState, skipping other commands.
58-func (r *captureRig) state(t *testing.T) ports.CaptureSessionState {
59- t.Helper()
60- for {
61- if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok {
62- return v
63- }
64- }
65-}
66-
67-// sceneWith returns the next scene whose capture presence equals want.
68-func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene {
69- t.Helper()
70- for {
71- if s := scene(t, r.scenes); (s.Capture != nil) == want {
72- return s
73- }
74- }
75-}
76-
77-func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface {
78- return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard}
79-}
80-
81-func TestCaptureSessionOutputTarget(t *testing.T) {
82- r := captureCore(t, nil)
83- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}, Record: true}
84- st := r.state(t)
85- if !st.Active || st.Workspace != 0 || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) {
86- t.Fatalf("state %+v", st)
87- }
88- s := r.sceneWith(t, true)
89- want := &ports.SceneCapture{Session: 1, TargetRect: st.Rect, Excluded: []ports.WindowID{}, BorderWidth: ports.CaptureBorderWidth, BorderColor: ports.CaptureBorderColor}
90- if s.Capture.Session != 1 || s.Capture.TargetRect != want.TargetRect || len(s.Capture.Excluded) != 0 || s.Capture.BorderWidth != 2 || s.Capture.BorderColor != "#ff3b30" || s.Capture.Workspace != 0 {
91- t.Fatalf("capture %+v", s.Capture)
92- }
93- // Without Record there is no border, and a zero region is the whole output.
94- r.client <- ports.CaptureSessionEnd{ID: 1}
95- r.sceneWith(t, false)
96- r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"}
97- if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) {
98- t.Fatalf("state %+v", st)
99- }
100- if s := r.sceneWith(t, true); s.Capture.BorderWidth != 0 || s.Capture.BorderColor != "" {
101- t.Fatalf("border without record: %+v", s.Capture)
102- }
103-}
104-
105-func TestCaptureSessionWorkspaceTarget(t *testing.T) {
106- r := captureCore(t, nil)
107- r.client <- ports.WindowMapped{ID: 1}
108- scene(t, r.scenes)
109- ws := receive(t, r.workspaces).Outputs[0].Workspaces
110- if ws[0].Frame != (ports.Rect{W: 100, H: 80}) {
111- t.Fatalf("frame %+v", ws[0])
112- }
113- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID}
114- if st := r.state(t); !st.Active || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame {
115- t.Fatalf("state %+v", st)
116- }
117- if s := r.sceneWith(t, true); s.Capture.Workspace != ws[0].ID {
118- t.Fatalf("capture %+v", s.Capture)
119- }
120- r.client <- ports.CaptureSessionEnd{ID: 1}
121- r.sceneWith(t, false)
122- // A workspace that does not exist ends the session.
123- r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Workspace: 9999}
124- if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() {
125- t.Fatalf("state %+v", st)
126- }
127-}
128-
129-func TestCaptureSessionTerminalReasons(t *testing.T) {
130- r := captureCore(t, nil)
131- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "NOPE"}
132- if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active {
133- t.Fatalf("state %+v", st)
134- }
135- r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}}
136- if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion {
137- t.Fatalf("state %+v", st)
138- }
139- // An unplugged output ends the session.
140- r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"}
141- if st := r.state(t); !st.Active {
142- t.Fatalf("state %+v", st)
143- }
144- r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
145- r.output <- ports.OutputRemoved{Name: "OUT-1"}
146- if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone {
147- t.Fatalf("state %+v", st)
148- }
149- r.sceneWith(t, false)
150-}
151-
152-func TestCaptureSessionBusy(t *testing.T) {
153- r := captureCore(t, nil)
154- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
155- if st := r.state(t); st.ID != 1 || !st.Active {
156- t.Fatalf("state %+v", st)
157- }
158- r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"}
159- if st := r.state(t); st.ID != 2 || st.Reason != ports.CaptureReasonBusy || st.Active {
160- t.Fatalf("state %+v", st)
161- }
162- // The first session is undisturbed and ends only on its own request.
163- r.client <- ports.CaptureSessionEnd{ID: 2}
164- r.client <- ports.CaptureSessionEnd{ID: 1}
165- r.sceneWith(t, false)
166- r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"}
167- if st := r.state(t); st.ID != 3 || !st.Active {
168- t.Fatalf("state %+v", st)
169- }
170-}
171-
172-// Attached layers stay over a fullscreen window while the session is active,
173-// are excluded with their popups, and other layers keep hiding.
174-func TestCaptureSessionLayersOverFullscreen(t *testing.T) {
175- r := captureCore(t, nil)
176- r.client <- ports.WindowMapped{ID: 1}
177- scene(t, r.scenes)
178- r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
179- scene(t, r.scenes)
180- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}}
181- if s := scene(t, r.scenes); len(s.Layers) != 0 {
182- t.Fatalf("fullscreen should hide layers: %+v", s.Layers)
183- }
184- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
185- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
186- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} // duplicate
187- r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
188- r.client <- ports.PopupMapped{ID: 20}
189- var s ports.Scene
190- for {
191- s = r.sceneWith(t, true)
192- if len(s.Capture.Excluded) == 2 {
193- break
194- }
195- }
196- if s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 {
197- t.Fatalf("excluded %v", s.Capture.Excluded)
198- }
199- if len(s.Layers) != 1 || s.Layers[0].ID != 10 {
200- t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers)
201- }
202- r.client <- ports.CaptureSessionEnd{ID: 1}
203- if s = r.sceneWith(t, false); len(s.Layers) != 0 {
204- t.Fatalf("layers after the session: %+v", s.Layers)
205- }
206-}
207-
208-func TestCaptureSessionLayerBounds(t *testing.T) {
209- r := captureCore(t, nil)
210- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
211- for id := ports.WindowID(1); id <= ports.MaxCaptureSessionLayers+3; id++ {
212- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: id, Attached: true}
213- }
214- r.client <- ports.CaptureSessionLayer{ID: 99, Layer: 50, Attached: true} // another session
215- var s ports.Scene
216- for {
217- s = r.sceneWith(t, true)
218- if len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers {
219- break
220- }
221- }
222- for _, id := range s.Capture.Excluded {
223- if id > ports.MaxCaptureSessionLayers {
224- t.Fatalf("excluded %v", s.Capture.Excluded)
225- }
226- }
227- // Detaching removes one.
228- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 1}
229- for {
230- if s = r.sceneWith(t, true); len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers-1 {
231- break
232- }
233- }
234-}
235-
236-// An attached layer asking for exclusive keyboard never gets it; on-demand stays.
237-func TestCaptureSessionKeyboardClamp(t *testing.T) {
238- r := captureCore(t, nil)
239- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}}
240- scene(t, r.scenes)
241- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
242- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
243- r.sceneWith(t, true)
244- // The HUD commits exclusive afterwards; an unrelated layer may have it.
245- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}}
246- for len(r.commands) > 0 {
247- <-r.commands
248- }
249- scene(t, r.scenes)
250- for {
251- f, ok := receive(t, r.commands).(ports.FocusWindow)
252- if ok {
253- if f.ID != 11 {
254- t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID)
255- }
256- return
257- }
258- }
259-}
260-
261-func TestCaptureSessionPingTimeout(t *testing.T) {
262- fire := make(chan time.Time)
263- timer := portsmocks.NewMockTimer(t)
264- timer.EXPECT().Stop().Return(true).Maybe()
265- timer.EXPECT().C().Return(fire)
266- clock := portsmocks.NewMockClock(t)
267- clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
268- clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Once()
269- r := captureCore(t, clock)
270- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
271- if st := r.state(t); !st.Active {
272- t.Fatalf("state %+v", st)
273- }
274- fire <- time.Now()
275- if st := r.state(t); st.ID != 1 || st.Reason != ports.CaptureReasonPingTimeout || st.Active {
276- t.Fatalf("state %+v", st)
277- }
278- r.sceneWith(t, false)
279-}
280-
281-// A ping restarts the timer; the session then times out from the last one.
282-func TestCaptureSessionPingRestartsTimer(t *testing.T) {
283- first, second := make(chan time.Time), make(chan time.Time)
284- t1, t2 := portsmocks.NewMockTimer(t), portsmocks.NewMockTimer(t)
285- t1.EXPECT().Stop().Return(true).Once()
286- t1.EXPECT().C().Return(first)
287- t2.EXPECT().Stop().Return(true).Maybe()
288- t2.EXPECT().C().Return(second)
289- clock := portsmocks.NewMockClock(t)
290- clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
291- clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t1).Once()
292- clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t2).Once()
293- r := captureCore(t, clock)
294- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
295- r.state(t)
296- r.client <- ports.CaptureSessionPing{ID: 1}
297- r.client <- ports.CaptureSessionPing{ID: 7} // unknown: no effect
298- r.client <- ports.WindowMapped{ID: 1} // a round trip through the owner loop
299- scene(t, r.scenes)
300- second <- time.Now()
301- if st := r.state(t); st.Reason != ports.CaptureReasonPingTimeout {
302- t.Fatalf("state %+v", st)
303- }
304-}
305-
306-// Every change a clean capture depends on bumps the revision, in the state
307-// and in the scene that carries it; an unchanged republish does not.
308-func TestCaptureSessionRevisionFence(t *testing.T) {
309- r := captureCore(t, nil)
310- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
311- st := r.state(t)
312- if st.Revision != 1 || len(st.Layers) != 0 {
313- t.Fatalf("state %+v", st)
314- }
315- if s := r.sceneWith(t, true); s.Capture.Revision != 1 {
316- t.Fatalf("scene revision %d", s.Capture.Revision)
317- }
318- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
319- st = r.state(t)
320- if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 {
321- t.Fatalf("state after attach %+v", st)
322- }
323- var s ports.Scene
324- for {
325- if s = r.sceneWith(t, true); s.Capture.Revision == 2 {
326- break
327- }
328- }
329- if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
330- t.Fatalf("scene %+v", s.Capture)
331- }
332- // An unrelated publish keeps the revision and sends no state.
333- r.client <- ports.WindowMapped{ID: 1}
334- if s = r.sceneWith(t, true); s.Capture.Revision != 2 {
335- t.Fatalf("revision moved without a change: %d", s.Capture.Revision)
336- }
337- for len(r.commands) > 0 {
338- if v, ok := (<-r.commands).(ports.CaptureSessionState); ok {
339- t.Fatalf("state without a change: %+v", v)
340- }
341- }
342- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
343- if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 {
344- t.Fatalf("state after detach %+v", st)
345- }
346-}
347-
348-// A detached layer stays excluded until a LayerChanged stops listing it, so
349-// the last frame of a destroyed HUD never leaks into a capture.
350-func TestCaptureSessionDetachRetainsExclusion(t *testing.T) {
351- r := captureCore(t, nil)
352- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
353- scene(t, r.scenes)
354- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
355- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
356- for {
357- if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 {
358- break
359- }
360- }
361- // Detach while the layer is still listed: still excluded.
362- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
363- s := r.sceneWith(t, true)
364- if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
365- t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture)
366- }
367- // Once the layer unmaps, the exclusion goes.
368- r.client <- ports.LayerChanged{Layers: nil}
369- for {
370- if s = r.sceneWith(t, true); len(s.Capture.Excluded) == 0 {
371- break
372- }
373- }
374-}
375-
376-// An unmap queued before the detach (the wayland order) never leaves a
377-// window where the layer is listed but not excluded.
378-func TestCaptureSessionUnmapThenDetach(t *testing.T) {
379- r := captureCore(t, nil)
380- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
381- scene(t, r.scenes)
382- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
383- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
384- for {
385- if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 {
386- break
387- }
388- }
389- r.client <- ports.LayerChanged{Layers: nil}
390- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
391- for {
392- s := r.sceneWith(t, true)
393- if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 {
394- t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture)
395- }
396- if len(s.Layers) == 0 {
397- return
398- }
399- }
400-}
401-
402-// An attached layer never gets keyboard interactivity, exclusive or on demand.
403-func TestCaptureSessionKeyboardNone(t *testing.T) {
404- r := captureCore(t, nil)
405- r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}}
406- scene(t, r.scenes)
407- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
408- r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
409- r.sceneWith(t, true)
410- for len(r.commands) > 0 {
411- <-r.commands
412- }
413- r.client <- ports.WindowMapped{ID: 1}
414- scene(t, r.scenes)
415- for len(r.commands) > 0 {
416- if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 {
417- t.Fatalf("keyboard went to the attached layer")
418- }
419- }
420-}
A internal/core/capture_test.go +601 -0
@@ -0,0 +1,601 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/config"
11+ "github.com/bnema/neferwl/internal/core"
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+type captureRig struct {
17+ client chan ports.ClientEvent
18+ output chan ports.OutputEvent
19+ commands chan ports.ClientCommand
20+ scenes chan []ports.Scene
21+ workspaces chan ports.Workspaces
22+}
23+
24+// captureCore runs a core with one 100x80 output OUT-1.
25+func captureCore(t *testing.T) *captureRig {
26+ t.Helper()
27+ cfg := config.Defaults()
28+ cfg.Border.Width = 0
29+ r := &captureRig{
30+ client: make(chan ports.ClientEvent, 16),
31+ output: make(chan ports.OutputEvent, 8),
32+ commands: make(chan ports.ClientCommand, 64),
33+ scenes: make(chan []ports.Scene, 1),
34+ workspaces: make(chan ports.Workspaces, 1),
35+ }
36+ c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ ctx, cancel := context.WithCancel(context.Background())
41+ t.Cleanup(cancel)
42+ go c.Run(ctx)
43+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
44+ scene(t, r.scenes)
45+ return r
46+}
47+
48+// state returns the next CaptureSessionState, skipping other commands.
49+func (r *captureRig) state(t *testing.T) ports.CaptureSessionState {
50+ t.Helper()
51+ for {
52+ if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok {
53+ return v
54+ }
55+ }
56+}
57+
58+// sceneWith returns the next scene whose capture presence equals want.
59+func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene {
60+ t.Helper()
61+ for {
62+ if s := scene(t, r.scenes); (s.Capture != nil) == want {
63+ return s
64+ }
65+ }
66+}
67+
68+func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface {
69+ return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard}
70+}
71+
72+// excludedScene waits for a scene whose exclusion list has n entries.
73+func (r *captureRig) excludedScene(t *testing.T, n int) ports.Scene {
74+ t.Helper()
75+ for {
76+ if s := r.sceneWith(t, true); len(s.Capture.Excluded) == n {
77+ return s
78+ }
79+ }
80+}
81+
82+func TestCaptureSessionOutputTarget(t *testing.T) {
83+ r := captureCore(t)
84+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}}
85+ st := r.state(t)
86+ if !st.Active || st.Hidden || st.Workspace != 0 || st.Output != "OUT-1" || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) {
87+ t.Fatalf("state %+v", st)
88+ }
89+ // Without an exclusion the scene carries no exclusion and no hidden workspace.
90+ if s := r.sceneWith(t, true); s.Capture.Session != 0 || len(s.Capture.Excluded) != 0 || s.Capture.Workspace != 0 || s.CaptureScene != nil {
91+ t.Fatalf("capture %+v", s.Capture)
92+ }
93+ r.client <- ports.CaptureSessionClose{ID: 1}
94+ r.sceneWith(t, false)
95+ // A zero region is the whole output.
96+ r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
97+ if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) {
98+ t.Fatalf("state %+v", st)
99+ }
100+}
101+
102+func TestCaptureSessionWorkspaceTarget(t *testing.T) {
103+ r := captureCore(t)
104+ r.client <- ports.WindowMapped{ID: 1}
105+ scene(t, r.scenes)
106+ ws := receive(t, r.workspaces).Outputs[0].Workspaces
107+ if ws[0].Frame != (ports.Rect{W: 100, H: 80}) {
108+ t.Fatalf("frame %+v", ws[0])
109+ }
110+ r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
111+ if st := r.state(t); !st.Active || st.Hidden || st.Output != "OUT-1" || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame {
112+ t.Fatalf("state %+v", st)
113+ }
114+ if s := r.sceneWith(t, true); s.Capture.Shown != ws[0].ID || s.Capture.Workspace != 0 {
115+ t.Fatalf("capture %+v", s.Capture)
116+ }
117+ r.client <- ports.CaptureSessionClose{ID: 1}
118+ r.sceneWith(t, false)
119+ // A workspace that does not exist ends the session.
120+ r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: 9999}
121+ if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() {
122+ t.Fatalf("state %+v", st)
123+ }
124+}
125+
126+// Only one hidden workspace is rendered at a time: a second session on
127+// another hidden workspace is not Active until the first one ends.
128+func TestCaptureSessionOneHiddenWorkspaceAtATime(t *testing.T) {
129+ r := captureCore(t)
130+ r.client <- ports.WindowMapped{ID: 1}
131+ scene(t, r.scenes)
132+ var ws []ports.WorkspaceInfo
133+ for len(ws) < 2 {
134+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
135+ }
136+ // Workspace 2 is on screen after this; 1 and a third are hidden.
137+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
138+ r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
139+ if st := r.state(t); !st.Active || !st.Hidden {
140+ t.Fatalf("first hidden session %+v", st)
141+ }
142+ r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: ws[0].ID}
143+ r.client <- ports.CaptureSessionOpen{ID: 3, Workspace: ws[1].ID}
144+ for {
145+ if st := r.state(t); st.ID == 3 {
146+ if !st.Active || st.Hidden {
147+ t.Fatalf("session on the workspace on screen %+v", st)
148+ }
149+ break
150+ }
151+ }
152+}
153+
154+func TestCaptureSessionTerminalReasons(t *testing.T) {
155+ r := captureCore(t)
156+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "NOPE"}
157+ if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active {
158+ t.Fatalf("state %+v", st)
159+ }
160+ r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}}
161+ if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion {
162+ t.Fatalf("state %+v", st)
163+ }
164+ // An unplugged output ends the session.
165+ r.client <- ports.CaptureSessionOpen{ID: 3, Output: "OUT-1"}
166+ if st := r.state(t); !st.Active {
167+ t.Fatalf("state %+v", st)
168+ }
169+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
170+ r.output <- ports.OutputRemoved{Name: "OUT-1"}
171+ if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone {
172+ t.Fatalf("state %+v", st)
173+ }
174+ r.sceneWith(t, false)
175+}
176+
177+// Several sessions live at once; each is decided on its own.
178+func TestCaptureSessionsAreIndependent(t *testing.T) {
179+ r := captureCore(t)
180+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
181+ r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{W: 10, H: 10}}
182+ got := map[uint64]ports.CaptureSessionState{}
183+ for len(got) < 2 {
184+ st := r.state(t)
185+ got[st.ID] = st
186+ }
187+ if !got[1].Active || !got[2].Active || got[2].Rect != (ports.Rect{W: 10, H: 10}) {
188+ t.Fatalf("states %+v", got)
189+ }
190+ r.client <- ports.CaptureSessionClose{ID: 1}
191+ r.client <- ports.CaptureSessionClose{ID: 7} // unknown: ignored
192+ r.client <- ports.WindowMapped{ID: 1}
193+ r.sceneWith(t, true) // session 2 lives on
194+}
195+
196+// An exclusion needs a live session; attached layers stay over a fullscreen
197+// window, are excluded with their popups, and other layers keep hiding.
198+func TestCaptureExclusionLayersOverFullscreen(t *testing.T) {
199+ r := captureCore(t)
200+ r.client <- ports.WindowMapped{ID: 1}
201+ scene(t, r.scenes)
202+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
203+ scene(t, r.scenes)
204+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}}
205+ if s := scene(t, r.scenes); len(s.Layers) != 0 {
206+ t.Fatalf("fullscreen should hide layers: %+v", s.Layers)
207+ }
208+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
209+ r.client <- ports.CaptureExclusionBegin{Session: 1}
210+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
211+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true} // duplicate
212+ r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
213+ r.client <- ports.PopupMapped{ID: 20}
214+ s := r.excludedScene(t, 2)
215+ if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 {
216+ t.Fatalf("capture %+v", s.Capture)
217+ }
218+ if len(s.Layers) != 1 || s.Layers[0].ID != 10 {
219+ t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers)
220+ }
221+ // The exclusion ends with its session.
222+ r.client <- ports.CaptureSessionClose{ID: 1}
223+ if s = r.sceneWith(t, false); len(s.Layers) != 0 {
224+ t.Fatalf("layers after the session: %+v", s.Layers)
225+ }
226+}
227+
228+// The exclusion of a session that is not registered, or of another session
229+// than the live one, does nothing.
230+func TestCaptureExclusionNeedsItsSession(t *testing.T) {
231+ r := captureCore(t)
232+ r.client <- ports.CaptureExclusionBegin{Session: 5} // unknown session
233+ r.client <- ports.CaptureExclusionLayer{Session: 5, Layer: 10, Attached: true}
234+ r.client <- ports.WindowMapped{ID: 1}
235+ if s := scene(t, r.scenes); s.Capture != nil {
236+ t.Fatalf("capture without session: %+v", s.Capture)
237+ }
238+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
239+ r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
240+ r.client <- ports.CaptureExclusionBegin{Session: 1}
241+ r.client <- ports.CaptureExclusionBegin{Session: 2} // only one is live
242+ r.client <- ports.CaptureExclusionLayer{Session: 2, Layer: 10, Attached: true} // not the owner
243+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true}
244+ if s := r.excludedScene(t, 1); s.Capture.Session != 1 || s.Capture.Excluded[0] != 11 {
245+ t.Fatalf("capture %+v", s.Capture)
246+ }
247+}
248+
249+// Wayland ends an exclusion whose object was destroyed; the session goes on.
250+func TestCaptureExclusionEndKeepsSession(t *testing.T) {
251+ r := captureCore(t)
252+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
253+ r.client <- ports.CaptureExclusionBegin{Session: 1}
254+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
255+ r.excludedScene(t, 1)
256+ r.client <- ports.CaptureExclusionEnd{Session: 1}
257+ for {
258+ s := r.sceneWith(t, true)
259+ if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
260+ break
261+ }
262+ }
263+ // A new exclusion can begin on the live session.
264+ r.client <- ports.CaptureExclusionBegin{Session: 1}
265+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 12, Attached: true}
266+ if s := r.excludedScene(t, 1); s.Capture.Excluded[0] != 12 {
267+ t.Fatalf("capture %+v", s.Capture)
268+ }
269+}
270+
271+// When the exclusion object is destroyed and the session goes on, its HUD
272+// layers that a screen still lists stay excluded (and are no longer kept over
273+// a fullscreen window) until they are not listed: the recorder's frames never
274+// show the HUD after the exclusion ended.
275+func TestCaptureExclusionEndKeepsListedLayersExcluded(t *testing.T) {
276+ r := captureCore(t)
277+ r.client <- ports.WindowMapped{ID: 1}
278+ scene(t, r.scenes)
279+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
280+ scene(t, r.scenes)
281+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
282+ scene(t, r.scenes)
283+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
284+ r.client <- ports.CaptureExclusionBegin{Session: 1}
285+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
286+ s := r.excludedScene(t, 1)
287+ if len(s.Layers) != 1 {
288+ t.Fatalf("attached layer shows over fullscreen: %+v", s.Layers)
289+ }
290+ before := s.Capture.Revision
291+ r.client <- ports.CaptureExclusionEnd{Session: 1}
292+ s = r.excludedScene(t, 1)
293+ if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Revision <= before {
294+ t.Fatalf("ended exclusion's layer must stay excluded and fenced: %+v", s.Capture)
295+ }
296+ if len(s.Layers) != 0 {
297+ t.Fatalf("an ended exclusion keeps nothing over fullscreen: %+v", s.Layers)
298+ }
299+ st := r.state(t)
300+ for st.Revision != s.Capture.Revision {
301+ st = r.state(t)
302+ }
303+ if !st.Exclusion || len(st.Layers) != 0 {
304+ t.Fatalf("state %+v", st)
305+ }
306+ // The layer goes: nothing is excluded any more and the fence is lifted.
307+ r.client <- ports.LayerChanged{}
308+ for {
309+ s = r.sceneWith(t, true)
310+ if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
311+ break
312+ }
313+ }
314+ for st := r.state(t); st.Exclusion; st = r.state(t) {
315+ }
316+}
317+
318+func TestCaptureExclusionLayerBounds(t *testing.T) {
319+ r := captureCore(t)
320+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
321+ r.client <- ports.CaptureExclusionBegin{Session: 1}
322+ for id := ports.WindowID(1); id <= ports.MaxExclusionLayers+3; id++ {
323+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: id, Attached: true}
324+ }
325+ s := r.excludedScene(t, ports.MaxExclusionLayers)
326+ for _, id := range s.Capture.Excluded {
327+ if id > ports.MaxExclusionLayers {
328+ t.Fatalf("excluded %v", s.Capture.Excluded)
329+ }
330+ }
331+ // Detaching removes one.
332+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 1}
333+ r.excludedScene(t, ports.MaxExclusionLayers-1)
334+}
335+
336+// More excluded surfaces than a capture can leave out (layers and popups)
337+// stop the session: fail closed.
338+func TestCaptureExclusionTooManyExcluded(t *testing.T) {
339+ r := captureCore(t)
340+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
341+ scene(t, r.scenes)
342+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
343+ r.client <- ports.CaptureExclusionBegin{Session: 1}
344+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
345+ stopped := make(chan struct{})
346+ go func() {
347+ // Drain the commands while the popups are sent.
348+ for cmd := range r.commands {
349+ if st, ok := cmd.(ports.CaptureSessionState); ok && st.Reason == ports.CaptureReasonTooManyExcluded {
350+ close(stopped)
351+ return
352+ }
353+ }
354+ }()
355+ for i := range ports.MaxCaptureExcluded {
356+ r.client <- ports.PopupRequest{ID: ports.WindowID(100 + i), Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
357+ r.client <- ports.PopupMapped{ID: ports.WindowID(100 + i)}
358+ }
359+ select {
360+ case <-stopped:
361+ case <-time.After(5 * time.Second):
362+ t.Fatal("session not stopped")
363+ }
364+}
365+
366+// An attached layer asking for exclusive keyboard never gets it; an
367+// unrelated layer may.
368+func TestCaptureExclusionKeyboardClamp(t *testing.T) {
369+ r := captureCore(t)
370+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}}
371+ scene(t, r.scenes)
372+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
373+ r.client <- ports.CaptureExclusionBegin{Session: 1}
374+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
375+ r.excludedScene(t, 1)
376+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}}
377+ for len(r.commands) > 0 {
378+ <-r.commands
379+ }
380+ scene(t, r.scenes)
381+ for {
382+ if f, ok := receive(t, r.commands).(ports.FocusWindow); ok {
383+ if f.ID != 11 {
384+ t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID)
385+ }
386+ return
387+ }
388+ }
389+}
390+
391+// An attached layer never gets keyboard interactivity, exclusive or on demand.
392+func TestCaptureExclusionKeyboardNone(t *testing.T) {
393+ r := captureCore(t)
394+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}}
395+ scene(t, r.scenes)
396+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
397+ r.client <- ports.CaptureExclusionBegin{Session: 1}
398+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
399+ r.excludedScene(t, 1)
400+ for len(r.commands) > 0 {
401+ <-r.commands
402+ }
403+ r.client <- ports.WindowMapped{ID: 1}
404+ scene(t, r.scenes)
405+ for len(r.commands) > 0 {
406+ if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 {
407+ t.Fatalf("keyboard went to the attached layer")
408+ }
409+ }
410+}
411+
412+// Every change an excluded frame depends on bumps the revision, in the state
413+// and in the scene that carries it; an unchanged republish does not.
414+func TestCaptureExclusionRevisionFence(t *testing.T) {
415+ r := captureCore(t)
416+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
417+ if st := r.state(t); st.Exclusion || st.Revision != 0 {
418+ t.Fatalf("state without exclusion %+v", st)
419+ }
420+ r.client <- ports.CaptureExclusionBegin{Session: 1}
421+ st := r.state(t)
422+ if !st.Exclusion || st.Revision != 1 || len(st.Layers) != 0 {
423+ t.Fatalf("state %+v", st)
424+ }
425+ for {
426+ if s := r.sceneWith(t, true); s.Capture.Revision == 1 {
427+ break
428+ }
429+ }
430+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
431+ st = r.state(t)
432+ if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 {
433+ t.Fatalf("state after attach %+v", st)
434+ }
435+ var s ports.Scene
436+ for {
437+ if s = r.sceneWith(t, true); s.Capture.Revision == 2 {
438+ break
439+ }
440+ }
441+ if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
442+ t.Fatalf("scene %+v", s.Capture)
443+ }
444+ // An unrelated publish keeps the revision and sends no state.
445+ r.client <- ports.WindowMapped{ID: 1}
446+ if s = r.sceneWith(t, true); s.Capture.Revision != 2 {
447+ t.Fatalf("revision moved without a change: %d", s.Capture.Revision)
448+ }
449+ for len(r.commands) > 0 {
450+ if v, ok := (<-r.commands).(ports.CaptureSessionState); ok {
451+ t.Fatalf("state without a change: %+v", v)
452+ }
453+ }
454+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
455+ if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 {
456+ t.Fatalf("state after detach %+v", st)
457+ }
458+}
459+
460+// A detached layer stays excluded until a LayerChanged stops listing it, so
461+// the last frame of a destroyed HUD never leaks into a capture.
462+func TestCaptureExclusionDetachRetains(t *testing.T) {
463+ r := captureCore(t)
464+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
465+ scene(t, r.scenes)
466+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
467+ r.client <- ports.CaptureExclusionBegin{Session: 1}
468+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
469+ r.excludedScene(t, 1)
470+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
471+ if s := r.sceneWith(t, true); len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
472+ t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture)
473+ }
474+ r.client <- ports.LayerChanged{Layers: nil}
475+ r.excludedScene(t, 0)
476+}
477+
478+// An unmap queued before the detach (the wayland order) never leaves a
479+// window where the layer is listed but not excluded.
480+func TestCaptureExclusionUnmapThenDetach(t *testing.T) {
481+ r := captureCore(t)
482+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
483+ scene(t, r.scenes)
484+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
485+ r.client <- ports.CaptureExclusionBegin{Session: 1}
486+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
487+ r.excludedScene(t, 1)
488+ r.client <- ports.LayerChanged{Layers: nil}
489+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
490+ for {
491+ s := r.sceneWith(t, true)
492+ if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 {
493+ t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture)
494+ }
495+ if len(s.Layers) == 0 {
496+ return
497+ }
498+ }
499+}
500+
501+// A captured frame shows the indicator for one second on the clock core
502+// reads, then the scene goes back without it, whoever asked for the frame.
503+func TestCaptureFrameFlashRunsOnTheCoreClock(t *testing.T) {
504+ cfg := config.Defaults()
505+ cfg.Border.Width = 0
506+ fire := make(chan time.Time)
507+ timer := portsmocks.NewMockTimer(t)
508+ timer.EXPECT().C().Return((<-chan time.Time)(fire))
509+ timer.EXPECT().Stop().Return(true).Maybe()
510+ var now atomic.Int64
511+ clock := portsmocks.NewMockClock(t)
512+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }).Maybe()
513+ clock.EXPECT().NewTimer(ports.CaptureFlash).Return(timer).Once()
514+ client := make(chan ports.ClientEvent, 4)
515+ output := make(chan ports.OutputEvent, 4)
516+ scenes := make(chan []ports.Scene, 1)
517+ c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Clock: clock})
518+ if err != nil {
519+ t.Fatal(err)
520+ }
521+ ctx, cancel := context.WithCancel(context.Background())
522+ t.Cleanup(cancel)
523+ go c.Run(ctx)
524+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
525+ scene(t, scenes)
526+ // A wlr-screencopy frame: no session.
527+ client <- ports.CaptureFrameTaken{Output: "OUT-1", Region: ports.Rect{X: 10, Y: 10, W: 20, H: 20}}
528+ if s := scene(t, scenes); len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != (ports.Rect{X: 10, Y: 10, W: 20, H: 20}) || s.CaptureIndicators[0].Pill {
529+ t.Fatalf("indicators %+v", s.CaptureIndicators)
530+ }
531+ now.Store(1)
532+ fire <- time.Time{}
533+ if s := scene(t, scenes); s.CaptureIndicators != nil {
534+ t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
535+ }
536+}
537+
538+// popupIDs lists the popups of a scene.
539+func popupIDs(s ports.Scene) []ports.WindowID {
540+ var ids []ports.WindowID
541+ for _, w := range s.Windows {
542+ if w.Popup {
543+ ids = append(ids, w.ID)
544+ }
545+ }
546+ return ids
547+}
548+
549+// A popup of a HUD layer is excluded for as long as it is in the scene, from
550+// the session's end to the layer's: when the session closes, or the layer is
551+// destroyed, the popup is never left in the scene while no longer excluded.
552+func TestCaptureExclusionPopupOfHUDLayerNeverLeaksWhenTheExclusionEnds(t *testing.T) {
553+ setup := func(t *testing.T) *captureRig {
554+ r := captureCore(t)
555+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
556+ r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
557+ r.client <- ports.CaptureExclusionBegin{Session: 1}
558+ r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
559+ r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
560+ r.client <- ports.PopupMapped{ID: 20}
561+ for {
562+ s := r.excludedScene(t, 2)
563+ if ids := popupIDs(s); len(ids) == 1 && ids[0] == 20 {
564+ break
565+ }
566+ }
567+ return r
568+ }
569+ // leaks: a scene that draws the popup without excluding it, while a
570+ // session that could still capture it with the exclusion is alive.
571+ t.Run("exclusion object destroyed, session lives, layer goes away", func(t *testing.T) {
572+ r := setup(t)
573+ r.client <- ports.CaptureExclusionEnd{Session: 1}
574+ // The layer is still listed: it and its popup stay excluded.
575+ s := r.excludedScene(t, 2)
576+ if ids := popupIDs(s); len(ids) != 1 || !slices.Contains(s.Capture.Excluded, 20) {
577+ t.Fatalf("popup %v of a retained HUD layer not excluded: %+v", ids, s.Capture)
578+ }
579+ r.client <- ports.LayerChanged{} // the layer is destroyed
580+ for {
581+ s := scene(t, r.scenes)
582+ if len(s.Layers) != 0 {
583+ continue
584+ }
585+ for _, w := range s.Windows {
586+ if w.ID == 20 && (s.Capture == nil || !slices.Contains(s.Capture.Excluded, 20)) {
587+ t.Fatalf("popup of a destroyed layer stays in the scene, unexcluded: %+v", s.Capture)
588+ }
589+ }
590+ break
591+ }
592+ })
593+ t.Run("session closed", func(t *testing.T) {
594+ r := setup(t)
595+ r.client <- ports.CaptureSessionClose{ID: 1}
596+ s := r.sceneWith(t, false)
597+ if ids := popupIDs(s); len(ids) != 0 && len(s.Layers) == 0 {
598+ t.Fatalf("popup %v outlived its HUD layer's exclusion while the layer is hidden", ids)
599+ }
600+ })
601+}
M internal/core/capture_workspace.go +6 -12
@@ -62,21 +62,15 @@ func (cw *captureWorkspace) placement(id WindowID) *Placement {
6262 // on screen, or the overview shows it as a preview) and forgets it
6363 // otherwise. It runs
6464 // right after captureEvaluate, before the popups and configures that use it.
65-func (c *Core) captureTrack(view *captureView) {
66- // Tracking follows the non-terminal view, not its Active flag: while the
67- // captured workspace slides in its own viewport its windows stay captured
68- // (not suspended) and its popups stay. A terminal or absent session has no
69- // view.
70- if view == nil || !view.hidden || view.state.Workspace == 0 {
65+func (c *Core) captureTrack(view *capView) {
66+ // Tracking follows the decided view, not whether frames were taken: while
67+ // the captured workspace slides in its own viewport its windows stay
68+ // captured (not suspended) and its popups stay.
69+ if view == nil || view.hidden == nil {
7170 c.configures.cw.reset()
7271 return
7372 }
74- sc, ws := c.workspaceByID(view.state.Workspace)
75- if ws == nil || sc == nil || sc.name() != view.output || (sc.mon.Current() == ws && !sc.mon.ov.open) {
76- c.configures.cw.reset()
77- return
78- }
79- c.configures.cw.load(sc, ws)
73+ c.configures.cw.load(view.hiddenScr, view.hidden)
8074 }
8175
8276 // captureConfigure picks how a physical placement p on sc is configured:
M internal/core/capture_workspace_e2e_test.go +3 -3
@@ -20,7 +20,7 @@ func (r *captureRig) configureOf(t *testing.T, id ports.WindowID) ports.Configur
2020 // stays active, the scene carries a sibling CaptureScene, and the window is
2121 // configured Captured (sized, physically invisible) until the session stops.
2222 func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) {
23- r := captureCore(t, nil)
23+ r := captureCore(t)
2424 r.client <- ports.WindowMapped{ID: 1}
2525 scene(t, r.scenes)
2626 // Mapping the window adds a spare workspace: wait for the inventory that lists it.
@@ -28,7 +28,7 @@ func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) {
2828 for len(ws) < 2 {
2929 ws = receive(t, r.workspaces).Outputs[0].Workspaces
3030 }
31- r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID}
31+ r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
3232 if st := r.state(t); !st.Active {
3333 t.Fatalf("state %+v", st)
3434 }
@@ -72,7 +72,7 @@ func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) {
7272 }
7373
7474 // Stopping the session hides the window for real again.
75- r.client <- ports.CaptureSessionEnd{ID: 1}
75+ r.client <- ports.CaptureSessionClose{ID: 1}
7676 var after ports.Scene
7777 for {
7878 if after = scene(t, r.scenes); after.Capture == nil {
M internal/core/capture_workspace_test.go +6 -6
@@ -190,7 +190,7 @@ func TestCaptureWorkspaceReusesStorage(t *testing.T) {
190190 func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
191191 c, ws, _ := hiddenCaptureCommands(t)
192192 c.configures.cw.reset()
193- c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}}
193+ c.capSessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195195 c.popupOrder = append(c.popupOrder, 10)
196196 ws.motion = newMotion(viewSpring(10, 0), time.Time{})
@@ -198,8 +198,8 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
198198 if err := c.publish(ctx); err != nil {
199199 t.Fatal(err)
200200 }
201- if v := c.capture.last; !v.hidden || !v.state.Active || v.state.Reason.Terminal() {
202- t.Fatalf("session view %+v", v)
201+ if v := c.capSessions[0].last; !v.Hidden || !v.Active || v.Reason.Terminal() {
202+ t.Fatalf("session state %+v", v)
203203 }
204204 if !c.configures.cw.active() || c.popups[10] == nil {
205205 t.Fatal("sliding session lost its captured workspace or popup")
@@ -212,10 +212,10 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
212212 t.Fatal(err)
213213 }
214214 if !c.configures.cw.active() {
215- t.Fatalf("captured workspace lost after the slide: %+v", c.capture.last.state)
215+ t.Fatalf("captured workspace lost after the slide: %+v", c.capSessions[0].last)
216216 }
217217 // The session ends: the captured workspace is forgotten and its popup goes.
218- c.dropCapture()
218+ c.captureClose(1)
219219 if err := c.publish(ctx); err != nil {
220220 t.Fatal(err)
221221 }
@@ -250,7 +250,7 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
250250 before[p.ID] = v
251251 }
252252 m.ToggleOverview()
253- c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}}
253+ c.capSessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
254254 if err := c.publish(ctx); err != nil {
255255 t.Fatal(err)
256256 }
M internal/core/core.go +34 -24
@@ -121,11 +121,18 @@ type Core struct {
121121 // last position sent in the pointer's window.
122122 motionMsec uint32
123123 pointerAt [2]float64
124- // capture is the live private capture session (capture_session.go);
125- // captureC fires when its owner stopped pinging.
126- capture *captureSession
127- captureC <-chan time.Time
128- captureStop func() bool
124+ // capSessions and capExcl are the capture state (capture.go).
125+ capSessions []*capSession
126+ capExcl *capExclusion
127+ // capScratch, capStates and capMarks are scratch of one publish, reused.
128+ capScratch capView
129+ capStates []ports.CaptureSessionState
130+ capMarks []ports.CaptureIndicator
131+ // capFlashes are the targets of captured frames still flashing; capC
132+ // fires at the earliest of them (capindicator.go).
133+ capFlashes []capFlash
134+ capC <-chan time.Time
135+ capStop func() bool
129136 }
130137
131138 func keyName(s string) string {
@@ -470,6 +477,7 @@ func (c *Core) publish(ctx context.Context) error {
470477 if c.security.Protected {
471478 return c.publishProtected(ctx)
472479 }
480+ c.captureExpire()
473481 capture, err := c.captureEvaluate(ctx)
474482 if err != nil {
475483 return err
@@ -557,8 +565,8 @@ func (c *Core) publish(ctx context.Context) error {
557565 }
558566 }
559567 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
560- if capture != nil && capture.scene != nil && sc.name() == capture.output {
561- scene.Capture = capture.scene
568+ scene.CaptureIndicators = c.captureIndicators(sc)
569+ if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil && capture.hiddenScr == sc {
562570 scene.CaptureScene = c.captureScene(scene.Seq)
563571 }
564572 scenes = append(scenes, scene)
@@ -593,6 +601,7 @@ func (c *Core) publish(ctx context.Context) error {
593601 latest(c.ch.Scenes, scenes)
594602 c.publishState()
595603 c.publishWorkspaces()
604+ c.armCaptureTimer()
596605 // A running slide moves on the next flip, or on the fallback timer.
597606 if c.animating() {
598607 if c.frameC == nil {
@@ -872,7 +881,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
872881 func (c *Core) Run(ctx context.Context) error {
873882 c.syncSecurity()
874883 defer c.stopFrame()
875- defer c.stopCapture()
884+ defer c.stopCaptureTimer()
876885 // Startup commands run once per session. Full launcher queues retain a
877886 // bounded remainder, selectable alongside owner events without polling.
878887 c.startup = make([][]string, len(c.cfg.Startup))
@@ -914,17 +923,21 @@ func (c *Core) Run(ctx context.Context) error {
914923 if !c.applyLockChanged(v) {
915924 continue
916925 }
917- case ports.CaptureSessionBegin:
918- if c.captureBegin(ctx, v) != nil {
919- return nil
926+ case ports.CaptureSessionOpen:
927+ c.captureOpen(v)
928+ case ports.CaptureSessionClose:
929+ c.captureClose(v.ID)
930+ case ports.CaptureFrameTaken:
931+ // Nothing new to show (the target flashes already): no scene.
932+ if !c.captureFrame(v) {
933+ continue
920934 }
921- case ports.CaptureSessionLayer:
922- c.captureLayer(v)
923- case ports.CaptureSessionPing:
924- c.capturePing(v.ID)
925- continue
926- case ports.CaptureSessionEnd:
927- c.captureEnd(v.ID)
935+ case ports.CaptureExclusionBegin:
936+ c.captureExclusionBegin(v)
937+ case ports.CaptureExclusionLayer:
938+ c.captureExclusionLayer(v)
939+ case ports.CaptureExclusionEnd:
940+ c.captureExclusionEnd(v.Session)
928941 case ports.LayerChanged:
929942 c.layerChanged = true
930943 c.setLayers(v.Layers)
@@ -1042,12 +1055,9 @@ func (c *Core) Run(ctx context.Context) error {
10421055 return nil
10431056 }
10441057 continue
1045- case <-c.captureC:
1046- if c.securityCheckpoint(ctx) != nil {
1047- return nil
1048- }
1049- c.captureExpired()
1050- if c.publish(ctx) != nil {
1058+ case <-c.capC:
1059+ c.capC, c.capStop = nil, nil
1060+ if c.captureFlashTick() && c.publish(ctx) != nil {
10511061 return nil
10521062 }
10531063 continue
M internal/core/screens.go +6 -2
@@ -18,9 +18,13 @@ type screen struct {
1818 off bool // turned off by a client (output power management)
1919 layers []ports.LayerSurface
2020 placed []ports.SceneLayer
21- // capture holds the layer surfaces of the active capture session on
22- // this output: shown over a fullscreen window (capture_session.go).
21+ // capture holds the attached layers of the capture exclusion: shown over
22+ // a fullscreen window (capture.go).
2323 capture map[WindowID]bool
24+ // capScene and capMarks are what the last scene carried (Capture and
25+ // CaptureIndicators): immutable, shared by the next one while unchanged.
26+ capScene *ports.SceneCapture
27+ capMarks []ports.CaptureIndicator
2428 }
2529
2630 func (s *screen) name() string { return s.info.Name }
M internal/core/screens_test.go +19 -4
@@ -675,10 +675,10 @@ func TestStateSnapshot(t *testing.T) {
675675 }
676676 want := ports.State{
677677 Output: "DP-2",
678- Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}},
678+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
679679 Windows: []ports.WindowState{
680- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true},
681- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true},
680+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
681+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
682682 },
683683 }
684684 want.Window = &want.Windows[1]
@@ -689,7 +689,8 @@ func TestStateSnapshot(t *testing.T) {
689689 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
690690 receive(t, r.scenes)
691691 st = receive(t, r.state)
692- if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil {
692+ // The window keeps its workspace ID; the output now shows the new one.
693+ if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
693694 t.Fatalf("%+v", st)
694695 }
695696 }
@@ -714,6 +715,20 @@ func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
714715 if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
715716 t.Fatalf("%+v", st)
716717 }
718+ // The hidden workspace has its own ID, and the window keeps the one of
719+ // the workspace it is on.
720+ if st.Outputs[0].WorkspaceID == 0 || st.Windows[0].WorkspaceID == 0 || st.Outputs[0].WorkspaceID == st.Windows[0].WorkspaceID {
721+ t.Fatalf("workspace IDs: %+v", st)
722+ }
723+ // A window on the configured workspace carries its name; the ID string
724+ // of ext-workspace and the state file is built from it.
725+ r.client <- ports.WindowMapped{ID: 2, PID: 200}
726+ receive(t, r.scenes)
727+ for st = receive(t, r.state); len(st.Windows) < 2; st = receive(t, r.state) {
728+ }
729+ if st.Windows[0].WorkspaceName != "" || st.Windows[1].WorkspaceName != "notes" || st.Windows[1].WorkspaceID != st.Outputs[0].WorkspaceID {
730+ t.Fatalf("workspace names: %+v", st.Windows)
731+ }
717732 }
718733
719734 func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
M internal/core/security.go +2 -2
@@ -40,7 +40,7 @@ func (c *Core) syncSecurity() bool {
4040 }
4141 c.stopFrame()
4242 if state.Protected {
43- c.dropCapture()
43+ c.dropCaptureSessions()
4444 }
4545 return true
4646 }
@@ -73,7 +73,7 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool {
7373 switch ev.(type) {
7474 case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,
7575 ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest,
76- ports.CaptureSessionBegin, ports.CaptureSessionLayer, ports.CaptureSessionPing:
76+ ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer:
7777 return true
7878 }
7979 return false
M internal/core/security_internal_test.go +7 -4
@@ -3,6 +3,7 @@ package core
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
89 "github.com/bnema/neferwl/internal/ports"
@@ -14,7 +15,9 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
1415 gate := portsmocks.NewMockSessionSecurity(t)
1516 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
1617 c.ch.Security = gate
17- c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 7, Output: "A", Workspace: ws.ID}}
18+ c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})
19+ c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})
20+ c.capFlashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}
1821 c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}}
1922 c.pressed["password"] = true
2023 c.buttons[0x110] = true
@@ -27,10 +30,10 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
2730 }
2831 scenes := <-c.ch.Scenes
2932 s := scenes[0]
30- if s.Security != state || s.Capture != nil || s.CaptureScene != nil || len(s.Windows) != 0 || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Background != "#000000" {
33+ if s.Security != state || s.Capture != nil || s.CaptureScene != nil || len(s.CaptureIndicators) != 0 || len(s.Windows) != 0 || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Background != "#000000" {
3134 t.Fatalf("unsafe protected scene %+v", s)
3235 }
33- if c.capture != nil || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil {
36+ if len(c.capSessions) != 0 || c.capExcl != nil || len(c.capFlashes) != 0 || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil {
3437 t.Fatal("transition kept desktop input/capture state")
3538 }
3639 // Logical dimensions must match exactly, including after output changes.
@@ -42,7 +45,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
4245 if c.lockKeyboardFocus() != 10 {
4346 t.Fatal("mapped role not focused")
4447 }
45- for _, ev := range []ports.ClientEvent{ports.WindowActivate{ID: 1}, ports.WorkspaceActivate{}, ports.PointerWarp{ID: 1}, ports.PointerConstrained{ID: 1}, ports.PopupRequest{}, ports.CaptureSessionBegin{}} {
48+ for _, ev := range []ports.ClientEvent{ports.WindowActivate{ID: 1}, ports.WorkspaceActivate{}, ports.PointerWarp{ID: 1}, ports.PointerConstrained{ID: 1}, ports.PopupRequest{}, ports.CaptureSessionOpen{}, ports.CaptureFrameTaken{}, ports.CaptureExclusionBegin{}, ports.CaptureExclusionLayer{}} {
4649 if !blockedProtectedEvent(ev) {
4750 t.Fatalf("allowed desktop effect %T", ev)
4851 }
M internal/core/state.go +2 -2
@@ -31,7 +31,7 @@ func (c *Core) state() ports.State {
3131 }
3232 m := s.mon
3333 cur := m.Current()
34- o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name}
34+ o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name, WorkspaceID: cur.ID}
3535 if i := indexOf(m.Workspaces, cur); i >= 0 {
3636 o.Active = i + 1
3737 }
@@ -45,7 +45,7 @@ func (c *Core) state() ports.State {
4545 n := indexOf(m.Workspaces, w) + 1
4646 for _, id := range w.windows() {
4747 rec := c.windows.lookup(id)
48- v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)}
48+ v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)}
4949 if i := w.stashIndex(id); i >= 0 {
5050 v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden
5151 }
A internal/ports/capture.go +229 -0
@@ -0,0 +1,229 @@
1+package ports
2+
3+import "time"
4+
5+// Capture sessions, exclusion and workspace sources.
6+//
7+// Core owns every decision; wayland relays requests and what core decided.
8+// Registered sessions are the ext-image-copy-capture sessions
9+// (CaptureSessionOpen). One of them at most has an exclusion
10+// (neferwl_capture_exclusion_v1): the layer surfaces its HUD attached stay
11+// visible over a fullscreen window and are left out of that session's frames.
12+//
13+// Every capture is shown on screen by the compositor, never by the client
14+// (CaptureIndicator): a border on the target of a recording session for as
15+// long as it lives, and on the target of every captured frame for
16+// CaptureFlash after it. No capture image ever holds an indicator.
17+
18+const (
19+ // MaxExclusionLayers bounds the layer surfaces attached to an exclusion.
20+ MaxExclusionLayers = 4
21+ // MaxCaptureExcluded bounds SceneCapture.Excluded: attached layers and
22+ // their popup descendants.
23+ MaxCaptureExcluded = 128
24+ // CaptureBorderWidth and CaptureBorderColor are the indicator border, in
25+ // logical pixels and #rrggbb. CapturePillSize, CapturePillInset and
26+ // CapturePillRadius are the marker of a target that is not on screen
27+ // (a workspace rendered for capture only): a rounded square in the
28+ // top-right corner of its output, in logical pixels.
29+ CaptureBorderWidth = 2
30+ CaptureBorderColor = "#ff3b30"
31+ CapturePillSize = 12
32+ CapturePillInset = 8
33+ CapturePillRadius = 3
34+ // CaptureFlash is how long the indicator of a captured frame stays on
35+ // screen after that frame.
36+ CaptureFlash = time.Second
37+)
38+
39+// CaptureReason says why a registered session ends. The zero value is none.
40+type CaptureReason string
41+
42+const (
43+ CaptureReasonNone CaptureReason = ""
44+ // Every reason ends the session: core forgets it and wayland stops it.
45+ CaptureReasonOutputGone CaptureReason = "output-gone"
46+ CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"
47+ CaptureReasonInvalidRegion CaptureReason = "invalid-region" // nothing of the region is left on the output
48+ // TooManyExcluded: the attached layers and their popups exceed
49+ // MaxCaptureExcluded; a capture could not leave them all out (fail closed).
50+ CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"
51+)
52+
53+// Terminal reports whether a state with this reason ends the session.
54+func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone }
55+
56+// CaptureSessionOpen carries wayland → core a new ext-image-copy-capture
57+// session. Its target is an output (Workspace 0), whole when Region is zero,
58+// else the part of it in Region (output-local logical pixels, clipped), or a
59+// workspace (ID of WorkspaceInfo.ID): its frame, on whichever output owns it
60+// now. ID is chosen by wayland and unique for the process lifetime.
61+type CaptureSessionOpen struct {
62+ ID uint64
63+ Output string
64+ Workspace uint64
65+ Region Rect
66+}
67+
68+func (CaptureSessionOpen) clientEvent() {}
69+
70+// CaptureSessionClose carries wayland → core the end of a session, for any
71+// reason. Unknown IDs are ignored.
72+type CaptureSessionClose struct{ ID uint64 }
73+
74+func (CaptureSessionClose) clientEvent() {}
75+
76+// CaptureFrameTaken carries wayland → core a capture accepted for rendering,
77+// from any protocol: a wlr-screencopy frame (Session 0) or a frame of an
78+// ext-image-copy-capture session. Output and Region, or Workspace, are the
79+// target as the client asked for it: an output (Region zero is the whole
80+// output, else output-local logical pixels, clipped by core) or a workspace
81+// (Workspace is its ID, Output and Region are empty). Core shows the
82+// indicator on that target for CaptureFlash after it, and while Session
83+// lives when it is not 0.
84+type CaptureFrameTaken struct {
85+ Session uint64
86+ Output string
87+ Workspace uint64
88+ Region Rect
89+}
90+
91+func (CaptureFrameTaken) clientEvent() {}
92+
93+// CaptureExclusionBegin carries wayland → core the exclusion of a session.
94+// Wayland has already refused it when another one is live.
95+type CaptureExclusionBegin struct{ Session uint64 }
96+
97+func (CaptureExclusionBegin) clientEvent() {}
98+
99+// CaptureExclusionLayer carries wayland → core a layer surface the session's
100+// authorised HUD attached (or detached, when it was destroyed). Layer is the
101+// LayerSurface.ID, known before the surface maps. Wayland has already checked
102+// the layer is top or overlay and that it never takes the keyboard
103+// exclusively; at most MaxExclusionLayers are attached.
104+type CaptureExclusionLayer struct {
105+ Session uint64
106+ Layer WindowID
107+ Attached bool
108+}
109+
110+func (CaptureExclusionLayer) clientEvent() {}
111+
112+// CaptureExclusionEnd carries wayland → core the end of an exclusion whose
113+// session lives on (its object was destroyed). A session that closes ends
114+// its exclusion with it. The layers of an ended exclusion that a screen still
115+// lists stay excluded and fenced (CaptureSessionState.Exclusion, Revision)
116+// until none is listed, so no frame of the session shows the HUD afterwards.
117+type CaptureExclusionEnd struct{ Session uint64 }
118+
119+func (CaptureExclusionEnd) clientEvent() {}
120+
121+// CaptureSessionState carries core → wayland what core decided about a
122+// registered session, on every change of it. Output is the output that owns
123+// the target now, Rect the clipped target in output-local logical pixels (for
124+// a workspace, its frame) and Workspace the workspace ID, 0 for an output.
125+// Hidden means the workspace is not drawn as itself on screen: it is rendered
126+// for capture only (Scene.CaptureScene) and frames must ask for it
127+// (CaptureRequest.Workspace). Active means frames are served; a hidden
128+// workspace is rendered for one session at a time in the compositor (its
129+// Workspace): another hidden one is not Active until that one is gone. A
130+// non-empty Reason ends the session (wayland stops it).
131+//
132+// Exclusion says core knows an exclusion of this session. Revision is its
133+// revision fence, from 1, and Layers the attached layer surfaces core knows
134+// (at most MaxExclusionLayers); both are zero without an exclusion. Core
135+// bumps the revision whenever what an excluded frame must leave out changes:
136+// the start, an attached or detached layer, an excluded popup, the target. The
137+// scene carrying that change has the same SceneCapture.Revision. Wayland
138+// confirms an attach to the client only once a state lists the layer, serves
139+// the session's frames with CaptureRequest.Exclude and stamps
140+// CaptureRevision with the last state's Revision; a renderer refuses the
141+// request unless its scene's Capture.Revision is at least that, so a scene
142+// published before the change is never used and no stale exclusion list can
143+// leak a HUD.
144+type CaptureSessionState struct {
145+ ID uint64
146+ Output string
147+ Rect Rect
148+ Workspace uint64
149+ Hidden bool
150+ Active bool
151+ Reason CaptureReason
152+ Exclusion bool
153+ Revision uint64
154+ Layers []WindowID
155+}
156+
157+func (CaptureSessionState) clientCommand() {}
158+
159+// SceneCapture carries core → renderer the capture state of one output's
160+// scene (Scene.Capture; nil when there is none, which is the whole cost of
161+// the feature while no session exists).
162+//
163+// Session is the session that owns the exclusion, 0 without one; Excluded
164+// lists its attached layer surfaces and their popup descendants (at most
165+// MaxCaptureExcluded), which that session's frames leave out, and Revision is
166+// the fence of CaptureSessionState. Workspace is the ID of the hidden
167+// workspace CaptureScene draws, 0 when there is none on this output. Shown is
168+// the ID of the workspace this scene draws as itself (0 in the overview): a
169+// request for a workspace is served only when it is Shown here, or is the
170+// hidden Workspace; else it fails, whatever wayland last believed.
171+type SceneCapture struct {
172+ Shown uint64
173+ Session uint64
174+ Excluded []WindowID
175+ Revision uint64
176+ Workspace uint64
177+}
178+
179+// CaptureIndicator carries core → renderer one mark of a capture on an
180+// output's scene (Scene.CaptureIndicators; nil while nothing is captured,
181+// which is the whole cost of the feature). The renderer draws it last, over
182+// everything, in logical pixels; it is only on the scene of the physical
183+// output and in no capture image (the capture pipeline draws captures
184+// without it).
185+//
186+// Without Pill the indicator is the border of a target on screen: Rect is
187+// the mark, output-local and clipped, and the border is drawn inside it, at
188+// most half its smaller side wide (CaptureBorderOf). Core never lists a mark
189+// thinner than MinCaptureMark unless the output itself is: a border of a
190+// 1-pixel target would have nothing to draw. With Pill it marks a target
191+// that is not on screen: Rect is the pill square, a rounded one.
192+type CaptureIndicator struct {
193+ Rect Rect
194+ Pill bool
195+}
196+
197+// MinCaptureMark is the smallest side of a border mark: the border on both
198+// sides and one pixel between them.
199+const MinCaptureMark = 2*CaptureBorderWidth + 1
200+
201+// CaptureBorderOf is the width of the border drawn inside a w×h mark: the
202+// indicator width, at most half of each side. Zero means nothing is drawn
203+// for a proper border; the renderer then fills the whole mark, and the fence
204+// refuses such a mark (the thin mark of a capture it cannot vouch for).
205+func CaptureBorderOf(w, h int) int { return min(CaptureBorderWidth, w/2, h/2) }
206+
207+// Drawable reports whether the indicator draws a visible mark, wherever the
208+// output is: a non-empty rectangle whose border, for a border mark, has a
209+// width. A pill must also lie in the output (DrawableIn).
210+func (m CaptureIndicator) Drawable() bool {
211+ if m.Rect.W <= 0 || m.Rect.H <= 0 {
212+ return false
213+ }
214+ return m.Pill || CaptureBorderOf(m.Rect.W, m.Rect.H) > 0
215+}
216+
217+// DrawableIn is Drawable on an output of w×h logical pixels: a pill must lie
218+// fully inside it (an empty or partly outside pill is cut by the renderer and
219+// may show nothing).
220+func (m CaptureIndicator) DrawableIn(w, h int) bool {
221+ if !m.Drawable() {
222+ return false
223+ }
224+ if !m.Pill {
225+ return true
226+ }
227+ r := m.Rect
228+ return r.X >= 0 && r.Y >= 0 && r.X+r.W <= w && r.Y+r.H <= h
229+}
D internal/ports/capture_session.go +0 -161
@@ -1,161 +0,0 @@
1-package ports
2-
3-import "time"
4-
5-// Private capture sessions (neferwl_capture_manager_v1, see
6-// internal/adapters/wayland/capturesession/neferwl-capture-v1.xml).
7-//
8-// A session is opened by one client connection (the capture connection) for
9-// one workspace of one output. While core reports it Active, the layer
10-// surfaces another connection attached to it (the HUD, authorised by token
11-// and peer UID) stay visible over a fullscreen window and are left out of
12-// clean captures; screen captures requested by the session's own connection
13-// are tagged Clean. Every other capture keeps the HUD and the native border.
14-//
15-// Only one session is live in the compositor. Core owns the session state;
16-// wayland only relays requests and tells clients what core decided.
17-
18-const (
19- // MaxCaptureSessionLayers bounds the layer surfaces attached to a session.
20- MaxCaptureSessionLayers = 4
21- // MaxCaptureExcluded bounds SceneCapture.Excluded: attached layers and
22- // their popup descendants.
23- MaxCaptureExcluded = 128
24- // CaptureSessionTimeout is how long core lets a session live without a
25- // CaptureSessionPing (the explicit keep-alive) before it stops it.
26- CaptureSessionTimeout = 5 * time.Second
27- // CaptureBorderWidth and CaptureBorderColor are the native compositor
28- // border of a recording session (CaptureSessionBegin.Record), in logical
29- // pixels and #rrggbb.
30- CaptureBorderWidth = 2
31- CaptureBorderColor = "#ff3b30"
32-)
33-
34-// CaptureReason says why a session is not active. The zero value is none.
35-type CaptureReason string
36-
37-const (
38- CaptureReasonNone CaptureReason = ""
39- // Every reason ends the session: core forgets it and wayland stops it.
40- CaptureReasonOutputGone CaptureReason = "output-gone"
41- CaptureReasonOutputOff CaptureReason = "output-off"
42- CaptureReasonPingTimeout CaptureReason = "ping-timeout"
43- CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"
44- CaptureReasonBusy CaptureReason = "busy" // another session is live
45- CaptureReasonInvalidRegion CaptureReason = "invalid-region"
46- CaptureReasonUnauthorized CaptureReason = "unauthorized" // wayland only: peer credentials unreadable or not the compositor's user
47- // TooManyExcluded: the attached layers and their popups exceed
48- // MaxCaptureExcluded; a capture could not leave them all out (fail closed).
49- CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"
50-)
51-
52-// Terminal reports whether a state with this reason ends the session.
53-func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone }
54-
55-// CaptureSessionBegin carries wayland → core a new session. There are two
56-// target kinds:
57-// - Workspace 0 is an output target: whatever the output shows, bars and
58-// margins included, overview too. A zero Region means the whole output;
59-// a non-zero one is clipped to the output.
60-// - Workspace is a ports.WorkspaceInfo.ID: that workspace's frame
61-// (its own viewport, no bars). A zero Region means the whole frame; a
62-// non-zero one is clipped to the frame. The workspace may be on screen or
63-// not, and the overview does not matter: one that is not drawn as itself
64-// is rendered for capture only (Scene.CaptureScene). It ends with
65-// CaptureReasonWorkspaceGone when it is removed or moves to another
66-// output; sliding never pauses it.
67-//
68-// Region is output-local logical pixels. Record asks for the native border.
69-// ID is chosen by wayland and unique for the process lifetime.
70-type CaptureSessionBegin struct {
71- ID uint64
72- Output string
73- Workspace uint64
74- Region Rect
75- Record bool
76-}
77-
78-func (CaptureSessionBegin) clientEvent() {}
79-
80-// CaptureSessionLayer carries wayland → core a layer surface the session's
81-// authorised HUD attached (or detached, when it was destroyed). Layer is the
82-// LayerSurface.ID, known before the surface maps. Wayland has already checked
83-// the layer is top or overlay and that it never takes the keyboard
84-// exclusively; at most MaxCaptureSessionLayers are attached.
85-type CaptureSessionLayer struct {
86- ID uint64
87- Layer WindowID
88- Attached bool
89-}
90-
91-func (CaptureSessionLayer) clientEvent() {}
92-
93-// CaptureSessionPing carries wayland → core the session owner's keep-alive.
94-type CaptureSessionPing struct{ ID uint64 }
95-
96-func (CaptureSessionPing) clientEvent() {}
97-
98-// CaptureSessionEnd carries wayland → core an explicit stop or the owner's
99-// disconnect. Unknown IDs are ignored.
100-type CaptureSessionEnd struct{ ID uint64 }
101-
102-func (CaptureSessionEnd) clientEvent() {}
103-
104-// CaptureSessionState carries core → wayland what core decided (wayland relays it as neferwl_capture_session_v1.state), on every
105-// change of it. Rect is the clipped target in output-local logical pixels and
106-// Workspace the real workspace ID. Active means captures are being served
107-// (and the scene carries SceneCapture); a non-empty Reason with Active false
108-// explains it, and every Reason ends the session. A live session is always
109-// Active: a slide or swipe does not pause it (a workspace target's geometry is
110-// its own viewport, unaffected), and a session that is lost is stopped, never
111-// paused.
112-//
113-// Revision is the session's revision fence, from 1. Core bumps it whenever
114-// what a clean capture must do changes: the start, an attached or detached
115-// layer, an excluded popup or the target. The scene carrying that
116-// change has the same SceneCapture.Revision. Layers lists the attached layer
117-// surfaces core knows (at most MaxCaptureSessionLayers): wayland confirms an
118-// attach to the client only once a state lists the layer.
119-//
120-// Wayland serves a clean capture only for a session Active in the last state
121-// it saw, stamps CaptureRequest.CaptureRevision with that state's Revision,
122-// and a renderer refuses the request unless its scene's Capture.Revision is
123-// at least that: a scene published before the change is never used, so no
124-// stale exclusion list can leak a HUD.
125-type CaptureSessionState struct {
126- ID uint64
127- Output string
128- Rect Rect
129- Workspace uint64
130- Active bool
131- Reason CaptureReason
132- Revision uint64
133- Layers []WindowID
134-}
135-
136-func (CaptureSessionState) clientCommand() {}
137-
138-// SceneCapture carries core → renderer the active session on one output's
139-// scene (Scene.Capture; nil when none). TargetRect is output-local logical:
140-// the clipped region of the output, or of the workspace frame. Excluded lists
141-// the attached layer surfaces and their popup descendants (at most
142-// MaxCaptureExcluded), which clean captures leave out. BorderWidth and
143-// BorderColor are the native border of a recording session, zero and empty
144-// otherwise; it is drawn inside TargetRect, on its edge, in every capture
145-// that is not Clean, and only when the target is drawn on screen: a
146-// workspace rendered for capture only (Scene.CaptureScene) has none.
147-// Workspace is the real workspace ID, 0 for an output target. For such a
148-// hidden workspace TargetRect is relative to the origin of CaptureScene (the
149-// workspace viewport), the scene that capture renders; otherwise it is
150-// output-local.
151-type SceneCapture struct {
152- Session uint64
153- TargetRect Rect
154- Excluded []WindowID
155- BorderWidth int
156- BorderColor string
157- Workspace uint64
158- // Revision is the CaptureSessionState.Revision this scene carries; a
159- // clean CaptureRequest needs Revision >= its CaptureRevision.
160- Revision uint64
161-}
M internal/ports/messages.go +48 -15
@@ -15,16 +15,38 @@ type CaptureRequest struct {
1515 Dst SHMBuffer
1616 Width, Height, Stride int
1717 Format uint32
18- // Clean asks for a capture without the private session's HUD layers
19- // and native border (SceneCapture). Wayland sets it, with Session, only
20- // on requests of the connection that owns the active session; every
21- // other capture is false/0 and sees the scene as shown.
22- Clean bool
18+ // Session is the registered capture session (CaptureSessionOpen) the
19+ // request belongs to, 0 for wlr-screencopy. It is informational, except
20+ // with Exclude.
2321 Session uint64
24- // CaptureRevision is the session Revision last confirmed by core
25- // (CaptureSessionState); a renderer fails a Clean request whose scene
26- // has Capture nil or Capture.Revision below it. 0 when not Clean.
22+ // Exclude asks for a capture without the exclusion's HUD layers and
23+ // popups (SceneCapture.Excluded). Wayland sets it, with Session and
24+ // CaptureRevision, only on requests of the session that owns the
25+ // exclusion. A renderer fails such a request unless its scene's
26+ // Capture.Session is Session and Capture.Revision at least
27+ // CaptureRevision (CaptureSessionState).
28+ Exclude bool
2729 CaptureRevision uint64
30+ // Workspace is not 0 for the capture of a workspace's frame. On screen
31+ // (OffScreen false) it is an ordinary region of the displayed frame, and
32+ // fails unless the scene's Capture.Shown is Workspace. OffScreen is set
33+ // when the workspace was not on screen when the request was made: it is
34+ // served from Scene.CaptureScene, whole (Region is the whole child
35+ // image), and fails unless the scene's Capture.Workspace is Workspace.
36+ // Either way a workspace that moved since fails instead of returning
37+ // another crop.
38+ Workspace uint64
39+ OffScreen bool
40+ // Indicate is set by wayland on every capture it requests: core shows the
41+ // capture indicator for it (CaptureFrameTaken), and the output owner
42+ // serves the request only from a scene that shows the mark it needs (a
43+ // pill for OffScreen, else a border covering Region), so the indicator is
44+ // on screen in the same frame as the capture or an earlier one. It holds
45+ // the request for a bounded time, then fails it.
46+ Indicate bool
47+ // Since is when the output owner took the request (monotonic), to bound
48+ // that wait. Only the output owner sets it.
49+ Since time.Time
2850 }
2951
3052 // CaptureDone is attempted once after the output closes the destination.
@@ -134,7 +156,7 @@ type WorkspaceOutput struct {
134156 }
135157 type WorkspaceInfo struct {
136158 ID uint64
137- Configured string // stable configured name; empty for dynamic workspaces
159+ Configured string // configured name; empty for dynamic workspaces. ID, not this, identifies the workspace
138160 Name string
139161 Index int // zero-based vertical coordinate
140162 Active, Hidden bool
@@ -821,13 +843,17 @@ type Scene struct {
821843 // Window popups are drawn after the windows, layer popups
822844 // (SceneWindow.OverLayers) last, over every layer.
823845 Layers []SceneLayer
824- // Capture is the active private capture session on this output, nil
825- // when none (capture_session.go).
846+ // Capture is the capture state of this output: exclusion and hidden
847+ // workspace; nil while nothing is captured (capture.go).
826848 Capture *SceneCapture
827- // CaptureScene is the workspace of the active session drawn for
828- // capture only (a workspace that is not on screen). Only the root scene
829- // carries it; CaptureScene.Capture and .CaptureScene are nil.
849+ // CaptureScene is the workspace captured off screen (Capture.Workspace)
850+ // drawn for capture only: a workspace that is not on screen. Only the
851+ // root scene carries it; CaptureScene.Capture and .CaptureScene are nil.
830852 CaptureScene *Scene
853+ // CaptureIndicators are the marks of live captures of this output,
854+ // drawn over everything; nil when none. Captures never hold them: the
855+ // capture pipeline serves requests from a scene without them.
856+ CaptureIndicators []CaptureIndicator
831857 }
832858
833859 // Shows reports whether the scene draws the surface of id: only its
@@ -1120,7 +1146,9 @@ type OutputState struct {
11201146 Name string
11211147 Active int
11221148 Count int
1123- Workspace string // name of the workspace on screen, "" if unnamed
1149+ Workspace string // configured name of the workspace on screen, "" if unnamed
1150+ // WorkspaceID is the core ID of the workspace on screen (WorkspaceInfo.ID).
1151+ WorkspaceID uint64
11241152 }
11251153
11261154 // WindowState is one mapped window and where it is.
@@ -1131,6 +1159,11 @@ type WindowState struct {
11311159 Output string
11321160 // Workspace is the window's numbered workspace, 0 for a hidden one.
11331161 Workspace int
1162+ // WorkspaceID is the core ID of the window's workspace, numbered or not
1163+ // (WorkspaceInfo.ID); WorkspaceName is its configured name, "" for a
1164+ // dynamic one (WorkspaceInfo.Configured).
1165+ WorkspaceID uint64
1166+ WorkspaceName string
11341167 // Visible means on the workspace on screen (it may be behind a
11351168 // fullscreen window).
11361169 Visible bool
M packaging/arch/PKGBUILD +4 -1
@@ -13,7 +13,9 @@ makedepends=('go')
1313 optdepends=('xwayland-satellite>=0.7: X11 apps'
1414 'foot: default terminal'
1515 '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'
18+ 'nefercap: screenshots and screen recording')
1719 install=neferwl.install
1820 source=("$pkgname-$pkgver.tar.gz")
1921 sha256sums=('SKIP')
@@ -46,6 +48,7 @@ package() {
4648 install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop"
4749 install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf"
4850 install -Dm644 examples/config "$pkgdir/usr/share/doc/neferwl/config.example"
51+ install -Dm644 examples/capture-allow "$pkgdir/usr/share/doc/neferwl/capture-allow.example"
4952 install -Dm644 README.md "$pkgdir/usr/share/doc/neferwl/README.md"
5053 install -Dm644 LICENSE "$pkgdir/usr/share/licenses/neferwl/LICENSE"
5154 }
M packaging/aur/neferwl-git/PKGBUILD +4 -1
@@ -15,7 +15,9 @@ makedepends=('go>=2:1.27' 'git')
1515 optdepends=('xwayland-satellite>=0.7: X11 apps'
1616 'foot: default terminal'
1717 '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'
20+ 'nefercap: screenshots and screen recording')
1921 provides=('neferwl')
2022 conflicts=('neferwl' 'neferwl-bin')
2123 install=neferwl.install
@@ -57,6 +59,7 @@ package() {
5759 install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop"
5860 install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf"
5961 install -Dm644 examples/config "$pkgdir/usr/share/doc/neferwl/config.example"
62+ install -Dm644 examples/capture-allow "$pkgdir/usr/share/doc/neferwl/capture-allow.example"
6063 install -Dm644 README.md "$pkgdir/usr/share/doc/neferwl/README.md"
6164 install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
6265 }
M packaging/neferwl-portals.conf +2 -0
@@ -1,2 +1,4 @@
11 [preferred]
22 default=gtk
3+org.freedesktop.impl.portal.ScreenCast=wlr
4+org.freedesktop.impl.portal.Screenshot=wlr

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close