Guides
Tutorials and how-tos covering every major waymux workflow, from a first build to running your own GUI tests in CI with no GPU.
Install / build
waymux is a Cargo workspace of Rust crates plus one Go module for the WebRTC viewer. You build everything from source.
Requirements
- Rust 1.88+ (the MSRV;
rust-toolchain.tomlpins the channel tostable, so a fresh clone fetches a current compiler automatically) - Go 1.26+ (only to build the WebRTC viewer bridge)
- ffmpeg 6.1+ as system LGPL libraries (
libavcodec,libavformat, etc.); this is the floor for FFV1 and the basic H.264 hardware paths. The in-process Vulkan encoder compiles against ffmpeg 6 and 7+.ffv1-vulkanwants a recent build;hevc-vulkan-losslessrequires ffmpeg 8.0. - System libs:
libwayland-dev,libgbm-dev,libvulkan-dev,libxkbcommon-dev, and your GPU's Vulkan / VA-API stack for hardware video encoding.
Debian bookworm ships ffmpeg 5.1, which cannot compile the Vulkan FFV1 shim. Use Debian trixie (or a distro with ffmpeg 6.1+) as the build host and CI base image.
# Rust binaries: daemon, session, CLI, attach client.
cargo build --release
# Web viewer (Go). Produces ./waymux-neko-bridge.
( cd crates/waymux-neko-bridge && go build -o waymux-neko-bridge . )
# Unit tests.
cargo test
Release binaries land in target/release/: waymux (the CLI), waymux-daemon, waymux-session, and waymux-attach. Add them to your PATH or reference them directly.
Quickstart
The waymux CLI is all you need. When a local command finds no running daemon it auto-spawns one in the background and retries, so a fresh install works from a single binary.
# Create a 1920x1080 headless session named "demo".
# The first local command auto-starts a background waymuxd.
waymux new demo --size 1920x1080
# Spawn a Wayland-native app. Everything after -- is the argv.
waymux spawn demo -- foot
# Wait for the window to appear, then list windows.
waymux wait demo --app-id foot
waymux windows demo
# Screenshot a window by id (from `windows`) and the whole desktop.
waymux screenshot demo <window_id> -o /tmp/window.png
waymux screenshot-desktop demo -o /tmp/desktop.png
# Record a lossless FFV1 clip.
waymux record start demo
# ... drive the app ...
waymux record stop demo
# Tear it down.
waymux rm demo
Machine-readable output (--json)
Pass --json to any verb for a uniform envelope on stdout. Scripts and agents should always use this flag.
$ waymux --json new demo --size 1280x720
{"ok":true,"verb":"new","data":{"name":"demo","width":1280,"height":720}}
$ waymux --json windows demo
{"ok":true,"verb":"windows","data":{"windows":[{"window_id":1,"app_id":"foot","title":"foot"}]}}
$ waymux --json record start demo
{"ok":true,"verb":"record","data":{"path":"/home/you/.local/share/waymux/recordings/demo-....mkv"}}
Success is {"ok":true,"verb":"...","data":{...}}. Failure is {"ok":false,"verb":"...","error":{"code":"E_...","message":"..."}} with a non-zero exit. Screenshots return the PNG base64 in data.png_b64. The streaming verbs events and logs emit newline-delimited JSON instead.
Daemon lifecycle
The waymux CLI drives a local control daemon (waymuxd) over a Unix socket at $WAYMUX_SOCKET (default $XDG_RUNTIME_DIR/waymux.sock). You have three ways to run it:
- Auto-spawn (default). When a local command finds the socket absent, the CLI starts a detached background
waymuxdand retries. Auto-spawn fires only when the socket is missing: a permission error, a protocol-version mismatch, or any other connection failure is surfaced verbatim. It never fires for--remote. waymux serve. Runswaymuxdin the foreground from the same binary. Use this under a process supervisor, or just to watch the daemon's logs in a terminal.- Explicit
waymuxd. Run the daemon yourself (waymuxddirectly, orcargo run --release -p waymux-daemon).
An auto-spawned daemon outlives the CLI command that started it and keeps your sessions alive across invocations. Stop it with a signal: Ctrl-C in the waymux serve terminal, or pkill waymuxd. To opt out of auto-spawn entirely, set WAYMUX_NO_AUTOSPAWN=1.
Spawn Chromium
Chromium is a Wayland client. Wrap it in dbus-run-session (Chromium needs a session bus; without one it spams D-Bus errors and may not start) and pass the Ozone Wayland backend flags.
waymux new web --size 1280x800
waymux spawn web -- dbus-run-session -- /usr/bin/chromium \
--ozone-platform=wayland \
--user-data-dir=/tmp/waymux-chromium \
https://example.org
waymux wait web --app-id chromium
# Screenshot the whole session (no window id needed for the desktop shot).
waymux screenshot-desktop web --output /tmp/web.png
# Record with the zero-copy hardware encoder.
waymux record start web
waymux record stop web
Use --app mode for clean web-content capture. --app=<url> makes the page the toplevel Wayland surface. In normal browser mode the page renders into a subsurface that the compositor does not expose to the capture path, so a screenshot of the whole desktop will show the browser chrome but may miss web content rendered in that sub-surface.
Single-instance gotcha. Most browsers route to an existing host-side instance over D-Bus unless you pass a fresh profile (--user-data-dir). Without it, your "inner" launch silently focuses the host window instead of rendering inside the session.
On AMD hardware, the daemon defaults AMD_DEBUG=nodcc for spawned clients so the GPU dmabuf is importable by the Vulkan H.264 encoder (DCC-tiled buffers are not importable).
Spawn a full Plasma 6 desktop
waymux can host a nested compositor as an inner client. Launch KWin (Plasma 6) into a session with its own D-Bus bus:
waymux new kde --size 1280x720
# Launch KWin into the session's inner socket on its own D-Bus bus.
# KWIN_WAYLAND_NO_PERMISSION_CHECKS lets KWin's nested backend start
# without a seat. On AMD, AMD_DEBUG/RADV_DEBUG=nodcc hand out a
# hardware-encoder-importable dmabuf modifier.
dbus-run-session -- env \
AMD_DEBUG=nodcc RADV_DEBUG=nodcc \
WAYLAND_DISPLAY=$XDG_RUNTIME_DIR/waymux/kde/wayland.sock \
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 \
XDG_CURRENT_DESKTOP=KDE QT_QPA_PLATFORM=wayland \
kwin_wayland --socket wayland-kwin --xwayland --no-lockscreen &
# Then start plasmashell (and any apps) against WAYLAND_DISPLAY=wayland-kwin
# inside that same bus.
To record the whole nested desktop (rather than a single window), use --mode whole-desktop. This tees KWin's composited output dmabuf straight into the Vulkan H.264 encoder with no CPU copy, and starts immediately even over an idle desktop:
waymux record start kde --codec h264-vulkan --mode whole-desktop --min-fps 30
# ... drive the desktop ...
waymux record stop kde
The scripts/laptop-local-viewer.sh script automates the full nested-KDE plus loopback-viewer path end to end (see the loopback viewer guide below).
Record a session
# Lossless FFV1 (default). Output lands in
# ~/.local/share/waymux/recordings/ by default.
waymux record start demo
# ... drive the session ...
waymux record stop demo
record start defaults to lossless FFV1. Additional options:
--codec h264-nvenc/--codec h264-vaapi/--codec h264-vulkanfor hardware H.264--codec ffv1-vulkanfor GPU-lossless (portable across AMD and NVIDIA Vulkan-video capable devices)--codec hevc-vulkan-losslessfor HEVC lossless (requires Vulkan-video HEVC 4:4:4 / Hi444 encode caps; not available on most integrated GPUs;record startfails fast with a clear error rather than producing an empty file)--mode focused-window(default) captures the focused Wayland surface;--mode whole-desktopcaptures the full virtual output (required for nested compositors like KWin)--min-fps Npads with duplicate frames to hold a minimum frame rate in the container--secondary-codecencodes a second output from the same frame tee
ffmpeg is dynamically linked against the system LGPL libraries. The recorder uses only FFV1 and hardware encoders (no GPL x264/x265). The live viewer has an opt-in CPU x264 fallback (GPL) used only when you set WAYMUX_VIEWER_CODEC=h264-software.
Measuring real motion. record pads to --min-fps with duplicate frames. To count genuinely unique frames, strip duplicates first: ffmpeg -i out.mkv -vf mpdecimate ... then count. Do not trust the container fps as proof of motion.
Watch live in a browser (loopback viewer)
A session can autostart a WebRTC web viewer on a local port. The viewer binds to 127.0.0.1 by default; binding to a non-loopback address is fail-closed (every WebSocket upgrade is rejected) unless you supply a viewer-token public key.
The simplest path is scripts/laptop-local-viewer.sh, which builds the binaries, starts a headless session with the viewer bound to loopback, nests a KDE desktop into it, and prints a URL:
# Loopback only (this machine):
WAYMUX_LOCAL_PORT=8082 bash scripts/laptop-local-viewer.sh up
# Open http://127.0.0.1:8082/?token=... in a browser.
bash scripts/laptop-local-viewer.sh status
bash scripts/laptop-local-viewer.sh down
To bind the viewer to a LAN address so another device on your network can connect, the bridge requires a signed viewer token. Mint an ephemeral one with scripts/laptop-mint-viewer-token.py (it generates a throwaway Ed25519 keypair, signs a short-lived token, and discards the private key). See docs/laptop-local-viewer.md for the full LAN walkthrough.
Under the hood, a session is started like this:
WAYMUX_VIEWER_CODEC=h264-vulkan \
WAYMUX_NEKO_BRIDGE_BIN="$PWD/crates/waymux-neko-bridge/waymux-neko-bridge" \
target/release/waymux-session --name demo --width 1280 --height 720 \
--inner-socket /tmp/inner.sock --control-socket /tmp/control.sock \
--viewer-port 8082 --viewer-bind 127.0.0.1
Headless CI testing (no GPU)
waymux doubles as an "Xvfb for Wayland": host real GUI apps in a nested session, inject input, and assert on what they drew, all with no display and no GPU. Screenshots are the workhorse here: the compositor does no rendering of its own, and frames are captured and encoded on the CPU, so a screenshot is cheap and immediate. Recording works too (FFV1 is a CPU encoder), but on the CPU it runs at roughly 10 frames per second, so treat a recording as a record of what happened, handy as a failure artifact, rather than smooth video. For high-frame-rate or performance-sensitive recording, add a GPU and a hardware encoder. The whole loop runs on a stock shared CI runner.
launch app -> inject input -> screenshot + assert -> record (FFV1)
What works without a GPU
| Capability | GPU needed? |
|---|---|
| Nested compositor + virtual output | no |
| Hosting Wayland + XWayland apps | no |
| Screenshot (PNG, from CPU memory) | no |
| FFV1 lossless recording (CPU codec) | no |
| Keyboard / pointer / touch injection | no |
| Live WebRTC viewer (default codecs) | yes |
| Hardware video encode (NVENC / VAAPI / Vulkan) | yes |
The first five capabilities cover functional, layout, input, and visual-regression testing. CI does not need the live viewer: it screenshots and records instead.
CI quickstart (manual setup)
Force software rendering and start the daemon:
export LIBGL_ALWAYS_SOFTWARE=1 # Mesa software GL (llvmpipe)
export WAYMUX_DISABLE_SYNCOBJ=1 # no /dev/dri => implicit sync, no DRM node
waymux serve & # or let the CLI auto-spawn the daemon
Create a session and launch an app as a direct Wayland client. Point the app's WAYLAND_DISPLAY at the session's inner socket:
waymux new app --size 1280x800
INNER="$XDG_RUNTIME_DIR/waymux/app/wayland.sock"
# A Qt/KDE app:
WAYLAND_DISPLAY="$INNER" QT_QPA_PLATFORM=wayland kwrite notes.txt &
# Or Chromium in --app mode. Use --disable-gpu flags so it renders via software.
WAYLAND_DISPLAY="$INNER" chromium \
--ozone-platform=wayland --no-sandbox --disable-gpu \
--disable-gpu-compositing --in-process-gpu --disable-dev-shm-usage \
--app="file://$PWD/page.html" &
waymux wait app --timeout-ms 15000
Drive it and capture the result:
waymux key app 17 # inject a keystroke (evdev keycode)
waymux screenshot-desktop app -o shot.png
waymux record start app --codec ffv1 # lossless, CPU-encoded
# ... exercise the app ...
waymux record stop app
That is the entire testing loop, and none of it touched a GPU.
waymux-run: the xvfb-run wrapper
waymux-run is a shell script that creates a session, points WAYLAND_DISPLAY at it, runs your command, and always saves a screenshot (plus a lossless FFV1 recording with --record) into ARTIFACT_DIR. It exits with your command's status. Think of it as xvfb-run for Wayland.
# Basic usage:
waymux-run -- ./run-gui-tests.sh
# With a custom size and a recording:
waymux-run --size 1920x1080 --record -- ./run-gui-tests.sh
# Named session, artifacts in a specific dir:
waymux-run --name mytest --artifacts /ci/out -- ./run-gui-tests.sh
Options:
--size WxH: session size (default1280x800)--record: record a lossless FFV1 clip for the whole run--name NAME: session name (defaultrun)--artifacts DIR: output directory (defaultARTIFACT_DIRor./waymux-artifacts)
Environment variables waymux-run sets automatically (you can override any of them before invoking it): LIBGL_ALWAYS_SOFTWARE=1, GALLIUM_DRIVER=llvmpipe, WAYMUX_DISABLE_SYNCOBJ=1. Set WAYMUX_BINDIR to point at the waymux binaries if they are not on PATH.
Running in a container
tests/e2e/Dockerfile builds a GPU-free image that bundles the binaries, Mesa software rendering, ffmpeg, and the apps, then runs the embedded e2e harness. It needs no --gpus and no /dev/dri:
docker build -f tests/e2e/Dockerfile -t waymux-e2e .
docker run --rm waymux-e2e
The embedded e2e (tests/e2e/run-e2e-embedded.sh) launches Chromium and a KDE app as direct Wayland clients under llvmpipe, asserts the captured frames have real content (a center-contrast check, so a blank frame cannot pass on the strength of a rendered toolbar), injects keystrokes and proves they changed the capture, and records FFV1, verifying the recording with ffprobe and counting genuinely unique frames (duplicate min-fps padding is stripped with mpdecimate first). Each scenario skips cleanly when its app is not installed.
bash tests/e2e/run-e2e-embedded.sh # build + run
WAYMUX_E2E_NO_BUILD=1 bash tests/e2e/run-e2e-embedded.sh # binaries already built
Published images
Two images are published to GHCR, Docker Hub, and the GitLab registry:
waymux-ci: lean image with Chromium, a Qt app, the waymux binaries, andwaymux-runwaymux-ci-plasma: adds a full Plasma 6 desktop
Build your app into an image based on the lean test image:
FROM ghcr.io/waymux/waymux-ci:latest
RUN apt-get update && apt-get install -y --no-install-recommends my-app
Then run your tests in plain Docker:
docker run --rm -v "$PWD/out:/out" -e ARTIFACT_DIR=/out \
--entrypoint dbus-run-session ghcr.io/waymux/waymux-ci \
-- waymux-run --record -- my-app-test-command
# screenshot + recording land in ./out
Pass --shm-size=512m when Chromium is one of your test apps (it uses /dev/shm heavily; the Docker default is 64 MB, which causes crashes). Alternatively, pass --disable-dev-shm-usage to Chromium.
GitHub Action
The repository doubles as a composite GitHub Action. Use it to run your GUI tests in a headless, no-GPU Wayland session:
- uses: waymux/waymux@v1
with:
image: ghcr.io/me/my-app-with-waymux:latest
run: ./run-gui-tests.sh
record: 'true'
Action inputs:
| Input | Default | Description |
|---|---|---|
run | required | Command to run inside the headless Wayland session. |
image | ghcr.io/waymux/waymux-ci:latest | Container image. Build yours FROM ghcr.io/waymux/waymux-ci. |
size | 1280x800 | Session size (WxH). |
record | false | Record a lossless FFV1 clip of the run (true/false). |
artifacts | waymux-artifacts | Directory (in the workspace) for the screenshot and recording. |
upload | true | Upload the artifacts directory with actions/upload-artifact. |
The action runs your command inside a Docker container from image, wrapping it in dbus-run-session and waymux-run. The screenshot (and recording, if record: 'true') are uploaded as the waymux-artifacts artifact by default.
GitLab CI template
Include the reusable template and extend .waymux-test:
include:
- remote: 'https://gitlab.com/tek.cat/waymux/-/raw/main/templates/waymux-test.gitlab-ci.yml'
gui-test:
extends: .waymux-test
variables:
WAYMUX_IMAGE: '$CI_REGISTRY_IMAGE/my-app-with-waymux:latest'
WAYMUX_RUN: './run-gui-tests.sh'
Available variables (all have defaults so the template works as-is for a smoke test):
| Variable | Default | Description |
|---|---|---|
WAYMUX_IMAGE | registry.gitlab.com/tek.cat/waymux/waymux-ci:latest | Image to run in. |
WAYMUX_SIZE | 1280x800 | Session size (WxH). |
WAYMUX_RECORD | 0 | Set to 1 to record a lossless FFV1 clip. |
WAYMUX_RUN | kwrite --version | Command to run inside the session. |
ARTIFACT_DIR | waymux-artifacts | Output directory for screenshot and recording. |
The template uploads artifacts on every run (when: always), so the screenshot is available even when the job fails.
If you prefer to inline the embedded e2e directly (rather than use the published image), here is the full job for GitLab CI:
embedded-e2e:
stage: check
image: rust:1.94-trixie
before_script:
- apt-get update
- >
apt-get install -y --no-install-recommends
libwayland-dev libgbm-dev libavutil-dev libavformat-dev libavcodec-dev
libavfilter-dev libavdevice-dev libswscale-dev libswresample-dev
libvulkan-dev libxkbcommon-dev pkg-config build-essential
libgl1-mesa-dri libegl-mesa0 libegl1 libgles2
ffmpeg python3 dbus procps fonts-dejavu-core
chromium kwrite foot
script:
- cargo build -p waymux-cli -p waymux-daemon -p waymux-session
- >
WAYMUX_E2E_NO_BUILD=1 LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
dbus-run-session -- bash tests/e2e/run-e2e-embedded.sh
And the equivalent for GitHub Actions (run in a Debian trixie container so apt install chromium is the real package):
embedded-e2e:
name: embedded app e2e (software / no GPU)
runs-on: ubuntu-24.04
container: rust:1.94-trixie
steps:
- uses: actions/checkout@v4
- name: deps
run: |
apt-get update
apt-get install -y --no-install-recommends \
libwayland-dev libgbm-dev libavutil-dev libavformat-dev libavcodec-dev \
libavfilter-dev libavdevice-dev libswscale-dev libswresample-dev \
libvulkan-dev libxkbcommon-dev pkg-config build-essential \
libgl1-mesa-dri libegl-mesa0 libegl1 libgles2 \
ffmpeg python3 dbus procps fonts-dejavu-core \
chromium kwrite foot
- run: cargo build -p waymux-cli -p waymux-daemon -p waymux-session
- name: embedded e2e
env:
WAYMUX_E2E_NO_BUILD: "1"
LIBGL_ALWAYS_SOFTWARE: "1"
GALLIUM_DRIVER: llvmpipe
run: dbus-run-session -- bash tests/e2e/run-e2e-embedded.sh
CI demo jobs and benchmark
The repository ships three additional CI jobs that run on stock, GPU-less shared runners via tests/e2e/ci-demo-all.sh and a purpose-built demo image (tests/e2e/Dockerfile.demo). The demo image builds release binaries, installs Mesa llvmpipe and Plasma 6, strips the cap_sys_nice capability from kwin_wayland (unnecessary under software rendering, and it causes execve to fail on shared runners), and runs ci-demo-all.sh as its entrypoint.
kde-app-demo(gating). Launches KWrite as a direct Wayland client against a software-rendered waymux session, asserts the screenshot has real content (not blank), injects keystrokes, records an FFV1 clip, and verifies the codec and that at least two unique frames were captured. Artifacts:kde-app.pngandkde-app.mkv.plasma-demo(allow-failure). Launches nested KWin plus plasmashell inside a waymux session under llvmpipe, waits up to 60 seconds for a desktop window to appear, screenshots the full desktop, optionally opens Dolphin (best-effort), and records a 6-second whole-desktop FFV1 clip. A failure here does not block the pipeline (Plasma under software rendering is slower and more variable than a single app). Artifacts:plasma.png,plasma.mkv, and (when Dolphin appears)plasma-app.png.benchmark(gating). Sweeps four recording configurations: Chromium at 1280x720 and 1920x1080 (whole-desktop), Chromium at 1920x1080 (focused-window), and KWrite at 1920x1080 (whole-desktop). Measures screenshot latency (median of three shots) and verifies two functional gates: codec isffv1and unique-frame rate is above zero (capture is not frozen). No fps or latency thresholds are enforced; this is a capability check, not a performance gate. Artifacts:benchmark.md,benchmark.json, andbench-sample.mkv.
To run the same suite locally:
docker build -f tests/e2e/Dockerfile.demo -t waymux-demo .
docker run --rm --shm-size=512m -v /tmp/ci-art:/artifacts waymux-demo
ls /tmp/ci-art
Security note
A waymux session is a test harness, not a security sandbox. Run untrusted or third-party code in a container or VM, not in a bare session.
- Any client in a session can screen-capture the whole session. The compositor advertises
wlr-screencopyto every client, so a client connected to the innerwayland.sockcan read the pixels of every other window in that session. Treat all apps sharing one session as mutually trusting; isolate untrusted apps in separate sessions, containers, or VMs. - Access control depends on
XDG_RUNTIME_DIRbeing0700. The daemon's control socket and each session's sockets are same-uid gated (SO_PEERCRED), but the per-session sockets live in a world-traversable directory, so their protection comes from the parent runtime dir's0700mode. On systemd,/run/user/<uid>is already0700; if you pointXDG_RUNTIME_DIRelsewhere, keep it0700. - No network listener is opened by the test path. The live WebRTC viewer binds
127.0.0.1and is never started by the embedded e2e; capture and recording are entirely local. - The container runs as root and Chromium with
--no-sandbox. That is acceptable only because the image is single-tenant and renders trusted first-party content. Do not reuse it to open untrusted URLs.