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 Unverified
B5690EEEBB952194The key is unknown
99 files · +9906 -3802
M .goreleaser.yaml +4 -0
| @@ -35,6 +35,7 @@ archives: | |||
| 35 | 35 | - LICENSE | |
| 36 | 36 | - README.md | |
| 37 | 37 | - examples/config | |
| 38 | + | - examples/capture-allow | |
| 38 | 39 | - packaging/neferwl-session | |
| 39 | 40 | - packaging/neferwl.service | |
| 40 | 41 | - packaging/neferwl-shutdown.target | |
| @@ -144,6 +145,8 @@ aurs: | |||
| 144 | 145 | - "foot: default terminal" | |
| 145 | 146 | - "fuzzel: launcher" | |
| 146 | 147 | - "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" | |
| 147 | 150 | package: |- | |
| 148 | 151 | install -Dm755 ./neferwl "${pkgdir}/usr/bin/neferwl" | |
| 149 | 152 | install -Dm755 ./packaging/neferwl-session "${pkgdir}/usr/bin/neferwl-session" | |
| @@ -152,5 +155,6 @@ aurs: | |||
| 152 | 155 | install -Dm644 ./packaging/neferwl.desktop "${pkgdir}/usr/share/wayland-sessions/neferwl.desktop" | |
| 153 | 156 | install -Dm644 ./packaging/neferwl-portals.conf "${pkgdir}/usr/share/xdg-desktop-portal/neferwl-portals.conf" | |
| 154 | 157 | 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" | |
| 155 | 159 | install -Dm644 ./README.md "${pkgdir}/usr/share/doc/neferwl/README.md" | |
| 156 | 160 | install -Dm644 ./LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" | |
M .mockery.yml +5 -0
| @@ -45,6 +45,11 @@ packages: | |||
| 45 | 45 | dir: internal/adapters/wayland | |
| 46 | 46 | pkgname: wayland | |
| 47 | 47 | 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 | |
| 48 | 53 | acquirePoller: | |
| 49 | 54 | config: | |
| 50 | 55 | 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 | |||
| 27 | 27 | - **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. | |
| 28 | 28 | - **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. | |
| 29 | 29 | - **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). | |
| 31 | 31 | - **Config.** One `key = value` file, reloaded on save without closing windows. A JSON state file describes outputs, workspaces and windows for scripts. | |
| 32 | 32 | ||
| 33 | 33 | Config example: | |
| @@ -111,6 +111,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil | |||
| 111 | 111 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | |
| 112 | 112 | - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts. | |
| 113 | 113 | - [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. | |
| 114 | 115 | - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods. | |
| 115 | 116 | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | |
| 116 | 117 | ||
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 | |||
| 16 | 16 | ||
| 17 | 17 | ```sh | |
| 18 | 18 | #!/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" | |
| 22 | 21 | ``` | |
| 23 | 22 | ||
| 24 | 23 | ```text | |
| 25 | - | bind.cmd+shift+p = spawn screenshot-area | |
| 24 | + | bind.cmd+shift+p = spawn screenshot-main | |
| 26 | 25 | ``` | |
| 27 | 26 | ||
| 28 | 27 | 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 | |||
| 72 | 71 | ||
| 73 | 72 | 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. | |
| 74 | 73 | ||
| 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 | + | ||
| 75 | 78 | ## Idle and screen off | |
| 76 | 79 | ||
| 77 | 80 | 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 '*' | |||
| 90 | 93 | ||
| 91 | 94 | ## Bars | |
| 92 | 95 | ||
| 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. | |
| 94 | 97 | ||
| 95 | 98 | 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. | |
| 96 | 99 | ||
| @@ -98,9 +101,9 @@ NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, foc | |||
| 98 | 101 | ||
| 99 | 102 | 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: | |
| 100 | 103 | ||
| 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; | |
| 102 | 105 | - 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`); | |
| 104 | 107 | - `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. | |
| 105 | 108 | ||
| 106 | 109 | ```sh | |
M docs/headless.md +6 -4
| @@ -9,13 +9,15 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 9 | 9 | ||
| 10 | 10 | 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. | |
| 11 | 11 | ||
| 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. | |
| 13 | 13 | ||
| 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). | |
| 15 | 15 | ||
| 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. | |
| 17 | 17 | ||
| 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. | |
| 19 | 21 | ||
| 20 | 22 | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | |
| 21 | 23 | ||
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 | |||
| 8 | 8 | - **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. | |
| 9 | 9 | - **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. | |
| 10 | 10 | - **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. | |
| 12 | 12 | - **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. | |
| 13 | 13 | ||
| 14 | 14 | 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 | |||
| 23 | 23 | - Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner. | |
| 24 | 24 | - 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. | |
| 25 | 25 | - 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. | |
| 27 | 27 | - 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. | |
| 28 | 28 | - 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. | |
| 29 | 29 | - 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 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 8 | github.com/bnema/purego-vulkan v0.6.0 | |
| 9 | 9 | github.com/bnema/wlturbo v0.5.0 | |
| 10 | 10 | github.com/bnema/zerowrap v1.4.1 | |
M go.sum +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |
| 2 | 2 | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 3 | - | github.com/bnema/purego-libwayland v0.7.0 h1:Q25nd/KmcivEucUceEmmNt1EgiryugAXnI4c7OV9tnU= | |
| 4 | - | github.com/bnema/purego-libwayland v0.7.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg= | |
| 3 | + | github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY= | |
| 4 | + | github.com/bnema/purego-libwayland v0.8.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg= | |
| 5 | 5 | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |
| 6 | 6 | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 7 | 7 | github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4= | |
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 ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // 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 | |
| 20 | 20 | // 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 | |
| 23 | 23 | // size changes, and with the output. | |
| 24 | 24 | ||
| 25 | 25 | const ( | |
| @@ -34,7 +34,7 @@ const ( | |||
| 34 | 34 | ) | |
| 35 | 35 | ||
| 36 | 36 | var ( | |
| 37 | - | // ErrOffscreenUnavailable fails a clean request whose source is a hidden | |
| 37 | + | // ErrOffscreenUnavailable fails a request whose source is a hidden | |
| 38 | 38 | // workspace that this output cannot draw (no child renderer factory). | |
| 39 | 39 | ErrOffscreenUnavailable = errors.New("workspace capture source is unavailable") | |
| 40 | 40 | // ErrOffscreenGeometry fails a request the child frame cannot serve. | |
| @@ -65,22 +65,21 @@ func (s *fenceSlot) release() { | |||
| 65 | 65 | ||
| 66 | 66 | // offscreen is owned by the goroutine of its Pipeline. | |
| 67 | 67 | 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 | |
| 73 | 73 | // slots[:nf] are the unfinished child renders, oldest first. | |
| 74 | 74 | slots [maxChildFences]fenceSlot | |
| 75 | 75 | nf int | |
| 76 | - | clean cleaner | |
| 77 | 76 | // capped is the scratch map CapHiddenSeen returns. | |
| 78 | 77 | capped map[ports.WindowID]uint64 | |
| 79 | 78 | // reads is the scratch map of the oldest outstanding child reads. | |
| 80 | 79 | reads map[ports.WindowID]uint64 | |
| 81 | 80 | } | |
| 82 | 81 | ||
| 83 | - | // EnableOffscreen lets the pipeline serve clean requests of a hidden | |
| 82 | + | // EnableOffscreen lets the pipeline serve requests of a hidden | |
| 84 | 83 | // workspace with renderers made by factory. Without it they fail closed | |
| 85 | 84 | // with ErrOffscreenUnavailable. | |
| 86 | 85 | func (p *Pipeline) EnableOffscreen(factory func(w, h int) (ports.Renderer, error)) { | |
| @@ -208,7 +207,7 @@ const ( | |||
| 208 | 207 | // A terminal fence error cannot be waited out, and its hold does not expire: | |
| 209 | 208 | // the child pipeline is cancelled and joined, then the device is closed (which | |
| 210 | 209 | // 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. | |
| 212 | 211 | func (o *offscreen) reap() { | |
| 213 | 212 | k := 0 | |
| 214 | 213 | failed := false | |
| @@ -293,7 +292,7 @@ func (o *offscreen) close() { | |||
| 293 | 292 | for i := range o.slots { | |
| 294 | 293 | o.slots[i].release() | |
| 295 | 294 | } | |
| 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 | |
| 297 | 296 | } | |
| 298 | 297 | ||
| 299 | 298 | // 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 | |||
| 328 | 327 | o.nf++ | |
| 329 | 328 | } | |
| 330 | 329 | ||
| 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 | |
| 333 | 332 | // every request: answered by the worker, or failed here (the display is not | |
| 334 | 333 | // 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 | |
| 337 | 335 | // origin in output coordinates, which the scene does not carry, and fails with | |
| 338 | 336 | // ErrOffscreenGeometry. | |
| 339 | 337 | 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 | |||
| 352 | 350 | case s.Off: | |
| 353 | 351 | fail(errors.New("output off"), reqs) | |
| 354 | 352 | return | |
| 355 | - | case s.CaptureScene == nil || s.Capture == nil: | |
| 353 | + | case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0: | |
| 356 | 354 | fail(ErrOffscreenUnavailable, reqs) | |
| 357 | 355 | return | |
| 358 | 356 | case o == nil || o.factory == nil: | |
| @@ -364,17 +362,11 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 364 | 362 | } | |
| 365 | 363 | cs := s.CaptureScene | |
| 366 | 364 | 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 | - | } | |
| 373 | 365 | if err != nil { | |
| 374 | 366 | fail(err, reqs) | |
| 375 | 367 | return | |
| 376 | 368 | } | |
| 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) { | |
| 378 | 370 | if o.busy() { | |
| 379 | 371 | fail(ErrOffscreenBusy, reqs) | |
| 380 | 372 | return | |
| @@ -393,15 +385,15 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 393 | 385 | fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs) | |
| 394 | 386 | return | |
| 395 | 387 | } | |
| 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 | |
| 397 | 389 | o.p.Security = p.Security | |
| 398 | 390 | } | |
| 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. | |
| 405 | 397 | k := 0 | |
| 406 | 398 | for _, q := range reqs { | |
| 407 | 399 | 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 | |||
| 417 | 409 | if k == 0 { | |
| 418 | 410 | return | |
| 419 | 411 | } | |
| 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) | |
| 424 | 415 | if !p.captureAllowed(s.Security) { | |
| 425 | 416 | fail(ErrSecurityState, reqs) | |
| 426 | 417 | return | |
| @@ -435,7 +426,8 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 435 | 426 | fail(fmt.Errorf("render workspace: %w", err), reqs) | |
| 436 | 427 | return | |
| 437 | 428 | } | |
| 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. | |
| 439 | 431 | // SubmitScoped rechecks immediately before native BeginCapture. | |
| 440 | 432 | o.p.SubmitScoped(s.Security, o.r, reqs) | |
| 441 | 433 | } | |
M internal/adapters/capture/offscreen_test.go +45 -9
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "image" | |
| 7 | 7 | "os" | |
| 8 | 8 | "testing" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -16,7 +17,7 @@ import ( | |||
| 16 | 17 | func hiddenScene() ports.Scene { | |
| 17 | 18 | return ports.Scene{ | |
| 18 | 19 | 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}, | |
| 20 | 21 | CaptureScene: &ports.Scene{ | |
| 21 | 22 | OutputWidth: 100, OutputHeight: 50, Scale: 2, Seq: 9, | |
| 22 | 23 | Windows: []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}}, | |
| @@ -27,7 +28,7 @@ func hiddenScene() ports.Scene { | |||
| 27 | 28 | ||
| 28 | 29 | func hiddenRequest(t *testing.T, id uint64, region image.Rectangle) ports.CaptureRequest { | |
| 29 | 30 | q := pipelineRequest(t, id) | |
| 30 | - | q.Clean, q.Session, q.CaptureRevision = true, 7, 1 | |
| 31 | + | q.Workspace, q.OffScreen = 9, true | |
| 31 | 32 | q.Region, q.Width, q.Height, q.Stride = region, region.Dx(), region.Dy(), region.Dx()*4 | |
| 32 | 33 | require.NoError(t, q.Dst.File.Truncate(int64(q.Stride*q.Height))) | |
| 33 | 34 | return q | |
| @@ -44,7 +45,7 @@ func childMock(t *testing.T, w, h int) (*portsmocks.MockRenderer, *portsmocks.Mo | |||
| 44 | 45 | return r, frame | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | - | func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) { | |
| 48 | + | func TestSubmitHiddenDrawsChildAndRebasesRegion(t *testing.T) { | |
| 48 | 49 | replies := make(chan ports.CaptureDone, 4) | |
| 49 | 50 | p := NewPipeline(context.Background(), replies) | |
| 50 | 51 | child, _ := childMock(t, 200, 100) | |
| @@ -56,13 +57,12 @@ func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) { | |||
| 56 | 57 | var made [2]int | |
| 57 | 58 | p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil }) | |
| 58 | 59 | s := hiddenScene() | |
| 59 | - | s.Capture.TargetRect = ports.Rect{W: 100, H: 50} | |
| 60 | 60 | p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(480, 360, 680, 460))}) | |
| 61 | 61 | require.Equal(t, [2]int{200, 100}, made, "child image is the frame in physical pixels") | |
| 62 | 62 | require.Nil(t, scene.Capture) | |
| 63 | 63 | 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) | |
| 66 | 66 | require.NoError(t, awaitCapture(t, replies).Err) | |
| 67 | 67 | p.Close(nil) | |
| 68 | 68 | } | |
| @@ -74,9 +74,9 @@ func TestSubmitHiddenFailsClosed(t *testing.T) { | |||
| 74 | 74 | want error | |
| 75 | 75 | }{ | |
| 76 | 76 | {"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}, | |
| 80 | 80 | } { | |
| 81 | 81 | t.Run(tc.name, func(t *testing.T) { | |
| 82 | 82 | replies := make(chan ports.CaptureDone, 2) | |
| @@ -126,6 +126,42 @@ func TestSubmitHiddenRegionOutsideFrameFailsOthersServed(t *testing.T) { | |||
| 126 | 126 | p.Close(nil) | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 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 | + | ||
| 129 | 165 | func TestSubmitHiddenChildClosesWhenSessionEnds(t *testing.T) { | |
| 130 | 166 | replies := make(chan ports.CaptureDone, 4) | |
| 131 | 167 | p := NewPipeline(context.Background(), replies) | |
M internal/adapters/capture/pipeline.go +85 -4
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package capture | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "cmp" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| @@ -26,6 +27,11 @@ type batch struct { | |||
| 26 | 27 | requests [MaxRequests]ports.CaptureRequest | |
| 27 | 28 | count int | |
| 28 | 29 | 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 | |
| 29 | 35 | } | |
| 30 | 36 | ||
| 31 | 37 | // Pipeline moves fence waiting and SHM copies off the renderer's owner. | |
| @@ -39,14 +45,22 @@ type Pipeline struct { | |||
| 39 | 45 | jobs chan *batch | |
| 40 | 46 | completed chan *batch | |
| 41 | 47 | 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 | |
| 45 | 56 | } | |
| 46 | 57 | ||
| 47 | 58 | func NewPipeline(ctx context.Context, replies chan<- ports.CaptureDone) *Pipeline { | |
| 48 | 59 | ctx, cancel := context.WithCancel(ctx) | |
| 49 | 60 | 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 | + | } | |
| 50 | 64 | p.worker.Go(p.run) | |
| 51 | 65 | return p | |
| 52 | 66 | } | |
| @@ -58,7 +72,55 @@ func (p *Pipeline) Completed() <-chan *batch { return p.completed } | |||
| 58 | 72 | // Recycle returns a completed GPU readback lease on the renderer's owner. | |
| 59 | 73 | func (p *Pipeline) Recycle(b *batch, r ports.Renderer) { | |
| 60 | 74 | 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 | + | } | |
| 62 | 124 | } | |
| 63 | 125 | ||
| 64 | 126 | // SubmitScoped consumes requests with explicit immutable scene admission. The | |
| @@ -97,6 +159,10 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req | |||
| 97 | 159 | } | |
| 98 | 160 | b.count = copy(b.requests[:], requests) | |
| 99 | 161 | b.at = monotonicNow() | |
| 162 | + | if b.gated = p.gating; b.gated { | |
| 163 | + | p.held[p.nheld] = b | |
| 164 | + | p.nheld++ | |
| 165 | + | } | |
| 100 | 166 | // There are exactly two reusable batches and two queue slots, so this | |
| 101 | 167 | // send cannot wait: no batch can be queued twice before recycling. | |
| 102 | 168 | p.jobs <- b | |
| @@ -140,6 +206,21 @@ func (p *Pipeline) process(b *batch) { | |||
| 140 | 206 | if err == nil && b.frame.Done() != nil { | |
| 141 | 207 | err = syncfile.Wait(p.ctx, b.frame.Done()) | |
| 142 | 208 | } | |
| 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 | + | } | |
| 143 | 224 | for i := range b.count { | |
| 144 | 225 | q := b.requests[i] | |
| 145 | 226 | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "image" | |
| 7 | 7 | "os" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 8 | 10 | "testing" | |
| 9 | 11 | "time" | |
| 10 | 12 | ||
| @@ -15,27 +17,28 @@ import ( | |||
| 15 | 17 | "github.com/stretchr/testify/require" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 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 { | |
| 19 | 22 | t.Helper() | |
| 20 | 23 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 21 | 24 | require.NoError(t, err) | |
| 22 | 25 | t.Cleanup(func() { _ = f.Close() }) | |
| 23 | 26 | require.NoError(t, f.Truncate(16)) | |
| 24 | 27 | 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 | |
| 27 | 30 | } | |
| 28 | 31 | return q | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | func drmSessionScene() ports.Scene { | |
| 32 | 35 | 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}}} | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 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 | |
| 37 | 40 | // same target; the standard capture is taken after the displayed frame. | |
| 38 | - | func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) { | |
| 41 | + | func TestSubmitFrameExcludeBeforeDisplayedThenStandard(t *testing.T) { | |
| 39 | 42 | o, _, _ := testOutput(t) | |
| 40 | 43 | o.cursor = nil | |
| 41 | 44 | var order []string | |
| @@ -44,8 +47,8 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) { | |||
| 44 | 47 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 45 | 48 | if s.Capture == nil { | |
| 46 | 49 | 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") | |
| 49 | 52 | } else { | |
| 50 | 53 | require.Len(t, s.Layers, 1, "displayed frame keeps the HUD") | |
| 51 | 54 | order = append(order, "shown") | |
| @@ -66,7 +69,7 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) { | |||
| 66 | 69 | direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline) | |
| 67 | 70 | require.NoError(t, err) | |
| 68 | 71 | require.False(t, direct) | |
| 69 | - | require.Equal(t, []string{"clean", "copy", "shown", "copy"}, order) | |
| 72 | + | require.Equal(t, []string{"excluded", "copy", "shown", "copy"}, order) | |
| 70 | 73 | for _, q := range requests { | |
| 71 | 74 | require.True(t, capture.Handed(q), "requests handed to the worker") | |
| 72 | 75 | } | |
| @@ -74,24 +77,30 @@ func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) { | |||
| 74 | 77 | require.Len(t, replies, 2) | |
| 75 | 78 | } | |
| 76 | 79 | ||
| 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) { | |
| 80 | 83 | o, _, _ := testOutput(t) | |
| 81 | 84 | o.cursor = nil | |
| 82 | 85 | r := portsmocks.NewMockRenderer(t) | |
| 83 | 86 | r.EXPECT().UseTarget(0).Return().Once() | |
| 84 | 87 | 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) | |
| 88 | 96 | require.NoError(t, err) | |
| 89 | 97 | require.False(t, direct) | |
| 90 | 98 | require.Equal(t, "capture", o.reason) | |
| 99 | + | pipeline.Close(r) | |
| 91 | 100 | } | |
| 92 | 101 | ||
| 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 | |
| 95 | 104 | // never answered twice. | |
| 96 | 105 | func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) { | |
| 97 | 106 | o, _, _ := testOutput(t) | |
| @@ -121,12 +130,12 @@ func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) { | |||
| 121 | 130 | } | |
| 122 | 131 | require.Equal(t, 1, owned) | |
| 123 | 132 | 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 | |
| 125 | 134 | } | |
| 126 | 135 | ||
| 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 | |
| 128 | 137 | // and is never captured from the displayed frame. | |
| 129 | - | func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) { | |
| 138 | + | func TestSubmitFrameExcludeRequestWithoutSessionFails(t *testing.T) { | |
| 130 | 139 | o, _, _ := testOutput(t) | |
| 131 | 140 | o.cursor = nil | |
| 132 | 141 | r := portsmocks.NewMockRenderer(t) | |
| @@ -145,19 +154,17 @@ func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) { | |||
| 145 | 154 | require.True(t, capture.Handed(requests[0])) | |
| 146 | 155 | } | |
| 147 | 156 | ||
| 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) { | |
| 151 | 161 | for _, tc := range []struct { | |
| 152 | - | name string | |
| 153 | - | capture *ports.SceneCapture | |
| 154 | - | wantDirect bool | |
| 162 | + | name string | |
| 163 | + | capture *ports.SceneCapture | |
| 155 | 164 | }{ | |
| 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}}}, | |
| 161 | 168 | } { | |
| 162 | 169 | t.Run(tc.name, func(t *testing.T) { | |
| 163 | 170 | o, k, _ := testOutput(t) | |
| @@ -166,22 +173,17 @@ func TestSessionMetadataWithoutBorderKeepsDirectScanout(t *testing.T) { | |||
| 166 | 173 | r := portsmocks.NewMockRenderer(t) | |
| 167 | 174 | s, c := fullscreenScene() | |
| 168 | 175 | 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() | |
| 175 | 177 | pipeline := capture.NewPipeline(context.Background(), nil) | |
| 176 | 178 | defer pipeline.Close(r) | |
| 177 | 179 | direct, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, pipeline) | |
| 178 | 180 | require.NoError(t, err) | |
| 179 | - | require.Equal(t, tc.wantDirect, direct) | |
| 181 | + | require.True(t, direct) | |
| 180 | 182 | }) | |
| 181 | 183 | } | |
| 182 | 184 | } | |
| 183 | 185 | ||
| 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 | |
| 185 | 187 | // fails closed; it is neither served from the displayed frame nor does it | |
| 186 | 188 | // stop the output. | |
| 187 | 189 | func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) { | |
| @@ -195,12 +197,40 @@ func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) { | |||
| 195 | 197 | defer pipeline.Close(r) | |
| 196 | 198 | scene := drmSessionScene() | |
| 197 | 199 | 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 | |
| 199 | 203 | _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline) | |
| 200 | 204 | require.NoError(t, err) | |
| 201 | 205 | require.ErrorIs(t, (<-replies).Err, capture.ErrOffscreenUnavailable) | |
| 202 | 206 | } | |
| 203 | 207 | ||
| 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 | + | ||
| 204 | 234 | // A report caps only the windows an unfinished child render drew; the display's | |
| 205 | 235 | // own windows are reported at their full content Seq, and the report goes on | |
| 206 | 236 | // (nothing waits for the child). | |
| @@ -222,10 +252,11 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) { | |||
| 222 | 252 | pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 223 | 253 | o.capHidden = pipeline.CapHiddenSeen | |
| 224 | 254 | 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}, | |
| 226 | 256 | CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 2, H: 2}}}}} | |
| 227 | 257 | 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 | |
| 229 | 260 | req.Region, req.Width, req.Height = image.Rect(0, 0, 2, 2), 2, 2 | |
| 230 | 261 | pipeline.SubmitHidden(scene, surfaces, []ports.CaptureRequest{req}) | |
| 231 | 262 | ||
| @@ -275,12 +306,10 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) { | |||
| 275 | 306 | pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 276 | 307 | o.capHidden = pipeline.CapHiddenSeen | |
| 277 | 308 | 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} | |
| 279 | 310 | 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 | |
| 284 | 313 | req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800 | |
| 285 | 314 | require.NoError(t, req.Dst.File.Truncate(800*100)) | |
| 286 | 315 | 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) { | |||
| 352 | 381 | contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}} | |
| 353 | 382 | // The request waits for the scene; once a display frame is pending no | |
| 354 | 383 | // 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 | |
| 356 | 386 | req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800 | |
| 357 | 387 | require.NoError(t, req.Dst.File.Truncate(800*100)) | |
| 358 | 388 | captures <- req | |
| 359 | 389 | 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}, | |
| 361 | 391 | CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}} | |
| 362 | 392 | ||
| 363 | 393 | next := func(match func(ports.OutputPresented) bool) ports.OutputPresented { | |
| @@ -392,3 +422,245 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | |||
| 392 | 422 | cancel() | |
| 393 | 423 | require.NoError(t, <-done) | |
| 394 | 424 | } | |
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -23,6 +24,7 @@ func (e renderError) Unwrap() error { return e.err } | |||
| 23 | 24 | // without the overlay; a renderError stops the output; other errors are | |
| 24 | 25 | // commit errors for commitFailed. | |
| 25 | 26 | 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 | |
| 26 | 28 | if !o.sceneCurrent(scene) { | |
| 27 | 29 | return false, errSecurityScene | |
| 28 | 30 | } | |
| @@ -33,32 +35,46 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 33 | 35 | // windows it does not draw is discarded, not presented. | |
| 34 | 36 | f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)} | |
| 35 | 37 | 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) | |
| 39 | 57 | pipeline.Retire(scene) | |
| 40 | - | hiddenClean := len(clean) > 0 && scene.CaptureScene != nil | |
| 41 | - | if hiddenClean { | |
| 58 | + | if len(hidden) > 0 { | |
| 42 | 59 | if !o.sceneCurrent(scene) || o.protected { | |
| 43 | 60 | return false, errSecurityScene | |
| 44 | 61 | } | |
| 45 | 62 | // A hidden workspace: the child renderer draws it, whatever the display | |
| 46 | 63 | // does (scanout included). Failures answer the requests and never stop | |
| 47 | 64 | // the output. | |
| 48 | - | pipeline.SubmitHidden(scene, surfaces, clean) | |
| 65 | + | pipeline.SubmitHidden(scene, surfaces, hidden) | |
| 49 | 66 | // Publish child holds before queuing a display flip, which may stall | |
| 50 | 67 | // past the stale-report timeout. Do not advance the display's Seen | |
| 51 | 68 | // until its own GPU work finishes. | |
| 52 | 69 | if o.capHidden != nil { | |
| 53 | 70 | o.report(nil, o.seenSnapshot) | |
| 54 | 71 | } | |
| 55 | - | clear(clean) | |
| 56 | - | clean = nil | |
| 72 | + | clear(hidden) | |
| 57 | 73 | } | |
| 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) | |
| 62 | 78 | if !o.sceneCurrent(scene) || o.protected { | |
| 63 | 79 | decision.overlay.close() | |
| 64 | 80 | return false, errSecurityScene | |
| @@ -66,42 +82,69 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 66 | 82 | if decision.fb != 0 { | |
| 67 | 83 | c := decision.content | |
| 68 | 84 | 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 | |
| 69 | 86 | return true, err | |
| 70 | 87 | } | |
| 71 | 88 | decision = o.composeFrame(scene, surfaces) | |
| 72 | 89 | } | |
| 73 | 90 | ov, composed := decision.overlay, decision.composed | |
| 74 | 91 | 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 | |
| 77 | 120 | // 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() | |
| 84 | 127 | } | |
| 85 | 128 | ov.close() | |
| 86 | - | return false, renderError{cerr} | |
| 129 | + | return false, renderError{xerr} | |
| 87 | 130 | } | |
| 88 | - | // A clean composition is not capture admission. Engage may have | |
| 131 | + | // An excluded composition is not capture admission. Engage may have | |
| 89 | 132 | // happened while Render submitted GPU work; retain its client reads | |
| 90 | 133 | // and leave the request credit with the caller on rejection. | |
| 91 | 134 | 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() | |
| 95 | 138 | } | |
| 96 | 139 | ov.close() | |
| 97 | 140 | return false, errSecurityScene | |
| 98 | 141 | } | |
| 99 | - | if cdone != nil { | |
| 100 | - | cdone.Close() | |
| 142 | + | if xdone != nil { | |
| 143 | + | xdone.Close() | |
| 101 | 144 | } | |
| 102 | - | pipeline.SubmitScoped(scene.Security, r, clean) | |
| 145 | + | pipeline.SubmitScoped(scene.Security, r, excluded) | |
| 103 | 146 | // Ownership moved to the worker (or an immediate failure reply). | |
| 104 | - | clear(clean) | |
| 147 | + | clear(excluded) | |
| 105 | 148 | } | |
| 106 | 149 | done, rerr := r.Render(composed, surfaces) | |
| 107 | 150 | if rerr != nil { | |
| @@ -150,6 +193,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 150 | 193 | // layer, subsurfaces, size mismatch). | |
| 151 | 194 | game := o.vrrProp != 0 && fullscreenShown(&scene) | |
| 152 | 195 | err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov) | |
| 196 | + | presented = err == nil | |
| 153 | 197 | if err != nil { | |
| 154 | 198 | // The GPU may still read client buffers for this frame. | |
| 155 | 199 | 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 | |||
| 1160 | 1160 | gapTimer := time.NewTimer(time.Hour) | |
| 1161 | 1161 | gapTimer.Stop() | |
| 1162 | 1162 | 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() | |
| 1163 | 1168 | retryTimer.Stop() | |
| 1164 | 1169 | vrrTimer.Stop() | |
| 1165 | 1170 | cursorTimer.Stop() | |
| @@ -1267,6 +1272,18 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1267 | 1272 | } else { | |
| 1268 | 1273 | gapTimer.Stop() | |
| 1269 | 1274 | } | |
| 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 | + | } | |
| 1270 | 1287 | // A refused protected commit is retried after a bounded backoff. | |
| 1271 | 1288 | var protectDue <-chan time.Time | |
| 1272 | 1289 | 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 | |||
| 1314 | 1331 | case <-cursorWake: | |
| 1315 | 1332 | stateDirty = true | |
| 1316 | 1333 | case <-gapDue: | |
| 1334 | + | case <-holdDue: | |
| 1335 | + | continue | |
| 1317 | 1336 | case <-ctx.Done(): | |
| 1318 | 1337 | return nil | |
| 1319 | 1338 | case b := <-pipeline.Completed(): | |
| @@ -1330,8 +1349,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1330 | 1349 | } else if len(requests) == cap(requests) { | |
| 1331 | 1350 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured) | |
| 1332 | 1351 | } else { | |
| 1352 | + | q.Since = time.Now() | |
| 1333 | 1353 | 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) | |
| 1335 | 1357 | } | |
| 1336 | 1358 | case on := <-active: | |
| 1337 | 1359 | o.observeSecurity() | |
| @@ -1513,9 +1535,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1513 | 1535 | } | |
| 1514 | 1536 | capture.Fail(ctx, q, err, captured) | |
| 1515 | 1537 | } | |
| 1538 | + | clear(requests) // held ones included: their frame is gone | |
| 1516 | 1539 | } | |
| 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) | |
| 1519 | 1542 | if errors.As(err, &fatal) { | |
| 1520 | 1543 | return err | |
| 1521 | 1544 | } | |
M internal/adapters/drm/security_capture_test.go +3 -3
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) { | |
| 16 | + | func TestSecurityEngageDuringExcludeRenderRejectsCaptureTransfer(t *testing.T) { | |
| 17 | 17 | o, _, commits := testOutput(t) | |
| 18 | 18 | o.cursor = nil | |
| 19 | 19 | var state atomic.Uint64 | |
| @@ -34,10 +34,10 @@ func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) { | |||
| 34 | 34 | t.Fatalf("capture admission result: %v", err) | |
| 35 | 35 | } | |
| 36 | 36 | 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") | |
| 38 | 38 | } | |
| 39 | 39 | 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") | |
| 41 | 41 | } | |
| 42 | 42 | // Match Run's remaining-credit rejection: nothing was transferred to the | |
| 43 | 43 | // 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 ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "reflect" | |
| 9 | 9 | "sync" | |
| 10 | + | "sync/atomic" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -50,9 +51,9 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa | |||
| 50 | 51 | ev := &events{} | |
| 51 | 52 | r := portsmocks.NewMockRenderer(t) | |
| 52 | 53 | 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. | |
| 54 | 55 | if s.Seq == 0 { | |
| 55 | - | ev.add("render:clean") | |
| 56 | + | ev.add("render:excluded") | |
| 56 | 57 | } else { | |
| 57 | 58 | ev.add("render:full") | |
| 58 | 59 | } | |
| @@ -99,23 +100,23 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa | |||
| 99 | 100 | ||
| 100 | 101 | func sessionScene() ports.Scene { | |
| 101 | 102 | 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}}} | |
| 103 | 104 | } | |
| 104 | 105 | ||
| 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 | |
| 108 | 109 | // 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) | |
| 110 | 111 | for _, d := range replies { | |
| 111 | 112 | require.NoError(t, d.Err) | |
| 112 | 113 | } | |
| 113 | 114 | } | |
| 114 | 115 | ||
| 115 | - | func TestCleanCaptureFailsClosedWithoutMatchingSession(t *testing.T) { | |
| 116 | + | func TestExcludeCaptureFailsClosedWithoutMatchingSession(t *testing.T) { | |
| 116 | 117 | scene := sessionScene() | |
| 117 | 118 | 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) | |
| 119 | 120 | require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only | |
| 120 | 121 | byID := map[uint64]error{} | |
| 121 | 122 | for _, d := range replies { | |
| @@ -146,7 +147,7 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) { | |||
| 146 | 147 | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 147 | 148 | child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 148 | 149 | 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") | |
| 150 | 151 | return read, nil | |
| 151 | 152 | }).Once() | |
| 152 | 153 | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| @@ -154,8 +155,8 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) { | |||
| 154 | 155 | child.EXPECT().Close().Return().Once() | |
| 155 | 156 | scene := sessionScene() | |
| 156 | 157 | 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}}} | |
| 159 | 160 | ctx, cancel := context.WithCancel(context.Background()) | |
| 160 | 161 | defer cancel() | |
| 161 | 162 | scenes := make(chan ports.Scene, 1) | |
| @@ -170,7 +171,7 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) { | |||
| 170 | 171 | NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil }, | |
| 171 | 172 | }, scenes, nil, nil, incoming) | |
| 172 | 173 | }() | |
| 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)} | |
| 174 | 175 | incoming <- q | |
| 175 | 176 | scenes <- scene | |
| 176 | 177 | // The owner may report an idle frame before receiving the first scene. | |
| @@ -211,12 +212,21 @@ waitHold: | |||
| 211 | 212 | require.Equal(t, [2]int{2, 2}, made) | |
| 212 | 213 | } | |
| 213 | 214 | ||
| 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 | |
| 215 | 225 | // must not answer it a second time as request 0. | |
| 216 | 226 | func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) { | |
| 217 | 227 | display := portsmocks.NewMockRenderer(t) | |
| 218 | 228 | 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 | |
| 220 | 230 | display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once() | |
| 221 | 231 | frame := portsmocks.NewMockCaptureFrame(t) | |
| 222 | 232 | frame.EXPECT().Done().Return(nil).Maybe() | |
| @@ -234,7 +244,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) { | |||
| 234 | 244 | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, | |
| 235 | 245 | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming) | |
| 236 | 246 | }() | |
| 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)} | |
| 238 | 248 | scenes <- sessionScene() | |
| 239 | 249 | select { | |
| 240 | 250 | case err := <-done: | |
| @@ -275,3 +285,176 @@ func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) { | |||
| 275 | 285 | require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable") | |
| 276 | 286 | require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(changed.Seen).Pointer(), "seen still unchanged") | |
| 277 | 287 | } | |
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "golang.org/x/sys/unix" | |
| 7 | 8 | "image" | |
| @@ -94,6 +95,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 94 | 95 | pipeline.EnableOffscreen(opts.NewCaptureRenderer) | |
| 95 | 96 | } | |
| 96 | 97 | defer func() { | |
| 98 | + | // An error path out of a gated frame: what it held is failed. | |
| 99 | + | pipeline.EndGate(capture.ErrFrameNotPresented) | |
| 97 | 100 | cancelCaptures() | |
| 98 | 101 | pipeline.Close(r) | |
| 99 | 102 | for _, q := range requests { | |
| @@ -123,6 +126,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 123 | 126 | var security ports.SecurityState | |
| 124 | 127 | securityKnown := false | |
| 125 | 128 | 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))) | |
| 126 | 131 | for _, q := range requests { | |
| 127 | 132 | if !capture.Handed(q) { | |
| 128 | 133 | 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 | |||
| 206 | 211 | } | |
| 207 | 212 | trim := clk.NewTicker(trimEvery) | |
| 208 | 213 | 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() | |
| 209 | 219 | 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) | |
| 210 | 223 | if ctx.Err() != nil { | |
| 211 | 224 | return nil | |
| 212 | 225 | } | |
| @@ -220,7 +233,23 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 220 | 233 | if pending != nil { | |
| 221 | 234 | retry = time.After(time.Millisecond) | |
| 222 | 235 | } | |
| 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 | + | } | |
| 223 | 250 | select { | |
| 251 | + | case <-holdDue: | |
| 252 | + | continue | |
| 224 | 253 | case <-ctx.Done(): | |
| 225 | 254 | return nil | |
| 226 | 255 | case _, ok := <-opts.SecurityChanges: | |
| @@ -275,8 +304,11 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 275 | 304 | } else if len(requests) == cap(requests) { | |
| 276 | 305 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured) | |
| 277 | 306 | } else { | |
| 307 | + | q.Since = time.Now() | |
| 278 | 308 | 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) | |
| 280 | 312 | } | |
| 281 | 313 | case <-retry: | |
| 282 | 314 | if err := checkSecurity(); err != nil { | |
| @@ -416,20 +448,26 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 416 | 448 | continue | |
| 417 | 449 | } | |
| 418 | 450 | 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) | |
| 423 | 461 | pipeline.Retire(scene) | |
| 424 | - | if len(clean) > 0 && scene.CaptureScene != nil { | |
| 462 | + | if len(hidden) > 0 { | |
| 425 | 463 | // A hidden workspace: a child renderer draws it. The display's | |
| 426 | 464 | // fences do not cover the child, so wait for it before reporting. | |
| 427 | 465 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 428 | 466 | failRequests("security epoch changed") | |
| 429 | 467 | continue | |
| 430 | 468 | } | |
| 431 | - | pipeline.SubmitHidden(scene, surfaces, clean) | |
| 432 | - | clear(clean) | |
| 469 | + | pipeline.SubmitHidden(scene, surfaces, hidden) | |
| 470 | + | clear(hidden) | |
| 433 | 471 | // A slow child may outlive Wayland's stale-report timeout even | |
| 434 | 472 | // though this owner waits. Publish its non-expiring holds first. | |
| 435 | 473 | if opts.Presented != nil { | |
| @@ -449,34 +487,58 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 449 | 487 | } | |
| 450 | 488 | return fmt.Errorf("workspace frame fence: %w", err) | |
| 451 | 489 | } | |
| 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. | |
| 453 | 515 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 454 | 516 | failRequests("security epoch changed") | |
| 455 | 517 | continue | |
| 456 | 518 | } | |
| 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 | |
| 460 | 522 | // frame is skipped by a gate/off transition. Own and wait its | |
| 461 | 523 | // 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() | |
| 464 | 526 | if waitErr != nil { | |
| 465 | 527 | if ctx.Err() != nil { | |
| 466 | 528 | return nil | |
| 467 | 529 | } | |
| 468 | - | return fmt.Errorf("clean frame fence: %w", waitErr) | |
| 530 | + | return fmt.Errorf("excluded frame fence: %w", waitErr) | |
| 469 | 531 | } | |
| 470 | 532 | } | |
| 471 | 533 | if renderErr != nil { | |
| 472 | - | return fmt.Errorf("render clean frame: %w", renderErr) | |
| 534 | + | return fmt.Errorf("render excluded frame: %w", renderErr) | |
| 473 | 535 | } | |
| 474 | 536 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 475 | 537 | failRequests("security epoch changed") | |
| 476 | 538 | continue | |
| 477 | 539 | } | |
| 478 | - | pipeline.SubmitScoped(scene.Security, r, clean) | |
| 479 | - | clear(clean) | |
| 540 | + | pipeline.SubmitScoped(scene.Security, r, excluded) | |
| 541 | + | clear(excluded) | |
| 480 | 542 | } | |
| 481 | 543 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 482 | 544 | failRequests("security epoch changed") | |
| @@ -508,8 +570,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 508 | 570 | continue | |
| 509 | 571 | } | |
| 510 | 572 | 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) | |
| 513 | 576 | frame++ | |
| 514 | 577 | pending = opts.flipped(pending, seen, scene, surfaces) | |
| 515 | 578 | if opts.ScreenshotDir != "" && !opts.protected() { | |
M internal/adapters/headless/security_capture_test.go +5 -5
| @@ -17,10 +17,10 @@ import ( | |||
| 17 | 17 | // Change the gate AFTER the output's post-Render check returns the admitting | |
| 18 | 18 | // state. SubmitScoped must close this last gap before native BeginCapture. | |
| 19 | 19 | func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) { | |
| 20 | - | for _, clean := range []bool{false, true} { | |
| 20 | + | for _, excluded := range []bool{false, true} { | |
| 21 | 21 | name := "normal" | |
| 22 | - | if clean { | |
| 23 | - | name = "clean" | |
| 22 | + | if excluded { | |
| 23 | + | name = "excluded" | |
| 24 | 24 | } | |
| 25 | 25 | t.Run(name, func(t *testing.T) { | |
| 26 | 26 | admitted := ports.SecurityState{Generation: 2} | |
| @@ -64,9 +64,9 @@ func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) { | |||
| 64 | 64 | 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) | |
| 65 | 65 | }() | |
| 66 | 66 | 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} | |
| 68 | 68 | scene := ports.Scene{Security: admitted, Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2} | |
| 69 | - | if clean { | |
| 69 | + | if excluded { | |
| 70 | 70 | scene = sessionScene() | |
| 71 | 71 | scene.Security = admitted | |
| 72 | 72 | 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 ( | |||
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |
| 17 | + | func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |
| 18 | 18 | for _, finish := range []string{"signal", "cancel", "render-error"} { | |
| 19 | 19 | t.Run(finish, func(t *testing.T) { | |
| 20 | 20 | admitted := ports.SecurityState{Generation: 2} | |
| @@ -32,7 +32,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 32 | 32 | r.EXPECT().Close().Return().Once() | |
| 33 | 33 | rendered := make(chan struct{}, 1) | |
| 34 | 34 | releaseRender := make(chan struct{}) | |
| 35 | - | failure := errors.New("clean render failed after submission") | |
| 35 | + | failure := errors.New("excluded render failed after submission") | |
| 36 | 36 | 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) { | |
| 37 | 37 | rendered <- struct{}{} | |
| 38 | 38 | <-releaseRender | |
| @@ -59,7 +59,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 59 | 59 | 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) | |
| 60 | 60 | }() | |
| 61 | 61 | 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} | |
| 63 | 63 | contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}} | |
| 64 | 64 | scene := sessionScene() | |
| 65 | 65 | scene.Security = admitted | |
| @@ -69,7 +69,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 69 | 69 | scenes <- scene | |
| 70 | 70 | securityReceive(t, rendered) | |
| 71 | 71 | // 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. | |
| 73 | 73 | for len(presented) > 0 { | |
| 74 | 74 | <-presented | |
| 75 | 75 | } | |
| @@ -81,15 +81,15 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 81 | 81 | // close/report path without assuming a polling schedule. | |
| 82 | 82 | select { | |
| 83 | 83 | 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) | |
| 85 | 85 | case <-time.After(150 * time.Millisecond): | |
| 86 | 86 | } | |
| 87 | 87 | 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) | |
| 89 | 89 | } | |
| 90 | 90 | select { | |
| 91 | 91 | 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) | |
| 93 | 93 | default: | |
| 94 | 94 | } | |
| 95 | 95 | if finish == "cancel" { | |
| @@ -112,7 +112,7 @@ func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 112 | 112 | t.Fatal(runErr) | |
| 113 | 113 | } | |
| 114 | 114 | 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) | |
| 116 | 116 | } | |
| 117 | 117 | reply := securityReceive(t, replies) | |
| 118 | 118 | if reply.ID != 99 || reply.Err == nil { | |
M internal/adapters/statefile/statefile.go +22 -15
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "strconv" | |
| 14 | 14 | "strings" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 18 | "github.com/bnema/zerowrap" | |
| 18 | 19 | ) | |
| @@ -39,13 +40,16 @@ type State struct { | |||
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | // 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>". | |
| 44 | 47 | 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"` | |
| 49 | 53 | } | |
| 50 | 54 | ||
| 51 | 55 | // Window is one mapped window. | |
| @@ -55,7 +59,10 @@ type Window struct { | |||
| 55 | 59 | PID int `json:"pid"` | |
| 56 | 60 | Output string `json:"output"` | |
| 57 | 61 | 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"` | |
| 59 | 66 | // Floating is set for native floats (dialogs) and stashed windows. | |
| 60 | 67 | Floating bool `json:"floating"` | |
| 61 | 68 | // StashIndex is the 1-based place of a stashed window in its | |
| @@ -66,28 +73,28 @@ type Window struct { | |||
| 66 | 73 | Hidden bool `json:"hidden"` | |
| 67 | 74 | } | |
| 68 | 75 | ||
| 69 | - | func fromPorts(st ports.State) State { | |
| 76 | + | func fromPorts(st ports.State, ids *workspaceid.IDs) State { | |
| 70 | 77 | out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))} | |
| 71 | 78 | 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)}) | |
| 73 | 80 | } | |
| 74 | 81 | for _, w := range st.Windows { | |
| 75 | - | out.Windows = append(out.Windows, window(w)) | |
| 82 | + | out.Windows = append(out.Windows, window(w, ids)) | |
| 76 | 83 | } | |
| 77 | 84 | if st.Window != nil { | |
| 78 | - | w := window(*st.Window) | |
| 85 | + | w := window(*st.Window, ids) | |
| 79 | 86 | out.Window = &w | |
| 80 | 87 | } | |
| 81 | 88 | return out | |
| 82 | 89 | } | |
| 83 | 90 | ||
| 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} | |
| 86 | 93 | } | |
| 87 | 94 | ||
| 88 | 95 | // Run writes every state it receives to path until ctx ends, then removes | |
| 89 | 96 | // 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 { | |
| 91 | 98 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 92 | 99 | return err | |
| 93 | 100 | } | |
| @@ -99,7 +106,7 @@ func Run(ctx context.Context, path string, states <-chan ports.State, log zerowr | |||
| 99 | 106 | case <-ctx.Done(): | |
| 100 | 107 | return nil | |
| 101 | 108 | case st := <-states: | |
| 102 | - | if err := write(path, fromPorts(st)); err != nil { | |
| 109 | + | if err := write(path, fromPorts(st, ids)); err != nil { | |
| 103 | 110 | // Scripts lose their state; the session goes on. | |
| 104 | 111 | log.Warn().Err(err).Str("path", path).Msg("write state") | |
| 105 | 112 | } | |
M internal/adapters/statefile/statefile_test.go +28 -7
| @@ -11,14 +11,15 @@ import ( | |||
| 11 | 11 | "testing" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 14 | 15 | "github.com/bnema/neferwl/internal/ports" | |
| 15 | 16 | "github.com/bnema/zerowrap" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | var sample = State{ | |
| 19 | 20 | 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}}, | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // The file holds the latest state while the session runs; a stale file from | |
| @@ -37,10 +38,10 @@ func TestRunWritesAndRemoves(t *testing.T) { | |||
| 37 | 38 | states := make(chan ports.State, 1) | |
| 38 | 39 | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | 40 | 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}}} | |
| 44 | 45 | deadline := time.Now().Add(2 * time.Second) | |
| 45 | 46 | for { | |
| 46 | 47 | if got, err := Read(path); err == nil && got.Output != "stale" { | |
| @@ -65,7 +66,7 @@ func TestRunWritesAndRemoves(t *testing.T) { | |||
| 65 | 66 | ||
| 66 | 67 | // Stash fields reach the file with their script-facing names. | |
| 67 | 68 | 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")) | |
| 69 | 70 | if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden { | |
| 70 | 71 | t.Fatalf("%+v", got) | |
| 71 | 72 | } | |
| @@ -137,3 +138,23 @@ func TestLinuxProcTreeParent(t *testing.T) { | |||
| 137 | 138 | t.Fatal(ppid, ok) | |
| 138 | 139 | } | |
| 139 | 140 | } | |
| 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 | |||
| 43 | 43 | w.bounds = outputBounds | |
| 44 | 44 | w.layers(true) | |
| 45 | 45 | w.popups(true) | |
| 46 | - | w.captureBorder() | |
| 46 | + | w.captureIndicators() | |
| 47 | 47 | r.scratchDraws = w.draws | |
| 48 | 48 | return w.draws | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 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 { | |
| 59 | 59 | return | |
| 60 | 60 | } | |
| 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) | |
| 64 | 91 | return | |
| 65 | 92 | } | |
| 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) | |
| 71 | 97 | } | |
| 72 | 98 | ||
| 73 | 99 | 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 ( | |||
| 10 | 10 | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 11 | 11 | wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |
| 12 | 12 | "github.com/bnema/wlturbo" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 13 | 14 | "golang.org/x/sys/unix" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -41,6 +42,13 @@ type pendingCapture struct { | |||
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | 44 | 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 { | |
| 44 | 52 | t.Helper() | |
| 45 | 53 | h := &admissionHarness{dir: t.TempDir(), requests: make(chan ports.CaptureRequest, queue), replies: make(chan ports.CaptureDone, maxCaptureInflight+2)} | |
| 46 | 54 | // Registered first so it runs after Run stopped and the clients closed: | |
| @@ -55,7 +63,8 @@ func newAdmissionHarness(t *testing.T, queue int) *admissionHarness { | |||
| 55 | 63 | } | |
| 56 | 64 | }) | |
| 57 | 65 | 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) | |
| 59 | 68 | if err != nil { | |
| 60 | 69 | t.Fatal(err) | |
| 61 | 70 | } | |
| @@ -74,7 +83,12 @@ func newAdmissionHarness(t *testing.T, queue int) *admissionHarness { | |||
| 74 | 83 | ||
| 75 | 84 | func (h *admissionHarness) client(t *testing.T) *admissionClient { | |
| 76 | 85 | 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() | |
| 78 | 92 | g, ok := c.Registry().FindGlobal("wl_output") | |
| 79 | 93 | if !ok { | |
| 80 | 94 | 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 { | |||
| 39 | 39 | func() error { return registerInhibit(d, s) }, | |
| 40 | 40 | func() error { return registerIdle(d, s) }, | |
| 41 | 41 | 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) }, | |
| 43 | 44 | func() error { return registerActivation(d, s) }, | |
| 44 | 45 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 45 | 46 | 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 { | |||
| 31 | 31 | // creation. The surface is closed when it is unplugged. | |
| 32 | 32 | output *output | |
| 33 | 33 | 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 | |
| 36 | 36 | attachment *layerAttachment | |
| 37 | 37 | } | |
| 38 | 38 | ||
M internal/adapters/wayland/outputs.go +4 -15
| @@ -180,13 +180,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 180 | 180 | o.global.Remove() | |
| 181 | 181 | delete(s.hdrOutputs, o.name()) | |
| 182 | 182 | 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) | |
| 190 | 184 | s.log.Info().Str("output", o.name()).Msg("output removed") | |
| 191 | 185 | for _, l := range s.layers { | |
| 192 | 186 | if l.output == o { | |
| @@ -215,7 +209,6 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 215 | 209 | continue | |
| 216 | 210 | } | |
| 217 | 211 | scaleChanged := o.place.Scale != p.Scale | |
| 218 | - | sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height | |
| 219 | 212 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | |
| 220 | 213 | o.place = p | |
| 221 | 214 | if logicalChanged { | |
| @@ -226,13 +219,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 226 | 219 | } | |
| 227 | 220 | s.lockSurfaceChanged() | |
| 228 | 221 | } | |
| 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() | |
| 236 | 225 | for _, r := range o.resources { | |
| 237 | 226 | o.sendAll(r) | |
| 238 | 227 | } | |
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) { | |||
| 144 | 144 | defer p.Close(nil) | |
| 145 | 145 | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 146 | 146 | s := ports.Scene{ | |
| 147 | - | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}}, | |
| 147 | + | Capture: &ports.SceneCapture{Workspace: 9}, | |
| 148 | 148 | CaptureScene: &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 1}}}, | |
| 149 | 149 | } | |
| 150 | 150 | 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), | |
| 152 | 152 | }}) | |
| 153 | 153 | now := time.Unix(100, 0) | |
| 154 | 154 | h := heldBuffer{window: 1, after: 5, at: now} | |
M internal/adapters/wayland/screencopy.go +45 -203
| @@ -7,8 +7,6 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | - | source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |
| 11 | - | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 12 | 10 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 13 | 11 | wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |
| 14 | 12 | "github.com/bnema/purego-libwayland/server" | |
| @@ -43,14 +41,7 @@ func registerCapture(d *server.Display, s *Server) error { | |||
| 43 | 41 | }); err != nil { | |
| 44 | 42 | return err | |
| 45 | 43 | } | |
| 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) | |
| 54 | 45 | } | |
| 55 | 46 | ||
| 56 | 47 | type screencopyManager struct{ s *Server } | |
| @@ -135,7 +126,7 @@ func (f *screencopyFrame) copy(b *wayland.Buffer, damage bool) { | |||
| 135 | 126 | } | |
| 136 | 127 | hi, lo, ns := captureTime(done.Time) | |
| 137 | 128 | 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)}}) | |
| 139 | 130 | f.replyID = id | |
| 140 | 131 | if !ok { | |
| 141 | 132 | f.res.SendFailed() | |
| @@ -151,13 +142,38 @@ func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, for | |||
| 151 | 142 | 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) | |
| 152 | 143 | } | |
| 153 | 144 | ||
| 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 | + | ||
| 154 | 174 | // requestCapture duplicates the pool descriptor, so destroying the wl_buffer | |
| 155 | 175 | // 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) { | |
| 161 | 177 | if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o { | |
| 162 | 178 | return 0, false | |
| 163 | 179 | } | |
| @@ -169,9 +185,9 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 169 | 185 | s.log.Debug().Str("output", o.name()).Int("inflight", len(s.captureInflight)).Msg("capture refused: too many captures in flight") | |
| 170 | 186 | return 0, false | |
| 171 | 187 | } | |
| 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") | |
| 175 | 191 | return 0, false | |
| 176 | 192 | } | |
| 177 | 193 | buf := s.buffers[b.Resource].(*buffer) | |
| @@ -184,7 +200,14 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 184 | 200 | id := s.nextCapture | |
| 185 | 201 | s.captureInflight[id] = struct{}{} | |
| 186 | 202 | start := time.Now() | |
| 203 | + | taken := tag.taken | |
| 204 | + | taken.Session = tag.session | |
| 187 | 205 | 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 | + | } | |
| 188 | 211 | ev := s.log.Debug() | |
| 189 | 212 | if done.Err != nil { | |
| 190 | 213 | ev = s.log.Info().Err(done.Err) | |
| @@ -194,10 +217,12 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 194 | 217 | reply(done) | |
| 195 | 218 | } | |
| 196 | 219 | } | |
| 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} | |
| 198 | 221 | select { | |
| 199 | 222 | case s.channels.Captures <- req: | |
| 200 | 223 | 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) | |
| 201 | 226 | return id, true | |
| 202 | 227 | default: | |
| 203 | 228 | delete(s.captureReplies, id) | |
| @@ -231,186 +256,3 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |||
| 231 | 256 | } | |
| 232 | 257 | } | |
| 233 | 258 | } | |
| 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) { | |||
| 43 | 43 | dir := t.TempDir() | |
| 44 | 44 | requests := make(chan ports.CaptureRequest, 1) | |
| 45 | 45 | 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")) | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | t.Fatal(err) | |
| 49 | 49 | } | |
| @@ -418,7 +418,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||
| 418 | 418 | replies := make(chan ports.CaptureDone, 1) | |
| 419 | 419 | commands := make(chan ports.ClientCommand, 4) | |
| 420 | 420 | 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")) | |
| 422 | 422 | if err != nil { | |
| 423 | 423 | t.Fatal(err) | |
| 424 | 424 | } | |
| @@ -574,7 +574,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||
| 574 | 574 | dir := t.TempDir() | |
| 575 | 575 | commands := make(chan ports.ClientCommand, 4) | |
| 576 | 576 | 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")) | |
| 578 | 578 | if err != nil { | |
| 579 | 579 | t.Fatal(err) | |
| 580 | 580 | } | |
| @@ -629,7 +629,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||
| 629 | 629 | replies := make(chan ports.CaptureDone, 1) | |
| 630 | 630 | commands := make(chan ports.ClientCommand, 2) | |
| 631 | 631 | 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")) | |
| 633 | 633 | if err != nil { | |
| 634 | 634 | t.Fatal(err) | |
| 635 | 635 | } | |
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 ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 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" | |
| 9 | 10 | "github.com/bnema/purego-libwayland/protocol/cursorshape" | |
| 10 | 11 | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |
| 11 | 12 | screencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |
| @@ -227,54 +228,94 @@ func TestProtectedInputMethod(t *testing.T) { | |||
| 227 | 228 | } | |
| 228 | 229 | } | |
| 229 | 230 | ||
| 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) | |
| 232 | 236 | 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 | + | } | |
| 246 | 246 | h.captured <- ports.CaptureDone{ID: accepted.ID, Output: accepted.Output, Time: time.Now()} | |
| 247 | 247 | hd := h.hud(t, ports.LayerTop, 0) | |
| 248 | 248 | 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") | |
| 252 | 251 | } | |
| 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") | |
| 258 | 254 | } | |
| 255 | + | noCapture(t, h.captures) | |
| 259 | 256 | 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. | |
| 261 | 259 | 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 | + | } | |
| 267 | 265 | } | |
| 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) | |
| 272 | 266 | for len(h.events) > 0 { | |
| 273 | 267 | 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) | |
| 276 | 270 | } | |
| 277 | 271 | } | |
| 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 | |
| 278 | 319 | } | |
| 279 | 320 | ||
| 280 | 321 | func TestProtectedActivationAndForeignInventory(t *testing.T) { | |
M internal/adapters/wayland/server.go +56 -4
| @@ -5,11 +5,14 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/purego-libwayland/protocol/relativepointer" | |
| 11 | 12 | ||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 12 | 14 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 15 | + | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 13 | 16 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 17 | "github.com/bnema/neferwl/internal/sessionlock" | |
| 15 | 18 | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| @@ -37,8 +40,17 @@ type Options struct { | |||
| 37 | 40 | // Security is display-owned protection control. Nil leaves session-lock | |
| 38 | 41 | // unadvertised; independent protocol tests remain unlocked. | |
| 39 | 42 | 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 | |
| 40 | 50 | // syncDev replaces the render node's syncobj interface (tests). | |
| 41 | 51 | syncDev syncobjDevice | |
| 52 | + | // peer replaces the peer executable lookup (tests). | |
| 53 | + | peer peerExe | |
| 42 | 54 | } | |
| 43 | 55 | ||
| 44 | 56 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| @@ -95,10 +107,29 @@ type Server struct { | |||
| 95 | 107 | // captureInflight counts accepted captures until the backend completes | |
| 96 | 108 | // them, independent of client resources; only the display loop touches it. | |
| 97 | 109 | captureInflight map[uint64]struct{} | |
| 98 | - | captureSources map[*server.Resource]*output | |
| 110 | + | captureSources map[*server.Resource]*captureSource | |
| 99 | 111 | 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 | |
| 102 | 133 | display *server.Display | |
| 103 | 134 | env procEnv | |
| 104 | 135 | slotsPending bool // core waits for a slot window | |
| @@ -191,6 +222,8 @@ type Server struct { | |||
| 191 | 222 | workspaceManagers []*workspaceManager | |
| 192 | 223 | toplevelManagers []*toplevelManager | |
| 193 | 224 | workspaceSnapshot ports.Workspaces | |
| 225 | + | workspaceFrames []*workspaceFrame | |
| 226 | + | workspaceIDs *workspaceid.IDs | |
| 194 | 227 | outputHeads ports.OutputHeads | |
| 195 | 228 | outputPlaces ports.Layout | |
| 196 | 229 | managementSerial uint32 | |
| @@ -259,15 +292,25 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 259 | 292 | s.pendingLeaseCards = map[uint64]string{} | |
| 260 | 293 | s.activeLeases = map[leaseKey]*leaseObject{} | |
| 261 | 294 | s.leaseReady = make(chan struct{}, 1) | |
| 295 | + | s.workspaceIDs = opts.WorkspaceIDs | |
| 296 | + | if s.workspaceIDs == nil { | |
| 297 | + | s.workspaceIDs = workspaceid.New() | |
| 298 | + | } | |
| 262 | 299 | s.clock = opts.Clock | |
| 263 | 300 | if s.clock == nil { | |
| 264 | 301 | s.clock = clock.System{} | |
| 265 | 302 | } | |
| 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 | |
| 266 | 308 | s.captureReplies = map[uint64]func(ports.CaptureDone){} | |
| 267 | 309 | s.captureInflight = map[uint64]struct{}{} | |
| 268 | 310 | s.outputReplies = map[uint64]*outputConfiguration{} | |
| 269 | 311 | s.managementSerial = 1 | |
| 270 | - | s.captureSources = map[*server.Resource]*output{} | |
| 312 | + | s.captureSources = map[*server.Resource]*captureSource{} | |
| 313 | + | s.sessionsByID = map[uint64]*captureSession{} | |
| 271 | 314 | s.captureSessions = map[*captureSession]struct{}{} | |
| 272 | 315 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 273 | 316 | 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) { | |||
| 316 | 359 | } | |
| 317 | 360 | s.syncWait.log = log | |
| 318 | 361 | s.cleanup = func() { | |
| 362 | + | if s.securityStop >= 0 { | |
| 363 | + | unix.Close(s.securityStop) | |
| 364 | + | } | |
| 319 | 365 | if s.seat.keymapFD >= 0 { | |
| 320 | 366 | unix.Close(s.seat.keymapFD) | |
| 321 | 367 | } | |
| @@ -328,6 +374,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 328 | 374 | } | |
| 329 | 375 | cleanup() | |
| 330 | 376 | } | |
| 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 | + | } | |
| 331 | 382 | if err = registerGlobals(d, opts, s); err != nil { | |
| 332 | 383 | s.cleanup() | |
| 333 | 384 | d.Close() | |
| @@ -403,6 +454,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 403 | 454 | go func() { defer wg.Done(); s.syncWait.run(ctx) }() | |
| 404 | 455 | } | |
| 405 | 456 | err := s.display.Run(ctx) | |
| 457 | + | s.stopSecurityContexts() | |
| 406 | 458 | wg.Wait() | |
| 407 | 459 | return err | |
| 408 | 460 | } | |
M internal/adapters/wayland/server_test.go +1 -1
| @@ -127,7 +127,7 @@ func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports. | |||
| 127 | 127 | dir := t.TempDir() | |
| 128 | 128 | events := make(chan ports.ClientEvent, 16) | |
| 129 | 129 | 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")) | |
| 131 | 131 | if err != nil { | |
| 132 | 132 | t.Fatal(err) | |
| 133 | 133 | } | |
M internal/adapters/wayland/session_lock.go +1 -4
| @@ -54,9 +54,7 @@ func (m sessionLockManager) Lock(r *extsessionlock.ExtSessionLockManagerV1, id u | |||
| 54 | 54 | s.lockReadiness.Begin(state.Generation) | |
| 55 | 55 | res.OnDestroy = l.orphan | |
| 56 | 56 | s.resetProtectionInput() | |
| 57 | - | if ps := s.private.session; ps != nil { | |
| 58 | - | ps.end(true) | |
| 59 | - | } | |
| 57 | + | s.endCaptureSessions() | |
| 60 | 58 | for _, lease := range s.activeLeases { | |
| 61 | 59 | lease.destroy() | |
| 62 | 60 | } | |
| @@ -94,7 +92,6 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) { | |||
| 94 | 92 | s.sessionLock = nil | |
| 95 | 93 | s.refreshToplevels() | |
| 96 | 94 | s.updateWorkspaceManagers(s.workspaceSnapshot) | |
| 97 | - | s.updateCaptureWorkspaces(s.workspaceSnapshot) | |
| 98 | 95 | for _, surface := range l.surfaces { | |
| 99 | 96 | surface.unmap() | |
| 100 | 97 | } | |
M internal/adapters/wayland/session_lock_barrier_test.go +1 -1
| @@ -35,7 +35,7 @@ func newBarrierServer(t *testing.T) *barrierServer { | |||
| 35 | 35 | 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()} | |
| 36 | 36 | layout := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}} | |
| 37 | 37 | 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")) | |
| 39 | 39 | if err != nil { | |
| 40 | 40 | t.Fatal(err) | |
| 41 | 41 | } | |
M internal/adapters/wayland/session_lock_surface_test.go +7 -0
| @@ -102,9 +102,16 @@ func TestLockSurfaceInitialConfigureAndCommitErrors(t *testing.T) { | |||
| 102 | 102 | if scenario == "queued before ack" { | |
| 103 | 103 | s.display.Do(func() { surf.barrier = true; surf.next.wait = true }) | |
| 104 | 104 | } | |
| 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 | |
| 105 | 111 | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |
| 106 | 112 | // This later ACK must not legalize an earlier FIFO commit. | |
| 107 | 113 | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |
| 114 | + | close(release) | |
| 108 | 115 | code = extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck | |
| 109 | 116 | case "null": | |
| 110 | 117 | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |
M internal/adapters/wayland/wire_client_test.go +9 -4
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "sync" | |
| 11 | 11 | "testing" | |
| 12 | 12 | ||
| 13 | - | wire_capturesession "github.com/bnema/neferwl/internal/adapters/wayland/capturesession" | |
| 13 | + | wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| 14 | 14 | wire_alphamodifier "github.com/bnema/purego-libwayland/protocol/alphamodifier" | |
| 15 | 15 | wire_colormanagement "github.com/bnema/purego-libwayland/protocol/colormanagement" | |
| 16 | 16 | wire_colorrepresentation "github.com/bnema/purego-libwayland/protocol/colorrepresentation" | |
| @@ -36,6 +36,7 @@ import ( | |||
| 36 | 36 | wire_presentationtime "github.com/bnema/purego-libwayland/protocol/presentationtime" | |
| 37 | 37 | wire_primaryselection "github.com/bnema/purego-libwayland/protocol/primaryselection" | |
| 38 | 38 | wire_relativepointer "github.com/bnema/purego-libwayland/protocol/relativepointer" | |
| 39 | + | wire_securitycontext "github.com/bnema/purego-libwayland/protocol/securitycontext" | |
| 39 | 40 | wire_tearingcontrol "github.com/bnema/purego-libwayland/protocol/tearingcontrol" | |
| 40 | 41 | wire_textinput "github.com/bnema/purego-libwayland/protocol/textinput" | |
| 41 | 42 | wire_viewporter "github.com/bnema/purego-libwayland/protocol/viewporter" | |
| @@ -119,9 +120,11 @@ var wireSchemas = map[string]wireSchema{ | |||
| 119 | 120 | "ext_workspace_group_handle_v1": {wire_extworkspace.ExtWorkspaceGroupHandleV1Interface, &client_workspace.ExtWorkspaceGroupHandle{}, map[uint16]bool{1: true}, nil}, | |
| 120 | 121 | "ext_workspace_handle_v1": {wire_extworkspace.ExtWorkspaceHandleV1Interface, &client_workspace.ExtWorkspaceHandle{}, map[uint16]bool{0: true}, nil}, | |
| 121 | 122 | "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}, | |
| 125 | 128 | "org_kde_kwin_server_decoration": {wire_kdedecoration.OrgKdeKwinServerDecorationInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecoration{}, map[uint16]bool{0: true}, nil}, | |
| 126 | 129 | "org_kde_kwin_server_decoration_manager": {wire_kdedecoration.OrgKdeKwinServerDecorationManagerInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecorationManager{}, nil, nil}, | |
| 127 | 130 | "wl_buffer": {wire_wayland.BufferInterface, &client_core.Buffer{}, map[uint16]bool{0: true}, nil}, | |
| @@ -178,6 +181,8 @@ var wireSchemas = map[string]wireSchema{ | |||
| 178 | 181 | "wp_pointer_warp_v1": {wire_pointerwarp.WpPointerWarpV1Interface, &client_pointerwarp.WpPointerWarp{}, map[uint16]bool{0: true}, nil}, | |
| 179 | 182 | "wp_presentation": {wire_presentationtime.WpPresentationInterface, &client_presentation.WpPresentation{}, map[uint16]bool{0: true}, nil}, | |
| 180 | 183 | "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}, | |
| 181 | 186 | "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil}, | |
| 182 | 187 | "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil}, | |
| 183 | 188 | "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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/binary" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 9 | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 9 | 10 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| @@ -139,7 +140,6 @@ func (s *Server) forwardWorkspaces(ctx context.Context) { | |||
| 139 | 140 | func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | |
| 140 | 141 | s.workspaceSnapshot = snapshot | |
| 141 | 142 | s.updateWorkspaceManagers(snapshot) | |
| 142 | - | s.updateCaptureWorkspaces(snapshot) | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | 145 | // updateWorkspaceManagers also refreshes inventory on protection transitions. | |
| @@ -149,7 +149,71 @@ func (s *Server) updateWorkspaceManagers(snapshot ports.Workspaces) { | |||
| 149 | 149 | for _, m := range s.workspaceManagers { | |
| 150 | 150 | m.update(snapshot) | |
| 151 | 151 | } | |
| 152 | + | s.updateWorkspaceFrames() | |
| 153 | + | s.refreshSessions() | |
| 152 | 154 | } | |
| 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 | + | ||
| 153 | 217 | func workspaceState(w ports.WorkspaceInfo) uint32 { | |
| 154 | 218 | var state uint32 | |
| 155 | 219 | if w.Active { | |
| @@ -183,8 +247,10 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) { | |||
| 183 | 247 | } | |
| 184 | 248 | // Move existing workspaces before retiring their old output group. | |
| 185 | 249 | 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 { | |
| 188 | 254 | if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() { | |
| 189 | 255 | g.res.SendWorkspaceLeave(h.res) | |
| 190 | 256 | } | |
| @@ -220,9 +286,7 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) { | |||
| 220 | 286 | h = &workspaceHandle{res: r} | |
| 221 | 287 | m.handles[w.ID] = h | |
| 222 | 288 | 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)) | |
| 226 | 290 | r.SendName(w.Name) | |
| 227 | 291 | m.sendCoordinates(r, w.Index) | |
| 228 | 292 | r.SendState(workspaceState(w)) | |
M internal/adapters/wayland/workspaces_sensitive_test.go +2 -1
| @@ -2,6 +2,7 @@ package wayland | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 8 | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| @@ -128,7 +129,7 @@ func TestProtectedWorkspaceInventoryAndActivation(t *testing.T) { | |||
| 128 | 129 | if !ok || len(activation.IDs) != 1 || activation.IDs[0] != 42 { | |
| 129 | 130 | t.Fatalf("unlocked activation: %v", ev) | |
| 130 | 131 | } | |
| 131 | - | default: | |
| 132 | + | case <-time.After(2 * time.Second): | |
| 132 | 133 | t.Fatal("unlocked activation not emitted") | |
| 133 | 134 | } | |
| 134 | 135 | } | |
M internal/adapters/wayland/workspaces_test.go +27 -17
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 10 | 11 | "github.com/bnema/neferwl/internal/logging" | |
| 11 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 13 | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| @@ -86,7 +87,7 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 86 | 87 | dir := t.TempDir() | |
| 87 | 88 | events := make(chan ports.ClientEvent, 16) | |
| 88 | 89 | 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")) | |
| 90 | 91 | if err != nil { | |
| 91 | 92 | t.Fatal(err) | |
| 92 | 93 | } | |
| @@ -127,10 +128,14 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 127 | 128 | if handle == 0 { | |
| 128 | 129 | t.Fatal("workspace not announced") | |
| 129 | 130 | } | |
| 131 | + | // A dynamic workspace sends "<launch prefix>-<core ID>". | |
| 130 | 132 | select { | |
| 131 | 133 | 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 | + | } | |
| 133 | 137 | default: | |
| 138 | + | t.Fatal("workspace sent no id") | |
| 134 | 139 | } | |
| 135 | 140 | out := bindProtocol(t, c, "wl_output") | |
| 136 | 141 | registerProtocol(t, c, out) | |
| @@ -161,8 +166,8 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 161 | 166 | if !entered || doneCount != 1 { | |
| 162 | 167 | t.Fatalf("late output enter=%v, done=%d", entered, doneCount) | |
| 163 | 168 | } | |
| 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)) | |
| 166 | 171 | } | |
| 167 | 172 | requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |
| 168 | 173 | requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate) | |
| @@ -228,7 +233,7 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 228 | 233 | func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) { | |
| 229 | 234 | dir := t.TempDir() | |
| 230 | 235 | 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")) | |
| 232 | 237 | if err != nil { | |
| 233 | 238 | t.Fatal(err) | |
| 234 | 239 | } | |
| @@ -284,25 +289,29 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) { | |||
| 284 | 289 | // initialized; workspace_enter follows all initial workspace details. | |
| 285 | 290 | wantOrder := []uint32{ | |
| 286 | 291 | 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, | |
| 288 | 293 | 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, | |
| 289 | 294 | 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, | |
| 291 | 296 | ext.ExtWorkspaceManagerV1EventDone, | |
| 292 | 297 | } | |
| 293 | 298 | if !slices.Equal(order, wantOrder) { | |
| 294 | 299 | t.Fatalf("creation order = %v, want %v", order, wantOrder) | |
| 295 | 300 | } | |
| 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) | |
| 300 | 311 | } | |
| 301 | - | default: | |
| 302 | - | t.Fatal("configured workspace has no id") | |
| 303 | 312 | } | |
| 304 | 313 | if len(p.idEvents) != 0 { | |
| 305 | - | t.Fatal("dynamic workspace received id") | |
| 314 | + | t.Fatal("extra id") | |
| 306 | 315 | } | |
| 307 | 316 | out := bindProtocol(t, c, "wl_output") | |
| 308 | 317 | registerProtocol(t, c, out) | |
| @@ -375,8 +384,9 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) { | |||
| 375 | 384 | t.Fatalf("recreated client-destroyed handle: %v", v) | |
| 376 | 385 | } | |
| 377 | 386 | } | |
| 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. | |
| 380 | 390 | 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}}}}} | |
| 381 | 391 | replaced, removed := 0, 0 | |
| 382 | 392 | for i := 0; i < 8 && replaced == 0; i++ { | |
| @@ -401,7 +411,7 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) { | |||
| 401 | 411 | } | |
| 402 | 412 | select { | |
| 403 | 413 | case id := <-p.idEvents: | |
| 404 | - | if id != "code" { | |
| 414 | + | if id != "name:code" { | |
| 405 | 415 | t.Fatalf("renamed id=%q", id) | |
| 406 | 416 | } | |
| 407 | 417 | 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 ( | |||
| 12 | 12 | "sync" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 15 | 16 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/adapters/drm" | |
| @@ -23,6 +24,7 @@ import ( | |||
| 23 | 24 | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| 24 | 25 | "github.com/bnema/neferwl/internal/adapters/vulkan" | |
| 25 | 26 | "github.com/bnema/neferwl/internal/adapters/wayland" | |
| 27 | + | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 26 | 28 | "github.com/bnema/neferwl/internal/adapters/xkb" | |
| 27 | 29 | "github.com/bnema/neferwl/internal/adapters/xwayland" | |
| 28 | 30 | "github.com/bnema/neferwl/internal/core" | |
| @@ -53,6 +55,11 @@ type Options struct { | |||
| 53 | 55 | // Run closes Script on shutdown; the reader goroutine exits after Close. | |
| 54 | 56 | Script io.ReadCloser | |
| 55 | 57 | 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 | |
| 56 | 63 | } | |
| 57 | 64 | ||
| 58 | 65 | 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) | |||
| 131 | 138 | // Every output renders on the same GPU: its formats are the clients'. | |
| 132 | 139 | dmabuf := vulkan.Probe() | |
| 133 | 140 | 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")) | |
| 135 | 155 | if err != nil { | |
| 136 | 156 | km.Close() | |
| 137 | 157 | return err | |
| @@ -160,7 +180,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 160 | 180 | inject(input) | |
| 161 | 181 | } | |
| 162 | 182 | var workers sync.WaitGroup | |
| 163 | - | workers.Add(7) | |
| 183 | + | workers.Add(8) | |
| 164 | 184 | done := make(chan error, 8) | |
| 165 | 185 | path := opts.ConfigPath | |
| 166 | 186 | if path == "" { | |
| @@ -238,6 +258,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 238 | 258 | close(script) | |
| 239 | 259 | } | |
| 240 | 260 | 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 | + | }() | |
| 241 | 268 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | |
| 242 | 269 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 243 | 270 | if xdisplay != nil { | |
| @@ -249,7 +276,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 249 | 276 | workers.Add(1) | |
| 250 | 277 | go func() { | |
| 251 | 278 | 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 { | |
| 253 | 280 | // Scripts lose their state; the session goes on. | |
| 254 | 281 | log.Warn().Err(err).Msg("state file disabled") | |
| 255 | 282 | } | |
M internal/app/session_lock_test.go +6 -1
| @@ -28,13 +28,18 @@ func TestHeadlessSessionLockEndToEnd(t *testing.T) { | |||
| 28 | 28 | t.Setenv("XDG_RUNTIME_DIR", runtime) | |
| 29 | 29 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 30 | 30 | 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 | + | } | |
| 31 | 36 | scenes := make(chan []ports.Scene, 128) | |
| 32 | 37 | script, writer := io.Pipe() | |
| 33 | 38 | defer writer.Close() | |
| 34 | 39 | ctx, cancel := context.WithCancel(context.Background()) | |
| 35 | 40 | done := make(chan error, 1) | |
| 36 | 41 | 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())}) | |
| 38 | 43 | }() | |
| 39 | 44 | // Cancellation closes clients' sockets too, bounding blocked Roundtrip. | |
| 40 | 45 | 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 { | |||
| 62 | 62 | // on screen, or the overview shows it as a preview) and forgets it | |
| 63 | 63 | // otherwise. It runs | |
| 64 | 64 | // 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 { | |
| 71 | 70 | c.configures.cw.reset() | |
| 72 | 71 | return | |
| 73 | 72 | } | |
| 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) | |
| 80 | 74 | } | |
| 81 | 75 | ||
| 82 | 76 | // 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 | |||
| 20 | 20 | // stays active, the scene carries a sibling CaptureScene, and the window is | |
| 21 | 21 | // configured Captured (sized, physically invisible) until the session stops. | |
| 22 | 22 | func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) { | |
| 23 | - | r := captureCore(t, nil) | |
| 23 | + | r := captureCore(t) | |
| 24 | 24 | r.client <- ports.WindowMapped{ID: 1} | |
| 25 | 25 | scene(t, r.scenes) | |
| 26 | 26 | // Mapping the window adds a spare workspace: wait for the inventory that lists it. | |
| @@ -28,7 +28,7 @@ func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) { | |||
| 28 | 28 | for len(ws) < 2 { | |
| 29 | 29 | ws = receive(t, r.workspaces).Outputs[0].Workspaces | |
| 30 | 30 | } | |
| 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} | |
| 32 | 32 | if st := r.state(t); !st.Active { | |
| 33 | 33 | t.Fatalf("state %+v", st) | |
| 34 | 34 | } | |
| @@ -72,7 +72,7 @@ func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // Stopping the session hides the window for real again. | |
| 75 | - | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 75 | + | r.client <- ports.CaptureSessionClose{ID: 1} | |
| 76 | 76 | var after ports.Scene | |
| 77 | 77 | for { | |
| 78 | 78 | 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) { | |||
| 190 | 190 | func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |
| 191 | 191 | c, ws, _ := hiddenCaptureCommands(t) | |
| 192 | 192 | 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}}} | |
| 194 | 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 195 | 195 | c.popupOrder = append(c.popupOrder, 10) | |
| 196 | 196 | ws.motion = newMotion(viewSpring(10, 0), time.Time{}) | |
| @@ -198,8 +198,8 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 198 | 198 | if err := c.publish(ctx); err != nil { | |
| 199 | 199 | t.Fatal(err) | |
| 200 | 200 | } | |
| 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) | |
| 203 | 203 | } | |
| 204 | 204 | if !c.configures.cw.active() || c.popups[10] == nil { | |
| 205 | 205 | t.Fatal("sliding session lost its captured workspace or popup") | |
| @@ -212,10 +212,10 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 212 | 212 | t.Fatal(err) | |
| 213 | 213 | } | |
| 214 | 214 | 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) | |
| 216 | 216 | } | |
| 217 | 217 | // The session ends: the captured workspace is forgotten and its popup goes. | |
| 218 | - | c.dropCapture() | |
| 218 | + | c.captureClose(1) | |
| 219 | 219 | if err := c.publish(ctx); err != nil { | |
| 220 | 220 | t.Fatal(err) | |
| 221 | 221 | } | |
| @@ -250,7 +250,7 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |||
| 250 | 250 | before[p.ID] = v | |
| 251 | 251 | } | |
| 252 | 252 | 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}}} | |
| 254 | 254 | if err := c.publish(ctx); err != nil { | |
| 255 | 255 | t.Fatal(err) | |
| 256 | 256 | } | |
M internal/core/core.go +34 -24
| @@ -121,11 +121,18 @@ type Core struct { | |||
| 121 | 121 | // last position sent in the pointer's window. | |
| 122 | 122 | motionMsec uint32 | |
| 123 | 123 | 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 | |
| 129 | 136 | } | |
| 130 | 137 | ||
| 131 | 138 | func keyName(s string) string { | |
| @@ -470,6 +477,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 470 | 477 | if c.security.Protected { | |
| 471 | 478 | return c.publishProtected(ctx) | |
| 472 | 479 | } | |
| 480 | + | c.captureExpire() | |
| 473 | 481 | capture, err := c.captureEvaluate(ctx) | |
| 474 | 482 | if err != nil { | |
| 475 | 483 | return err | |
| @@ -557,8 +565,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 557 | 565 | } | |
| 558 | 566 | } | |
| 559 | 567 | 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 { | |
| 562 | 570 | scene.CaptureScene = c.captureScene(scene.Seq) | |
| 563 | 571 | } | |
| 564 | 572 | scenes = append(scenes, scene) | |
| @@ -593,6 +601,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 593 | 601 | latest(c.ch.Scenes, scenes) | |
| 594 | 602 | c.publishState() | |
| 595 | 603 | c.publishWorkspaces() | |
| 604 | + | c.armCaptureTimer() | |
| 596 | 605 | // A running slide moves on the next flip, or on the fallback timer. | |
| 597 | 606 | if c.animating() { | |
| 598 | 607 | if c.frameC == nil { | |
| @@ -872,7 +881,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 872 | 881 | func (c *Core) Run(ctx context.Context) error { | |
| 873 | 882 | c.syncSecurity() | |
| 874 | 883 | defer c.stopFrame() | |
| 875 | - | defer c.stopCapture() | |
| 884 | + | defer c.stopCaptureTimer() | |
| 876 | 885 | // Startup commands run once per session. Full launcher queues retain a | |
| 877 | 886 | // bounded remainder, selectable alongside owner events without polling. | |
| 878 | 887 | c.startup = make([][]string, len(c.cfg.Startup)) | |
| @@ -914,17 +923,21 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 914 | 923 | if !c.applyLockChanged(v) { | |
| 915 | 924 | continue | |
| 916 | 925 | } | |
| 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 | |
| 920 | 934 | } | |
| 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) | |
| 928 | 941 | case ports.LayerChanged: | |
| 929 | 942 | c.layerChanged = true | |
| 930 | 943 | c.setLayers(v.Layers) | |
| @@ -1042,12 +1055,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1042 | 1055 | return nil | |
| 1043 | 1056 | } | |
| 1044 | 1057 | 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 { | |
| 1051 | 1061 | return nil | |
| 1052 | 1062 | } | |
| 1053 | 1063 | continue | |
M internal/core/screens.go +6 -2
| @@ -18,9 +18,13 @@ type screen struct { | |||
| 18 | 18 | off bool // turned off by a client (output power management) | |
| 19 | 19 | layers []ports.LayerSurface | |
| 20 | 20 | 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). | |
| 23 | 23 | 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 | |
| 24 | 28 | } | |
| 25 | 29 | ||
| 26 | 30 | 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) { | |||
| 675 | 675 | } | |
| 676 | 676 | want := ports.State{ | |
| 677 | 677 | 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}}, | |
| 679 | 679 | 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}, | |
| 682 | 682 | }, | |
| 683 | 683 | } | |
| 684 | 684 | want.Window = &want.Windows[1] | |
| @@ -689,7 +689,8 @@ func TestStateSnapshot(t *testing.T) { | |||
| 689 | 689 | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 690 | 690 | receive(t, r.scenes) | |
| 691 | 691 | 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 { | |
| 693 | 694 | t.Fatalf("%+v", st) | |
| 694 | 695 | } | |
| 695 | 696 | } | |
| @@ -714,6 +715,20 @@ func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |||
| 714 | 715 | if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible { | |
| 715 | 716 | t.Fatalf("%+v", st) | |
| 716 | 717 | } | |
| 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 | + | } | |
| 717 | 732 | } | |
| 718 | 733 | ||
| 719 | 734 | func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) { | |
M internal/core/security.go +2 -2
| @@ -40,7 +40,7 @@ func (c *Core) syncSecurity() bool { | |||
| 40 | 40 | } | |
| 41 | 41 | c.stopFrame() | |
| 42 | 42 | if state.Protected { | |
| 43 | - | c.dropCapture() | |
| 43 | + | c.dropCaptureSessions() | |
| 44 | 44 | } | |
| 45 | 45 | return true | |
| 46 | 46 | } | |
| @@ -73,7 +73,7 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool { | |||
| 73 | 73 | switch ev.(type) { | |
| 74 | 74 | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | |
| 75 | 75 | ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest, | |
| 76 | - | ports.CaptureSessionBegin, ports.CaptureSessionLayer, ports.CaptureSessionPing: | |
| 76 | + | ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer: | |
| 77 | 77 | return true | |
| 78 | 78 | } | |
| 79 | 79 | return false | |
M internal/core/security_internal_test.go +7 -4
| @@ -3,6 +3,7 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -14,7 +15,9 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 14 | 15 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 15 | 16 | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 16 | 17 | 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)}} | |
| 18 | 21 | c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}} | |
| 19 | 22 | c.pressed["password"] = true | |
| 20 | 23 | c.buttons[0x110] = true | |
| @@ -27,10 +30,10 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 27 | 30 | } | |
| 28 | 31 | scenes := <-c.ch.Scenes | |
| 29 | 32 | 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" { | |
| 31 | 34 | t.Fatalf("unsafe protected scene %+v", s) | |
| 32 | 35 | } | |
| 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 { | |
| 34 | 37 | t.Fatal("transition kept desktop input/capture state") | |
| 35 | 38 | } | |
| 36 | 39 | // Logical dimensions must match exactly, including after output changes. | |
| @@ -42,7 +45,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 42 | 45 | if c.lockKeyboardFocus() != 10 { | |
| 43 | 46 | t.Fatal("mapped role not focused") | |
| 44 | 47 | } | |
| 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{}} { | |
| 46 | 49 | if !blockedProtectedEvent(ev) { | |
| 47 | 50 | t.Fatalf("allowed desktop effect %T", ev) | |
| 48 | 51 | } | |
M internal/core/state.go +2 -2
| @@ -31,7 +31,7 @@ func (c *Core) state() ports.State { | |||
| 31 | 31 | } | |
| 32 | 32 | m := s.mon | |
| 33 | 33 | 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} | |
| 35 | 35 | if i := indexOf(m.Workspaces, cur); i >= 0 { | |
| 36 | 36 | o.Active = i + 1 | |
| 37 | 37 | } | |
| @@ -45,7 +45,7 @@ func (c *Core) state() ports.State { | |||
| 45 | 45 | n := indexOf(m.Workspaces, w) + 1 | |
| 46 | 46 | for _, id := range w.windows() { | |
| 47 | 47 | 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)} | |
| 49 | 49 | if i := w.stashIndex(id); i >= 0 { | |
| 50 | 50 | v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden | |
| 51 | 51 | } | |
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 { | |||
| 15 | 15 | Dst SHMBuffer | |
| 16 | 16 | Width, Height, Stride int | |
| 17 | 17 | 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. | |
| 23 | 21 | 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 | |
| 27 | 29 | 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 | |
| 28 | 50 | } | |
| 29 | 51 | ||
| 30 | 52 | // CaptureDone is attempted once after the output closes the destination. | |
| @@ -134,7 +156,7 @@ type WorkspaceOutput struct { | |||
| 134 | 156 | } | |
| 135 | 157 | type WorkspaceInfo struct { | |
| 136 | 158 | 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 | |
| 138 | 160 | Name string | |
| 139 | 161 | Index int // zero-based vertical coordinate | |
| 140 | 162 | Active, Hidden bool | |
| @@ -821,13 +843,17 @@ type Scene struct { | |||
| 821 | 843 | // Window popups are drawn after the windows, layer popups | |
| 822 | 844 | // (SceneWindow.OverLayers) last, over every layer. | |
| 823 | 845 | 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). | |
| 826 | 848 | 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. | |
| 830 | 852 | 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 | |
| 831 | 857 | } | |
| 832 | 858 | ||
| 833 | 859 | // Shows reports whether the scene draws the surface of id: only its | |
| @@ -1120,7 +1146,9 @@ type OutputState struct { | |||
| 1120 | 1146 | Name string | |
| 1121 | 1147 | Active int | |
| 1122 | 1148 | 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 | |
| 1124 | 1152 | } | |
| 1125 | 1153 | ||
| 1126 | 1154 | // WindowState is one mapped window and where it is. | |
| @@ -1131,6 +1159,11 @@ type WindowState struct { | |||
| 1131 | 1159 | Output string | |
| 1132 | 1160 | // Workspace is the window's numbered workspace, 0 for a hidden one. | |
| 1133 | 1161 | 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 | |
| 1134 | 1167 | // Visible means on the workspace on screen (it may be behind a | |
| 1135 | 1168 | // fullscreen window). | |
| 1136 | 1169 | Visible bool | |
M packaging/arch/PKGBUILD +4 -1
| @@ -13,7 +13,9 @@ makedepends=('go') | |||
| 13 | 13 | optdepends=('xwayland-satellite>=0.7: X11 apps' | |
| 14 | 14 | 'foot: default terminal' | |
| 15 | 15 | 'fuzzel: launcher' | |
| 16 | - | 'xdg-desktop-portal-gtk: file pickers and settings for apps') | |
| 16 | + | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | |
| 17 | + | 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal' | |
| 18 | + | 'nefercap: screenshots and screen recording') | |
| 17 | 19 | install=neferwl.install | |
| 18 | 20 | source=("$pkgname-$pkgver.tar.gz") | |
| 19 | 21 | sha256sums=('SKIP') | |
| @@ -46,6 +48,7 @@ package() { | |||
| 46 | 48 | install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop" | |
| 47 | 49 | install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf" | |
| 48 | 50 | 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" | |
| 49 | 52 | install -Dm644 README.md "$pkgdir/usr/share/doc/neferwl/README.md" | |
| 50 | 53 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/neferwl/LICENSE" | |
| 51 | 54 | } | |
M packaging/aur/neferwl-git/PKGBUILD +4 -1
| @@ -15,7 +15,9 @@ makedepends=('go>=2:1.27' 'git') | |||
| 15 | 15 | optdepends=('xwayland-satellite>=0.7: X11 apps' | |
| 16 | 16 | 'foot: default terminal' | |
| 17 | 17 | 'fuzzel: launcher' | |
| 18 | - | 'xdg-desktop-portal-gtk: file pickers and settings for apps') | |
| 18 | + | 'xdg-desktop-portal-gtk: file pickers and settings for apps' | |
| 19 | + | 'xdg-desktop-portal-wlr: screen sharing and screenshots through the portal' | |
| 20 | + | 'nefercap: screenshots and screen recording') | |
| 19 | 21 | provides=('neferwl') | |
| 20 | 22 | conflicts=('neferwl' 'neferwl-bin') | |
| 21 | 23 | install=neferwl.install | |
| @@ -57,6 +59,7 @@ package() { | |||
| 57 | 59 | install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop" | |
| 58 | 60 | install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf" | |
| 59 | 61 | 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" | |
| 60 | 63 | install -Dm644 README.md "$pkgdir/usr/share/doc/neferwl/README.md" | |
| 61 | 64 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" | |
| 62 | 65 | } | |
M packaging/neferwl-portals.conf +2 -0
| @@ -1,2 +1,4 @@ | |||
| 1 | 1 | [preferred] | |
| 2 | 2 | default=gtk | |
| 3 | + | org.freedesktop.impl.portal.ScreenCast=wlr | |
| 4 | + | org.freedesktop.impl.portal.Screenshot=wlr | |