record.sh RawHistoryFolder
1#!/usr/bin/env bash
2# Requires Go 1.27, rootless Docker, bash, ffmpeg, network access and an AMD GPU.
3# Run from any directory. The image holds the compositor's runtime (tesseract,
4# AT-SPI, Mesa's radeon Vulkan driver) and the apps; the host builds the binary.
5# The compositor renders on the host GPU through /dev/dri, and hint scans need
6# the sync files only a hardware driver exports. The image only carries
7# vulkan-radeon, so another GPU needs its Mesa driver in tools/demo/Dockerfile
8# and the vendor check below changed.
9# A recording only succeeds if the compositor stops on the quit key that ends
10# tools/demo/input.txt: hitting --timeout first means a truncated video.
11# KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
12set -euo pipefail
13root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
14cd "$root"
15for command in docker go ffmpeg; do
16 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
17done
18docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
19amd=''
20for vendor in /sys/class/drm/renderD*/device/vendor; do
21 if [[ -r $vendor && $(<"$vendor") == 0x1002 ]]; then amd=yes; fi
22done
23[[ -n $amd ]] || {
24 echo 'No AMD GPU render node (PCI vendor 0x1002) found: the demo image only carries the radeon Vulkan driver.' >&2
25 echo 'See the header of this script to use another GPU.' >&2
26 exit 1
27}
28work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
29launcher='' feeder='' success=''
30container=neferwl-demo-$$
31cleanup() {
32 local status=$?
33 trap - EXIT
34 # launcher and feeder run with job control (set -m) in their own process
35 # groups, so killing the group also stops their sleep/docker exec children.
36 for pid in "$launcher" "$feeder"; do
37 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
38 done
39 docker rm -f "$container" >/dev/null 2>&1 || true
40 for pid in "$launcher" "$feeder"; do
41 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
42 done
43 exec 3>&-
44 if [[ $success != yes || ${KEEP_CAPTURE:-0} != 1 ]]; then
45 rm -rf -- "$work"
46 fi
47 exit "$status"
48}
49trap cleanup EXIT
50trap 'exit 130' INT
51trap 'exit 143' TERM
52printf 'Capture directory: %s\n' "$work"
53mkdir -p "$work/shots" "$work/state" docs/media
54docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
55CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
56log=$work/state/neferwl/runs/headless/latest.log
57mkfifo "$work/input"
58# Hold both ends open so the FIFO cannot block compositor startup or produce
59# premature EOF. No input is fed until the first app is ready.
60exec 3<>"$work/input"
61# The compositor is the container's main process, so the container ends with
62# it. Root in a rootless container is the host user: the bind-mounted work
63# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
64# The GPU render node gives the compositor the sync files that hint scans need.
65docker run -d --name "$container" --hostname neferwl-demo --device /dev/dri \
66 -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
67 -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
68 --backend=headless --size 1920x1080 --config /home/demo/config --no-xwayland \
69 --screenshot /work/shots --input /work/input --timeout 75s >/dev/null
70display=''
71deadline=$((SECONDS + 30))
72while ((SECONDS < deadline)); do
73 [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
74 { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
75 if [[ -f $log ]]; then
76 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
77 [[ -n $display ]] && break
78 fi
79 sleep 0.1
80done
81[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor to listen; see $log" >&2; exit 1; }
82# Mousepad runs as another user (see launch_apps) and needs the socket.
83docker exec "$container" chmod 666 "/run/wl/${display##*/}"
84app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
85at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
86launch_apps() {
87 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
88 # app launches cannot be invoked by compositor input scripts.
89 at 3
90 app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
91 # btop's first per-core sample is bogus in this container. Let it collect
92 # real deltas on workspace 2; input.txt brings it into view at t=17.
93 at 5
94 app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
95 at 9
96 # Mousepad runs as demo (it warns about root), with a file-backed
97 # GSettings and no session bus (it needs the root-owned bus otherwise).
98 app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
99 GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
100 mousepad --disable-server /home/demo/config
101 at 30
102 app foot --title 'Fonts / workspace 2' bash -c \
103 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
104}
105feed() {
106 # Forwards input.txt to the compositor in real time. `sleep` lines are
107 # served here (not forwarded), so host-side steps can sit between two
108 # compositor steps:
109 # hint-read reads the still page with OCR, in the background
110 # hint-click <word>... types the label of the link that shows all the
111 # words, from the dump of the scan the last
112 # `hint` bind started (demo-hint-label reproduces
113 # the labelling of internal/core/hint.go)
114 local line cmd d mark label reader=''
115 while IFS= read -r line || [[ -n $line ]]; do
116 cmd=${line%% *}
117 case $cmd in
118 '' | '#'*) ;;
119 sleep)
120 d=${line#sleep }
121 if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
122 sleep "$d" ;;
123 hint-read)
124 docker exec "$container" demo-hint-label ocr &
125 reader=$! ;;
126 hint-click)
127 [[ -n $reader ]] && wait "$reader"
128 # shellcheck disable=SC2086
129 label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) ||
130 { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
131 echo "Hint label for ${line#hint-click }: $label" >&2
132 printf 'type %s\n' "$label" ;;
133 *)
134 [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
135 printf '%s\n' "$line" ;;
136 esac
137 done <tools/demo/input.txt
138 printf 'key Ctrl+Alt+BackSpace\n'
139}
140app foot --title 'System / NeferWL' bash -c \
141 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
142# The first app is launched before t=0. FIFO input and shell launches now
143# share this epoch, independent of compositor/image/container startup time.
144SECONDS=0
145set -m
146feed >&3 &
147feeder=$!
148launch_apps &
149launcher=$!
150set +m
151# The compositor ends with the quit key at the end of input.txt. Hitting
152# --timeout first (a stalled timeline) leaves a truncated recording: that fails.
153status=$(docker wait "$container")
154if grep -q '"reason":"timeout"' "$log"; then
155 echo 'The compositor hit --timeout before the quit key: the recording is truncated.' >&2
156 exit 1
157fi
158[[ $status == 0 ]] || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
159wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
160feeder=
161wait "$launcher"
162launcher=
163# Firefox's links come from the accessibility tree; OCR (tesseract) must still load.
164if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
165if grep -q 'capture needs sync file export' "$log"; then
166 echo 'Hint scans failed: the compositor is not on a hardware Vulkan driver (radeon); see the header of this script.' >&2
167 exit 1
168fi
169grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
170 { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
171grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
172ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
173 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
174 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
175ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
176 -frames:v 1 -update 1 docs/media/demo.png -y
177success=yes
178if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
179printf 'Recorded: docs/media/demo.mp4 and docs/media/demo.png\n'