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.toml pins the channel to stable, 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-vulkan wants a recent build; hevc-vulkan-lossless requires 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 waymuxd and 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. Runs waymuxd in 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 (waymuxd directly, or cargo 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-vulkan for hardware H.264
  • --codec ffv1-vulkan for GPU-lossless (portable across AMD and NVIDIA Vulkan-video capable devices)
  • --codec hevc-vulkan-lossless for HEVC lossless (requires Vulkan-video HEVC 4:4:4 / Hi444 encode caps; not available on most integrated GPUs; record start fails fast with a clear error rather than producing an empty file)
  • --mode focused-window (default) captures the focused Wayland surface; --mode whole-desktop captures the full virtual output (required for nested compositors like KWin)
  • --min-fps N pads with duplicate frames to hold a minimum frame rate in the container
  • --secondary-codec encodes 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

CapabilityGPU needed?
Nested compositor + virtual outputno
Hosting Wayland + XWayland appsno
Screenshot (PNG, from CPU memory)no
FFV1 lossless recording (CPU codec)no
Keyboard / pointer / touch injectionno
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 (default 1280x800)
  • --record: record a lossless FFV1 clip for the whole run
  • --name NAME: session name (default run)
  • --artifacts DIR: output directory (default ARTIFACT_DIR or ./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, and waymux-run
  • waymux-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:

InputDefaultDescription
runrequiredCommand to run inside the headless Wayland session.
imageghcr.io/waymux/waymux-ci:latestContainer image. Build yours FROM ghcr.io/waymux/waymux-ci.
size1280x800Session size (WxH).
recordfalseRecord a lossless FFV1 clip of the run (true/false).
artifactswaymux-artifactsDirectory (in the workspace) for the screenshot and recording.
uploadtrueUpload 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):

VariableDefaultDescription
WAYMUX_IMAGEregistry.gitlab.com/tek.cat/waymux/waymux-ci:latestImage to run in.
WAYMUX_SIZE1280x800Session size (WxH).
WAYMUX_RECORD0Set to 1 to record a lossless FFV1 clip.
WAYMUX_RUNkwrite --versionCommand to run inside the session.
ARTIFACT_DIRwaymux-artifactsOutput 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.png and kde-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 is ffv1 and 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, and bench-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-screencopy to every client, so a client connected to the inner wayland.sock can 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_DIR being 0700. 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's 0700 mode. On systemd, /run/user/<uid> is already 0700; if you point XDG_RUNTIME_DIR elsewhere, keep it 0700.
  • No network listener is opened by the test path. The live WebRTC viewer binds 127.0.0.1 and 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.