#!/bin/sh # Run an iris example on this machine, which has no display. # # ./run-headless.sh tabs [-- cargo args] # ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 # ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png # ./run-headless.sh phone --phone --dir ../app-rust \ # --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png # # `--dir DIR` names the workspace to build in, defaulting to `iris/` (this # script's own directory). The app's examples -- the phone-sized transcript # screen and everything else that is about *this product* -- live in # `app-rust/`, which is a workspace of its own; `replay-touch` is still # built from iris, since it is part of the rig rather than of either app. # # `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output # and the window take Iris's phone's own size and density (1080x2424 at # `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md, # carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that # density to iris the way `DisplayMetrics.density` does on Android # (`iris::default::content_scale`). So a screenshot from here and one # from the phone are the same layout at the same density, and what # differs is only the renderer. Without it the output stays desktop- # shaped, which is what every other example wants. # # `--replay FILE` drives one of the `.touch` recordings the headless # tests use (`app-rust/touch/`) into the window through # `rig-input`'s `replay-touch` -- one recording, both layers. With # `--shot` it also writes `-before.png` from just before the # gesture, since "the list moved" is a claim about two pictures. # # `--bin` runs a real crate binary instead of an example (E4's # `ai-app-desktop`, which is a window a person runs, not a demo) -- # `cargo build --bin NAME` instead of `--example NAME`, and # `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own # argv (the CLI flags a real binary takes, as opposed to `cargo build`'s # own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on # purpose -- an example never needed one, so there was nowhere to plumb it # through positionally without disturbing the existing `-- cargo args` # convention above. # # The VM has a real GPU and no display (the `this-machine-graphics` skill # says what it is and how it fails), so what is missing here is only a # compositor to give winit a surface. So: a headless sway, the same trick # `emu` uses for the Android emulator, and `grim` to see the result. # # It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window # to the one an emulator is sitting in resizes that emulator's window, and a # peer session's `emu up` could join at any moment. This one has its own # socket and its own runtime directory and goes away with the machine. set -eu here=$(cd "$(dirname "$0")" && pwd) # The workspace `--dir` selects; see the header. `$here` is iris itself. workdir="$here" run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" seconds=3 shot="" replay="" example="" kind=example phone=no # The phone Iris runs the bench on. Not typed from memory: these are # `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which # in turn come from her own reports -- keep the three in step. PHONE_MODE=1080x2424@120Hz PHONE_SCALE=2.55 DESKTOP_MODE=1920x1200@60Hz while [ $# -gt 0 ]; do case "$1" in --shot) shot=$2; shift 2 ;; --seconds) seconds=$2; shift 2 ;; --bin) kind=bin; shift ;; --phone) phone=yes; shift ;; --replay) replay=$2; shift 2 ;; --dir) workdir=$(cd "$2" && pwd); shift 2 ;; --) shift; break ;; *) example=$1; shift ;; esac done [ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } [ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; } mkdir -p "$run" export SWAYSOCK="$run/sway.sock" # Named rather than left to sway's pid-based default, so a second run reuses # this compositor instead of starting another beside it. if ! swaymsg -t get_version >/dev/null 2>&1; then rm -f "$SWAYSOCK" WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \ setsid sway -c "$here/headless.conf" >"$run/sway.log" 2>&1 & i=0 while [ $i -lt 20 ]; do swaymsg -t get_version >/dev/null 2>&1 && break i=$((i + 1)); sleep 0.5 done swaymsg -t get_version >/dev/null 2>&1 || { echo "run-headless: compositor did not start; see $run/sway.log" >&2 exit 1 } fi # Asked of the compositor rather than guessed: sway takes the first free # wayland-N, and this machine may already have one. rm -f "$run/display" swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null i=0 while [ $i -lt 20 ]; do [ -s "$run/display" ] && break i=$((i + 1)); sleep 0.5 done [ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; } WAYLAND_DISPLAY=$(cat "$run/display") export WAYLAND_DISPLAY echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 # Set every run rather than only when it changes: this compositor is # reused across runs (see the socket comment above), so a desktop-shaped # run after a phone-shaped one would otherwise inherit the phone's output # and silently screenshot the wrong size. if [ "$phone" = yes ]; then mode=$PHONE_MODE export IRIS_SCALE="$PHONE_SCALE" echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2 else mode=$DESKTOP_MODE fi swaymsg output HEADLESS-1 mode "$mode" >/dev/null # The extent `replay-touch` positions against, so a script's coordinates # are the output's own pixels. out_w=${mode%x*} out_h=${mode#*x}; out_h=${out_h%@*} # Built before the app starts, so a compile error is not reported as a # window that failed to move. [ -z "$replay" ] || (cd "$here" && cargo build --bin replay-touch -p rig-input) >&2 cd "$workdir" if [ "$kind" = bin ]; then cargo build --bin "$example" "$@" >&2 bin="$workdir/target/debug/$example" else cargo build --example "$example" "$@" >&2 bin="$workdir/target/debug/examples/$example" fi # shellcheck disable=SC2086 -- deliberately word-split: this is the # binary's own argv, not a single path. "$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 & pid=$! trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM # Wait for the window to be mapped rather than for a number of seconds. A # fixed sleep took an all-black screenshot the first time this ran, when sway # had started in the same invocation and had not composited its output yet -- # which is indistinguishable from an app that draws nothing. i=0 while [ $i -lt 40 ]; do kill -0 "$pid" 2>/dev/null || break swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break i=$((i + 1)); sleep 0.25 done # Then settle, for whatever the example does after its first frame. i=0 while [ $i -lt "$((seconds * 2))" ]; do kill -0 "$pid" 2>/dev/null || break i=$((i + 1)); sleep 0.5 done if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then if [ -n "$shot" ]; then grim "${shot%.png}-before.png" echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2 fi "$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay" # A fling outlives the finger: the gesture's own last sample is not # when the list stops. Long enough for Android's spline to settle # (`FlingCalculator::duration` tops out around a second and a half). sleep 2 fi if kill -0 "$pid" 2>/dev/null; then [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true status=0 else wait "$pid" 2>/dev/null || status=$? echo "run-headless: $example exited early (status ${status:-0})" >&2 status=${status:-1} fi echo "--- $example output ---" >&2 cat "$run/$example.log" >&2 exit "$status"