Merge pull request 'docs: concise performance, capture, headless and session-lock docs' (#31) from docs/concise into main
6 files · +191 -141
M README.md +1 -0
| @@ -67,6 +67,7 @@ A full build takes seconds, and tests, race detection, profiling and formatting | |||
| 67 | 67 | - **Explicit sync:** client fences go to the GPU and KMS. Enabled by default, with no flag to set. | |
| 68 | 68 | - **HDR:** HDR10 on capable displays, with configurable brightness for SDR content. | |
| 69 | 69 | - **Multi-monitor:** separate workspaces and fractional scale per output. Workspaces move when a monitor is unplugged and return when it reconnects. | |
| 70 | + | - **Hidden apps rest:** a window you cannot see (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second and the suspended state. Browsers and games stop drawing frames nobody sees. A window being captured keeps its output's refresh rate. | |
| 70 | 71 | - **X11 apps:** Steam and other X11 clients run through xwayland-satellite. Wine can run natively on Wayland. | |
| 71 | 72 | ||
| 72 | 73 | ### Windows and workspaces | |
| @@ -67,6 +67,7 @@ A full build takes seconds, and tests, race detection, profiling and formatting | |||||
| 67 | - **Explicit sync:** client fences go to the GPU and KMS. Enabled by default, with no flag to set. | 67 | - **Explicit sync:** client fences go to the GPU and KMS. Enabled by default, with no flag to set. | ||
| 68 | - **HDR:** HDR10 on capable displays, with configurable brightness for SDR content. | 68 | - **HDR:** HDR10 on capable displays, with configurable brightness for SDR content. | ||
| 69 | - **Multi-monitor:** separate workspaces and fractional scale per output. Workspaces move when a monitor is unplugged and return when it reconnects. | 69 | - **Multi-monitor:** separate workspaces and fractional scale per output. Workspaces move when a monitor is unplugged and return when it reconnects. | ||
| 70 | + | - **Hidden apps rest:** a window you cannot see (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second and the suspended state. Browsers and games stop drawing frames nobody sees. A window being captured keeps its output's refresh rate. | |||
| 70 | - **X11 apps:** Steam and other X11 clients run through xwayland-satellite. Wine can run natively on Wayland. | 71 | - **X11 apps:** Steam and other X11 clients run through xwayland-satellite. Wine can run natively on Wayland. | ||
| 71 | 72 | ||||
| 72 | ### Windows and workspaces | 73 | ### Windows and workspaces | ||
M docs/capture.md +72 -37
| @@ -1,24 +1,62 @@ | |||
| 1 | 1 | # Screen capture | |
| 2 | 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/`. | |
| 3 | + | NeferWL supports `zwlr_screencopy_manager_v1` (used by `grim`) and `ext_image_copy_capture_manager_v1` (used by recorders). Three rules apply to every capture: | |
| 4 | 4 | ||
| 5 | - | Three rules decide what a capture does. They apply to every protocol and every source. | |
| 5 | + | 1. A red indicator shows on screen while something is captured. | |
| 6 | + | 2. Only allowed executables may capture. | |
| 7 | + | 3. Sandboxed apps go through the desktop portal. | |
| 8 | + | ||
| 9 | + | ## Sources | |
| 10 | + | ||
| 11 | + | With ext-image-copy-capture, a client can capture: | |
| 12 | + | ||
| 13 | + | | Source | Protocol | Notes | | |
| 14 | + | |---|---|---| | |
| 15 | + | | An output | `ext_output_image_capture_source_manager_v1` | | | |
| 16 | + | | A window | `ext_foreign_toplevel_image_capture_source_manager_v1` | Rendered at its own size, even on another workspace or under a fullscreen window | | |
| 17 | + | | A workspace | `neferwl_image_capture_source_manager_v1.create_workspace_source` | Its real layout, without bars, even when not on screen | | |
| 18 | + | | A region of an output | `neferwl_image_capture_source_manager_v1.create_output_region_source` | In logical pixels, clipped to the output | | |
| 19 | + | ||
| 20 | + | The NeferWL protocol XML is in `internal/adapters/wayland/imagecapture/`. | |
| 21 | + | ||
| 22 | + | - Captures need Vulkan sync-file export; on devices without it, such as lavapipe, capture requests fail. | |
| 23 | + | - Captures are opaque 8-bit sRGB. HDR content is tone-mapped on the GPU. | |
| 24 | + | - The cursor is never included. | |
| 25 | + | - Only one off-screen target (a window or hidden workspace) renders at a time. Other sessions wait for it, then fail. Off-screen targets are limited to 16384 px per side and 256 MiB per image. | |
| 26 | + | - A session stops when its output, workspace or window goes away. A window that unmaps and maps again is a new window: capture it again. | |
| 27 | + | - A frame whose target is briefly unavailable is retried for up to one second, so a short hiccup does not end a screencast. | |
| 28 | + | - `neferwl_workspace_frame_v1` (from `get_workspace_frame`) reports a workspace's position and size, which ext-workspace does not carry. | |
| 6 | 29 | ||
| 7 | 30 | ## The indicator | |
| 8 | 31 | ||
| 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). | |
| 32 | + | Every capture is visible, and no client can turn that off. NeferWL draws a 2-logical-pixel red border (`#ff3b30`) around the captured output, region or on-screen workspace. A captured window always shows a small red square in the top-right corner of its output, plus its border while it is on screen. A hidden workspace shows only the square. | |
| 33 | + | ||
| 34 | + | - A live session keeps the indicator on. A one-shot screenshot flashes it for one second. | |
| 35 | + | - The indicator is never in the captured image. | |
| 36 | + | - The indicator is on screen before or with the frame it captures. If it cannot be shown, a screencopy frame fails after 250 ms; an ext-image-copy-capture frame keeps retrying for up to 1 s. | |
| 37 | + | - While it shows, the output is composed (no direct scanout), and capturing the displayed frame costs a second composition. | |
| 38 | + | ||
| 39 | + | ## Hiding a recorder's own HUD | |
| 40 | + | ||
| 41 | + | `neferwl_capture_exclusion_manager_v1` keeps a recorder's HUD out of its own frames: | |
| 42 | + | ||
| 43 | + | 1. The recorder calls `get_exclusion` on its capture session and gets a token. It fails with `busy` if another exclusion is live. | |
| 44 | + | 2. The HUD attaches up to 4 layer surfaces (top or overlay, not yet mapped) with that token. | |
| 45 | + | 3. The HUD stays visible over fullscreen windows without taking keyboard focus. That session's frames leave it out; every other capture keeps it. | |
| 46 | + | ||
| 47 | + | The HUD must run as the same user and be allowed to capture itself. The exclusion ends with the session, or when the recorder destroys it; its HUD surfaces are then closed. | |
| 10 | 48 | ||
| 11 | 49 | ## Who may capture | |
| 12 | 50 | ||
| 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: | |
| 51 | + | A client that may not capture gets its protocol's failure (`failed`, or `stopped` for a session). In order: | |
| 14 | 52 | ||
| 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. | |
| 53 | + | 1. **Locked session:** nobody captures. See [Session locking](session-lock.md). | |
| 54 | + | 2. **Sandboxed client:** a client connected through `wp_security_context_v1` (Flatpak, for instance) never captures directly; it uses the portal. Sandboxed clients still see the capture globals, but their requests are refused. Other privileged protocols are not restricted. A sandbox engine must call `set_sandbox_engine` before `commit`, and a sandboxed client cannot create its own security context; both are protocol errors. | |
| 55 | + | 3. **Allowlist:** the executable must be listed. | |
| 18 | 56 | ||
| 19 | 57 | ## The allowlist | |
| 20 | 58 | ||
| 21 | - | NeferWL ships a built-in allowlist. These executables may capture: | |
| 59 | + | By default, these executables may capture: | |
| 22 | 60 | ||
| 23 | 61 | ```text | |
| 24 | 62 | /usr/bin/grim | |
| @@ -27,61 +65,58 @@ NeferWL ships a built-in allowlist. These executables may capture: | |||
| 27 | 65 | /usr/libexec/xdg-desktop-portal-wlr | |
| 28 | 66 | ``` | |
| 29 | 67 | ||
| 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: | |
| 68 | + | To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need: | |
| 33 | 69 | ||
| 34 | 70 | ```sh | |
| 35 | 71 | sudo install -Dm644 /usr/share/doc/neferwl/capture-allow.example /etc/neferwl/capture-allow | |
| 36 | 72 | sudoedit /etc/neferwl/capture-allow | |
| 37 | 73 | ``` | |
| 38 | 74 | ||
| 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 | 75 | ```text | |
| 42 | 76 | # one absolute executable path per line | |
| 43 | 77 | /usr/bin/grim | |
| 44 | 78 | /usr/bin/nefercap | |
| 45 | 79 | /usr/lib/xdg-desktop-portal-wlr | |
| 46 | - | /usr/libexec/xdg-desktop-portal-wlr | |
| 47 | 80 | /opt/tools/bin/recorder | |
| 48 | 81 | ``` | |
| 49 | 82 | ||
| 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. | |
| 83 | + | - List the real file, as `/proc/<pid>/exe` shows it: `/usr/bin/python3.12`, not a `python3` symlink. | |
| 84 | + | - Paths must match exactly; there are no globs. | |
| 85 | + | - A line with only `*` lets every client capture. | |
| 86 | + | - Changes apply within a fraction of a second, or about 2 s after creating `/etc/neferwl`. They apply to the next request and to the next frame of running sessions; images already delivered cannot be taken back. If the file cannot be watched, NeferWL logs a warning and changes need a restart. | |
| 87 | + | - If the file or `/etc/neferwl` is missing, the built-in list applies. | |
| 88 | + | - If the file is invalid or unsafe, no client may capture. The log names the problem. | |
| 58 | 89 | ||
| 59 | - | ### The file must be root's | |
| 90 | + | The path is fixed and not a config key: any program running as you could edit your config, but not a root-owned file. | |
| 60 | 91 | ||
| 61 | - | The allowlist is only as good as the ability to write it. NeferWL therefore checks, at every load: | |
| 92 | + | ### The file must belong to root | |
| 62 | 93 | ||
| 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). | |
| 94 | + | The file must be a regular file. It and every directory above it (`/`, `/etc`, `/etc/neferwl`) must be owned by root, not writable by group or others (sticky directories are accepted), and not symlinks. Otherwise no client may capture, even if `/` or `/etc` is the problem: the built-in list does not apply. | |
| 65 | 95 | ||
| 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. | |
| 96 | + | ### How a client is identified | |
| 67 | 97 | ||
| 68 | - | ### What identifies a client | |
| 98 | + | By the executable actually running, not its name or app ID. NeferWL takes a pidfd of the client (`SO_PEERPIDFD`, Linux 6.5+) and checks `/proc/<pid>/exe` at every screencopy frame, every new ext session and every HUD attach. A process that exits, or whose executable was deleted or replaced, is refused. | |
| 69 | 99 | ||
| 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. | |
| 100 | + | Frames of an open ext session reuse the executable found when the client was last checked. A program that `exec`s into another binary keeps its open sessions, and is judged by the new binary at its next request. | |
| 71 | 101 | ||
| 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. | |
| 102 | + | On kernels older than 6.5, no client may capture unless the list is `*`. | |
| 73 | 103 | ||
| 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. | |
| 104 | + | Each refused executable is logged once: `capture refused`, with the pid, path and reason. | |
| 75 | 105 | ||
| 76 | 106 | ### Limits | |
| 77 | 107 | ||
| 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. | |
| 108 | + | - **Binaries you own.** Listing a file under your home trusts every program that can overwrite it. Prefer root-owned paths. | |
| 109 | + | - **Interpreters.** A script runs as its interpreter. Never list `python`, `bash` or `node`: every script would be allowed. | |
| 110 | + | - **Same-user attacks.** The allowlist stops unknown programs. It does not stop a program running as you from driving a listed one (`LD_PRELOAD`, `ptrace`, or simply asking it). The indicator still shows. | |
| 111 | + | - **What happens next.** A listed program can send its images anywhere. | |
| 82 | 112 | ||
| 83 | 113 | ## Portals | |
| 84 | 114 | ||
| 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`. A whole output or a single window can be shared: a shared window is captured at its own size, whether it is on screen, on another workspace or under a fullscreen window, and keeps drawing while it is shared. 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. | |
| 115 | + | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-wlr`. | |
| 116 | + | - **Screenshots and recordings:** install [nefercap](https://github.com/bnema/nefercap). | |
| 117 | + | ||
| 118 | + | The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. You can share a whole output or a single window. | |
| 119 | + | ||
| 120 | + | NeferWL has no window picker. The wlr portal starts one itself, such as slurp, wofi or bemenu: see `chooser_type` and `chooser_cmd` in `man xdg-desktop-portal-wlr`. | |
| 86 | 121 | ||
| 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. | |
| 122 | + | Any program running as you can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes each time. | |
| @@ -1,24 +1,62 @@ | |||||
| 1 | # Screen capture | 1 | # Screen capture | ||
| 2 | 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/`. | 3 | + | NeferWL supports `zwlr_screencopy_manager_v1` (used by `grim`) and `ext_image_copy_capture_manager_v1` (used by recorders). Three rules apply to every capture: |
| 4 | 4 | ||||
| 5 | - | Three rules decide what a capture does. They apply to every protocol and every source. | 5 | + | 1. A red indicator shows on screen while something is captured. |
| 6 | + | 2. Only allowed executables may capture. | |||
| 7 | + | 3. Sandboxed apps go through the desktop portal. | |||
| 8 | + | ||||
| 9 | + | ## Sources | |||
| 10 | + | ||||
| 11 | + | With ext-image-copy-capture, a client can capture: | |||
| 12 | + | ||||
| 13 | + | | Source | Protocol | Notes | | |||
| 14 | + | |---|---|---| | |||
| 15 | + | | An output | `ext_output_image_capture_source_manager_v1` | | | |||
| 16 | + | | A window | `ext_foreign_toplevel_image_capture_source_manager_v1` | Rendered at its own size, even on another workspace or under a fullscreen window | | |||
| 17 | + | | A workspace | `neferwl_image_capture_source_manager_v1.create_workspace_source` | Its real layout, without bars, even when not on screen | | |||
| 18 | + | | A region of an output | `neferwl_image_capture_source_manager_v1.create_output_region_source` | In logical pixels, clipped to the output | | |||
| 19 | + | ||||
| 20 | + | The NeferWL protocol XML is in `internal/adapters/wayland/imagecapture/`. | |||
| 21 | + | ||||
| 22 | + | - Captures need Vulkan sync-file export; on devices without it, such as lavapipe, capture requests fail. | |||
| 23 | + | - Captures are opaque 8-bit sRGB. HDR content is tone-mapped on the GPU. | |||
| 24 | + | - The cursor is never included. | |||
| 25 | + | - Only one off-screen target (a window or hidden workspace) renders at a time. Other sessions wait for it, then fail. Off-screen targets are limited to 16384 px per side and 256 MiB per image. | |||
| 26 | + | - A session stops when its output, workspace or window goes away. A window that unmaps and maps again is a new window: capture it again. | |||
| 27 | + | - A frame whose target is briefly unavailable is retried for up to one second, so a short hiccup does not end a screencast. | |||
| 28 | + | - `neferwl_workspace_frame_v1` (from `get_workspace_frame`) reports a workspace's position and size, which ext-workspace does not carry. | |||
| 6 | 29 | ||||
| 7 | ## The indicator | 30 | ## The indicator | ||
| 8 | 31 | ||||
| 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). | 32 | + | Every capture is visible, and no client can turn that off. NeferWL draws a 2-logical-pixel red border (`#ff3b30`) around the captured output, region or on-screen workspace. A captured window always shows a small red square in the top-right corner of its output, plus its border while it is on screen. A hidden workspace shows only the square. |
| 33 | + | ||||
| 34 | + | - A live session keeps the indicator on. A one-shot screenshot flashes it for one second. | |||
| 35 | + | - The indicator is never in the captured image. | |||
| 36 | + | - The indicator is on screen before or with the frame it captures. If it cannot be shown, a screencopy frame fails after 250 ms; an ext-image-copy-capture frame keeps retrying for up to 1 s. | |||
| 37 | + | - While it shows, the output is composed (no direct scanout), and capturing the displayed frame costs a second composition. | |||
| 38 | + | ||||
| 39 | + | ## Hiding a recorder's own HUD | |||
| 40 | + | ||||
| 41 | + | `neferwl_capture_exclusion_manager_v1` keeps a recorder's HUD out of its own frames: | |||
| 42 | + | ||||
| 43 | + | 1. The recorder calls `get_exclusion` on its capture session and gets a token. It fails with `busy` if another exclusion is live. | |||
| 44 | + | 2. The HUD attaches up to 4 layer surfaces (top or overlay, not yet mapped) with that token. | |||
| 45 | + | 3. The HUD stays visible over fullscreen windows without taking keyboard focus. That session's frames leave it out; every other capture keeps it. | |||
| 46 | + | ||||
| 47 | + | The HUD must run as the same user and be allowed to capture itself. The exclusion ends with the session, or when the recorder destroys it; its HUD surfaces are then closed. | |||
| 10 | 48 | ||||
| 11 | ## Who may capture | 49 | ## Who may capture | ||
| 12 | 50 | ||||
| 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: | 51 | + | A client that may not capture gets its protocol's failure (`failed`, or `stopped` for a session). In order: |
| 14 | 52 | ||||
| 15 | - | 1. **A locked session.** While an `ext-session-lock` lock is held, no client may capture. See [Session locking](session-lock.md). | 53 | + | 1. **Locked session:** nobody captures. 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. | 54 | + | 2. **Sandboxed client:** a client connected through `wp_security_context_v1` (Flatpak, for instance) never captures directly; it uses the portal. Sandboxed clients still see the capture globals, but their requests are refused. Other privileged protocols are not restricted. A sandbox engine must call `set_sandbox_engine` before `commit`, and a sandboxed client cannot create its own security context; both are protocol errors. |
| 17 | - | 3. **The executable allowlist**, below. | 55 | + | 3. **Allowlist:** the executable must be listed. |
| 18 | 56 | ||||
| 19 | ## The allowlist | 57 | ## The allowlist | ||
| 20 | 58 | ||||
| 21 | - | NeferWL ships a built-in allowlist. These executables may capture: | 59 | + | By default, these executables may capture: |
| 22 | 60 | ||||
| 23 | ```text | 61 | ```text | ||
| 24 | /usr/bin/grim | 62 | /usr/bin/grim | ||
| @@ -27,61 +65,58 @@ NeferWL ships a built-in allowlist. These executables may capture: | |||||
| 27 | /usr/libexec/xdg-desktop-portal-wlr | 65 | /usr/libexec/xdg-desktop-portal-wlr | ||
| 28 | ``` | 66 | ``` | ||
| 29 | 67 | ||||
| 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. | 68 | + | To change it, create `/etc/neferwl/capture-allow`. It **replaces** the built-in list, so keep the entries you still need: |
| 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 | 69 | ||||
| 34 | ```sh | 70 | ```sh | ||
| 35 | sudo install -Dm644 /usr/share/doc/neferwl/capture-allow.example /etc/neferwl/capture-allow | 71 | sudo install -Dm644 /usr/share/doc/neferwl/capture-allow.example /etc/neferwl/capture-allow | ||
| 36 | sudoedit /etc/neferwl/capture-allow | 72 | sudoedit /etc/neferwl/capture-allow | ||
| 37 | ``` | 73 | ``` | ||
| 38 | 74 | ||||
| 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 | 75 | ```text | ||
| 42 | # one absolute executable path per line | 76 | # one absolute executable path per line | ||
| 43 | /usr/bin/grim | 77 | /usr/bin/grim | ||
| 44 | /usr/bin/nefercap | 78 | /usr/bin/nefercap | ||
| 45 | /usr/lib/xdg-desktop-portal-wlr | 79 | /usr/lib/xdg-desktop-portal-wlr | ||
| 46 | - | /usr/libexec/xdg-desktop-portal-wlr | |||
| 47 | /opt/tools/bin/recorder | 80 | /opt/tools/bin/recorder | ||
| 48 | ``` | 81 | ``` | ||
| 49 | 82 | ||||
| 50 | - | - Blank lines and lines starting with `#` are ignored. | 83 | + | - List the real file, as `/proc/<pid>/exe` shows it: `/usr/bin/python3.12`, not a `python3` symlink. |
| 51 | - | - A line with only `*` disables the check: every client may capture. | 84 | + | - Paths must match exactly; there are no globs. |
| 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. | 85 | + | - A line with only `*` lets every client capture. |
| 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. | 86 | + | - Changes apply within a fraction of a second, or about 2 s after creating `/etc/neferwl`. They apply to the next request and to the next frame of running sessions; images already delivered cannot be taken back. If the file cannot be watched, NeferWL logs a warning and changes need a restart. |
| 54 | - | - An untrustworthy file lets no client capture either, and is logged as an error naming the problem (see below). | 87 | + | - If the file or `/etc/neferwl` is missing, the built-in list applies. |
| 55 | - | - Comparison is exact, on the cleaned path. There are no globs or directories. | 88 | + | - If the file is invalid or unsafe, no client may capture. The log names the problem. |
| 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 | 89 | ||||
| 59 | - | ### The file must be root's | 90 | + | The path is fixed and not a config key: any program running as you could edit your config, but not a root-owned file. |
| 60 | 91 | ||||
| 61 | - | The allowlist is only as good as the ability to write it. NeferWL therefore checks, at every load: | 92 | + | ### The file must belong to root |
| 62 | 93 | ||||
| 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); | 94 | + | The file must be a regular file. It and every directory above it (`/`, `/etc`, `/etc/neferwl`) must be owned by root, not writable by group or others (sticky directories are accepted), and not symlinks. Otherwise no client may capture, even if `/` or `/etc` is the problem: the built-in list does not apply. |
| 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 | 95 | ||||
| 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. | 96 | + | ### How a client is identified |
| 67 | 97 | ||||
| 68 | - | ### What identifies a client | 98 | + | By the executable actually running, not its name or app ID. NeferWL takes a pidfd of the client (`SO_PEERPIDFD`, Linux 6.5+) and checks `/proc/<pid>/exe` at every screencopy frame, every new ext session and every HUD attach. A process that exits, or whose executable was deleted or replaced, is refused. |
| 69 | 99 | ||||
| 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. | 100 | + | Frames of an open ext session reuse the executable found when the client was last checked. A program that `exec`s into another binary keeps its open sessions, and is judged by the new binary at its next request. |
| 71 | 101 | ||||
| 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. | 102 | + | On kernels older than 6.5, no client may capture unless the list is `*`. |
| 73 | 103 | ||||
| 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. | 104 | + | Each refused executable is logged once: `capture refused`, with the pid, path and reason. |
| 75 | 105 | ||||
| 76 | ### Limits | 106 | ### Limits | ||
| 77 | 107 | ||||
| 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. | 108 | + | - **Binaries you own.** Listing a file under your home trusts every program that can overwrite it. 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. | 109 | + | - **Interpreters.** A script runs as its interpreter. Never list `python`, `bash` or `node`: every script would be allowed. |
| 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. | 110 | + | - **Same-user attacks.** The allowlist stops unknown programs. It does not stop a program running as you from driving a listed one (`LD_PRELOAD`, `ptrace`, or simply asking it). The indicator still shows. |
| 81 | - | - The allowlist limits who may ask, not what they do with the image. Whatever a listed program receives, it can send anywhere. | 111 | + | - **What happens next.** A listed program can send its images anywhere. |
| 82 | 112 | ||||
| 83 | ## Portals | 113 | ## Portals | ||
| 84 | 114 | ||||
| 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`. A whole output or a single window can be shared: a shared window is captured at its own size, whether it is on screen, on another workspace or under a fullscreen window, and keeps drawing while it is shared. 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. | 115 | + | - **Screen sharing** in browsers and apps: install `xdg-desktop-portal-wlr`. |
| 116 | + | - **Screenshots and recordings:** install [nefercap](https://github.com/bnema/nefercap). | |||
| 117 | + | ||||
| 118 | + | The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. You can share a whole output or a single window. | |||
| 119 | + | ||||
| 120 | + | NeferWL has no window picker. The wlr portal starts one itself, such as slurp, wofi or bemenu: see `chooser_type` and `chooser_cmd` in `man xdg-desktop-portal-wlr`. | |||
| 86 | 121 | ||||
| 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. | 122 | + | Any program running as you can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes each time. |
M docs/desktop.md +12 -3
| @@ -80,7 +80,7 @@ External lockers use `ext-session-lock-v1`. NeferWL isolates desktop input and c | |||
| 80 | 80 | ||
| 81 | 81 | ## Screen capture | |
| 82 | 82 | ||
| 83 | - | 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). | |
| 83 | + | nefercap, `grim` and screen sharing through `xdg-desktop-portal-wlr` work out of the box. Other recorders must be added to `/etc/neferwl/capture-allow`. Every capture shows a red border. See [Screen capture](capture.md). | |
| 84 | 84 | ||
| 85 | 85 | ## Idle and screen off | |
| 86 | 86 | ||
| @@ -120,9 +120,18 @@ The lid switch is not covered: logind still handles it. The locks last as long a | |||
| 120 | 120 | ||
| 121 | 121 | ## Bars | |
| 122 | 122 | ||
| 123 | - | 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. | |
| 123 | + | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars get workspace updates and can switch workspaces without polling. | |
| 124 | 124 | ||
| 125 | - | 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. The read-only `ext_foreign_toplevel_list_v1` lists the same windows with a stable `identifier` (`neferwl-<n>`, never reused in a session); screen-sharing portals use it to offer single windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection. | |
| 125 | + | Each workspace has an `id`, the same as `workspace_id` in the [state file](#state-for-scripts): | |
| 126 | + | ||
| 127 | + | | Workspace | `id` | Stable | | |
| 128 | + | |---|---|---| | |
| 129 | + | | Named | `name:<configured name>` | Across launches | | |
| 130 | + | | Numbered | `<prefix>-<n>`, with a random prefix per launch | For the current launch only | | |
| 131 | + | ||
| 132 | + | Only store settings keyed on named workspace IDs. | |
| 133 | + | ||
| 134 | + | Taskbars can list, focus, close and fullscreen windows through `zwlr_foreign_toplevel_manager_v1`, and notification daemons such as Dunst can detect fullscreen windows. `ext_foreign_toplevel_list_v1` lists the same windows with an `identifier` (`neferwl-<window>-<mapping>`) that is never reused in a session (a window that maps again gets a new one); portals use it to share single windows. While the session is locked, bars and taskbars get no updates. | |
| 126 | 135 | ||
| 127 | 136 | ## State for scripts | |
| 128 | 137 | ||
| @@ -80,7 +80,7 @@ External lockers use `ext-session-lock-v1`. NeferWL isolates desktop input and c | |||||
| 80 | 80 | ||||
| 81 | ## Screen capture | 81 | ## Screen capture | ||
| 82 | 82 | ||||
| 83 | - | 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). | 83 | + | nefercap, `grim` and screen sharing through `xdg-desktop-portal-wlr` work out of the box. Other recorders must be added to `/etc/neferwl/capture-allow`. Every capture shows a red border. See [Screen capture](capture.md). |
| 84 | 84 | ||||
| 85 | ## Idle and screen off | 85 | ## Idle and screen off | ||
| 86 | 86 | ||||
| @@ -120,9 +120,18 @@ The lid switch is not covered: logind still handles it. The locks last as long a | |||||
| 120 | 120 | ||||
| 121 | ## Bars | 121 | ## Bars | ||
| 122 | 122 | ||||
| 123 | - | 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. | 123 | + | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars get workspace updates and can switch workspaces without polling. |
| 124 | 124 | ||||
| 125 | - | 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. The read-only `ext_foreign_toplevel_list_v1` lists the same windows with a stable `identifier` (`neferwl-<n>`, never reused in a session); screen-sharing portals use it to offer single windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection. | 125 | + | Each workspace has an `id`, the same as `workspace_id` in the [state file](#state-for-scripts): |
| 126 | + | ||||
| 127 | + | | Workspace | `id` | Stable | | |||
| 128 | + | |---|---|---| | |||
| 129 | + | | Named | `name:<configured name>` | Across launches | | |||
| 130 | + | | Numbered | `<prefix>-<n>`, with a random prefix per launch | For the current launch only | | |||
| 131 | + | ||||
| 132 | + | Only store settings keyed on named workspace IDs. | |||
| 133 | + | ||||
| 134 | + | Taskbars can list, focus, close and fullscreen windows through `zwlr_foreign_toplevel_manager_v1`, and notification daemons such as Dunst can detect fullscreen windows. `ext_foreign_toplevel_list_v1` lists the same windows with an `identifier` (`neferwl-<window>-<mapping>`) that is never reused in a session (a window that maps again gets a new one); portals use it to share single windows. While the session is locked, bars and taskbars get no updates. | |||
| 126 | 135 | ||||
| 127 | ## State for scripts | 136 | ## State for scripts | ||
| 128 | 137 | ||||
M docs/headless.md +25 -24
| @@ -7,45 +7,46 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||
| 7 | 7 | WAYLAND_DISPLAY=<name from the log> foot | |
| 8 | 8 | ``` | |
| 9 | 9 | ||
| 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. | |
| 10 | + | - `/tmp/neferwl-shots/latest.png` shows the current frame, cursor included. A frame that cannot be read back is skipped, never written black. | |
| 11 | + | - Every frame is also saved as `frame-NNNNNN.png`, so the directory keeps growing. With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | |
| 12 | + | - `--timeout 5s` stops NeferWL after 5 seconds. | |
| 13 | + | - While the session is locked, the PNG stops updating and keeps the last unlocked frame. See [Session locking](session-lock.md). | |
| 14 | + | - The cursor is not drawn on screen. After a layout change, pointer focus updates on the next move. | |
| 11 | 15 | ||
| 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. The standard `ext_foreign_toplevel_image_capture_source_manager_v1` turns an `ext_foreign_toplevel_list_v1` handle into a source of that window: its client area and popups, at its own size, rendered off screen wherever the window is (one off-screen target, a window or a hidden workspace, at a time: a session that finds it taken gets no frames until it is free); its buffer size follows the window, and its sessions stop when the window unmaps or closes. A remapped window is a new toplevel with a new identifier; sources made from the old handle are dead. The indicator of a window is the pill on its output, plus its outline while it is on screen. `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; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions. | |
| 16 | + | `grim` and recorders also work here; see [Screen capture](capture.md). | |
| 13 | 17 | ||
| 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. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. 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). | |
| 18 | + | ## Input scripts | |
| 15 | 19 | ||
| 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 (an ext-image-copy-capture frame then asks again, until its one-second deadline). 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. | |
| 20 | + | `--input <path>`, or `--input -` for stdin, plays one command per line: | |
| 17 | 21 | ||
| 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. | |
| 22 | + | | Command | Effect | | |
| 23 | + | |---|---| | |
| 24 | + | | `type <text>` | Type text | | |
| 25 | + | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | |
| 26 | + | | `sleep 1s` | Wait | | |
| 27 | + | | `move <x> <y>` | Move the pointer, in output coordinates | | |
| 28 | + | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | |
| 29 | + | | `swipe up\|down\|left\|right` | Quick three-finger touchpad flick | | |
| 30 | + | | `swipe4 up\|down` | Four-finger flick | | |
| 19 | 31 | ||
| 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. | |
| 32 | + | ## Testing input methods | |
| 21 | 33 | ||
| 22 | - | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | |
| 23 | - | ||
| 24 | - | - `type <text>` | |
| 25 | - | - `key Super+Return` (Shift, Ctrl and Alt also work, as does a bare key) | |
| 26 | - | - `sleep 1s` | |
| 27 | - | - `move <x> <y>` (output coordinates) | |
| 28 | - | - `click [left|right|middle]`, `down <button>`, `up <button>` | |
| 29 | - | - `swipe up|down|left|right` (a quick three-finger touchpad flick); `swipe4 up|down` is a four-finger one | |
| 30 | - | ||
| 31 | - | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | |
| 34 | + | `examples/testime` is a minimal input method. Each time an app enables text input, it sends a preedit string, then commits the final text: | |
| 32 | 35 | ||
| 33 | 36 | ```sh | |
| 34 | 37 | neferwl --backend=headless | |
| 35 | 38 | WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -commit 日本 | |
| 36 | 39 | ``` | |
| 37 | 40 | ||
| 38 | - | `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit. | |
| 39 | - | ||
| 40 | - | The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move. | |
| 41 | + | `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit. | |
| 41 | 42 | ||
| 42 | 43 | ## Colour check | |
| 43 | 44 | ||
| 44 | - | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern: | |
| 45 | + | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless 640×360 output with `examples/testpattern -fullscreen` and compares 5×5 boxes at the centre of each patch: | |
| 45 | 46 | ||
| 46 | - | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel). | |
| 47 | - | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails. | |
| 47 | + | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output. `latest.png` must match the sRGB values within ±1 per channel. | |
| 48 | + | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`), read back as raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`; a PQ client (`-mode hdr`) its own codes. Tolerance is ±0.012 PQ, or 0.03 nit for channels below 0.05 nit, where PQ is very steep. The test fails if the output falls back to SDR. | |
| 48 | 49 | ||
| 49 | - | `--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it). | |
| 50 | + | `--screenshot-raw`, with `--screenshot` and `--headless-hdr`, also writes `latest-pq.png`: a 16-bit PNG of the 10-bit PQ codes, without cursor or tone mapping. It is a test-only flag. | |
| 50 | 51 | ||
| 51 | - | The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests. | |
| 52 | + | The check cannot see hardware scanout or the KMS colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare by eye. The client asks for fullscreen 1.2 s after its first commit, because earlier requests are ignored. | |
| @@ -7,45 +7,46 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||||
| 7 | WAYLAND_DISPLAY=<name from the log> foot | 7 | WAYLAND_DISPLAY=<name from the log> foot | ||
| 8 | ``` | 8 | ``` | ||
| 9 | 9 | ||||
| 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. | 10 | + | - `/tmp/neferwl-shots/latest.png` shows the current frame, cursor included. A frame that cannot be read back is skipped, never written black. |
| 11 | + | - Every frame is also saved as `frame-NNNNNN.png`, so the directory keeps growing. With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | |||
| 12 | + | - `--timeout 5s` stops NeferWL after 5 seconds. | |||
| 13 | + | - While the session is locked, the PNG stops updating and keeps the last unlocked frame. See [Session locking](session-lock.md). | |||
| 14 | + | - The cursor is not drawn on screen. After a layout change, pointer focus updates on the next move. | |||
| 11 | 15 | ||||
| 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. The standard `ext_foreign_toplevel_image_capture_source_manager_v1` turns an `ext_foreign_toplevel_list_v1` handle into a source of that window: its client area and popups, at its own size, rendered off screen wherever the window is (one off-screen target, a window or a hidden workspace, at a time: a session that finds it taken gets no frames until it is free); its buffer size follows the window, and its sessions stop when the window unmaps or closes. A remapped window is a new toplevel with a new identifier; sources made from the old handle are dead. The indicator of a window is the pill on its output, plus its outline while it is on screen. `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; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions. | 16 | + | `grim` and recorders also work here; see [Screen capture](capture.md). |
| 13 | 17 | ||||
| 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. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. 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). | 18 | + | ## Input scripts |
| 15 | 19 | ||||
| 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 (an ext-image-copy-capture frame then asks again, until its one-second deadline). 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. | 20 | + | `--input <path>`, or `--input -` for stdin, plays one command per line: |
| 17 | 21 | ||||
| 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. | 22 | + | | Command | Effect | |
| 23 | + | |---|---| | |||
| 24 | + | | `type <text>` | Type text | | |||
| 25 | + | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | |||
| 26 | + | | `sleep 1s` | Wait | | |||
| 27 | + | | `move <x> <y>` | Move the pointer, in output coordinates | | |||
| 28 | + | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | |||
| 29 | + | | `swipe up\|down\|left\|right` | Quick three-finger touchpad flick | | |||
| 30 | + | | `swipe4 up\|down` | Four-finger flick | | |||
| 19 | 31 | ||||
| 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. | 32 | + | ## Testing input methods |
| 21 | 33 | ||||
| 22 | - | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | 34 | + | `examples/testime` is a minimal input method. Each time an app enables text input, it sends a preedit string, then commits the final text: |
| 23 | - | ||||
| 24 | - | - `type <text>` | |||
| 25 | - | - `key Super+Return` (Shift, Ctrl and Alt also work, as does a bare key) | |||
| 26 | - | - `sleep 1s` | |||
| 27 | - | - `move <x> <y>` (output coordinates) | |||
| 28 | - | - `click [left|right|middle]`, `down <button>`, `up <button>` | |||
| 29 | - | - `swipe up|down|left|right` (a quick three-finger touchpad flick); `swipe4 up|down` is a four-finger one | |||
| 30 | - | ||||
| 31 | - | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | |||
| 32 | 35 | ||||
| 33 | ```sh | 36 | ```sh | ||
| 34 | neferwl --backend=headless | 37 | neferwl --backend=headless | ||
| 35 | WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -commit 日本 | 38 | WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -commit 日本 | ||
| 36 | ``` | 39 | ``` | ||
| 37 | 40 | ||||
| 38 | - | `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit. | 41 | + | `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit. |
| 39 | - | ||||
| 40 | - | The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move. | |||
| 41 | 42 | ||||
| 42 | ## Colour check | 43 | ## Colour check | ||
| 43 | 44 | ||||
| 44 | - | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern: | 45 | + | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless 640×360 output with `examples/testpattern -fullscreen` and compares 5×5 boxes at the centre of each patch: |
| 45 | 46 | ||||
| 46 | - | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel). | 47 | + | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output. `latest.png` must match the sRGB values within ±1 per channel. |
| 47 | - | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails. | 48 | + | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`), read back as raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`; a PQ client (`-mode hdr`) its own codes. Tolerance is ±0.012 PQ, or 0.03 nit for channels below 0.05 nit, where PQ is very steep. The test fails if the output falls back to SDR. |
| 48 | 49 | ||||
| 49 | - | `--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it). | 50 | + | `--screenshot-raw`, with `--screenshot` and `--headless-hdr`, also writes `latest-pq.png`: a 16-bit PNG of the 10-bit PQ codes, without cursor or tone mapping. It is a test-only flag. |
| 50 | 51 | ||||
| 51 | - | The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests. | 52 | + | The check cannot see hardware scanout or the KMS colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare by eye. The client asks for fullscreen 1.2 s after its first commit, because earlier requests are ignored. |
M docs/performance.md +59 -54
| @@ -1,89 +1,94 @@ | |||
| 1 | 1 | # Performance | |
| 2 | 2 | ||
| 3 | - | How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against. | |
| 3 | + | NeferWL uses about 40–80 MB of RAM with two 4K monitors and almost no CPU while the screen does not change. | |
| 4 | 4 | ||
| 5 | - | ## Design | |
| 5 | + | ## How it stays light | |
| 6 | 6 | ||
| 7 | - | - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts. | |
| 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 | - | - **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 | - | - **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 | - | - **Transitions** (experimental, `animations = on`; off by default). Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen, the floating toggle, drag drops and workspaces sent to another monitor each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Windows that open, close or leave with the stash fade and scale from 90 %, and the overview zooms its cards; both reuse the per-window alpha and preview zoom of the compose pass, so no transition adds a GPU pass, a blur or a texture copy. A closing window is drawn from its last content, which the renderer already imported; the veil under a closing float goes at once, so the close redraws only the float's rect. A window that fades or zooms is never scanned out directly. Client-driven resizes, panels and hotplug never animate. Clients get one configure, to the final size. During a move or a resize the window frame moves and the content is not scaled, only clipped or padded; the content zooms with the frame only while a window opens, closes or is an overview card. A spring that moves pixels settles as soon as it can no longer leave half a pixel of its target, so its last rendered frame is already the settled one and the sub-pixel tail (about a third of a 100 px move's frames) is not drawn; fades keep their fine epsilon. The frame fallback timer is made once and reset per frame. | |
| 12 | - | - **Redraw only what moved.** An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. When only windows move, the renderer compares the new scene with the one each render target holds and redraws the union of the old and new rects of the windows that changed; anything else (layers, scale, transform, dim, capture) redraws the whole output. Resizing one of three columns at 2560×1600 redraws about 0.2 M pixels per frame instead of 4.1 M. Advancing the springs, the scene comparison (separator lists of the same length are compared index by index, as core emits them in layout order) and the draw list of a moving window cost 0 allocations; publishing the scenes of two outputs with six moving windows (position, fade, dim and zoom motions) costs a pinned 6 on both the page-flip and the fallback-timer paths, 4 with closing windows and 18 while the overview opens. All are guarded by `make perf-check`. Measured cost of a full composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.4 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz. | |
| 13 | - | - **Compressed render targets.** Output images take their modifiers from the primary plane's `IN_FORMATS` for XRGB8888 (XRGB2101010 in HDR); KMS refuses any other (`ADDFB2: EINVAL`). A multi-plane (AMD DCC) modifier is used only when the plane lists the exact variant the GPU exports; otherwise NeferWL uses a single-plane tiled modifier, then linear. AMD DCC retile modifiers (`DCC_RETILE`, bit 14 of the modifier, e.g. `…567b03`) are never exported: such an image is rendered into pipe-aligned DCC and a compute pass rewrites its displayable copy, a full-surface GPU pass on top of the composition. On the measured Radeon 680M laptop (RDNA2, current RADV and amdgpu) the pick the plane accepts is single-plane tiled `0x200000010401b03`. Plain DCC costs no retile pass. As a renderer-only benchmark on a Raphael iGPU, a full composed frame at 2560×1600 takes about 1.63 ms into pipe-aligned DCC (`…56bb03`), 1.85 ms into single-plane tiled (`…401b03`) and 3.0 ms into linear (`go test ./internal/adapters/vulkan -bench ComposeFullTarget`, `NEFERWL_VK_DEVICE` picks the GPU; it exports one target per modifier the device offers, directly and not through the plane's `IN_FORMATS`, and times one frame with the GPU wait included). The DCC figure is thus not what the DRM output gets unless the plane lists that modifier. The chosen modifier and its plane count are logged as `zero-copy output`. | |
| 14 | - | - **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. | |
| 15 | - | - **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. | |
| 7 | + | - **No reflection across the C boundary.** libwayland and Vulkan calls go through fixed-arity `purego` syscalls and callbacks, with zero allocations per call. | |
| 8 | + | - **Few allocations per frame.** Frame callbacks, flips, Vulkan submissions and surface updates reuse buffers; scene publication and `Render` stay within small fixed allocation counts. `make perf-check` fails if a hot path allocates more than its budget. | |
| 9 | + | - **Paced frame callbacks.** Clients are told to draw on each output's flip or refresh, so a 165 Hz output paces them at 165 Hz. Cursors and surfaces without an output get 60 Hz. The pacer sleeps while no callback waits. | |
| 10 | + | - **Hidden windows rest.** A window nobody sees (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second, and the `suspended` state if its client supports xdg-shell v6. A captured window keeps its output's rate. | |
| 11 | + | - **Redraw only what changed.** An unchanged output is not recomposed. A content change redraws only its damage, and a moving window only its old and new area. A change to layers, scale, transform, dim or capture redraws the whole output. Resizing one of three columns at 2560×1600 redraws about 0.2 M pixels per frame instead of 4.1 M. | |
| 12 | + | - **Direct scanout.** A fullscreen buffer goes straight to the display, with tearing for games, when `render.direct-scanout` and `render.tearing` allow it (see [Configuration](config.md#keys)). | |
| 13 | + | - **Cheap animations** (experimental, off by default: `animations = on`). Springs reuse the composition pass: no extra GPU pass, blur or texture copy. Clients get one configure, at the final size. A window that is fading or zooming is composed, not scanned out directly. | |
| 14 | + | - **Compressed render targets.** Output images use the best modifier the display plane accepts (AMD DCC, then tiled, then linear). The log line `zero-copy output` shows the choice. | |
| 15 | + | - **Bounded capture.** At most 32 capture requests are in flight. A new request is never queued: wlr-screencopy fails it at once, and ext-image-copy retries for up to 1 s, then fails. Display capture uses up to 128 MiB of host memory per output (two 5K frames; larger outputs cannot be captured). Capturing a hidden workspace adds a separate renderer with its own 128 MiB, freed when idle. The capture indicator also costs composition; see [Screen capture](capture.md#the-indicator). | |
| 16 | + | - **GC settings.** The Go heap is a few MB because client buffers live in shared and GPU memory. NeferWL runs with `GOGC=100` and a 256 MiB soft memory limit; `GOGC` and `GOMEMLIMIT` from the environment override them. | |
| 16 | 17 | ||
| 17 | - | Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch and Debian 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`. | |
| 18 | + | A full composed frame at 2560×1600 costs about 0.16 ms on an RX 9070 XT, 1.4 ms on a Raphael iGPU and 3 ms on a Radeon 680M, against 11.1 ms per frame at 90 Hz. To measure a GPU yourself: `go test ./internal/adapters/vulkan -bench ComposeFullTarget` (`NEFERWL_VK_DEVICE` picks the GPU). | |
| 18 | 19 | ||
| 19 | - | With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH` for NeferWL, and debuggers need root to attach. | |
| 20 | + | ## VRR | |
| 20 | 21 | ||
| 21 | - | ## Guards | |
| 22 | + | With a fullscreen game under VRR: | |
| 22 | 23 | ||
| 23 | - | `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: spring advance, scene publication (pinned budgets, with the overview open and with closing windows), scene comparison for partial redraws, tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`. | |
| 24 | + | - The cursor moves with the game's next frame instead of forcing its own refresh, which would drop the panel to its slowest rate. If the game stops drawing, the cursor updates alone at 24 Hz. | |
| 25 | + | - A game frame commits at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. On amdgpu, committing earlier can hold the panel at its slowest refresh (48 Hz on the measured panel) while the game renders at 150 fps. Measurements are in `internal/adapters/drm/vrr_flip_gap.go`. | |
| 26 | + | ||
| 27 | + | ## Real-time priority | |
| 28 | + | ||
| 29 | + | Output and input threads request real-time scheduling, which needs `CAP_SYS_NICE`. The Arch and Debian packages set it on `/usr/bin/neferwl`. Apps started by NeferWL do not inherit it. The Vulkan queue also requests realtime GPU priority, falling back to high. | |
| 30 | + | ||
| 31 | + | - Turn off real-time threads: `performance.realtime = off`. The GPU queue priority is still requested. | |
| 32 | + | - Grant the capability to a local build: `sudo setcap cap_sys_nice+ep bin/neferwl`. | |
| 33 | + | ||
| 34 | + | With the capability, the loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH`, and debuggers need root to attach. | |
| 24 | 35 | ||
| 25 | 36 | ## Measuring | |
| 26 | 37 | ||
| 27 | - | Profile a running compositor on another TTY or over SSH. | |
| 38 | + | Profile a running compositor from another TTY or over SSH: | |
| 28 | 39 | ||
| 29 | 40 | ```sh | |
| 30 | - | # CPU: where the cycles go (Go and C frames). | |
| 41 | + | # CPU, Go and C frames | |
| 31 | 42 | perf record -g -p (pgrep -f bin/neferwl) -- sleep 8 | |
| 32 | 43 | perf report --children --percent-limit 1 | |
| 33 | 44 | ||
| 34 | - | # Allocations: which Go line allocates how many objects. | |
| 45 | + | # Allocations per Go line | |
| 35 | 46 | neferwl --backend=drm --pprof=localhost:6060 | |
| 36 | 47 | go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap | |
| 37 | 48 | ||
| 38 | - | # GC: one line per collection (count, heap before/after, CPU). | |
| 49 | + | # One line per GC cycle | |
| 39 | 50 | GODEBUG=gctrace=1 neferwl --backend=drm | |
| 40 | 51 | ``` | |
| 41 | 52 | ||
| 42 | - | `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | |
| 53 | + | `make tty` serves pprof on `localhost:6060` (`make tty PPROF=` turns it off). It also serves `/debug/pprof/profile` and `/debug/pprof/trace`. | |
| 54 | + | ||
| 55 | + | Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion` and `input-keys`; name them to turn them on. | |
| 43 | 56 | ||
| 44 | - | Each output's `stats` entry (every 10 s) counts what happened since the previous one, from the kernel's flip timestamps, and starts over: | |
| 57 | + | ### Frame stats | |
| 45 | 58 | ||
| 46 | - | - `missed_vblanks`: frame flips that came over 1.5 refresh periods after the previous frame flip. An output renders only on change, so only a frame that was due counts: one wanted (a scene, content or capture change made it dirty) before, or less than one period after, the previous flip, so it was meant for the next vblank even if it was committed late. A frame wanted after an idle period is ignored. Not counted under VRR, where the period is not fixed. | |
| 47 | - | - `max_flip_interval_ms`: the longest interval between two flips of due frames (also under VRR, where the gate uses the mode's refresh period, the shortest one). The chain restarts after a modeset or a VRR change. | |
| 48 | - | - `max_commit_delay_ms`: for due frames, the longest time from when the frame was due (wanted, and not before the previous flip or the end of the deliberate `render.vrr-flip-gap`) to its commit. It is large when the commit came late: the previous flip's event was read late, or the CPU side of the composition was slow. | |
| 49 | - | - `max_flip_to_read_ms`: the longest time between a flip's kernel timestamp and the output goroutine handling its event, for the output's own commits. | |
| 50 | - | - `late_fences` (only with `--debug=drm-flip`, which reads the fence times): missed vblanks whose composition fence signalled after the vblank the frame targeted (the previous flip plus one period), so the GPU was not done in time. Not counted under VRR. | |
| 51 | - | - `redrawn_pixels`: target pixels the renderer drew; a damage-limited frame counts its damage, not the whole target. | |
| 59 | + | Every 10 s, each output logs a `stats` entry for the last interval. The main fields: | |
| 52 | 60 | ||
| 53 | - | Reading them together: missed vblanks with `late_fences` are a late GPU composition. Missed vblanks without late fences and with a large `max_commit_delay_ms` (usually next to a large `max_flip_to_read_ms`) are a late commit: the event reader or the CPU. Few `missed_vblanks` mean flips were on time only if `max_commit_delay_ms` is also small: a delayed commit can still make its vblank, and `max_flip_to_read_ms` alone says the event arrived late, not that the flip did. | |
| 61 | + | | Field | Meaning | | |
| 62 | + | |---|---| | |
| 63 | + | | `missed_vblanks` | Due frames that flipped more than 1.5 refresh periods after the previous one. Not counted under VRR | | |
| 64 | + | | `max_flip_interval_ms` | Longest gap between two flips of due frames, VRR included. Restarts after a modeset | | |
| 65 | + | | `max_commit_delay_ms` | Longest wait from "frame due" to commit | | |
| 66 | + | | `max_flip_to_read_ms` | Longest wait from a flip to NeferWL reading its event | | |
| 67 | + | | `late_fences` | Missed vblanks where the GPU finished too late. Needs `--debug=drm-flip` | | |
| 68 | + | | `redrawn_pixels` | Pixels the renderer drew | | |
| 54 | 69 | ||
| 55 | - | `input stats` shows coalesced pointer motion and the longest wait for core. | |
| 70 | + | It also counts `frames`, `flips`, `pending`, `cursor_moves` and `cursor_commits`. | |
| 56 | 71 | ||
| 57 | - | `--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every commit completion as a `flip` entry: `commit_to_flip_ms`, `flip_interval_ms` (between frame flips), `fence_ready_at_commit`, and, when the client fence reports its signal time, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit. | |
| 72 | + | To find the cause of missed vblanks: | |
| 58 | 73 | ||
| 59 | - | Logs go to `$XDG_STATE_HOME/neferwl/runs/<backend>/`, one file per run with `latest.log` pointing at the newest; each backend keeps its last 20 runs, so headless test runs never rotate a DRM session's log away. `--debug=all` leaves out the per-event categories `drm-flip`, `input-motion` and `input-keys` (typed text); name them to turn them on. | |
| 74 | + | - With `late_fences`: the GPU composition was late. | |
| 75 | + | - Without `late_fences` but with a large `max_commit_delay_ms`: the commit was late (CPU work or a late event read). | |
| 60 | 76 | ||
| 61 | - | ## Reference: 4K HDR tiled video playback | |
| 77 | + | Few `missed_vblanks` mean flips were on time only if `max_commit_delay_ms` is also small. | |
| 62 | 78 | ||
| 63 | - | Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive). | |
| 79 | + | `--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every flip as a `flip` entry with `commit_to_flip_ms`, `flip_interval_ms`, `fence_ready_at_commit`, and, when the client fence reports it, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit. `input stats` shows coalesced pointer motion and the longest wait for core. | |
| 80 | + | ||
| 81 | + | ## Reference: 4K HDR video with tiled subsurfaces | |
| 82 | + | ||
| 83 | + | A browser playing 4K HDR video with 65 subsurfaces committing every frame, measured with `perf record -g` for 8 s on the DRM backend: | |
| 64 | 84 | ||
| 65 | 85 | | Metric | Before (issue #19) | 2026-09-27 | | |
| 66 | 86 | |---|---|---| | |
| 67 | - | | CPU (top) | 20–30 % | 13–14 % | | |
| 87 | + | | CPU | 20–30 % | 13–14 % | | |
| 68 | 88 | | GC (`gcBgMarkWorker`) | 20 % | 8.7 % | | |
| 69 | - | | `runtime.mallocgc` | — | 3.2 % | | |
| 70 | - | | Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 | | |
| 71 | - | | C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 | | |
| 72 | - | | Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) | | |
| 73 | - | | Vulkan `Render` | — | 15.3 % | | |
| 89 | + | | Go → C reflection | 46.7 % | 0 | | |
| 90 | + | | C → Go reflection | ~30 % | 0 | | |
| 91 | + | | `Render` allocations per frame | 1321 | 10 | | |
| 92 | + | | GC cycles on the playback test | 13 (91 ms) | 2 (3 ms) | | |
| 74 | 93 | ||
| 75 | - | Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`): | |
| 76 | - | ||
| 77 | - | | Path | Before | Now | | |
| 78 | - | |---|---|---| | |
| 79 | - | | C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 | | |
| 80 | - | | Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 | | |
| 81 | - | | Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 | | |
| 82 | - | | Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 | | |
| 83 | - | | Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 | | |
| 84 | - | | `Render`, tiled scene, per frame | 1321 allocs | 10 allocs | | |
| 85 | - | | Surface update per commit | 2 allocs | 0 (recycled) | | |
| 86 | - | | DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) | | |
| 87 | - | | GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms | | |
| 88 | - | ||
| 89 | - | What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %). | |
| 94 | + | The remaining CPU time is real work: surface commits and fifo logic (~23 %), C calls (~5 %) and the Go scheduler (~8 %). | |
| @@ -1,89 +1,94 @@ | |||||
| 1 | # Performance | 1 | # Performance | ||
| 2 | 2 | ||||
| 3 | - | How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against. | 3 | + | NeferWL uses about 40–80 MB of RAM with two 4K monitors and almost no CPU while the screen does not change. |
| 4 | 4 | ||||
| 5 | - | ## Design | 5 | + | ## How it stays light |
| 6 | 6 | ||||
| 7 | - | - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts. | 7 | + | - **No reflection across the C boundary.** libwayland and Vulkan calls go through fixed-arity `purego` syscalls and callbacks, with zero allocations per call. |
| 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. | 8 | + | - **Few allocations per frame.** Frame callbacks, flips, Vulkan submissions and surface updates reuse buffers; scene publication and `Render` stay within small fixed allocation counts. `make perf-check` fails if a hot path allocates more than its budget. |
| 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. | 9 | + | - **Paced frame callbacks.** Clients are told to draw on each output's flip or refresh, so a 165 Hz output paces them at 165 Hz. Cursors and surfaces without an output get 60 Hz. The pacer sleeps while no callback waits. |
| 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. | 10 | + | - **Hidden windows rest.** A window nobody sees (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second, and the `suspended` state if its client supports xdg-shell v6. A captured window keeps its output's rate. |
| 11 | - | - **Transitions** (experimental, `animations = on`; off by default). Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen, the floating toggle, drag drops and workspaces sent to another monitor each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Windows that open, close or leave with the stash fade and scale from 90 %, and the overview zooms its cards; both reuse the per-window alpha and preview zoom of the compose pass, so no transition adds a GPU pass, a blur or a texture copy. A closing window is drawn from its last content, which the renderer already imported; the veil under a closing float goes at once, so the close redraws only the float's rect. A window that fades or zooms is never scanned out directly. Client-driven resizes, panels and hotplug never animate. Clients get one configure, to the final size. During a move or a resize the window frame moves and the content is not scaled, only clipped or padded; the content zooms with the frame only while a window opens, closes or is an overview card. A spring that moves pixels settles as soon as it can no longer leave half a pixel of its target, so its last rendered frame is already the settled one and the sub-pixel tail (about a third of a 100 px move's frames) is not drawn; fades keep their fine epsilon. The frame fallback timer is made once and reset per frame. | 11 | + | - **Redraw only what changed.** An unchanged output is not recomposed. A content change redraws only its damage, and a moving window only its old and new area. A change to layers, scale, transform, dim or capture redraws the whole output. Resizing one of three columns at 2560×1600 redraws about 0.2 M pixels per frame instead of 4.1 M. |
| 12 | - | - **Redraw only what moved.** An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. When only windows move, the renderer compares the new scene with the one each render target holds and redraws the union of the old and new rects of the windows that changed; anything else (layers, scale, transform, dim, capture) redraws the whole output. Resizing one of three columns at 2560×1600 redraws about 0.2 M pixels per frame instead of 4.1 M. Advancing the springs, the scene comparison (separator lists of the same length are compared index by index, as core emits them in layout order) and the draw list of a moving window cost 0 allocations; publishing the scenes of two outputs with six moving windows (position, fade, dim and zoom motions) costs a pinned 6 on both the page-flip and the fallback-timer paths, 4 with closing windows and 18 while the overview opens. All are guarded by `make perf-check`. Measured cost of a full composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.4 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz. | 12 | + | - **Direct scanout.** A fullscreen buffer goes straight to the display, with tearing for games, when `render.direct-scanout` and `render.tearing` allow it (see [Configuration](config.md#keys)). |
| 13 | - | - **Compressed render targets.** Output images take their modifiers from the primary plane's `IN_FORMATS` for XRGB8888 (XRGB2101010 in HDR); KMS refuses any other (`ADDFB2: EINVAL`). A multi-plane (AMD DCC) modifier is used only when the plane lists the exact variant the GPU exports; otherwise NeferWL uses a single-plane tiled modifier, then linear. AMD DCC retile modifiers (`DCC_RETILE`, bit 14 of the modifier, e.g. `…567b03`) are never exported: such an image is rendered into pipe-aligned DCC and a compute pass rewrites its displayable copy, a full-surface GPU pass on top of the composition. On the measured Radeon 680M laptop (RDNA2, current RADV and amdgpu) the pick the plane accepts is single-plane tiled `0x200000010401b03`. Plain DCC costs no retile pass. As a renderer-only benchmark on a Raphael iGPU, a full composed frame at 2560×1600 takes about 1.63 ms into pipe-aligned DCC (`…56bb03`), 1.85 ms into single-plane tiled (`…401b03`) and 3.0 ms into linear (`go test ./internal/adapters/vulkan -bench ComposeFullTarget`, `NEFERWL_VK_DEVICE` picks the GPU; it exports one target per modifier the device offers, directly and not through the plane's `IN_FORMATS`, and times one frame with the GPU wait included). The DCC figure is thus not what the DRM output gets unless the plane lists that modifier. The chosen modifier and its plane count are logged as `zero-copy output`. | 13 | + | - **Cheap animations** (experimental, off by default: `animations = on`). Springs reuse the composition pass: no extra GPU pass, blur or texture copy. Clients get one configure, at the final size. A window that is fading or zooming is composed, not scanned out directly. |
| 14 | - | - **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. | 14 | + | - **Compressed render targets.** Output images use the best modifier the display plane accepts (AMD DCC, then tiled, then linear). The log line `zero-copy output` shows the choice. |
| 15 | - | - **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. | 15 | + | - **Bounded capture.** At most 32 capture requests are in flight. A new request is never queued: wlr-screencopy fails it at once, and ext-image-copy retries for up to 1 s, then fails. Display capture uses up to 128 MiB of host memory per output (two 5K frames; larger outputs cannot be captured). Capturing a hidden workspace adds a separate renderer with its own 128 MiB, freed when idle. The capture indicator also costs composition; see [Screen capture](capture.md#the-indicator). |
| 16 | + | - **GC settings.** The Go heap is a few MB because client buffers live in shared and GPU memory. NeferWL runs with `GOGC=100` and a 256 MiB soft memory limit; `GOGC` and `GOMEMLIMIT` from the environment override them. | |||
| 16 | 17 | ||||
| 17 | - | Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch and Debian 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`. | 18 | + | A full composed frame at 2560×1600 costs about 0.16 ms on an RX 9070 XT, 1.4 ms on a Raphael iGPU and 3 ms on a Radeon 680M, against 11.1 ms per frame at 90 Hz. To measure a GPU yourself: `go test ./internal/adapters/vulkan -bench ComposeFullTarget` (`NEFERWL_VK_DEVICE` picks the GPU). |
| 18 | 19 | ||||
| 19 | - | With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH` for NeferWL, and debuggers need root to attach. | 20 | + | ## VRR |
| 20 | 21 | ||||
| 21 | - | ## Guards | 22 | + | With a fullscreen game under VRR: |
| 22 | 23 | ||||
| 23 | - | `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: spring advance, scene publication (pinned budgets, with the overview open and with closing windows), scene comparison for partial redraws, tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`. | 24 | + | - The cursor moves with the game's next frame instead of forcing its own refresh, which would drop the panel to its slowest rate. If the game stops drawing, the cursor updates alone at 24 Hz. |
| 25 | + | - A game frame commits at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. On amdgpu, committing earlier can hold the panel at its slowest refresh (48 Hz on the measured panel) while the game renders at 150 fps. Measurements are in `internal/adapters/drm/vrr_flip_gap.go`. | |||
| 26 | + | ||||
| 27 | + | ## Real-time priority | |||
| 28 | + | ||||
| 29 | + | Output and input threads request real-time scheduling, which needs `CAP_SYS_NICE`. The Arch and Debian packages set it on `/usr/bin/neferwl`. Apps started by NeferWL do not inherit it. The Vulkan queue also requests realtime GPU priority, falling back to high. | |||
| 30 | + | ||||
| 31 | + | - Turn off real-time threads: `performance.realtime = off`. The GPU queue priority is still requested. | |||
| 32 | + | - Grant the capability to a local build: `sudo setcap cap_sys_nice+ep bin/neferwl`. | |||
| 33 | + | ||||
| 34 | + | With the capability, the loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH`, and debuggers need root to attach. | |||
| 24 | 35 | ||||
| 25 | ## Measuring | 36 | ## Measuring | ||
| 26 | 37 | ||||
| 27 | - | Profile a running compositor on another TTY or over SSH. | 38 | + | Profile a running compositor from another TTY or over SSH: |
| 28 | 39 | ||||
| 29 | ```sh | 40 | ```sh | ||
| 30 | - | # CPU: where the cycles go (Go and C frames). | 41 | + | # CPU, Go and C frames |
| 31 | perf record -g -p (pgrep -f bin/neferwl) -- sleep 8 | 42 | perf record -g -p (pgrep -f bin/neferwl) -- sleep 8 | ||
| 32 | perf report --children --percent-limit 1 | 43 | perf report --children --percent-limit 1 | ||
| 33 | 44 | ||||
| 34 | - | # Allocations: which Go line allocates how many objects. | 45 | + | # Allocations per Go line |
| 35 | neferwl --backend=drm --pprof=localhost:6060 | 46 | neferwl --backend=drm --pprof=localhost:6060 | ||
| 36 | go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap | 47 | go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap | ||
| 37 | 48 | ||||
| 38 | - | # GC: one line per collection (count, heap before/after, CPU). | 49 | + | # One line per GC cycle |
| 39 | GODEBUG=gctrace=1 neferwl --backend=drm | 50 | GODEBUG=gctrace=1 neferwl --backend=drm | ||
| 40 | ``` | 51 | ``` | ||
| 41 | 52 | ||||
| 42 | - | `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | 53 | + | `make tty` serves pprof on `localhost:6060` (`make tty PPROF=` turns it off). It also serves `/debug/pprof/profile` and `/debug/pprof/trace`. |
| 54 | + | ||||
| 55 | + | Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion` and `input-keys`; name them to turn them on. | |||
| 43 | 56 | ||||
| 44 | - | Each output's `stats` entry (every 10 s) counts what happened since the previous one, from the kernel's flip timestamps, and starts over: | 57 | + | ### Frame stats |
| 45 | 58 | ||||
| 46 | - | - `missed_vblanks`: frame flips that came over 1.5 refresh periods after the previous frame flip. An output renders only on change, so only a frame that was due counts: one wanted (a scene, content or capture change made it dirty) before, or less than one period after, the previous flip, so it was meant for the next vblank even if it was committed late. A frame wanted after an idle period is ignored. Not counted under VRR, where the period is not fixed. | 59 | + | Every 10 s, each output logs a `stats` entry for the last interval. The main fields: |
| 47 | - | - `max_flip_interval_ms`: the longest interval between two flips of due frames (also under VRR, where the gate uses the mode's refresh period, the shortest one). The chain restarts after a modeset or a VRR change. | |||
| 48 | - | - `max_commit_delay_ms`: for due frames, the longest time from when the frame was due (wanted, and not before the previous flip or the end of the deliberate `render.vrr-flip-gap`) to its commit. It is large when the commit came late: the previous flip's event was read late, or the CPU side of the composition was slow. | |||
| 49 | - | - `max_flip_to_read_ms`: the longest time between a flip's kernel timestamp and the output goroutine handling its event, for the output's own commits. | |||
| 50 | - | - `late_fences` (only with `--debug=drm-flip`, which reads the fence times): missed vblanks whose composition fence signalled after the vblank the frame targeted (the previous flip plus one period), so the GPU was not done in time. Not counted under VRR. | |||
| 51 | - | - `redrawn_pixels`: target pixels the renderer drew; a damage-limited frame counts its damage, not the whole target. | |||
| 52 | 60 | ||||
| 53 | - | Reading them together: missed vblanks with `late_fences` are a late GPU composition. Missed vblanks without late fences and with a large `max_commit_delay_ms` (usually next to a large `max_flip_to_read_ms`) are a late commit: the event reader or the CPU. Few `missed_vblanks` mean flips were on time only if `max_commit_delay_ms` is also small: a delayed commit can still make its vblank, and `max_flip_to_read_ms` alone says the event arrived late, not that the flip did. | 61 | + | | Field | Meaning | |
| 62 | + | |---|---| | |||
| 63 | + | | `missed_vblanks` | Due frames that flipped more than 1.5 refresh periods after the previous one. Not counted under VRR | | |||
| 64 | + | | `max_flip_interval_ms` | Longest gap between two flips of due frames, VRR included. Restarts after a modeset | | |||
| 65 | + | | `max_commit_delay_ms` | Longest wait from "frame due" to commit | | |||
| 66 | + | | `max_flip_to_read_ms` | Longest wait from a flip to NeferWL reading its event | | |||
| 67 | + | | `late_fences` | Missed vblanks where the GPU finished too late. Needs `--debug=drm-flip` | | |||
| 68 | + | | `redrawn_pixels` | Pixels the renderer drew | | |||
| 54 | 69 | ||||
| 55 | - | `input stats` shows coalesced pointer motion and the longest wait for core. | 70 | + | It also counts `frames`, `flips`, `pending`, `cursor_moves` and `cursor_commits`. |
| 56 | 71 | ||||
| 57 | - | `--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every commit completion as a `flip` entry: `commit_to_flip_ms`, `flip_interval_ms` (between frame flips), `fence_ready_at_commit`, and, when the client fence reports its signal time, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit. | 72 | + | To find the cause of missed vblanks: |
| 58 | 73 | ||||
| 59 | - | Logs go to `$XDG_STATE_HOME/neferwl/runs/<backend>/`, one file per run with `latest.log` pointing at the newest; each backend keeps its last 20 runs, so headless test runs never rotate a DRM session's log away. `--debug=all` leaves out the per-event categories `drm-flip`, `input-motion` and `input-keys` (typed text); name them to turn them on. | 74 | + | - With `late_fences`: the GPU composition was late. |
| 75 | + | - Without `late_fences` but with a large `max_commit_delay_ms`: the commit was late (CPU work or a late event read). | |||
| 60 | 76 | ||||
| 61 | - | ## Reference: 4K HDR tiled video playback | 77 | + | Few `missed_vblanks` mean flips were on time only if `max_commit_delay_ms` is also small. |
| 62 | 78 | ||||
| 63 | - | Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive). | 79 | + | `--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every flip as a `flip` entry with `commit_to_flip_ms`, `flip_interval_ms`, `fence_ready_at_commit`, and, when the client fence reports it, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit. `input stats` shows coalesced pointer motion and the longest wait for core. |
| 80 | + | ||||
| 81 | + | ## Reference: 4K HDR video with tiled subsurfaces | |||
| 82 | + | ||||
| 83 | + | A browser playing 4K HDR video with 65 subsurfaces committing every frame, measured with `perf record -g` for 8 s on the DRM backend: | |||
| 64 | 84 | ||||
| 65 | | Metric | Before (issue #19) | 2026-09-27 | | 85 | | Metric | Before (issue #19) | 2026-09-27 | | ||
| 66 | |---|---|---| | 86 | |---|---|---| | ||
| 67 | - | | CPU (top) | 20–30 % | 13–14 % | | 87 | + | | CPU | 20–30 % | 13–14 % | |
| 68 | | GC (`gcBgMarkWorker`) | 20 % | 8.7 % | | 88 | | GC (`gcBgMarkWorker`) | 20 % | 8.7 % | | ||
| 69 | - | | `runtime.mallocgc` | — | 3.2 % | | 89 | + | | Go → C reflection | 46.7 % | 0 | |
| 70 | - | | Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 | | 90 | + | | C → Go reflection | ~30 % | 0 | |
| 71 | - | | C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 | | 91 | + | | `Render` allocations per frame | 1321 | 10 | |
| 72 | - | | Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) | | 92 | + | | GC cycles on the playback test | 13 (91 ms) | 2 (3 ms) | |
| 73 | - | | Vulkan `Render` | — | 15.3 % | | |||
| 74 | 93 | ||||
| 75 | - | Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`): | 94 | + | The remaining CPU time is real work: surface commits and fifo logic (~23 %), C calls (~5 %) and the Go scheduler (~8 %). |
| 76 | - | ||||
| 77 | - | | Path | Before | Now | | |||
| 78 | - | |---|---|---| | |||
| 79 | - | | C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 | | |||
| 80 | - | | Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 | | |||
| 81 | - | | Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 | | |||
| 82 | - | | Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 | | |||
| 83 | - | | Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 | | |||
| 84 | - | | `Render`, tiled scene, per frame | 1321 allocs | 10 allocs | | |||
| 85 | - | | Surface update per commit | 2 allocs | 0 (recycled) | | |||
| 86 | - | | DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) | | |||
| 87 | - | | GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms | | |||
| 88 | - | ||||
| 89 | - | What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %). | |||
M docs/session-lock.md +22 -23
| @@ -1,35 +1,34 @@ | |||
| 1 | 1 | # Session locking | |
| 2 | 2 | ||
| 3 | - | NeferWL supports `ext-session-lock-v1` with an external Wayland locker. The locker owns authentication and requests unlock after it succeeds; NeferWL owns input isolation and output protection. A fullscreen window, layer-shell cover or fade is not a session lock. | |
| 3 | + | NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL blocks input and hides the desktop. A fullscreen window or a layer-shell cover is not a lock. | |
| 4 | 4 | ||
| 5 | - | ## Protection | |
| 5 | + | ## While locked | |
| 6 | 6 | ||
| 7 | - | NeferWL accepts one live lock owner. From acceptance, it refuses desktop input, binds, activation, virtual-keyboard injection, input-method grabs, clipboard requests and capture admission. Queued desktop input from an earlier transition is discarded rather than relabelled. Protected scenes contain only an opaque compositor-owned black background and the owner's validated lock surfaces. Direct scanout, overlays, tearing and hardware cursors are disabled while protected. | |
| 7 | + | - Only the locker gets input. Binds, activation, virtual keyboards, input-method grabs, clipboard requests and new captures are refused. | |
| 8 | + | - Outputs show only black and the locker's surfaces. Direct scanout, overlays, tearing and hardware cursors are off. | |
| 9 | + | - Every running capture session stops and the capture indicator clears. Clients start new sessions after unlock. | |
| 10 | + | - Keys held during lock or unlock do not reach the desktop afterwards. | |
| 11 | + | - `Ctrl+Alt+Backspace` and the `--debug=input-keys` log are disabled. VT switching still works. | |
| 12 | + | - A second locker is refused while the first is alive. Activity can wake outputs but never unlocks. | |
| 13 | + | - A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist), and capture is refused once locked. | |
| 8 | 14 | ||
| 9 | - | The locker receives `locked` only after every registered output lifetime has affirmative evidence of a protected frame or actual inactivity, the backend has revoked outstanding DRM leases, and admitted prelock captures have finished writing their destination buffers. Queued rendering, a timeout, renderer failure and loss of DRM master are not protection evidence. A lease that cannot be revoked keeps `locked` withheld. | |
| 15 | + | The locker gets `locked` only once every output really shows a protected frame (or is off), DRM leases are revoked and captures started before the lock have finished. If that cannot be confirmed, `locked` is withheld. | |
| 10 | 16 | ||
| 11 | - | Lock surfaces receive exact logical dimensions through configure/acknowledge. Outputs without a mapped lock surface remain black. An output added during protection starts safe-first; stale frames or proofs from an earlier lock or connector lifetime cannot authorize it. Resume and recovery require fresh output protection rather than replaying a desktop framebuffer. | |
| 17 | + | ## If the locker dies | |
| 12 | 18 | ||
| 13 | - | ## Owner failure and unlock | |
| 19 | + | The session stays locked and outputs stay black. Start a new locker to take over. There is no emergency unlock key and no unlock timeout. | |
| 14 | 20 | ||
| 15 | - | Destroying a lock surface, disconnecting the locker or encountering its protocol error does not unlock. NeferWL keeps protection and falls back to black. A new ordinary locker can take over after the previous owner disappears; a second live owner is refused. | |
| 21 | + | Only the current locker, after `locked`, can unlock. A locker that exits right after unlocking must do a Wayland roundtrip first; otherwise its exit may count as a crash and keep the session locked. | |
| 16 | 22 | ||
| 17 | - | Only the current owner, after `locked`, may send `unlock_and_destroy`. A locker exiting afterward must perform a Wayland roundtrip before closing its connection. Closing before the server processes unlock can be treated as owner death, which keeps the session protected. Input held across a transition is cleared or quarantined until released, so password-held keys do not enter the desktop after unlock. | |
| 23 | + | ## Limits | |
| 18 | 24 | ||
| 19 | - | Returning activity may wake powered-off outputs but never unlocks. There is no emergency unlock key or timeout-to-unlock. VT switching remains available; `Ctrl+Alt+Backspace` and the `--debug=input-keys` key log are suppressed while protected. | |
| 25 | + | - NeferWL does not authenticate anything: no PAM, no stored credentials. | |
| 26 | + | - A process running as your user can start its own locker and unlock. Root, the kernel and other VTs are outside the compositor's control. | |
| 27 | + | - Clipboard data and window contents a client received before the lock stay with that client. | |
| 28 | + | - The state file for scripts is not cleared while locked. | |
| 29 | + | - A stalled GPU or KMS call delays `locked`; lock latency is not guaranteed. | |
| 30 | + | - If KMS refuses a protected frame, the output stays registered and retries every 50 ms to 2 s. If the driver refuses to turn an output off, its old content can stay lit, and `locked` is withheld. | |
| 20 | 31 | ||
| 21 | - | ## Boundaries and limits | |
| 32 | + | ## Testing | |
| 22 | 33 | ||
| 23 | - | - Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner. | |
| 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 | - | - 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 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 | - | - 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 | - | - 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 | - | - 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. | |
| 30 | - | ||
| 31 | - | ## Verification | |
| 32 | - | ||
| 33 | - | Headless integration tests exercise two outputs, bufferless acquisition, locker input isolation, capture refusal, owner death/takeover and synchronized unlock. Headless inactivity means the software output is off; there is no physical display. Protected headless runs do not update debug PNG files or read back protected content; an existing PNG remains an earlier unlocked image, not a live protected screenshot. | |
| 34 | - | ||
| 35 | - | KMS tests use generated mocks to verify detachment, black clearing, fence ordering, failure handling and shutdown. They do not prove physical behavior on every GPU. Multi-monitor hotplug, DPMS, VT/resume and suspend require separately authorized isolated hardware testing before making hardware-specific guarantees. | |
| 34 | + | Headless tests cover two outputs, input isolation, capture refusal, locker death and takeover, and unlock. KMS behaviour is tested with mocks only; hotplug, DPMS, VT switching and suspend still need testing on real hardware. | |
| @@ -1,35 +1,34 @@ | |||||
| 1 | # Session locking | 1 | # Session locking | ||
| 2 | 2 | ||||
| 3 | - | NeferWL supports `ext-session-lock-v1` with an external Wayland locker. The locker owns authentication and requests unlock after it succeeds; NeferWL owns input isolation and output protection. A fullscreen window, layer-shell cover or fade is not a session lock. | 3 | + | NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL blocks input and hides the desktop. A fullscreen window or a layer-shell cover is not a lock. |
| 4 | 4 | ||||
| 5 | - | ## Protection | 5 | + | ## While locked |
| 6 | 6 | ||||
| 7 | - | NeferWL accepts one live lock owner. From acceptance, it refuses desktop input, binds, activation, virtual-keyboard injection, input-method grabs, clipboard requests and capture admission. Queued desktop input from an earlier transition is discarded rather than relabelled. Protected scenes contain only an opaque compositor-owned black background and the owner's validated lock surfaces. Direct scanout, overlays, tearing and hardware cursors are disabled while protected. | 7 | + | - Only the locker gets input. Binds, activation, virtual keyboards, input-method grabs, clipboard requests and new captures are refused. |
| 8 | + | - Outputs show only black and the locker's surfaces. Direct scanout, overlays, tearing and hardware cursors are off. | |||
| 9 | + | - Every running capture session stops and the capture indicator clears. Clients start new sessions after unlock. | |||
| 10 | + | - Keys held during lock or unlock do not reach the desktop afterwards. | |||
| 11 | + | - `Ctrl+Alt+Backspace` and the `--debug=input-keys` log are disabled. VT switching still works. | |||
| 12 | + | - A second locker is refused while the first is alive. Activity can wake outputs but never unlocks. | |||
| 13 | + | - A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist), and capture is refused once locked. | |||
| 8 | 14 | ||||
| 9 | - | The locker receives `locked` only after every registered output lifetime has affirmative evidence of a protected frame or actual inactivity, the backend has revoked outstanding DRM leases, and admitted prelock captures have finished writing their destination buffers. Queued rendering, a timeout, renderer failure and loss of DRM master are not protection evidence. A lease that cannot be revoked keeps `locked` withheld. | 15 | + | The locker gets `locked` only once every output really shows a protected frame (or is off), DRM leases are revoked and captures started before the lock have finished. If that cannot be confirmed, `locked` is withheld. |
| 10 | 16 | ||||
| 11 | - | Lock surfaces receive exact logical dimensions through configure/acknowledge. Outputs without a mapped lock surface remain black. An output added during protection starts safe-first; stale frames or proofs from an earlier lock or connector lifetime cannot authorize it. Resume and recovery require fresh output protection rather than replaying a desktop framebuffer. | 17 | + | ## If the locker dies |
| 12 | 18 | ||||
| 13 | - | ## Owner failure and unlock | 19 | + | The session stays locked and outputs stay black. Start a new locker to take over. There is no emergency unlock key and no unlock timeout. |
| 14 | 20 | ||||
| 15 | - | Destroying a lock surface, disconnecting the locker or encountering its protocol error does not unlock. NeferWL keeps protection and falls back to black. A new ordinary locker can take over after the previous owner disappears; a second live owner is refused. | 21 | + | Only the current locker, after `locked`, can unlock. A locker that exits right after unlocking must do a Wayland roundtrip first; otherwise its exit may count as a crash and keep the session locked. |
| 16 | 22 | ||||
| 17 | - | Only the current owner, after `locked`, may send `unlock_and_destroy`. A locker exiting afterward must perform a Wayland roundtrip before closing its connection. Closing before the server processes unlock can be treated as owner death, which keeps the session protected. Input held across a transition is cleared or quarantined until released, so password-held keys do not enter the desktop after unlock. | 23 | + | ## Limits |
| 18 | 24 | ||||
| 19 | - | Returning activity may wake powered-off outputs but never unlocks. There is no emergency unlock key or timeout-to-unlock. VT switching remains available; `Ctrl+Alt+Backspace` and the `--debug=input-keys` key log are suppressed while protected. | 25 | + | - NeferWL does not authenticate anything: no PAM, no stored credentials. |
| 26 | + | - A process running as your user can start its own locker and unlock. Root, the kernel and other VTs are outside the compositor's control. | |||
| 27 | + | - Clipboard data and window contents a client received before the lock stay with that client. | |||
| 28 | + | - The state file for scripts is not cleared while locked. | |||
| 29 | + | - A stalled GPU or KMS call delays `locked`; lock latency is not guaranteed. | |||
| 30 | + | - If KMS refuses a protected frame, the output stays registered and retries every 50 ms to 2 s. If the driver refuses to turn an output off, its old content can stay lit, and `locked` is withheld. | |||
| 20 | 31 | ||||
| 21 | - | ## Boundaries and limits | 32 | + | ## Testing |
| 22 | 33 | ||||
| 23 | - | - Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner. | 34 | + | Headless tests cover two outputs, input isolation, capture refusal, locker death and takeover, and unlock. KMS behaviour is tested with mocks only; hotplug, DPMS, VT switching and suspend still need testing on real hardware. |
| 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 | - | - 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 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 | - | - 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 | - | - 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 | - | - 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. | |||
| 30 | - | ||||
| 31 | - | ## Verification | |||
| 32 | - | ||||
| 33 | - | Headless integration tests exercise two outputs, bufferless acquisition, locker input isolation, capture refusal, owner death/takeover and synchronized unlock. Headless inactivity means the software output is off; there is no physical display. Protected headless runs do not update debug PNG files or read back protected content; an existing PNG remains an earlier unlocked image, not a live protected screenshot. | |||
| 34 | - | ||||
| 35 | - | KMS tests use generated mocks to verify detachment, black clearing, fence ordering, failure handling and shutdown. They do not prove physical behavior on every GPU. Multi-monitor hotplug, DPMS, VT/resume and suspend require separately authorized isolated hardware testing before making hardware-specific guarantees. | |||