Files
playpen/shot.sh
T
2026-08-11 14:26:12 -04:00

119 lines
3.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Run playpen inside a throwaway headless Sway and screenshot it.
#
# This keeps UI checks out of the developer's real Wayland session: nothing
# pops up on screen, and it works even while the session is locked.
#
# Usage: ./shot.sh OUT.png ["keys to type"] [settle-seconds]
set -u
cd "$(dirname "$0")"
OUT="${1:-/tmp/playpen-shot.png}"
KEYS="${2:-}"
SETTLE="${3:-2}"
RUNDIR="$(mktemp -d)"
trap 'rm -rf "$RUNDIR"' EXIT
cat > "$RUNDIR/sway.conf" <<CONF
output HEADLESS-1 resolution 1200x800
default_border none
exec "'$RUNDIR/launch.sh' >'$RUNDIR/app.log' 2>&1"
CONF
# The nix dev shell exports bash functions into the environment. Any /bin/sh
# the terminal's child spawns tries to import them and spews parse errors that
# have nothing to do with playpen, so drop them just before exec.
cat > "$RUNDIR/launch.sh" <<'LAUNCH'
#!/usr/bin/env bash
while read -r fn; do unset -f "$fn" 2>/dev/null; done < <(declare -Fx | awk '{print $3}')
exec "$PLAYPEN_BIN"
LAUNCH
chmod +x "$RUNDIR/launch.sh"
export PLAYPEN_BIN="$PWD/zig-out/bin/playpen"
export WLR_BACKENDS=headless
export WLR_LIBINPUT_NO_DEVICES=1
# No GPU is available to a headless compositor here, so force software
# rendering on both the compositor and GTK sides.
export WLR_RENDERER=pixman
export LIBGL_ALWAYS_SOFTWARE=1
export GSK_RENDERER=cairo
# WebKit's web process renders through mesa, which the dev shell has already
# pointed at its own copy; here we only force that mesa down to its software
# rasterizer, so a web pane paints the same way with or without a GPU.
export GALLIUM_DRIVER=llvmpipe
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export SWAYSOCK="$RUNDIR/sway.sock"
# Don't let the app fall back to the real session.
unset DISPLAY
unset WAYLAND_DISPLAY
for sock in "$XDG_RUNTIME_DIR"/wayland-*; do
[ -S "$sock" ] && basename "$sock"
done > "$RUNDIR/pre-sockets" 2>/dev/null || true
sway -c "$RUNDIR/sway.conf" >"$RUNDIR/sway.log" 2>&1 &
SWAY_PID=$!
# Wait for the compositor socket before talking to it.
for _ in $(seq 1 50); do
[ -S "$SWAYSOCK" ] && break
sleep 0.2
done
# Sway picks its own socket name, so discover it by diffing the sockets in
# XDG_RUNTIME_DIR against the ones that existed before we started.
DISPLAY_NAME=""
for _ in $(seq 1 50); do
for sock in "$XDG_RUNTIME_DIR"/wayland-*; do
case "$sock" in *.lock) continue;; esac
[ -S "$sock" ] || continue
name="$(basename "$sock")"
grep -qx "$name" "$RUNDIR/pre-sockets" && continue
DISPLAY_NAME="$name"
break
done
[ -n "$DISPLAY_NAME" ] && break
sleep 0.2
done
if [ -z "$DISPLAY_NAME" ]; then
echo "could not determine wayland display" >&2
cat "$RUNDIR/sway.log" >&2
exit 1
fi
export WAYLAND_DISPLAY="$DISPLAY_NAME"
sleep "$SETTLE"
if [ -n "$KEYS" ]; then
# wtype drops the first keystroke of every invocation while the compositor
# takes up its freshly uploaded keymap, so burn one on a harmless key.
wtype -k Shift_L 2>/dev/null || true
sleep 0.3
wtype "$KEYS" 2>/dev/null || true
sleep 1.5
fi
if [ -n "${PLAYPEN_SHOT_DEBUG:-}" ]; then
echo "--- swaymsg get_tree (apps) ---" >&2
swaymsg -t get_tree 2>&1 | grep -E '"(app_id|name|pid)"' | head -30 >&2
echo "--- sway log ---" >&2
cat "$RUNDIR/sway.log" >&2
echo "--- app log ---" >&2
cat "$RUNDIR/app.log" >&2 2>/dev/null || echo "(no app log)" >&2
fi
if ! grim -o HEADLESS-1 "$OUT"; then
echo "grim -o failed, trying full capture" >&2
grim "$OUT" || echo "grim failed entirely" >&2
fi
swaymsg exit >/dev/null 2>&1 || kill "$SWAY_PID" 2>/dev/null
wait "$SWAY_PID" 2>/dev/null
echo "wrote $OUT"
# Surface app-side errors, which sway captures on its stdout.
grep -iE "error|warn|panic|segfault" "$RUNDIR/sway.log" | grep -v "portal" | head -20 || true