Files
Classroom_Desktop/tools/lab/lab
T
ekklesiaandClaude Opus 5.5 48b3097c43 feat: a lesson in Wonder Ink is the size the browser shows it, and zooms
Teachers found lessons in Wonder Ink too small next to the ordinary
browser. Measured by asking each page for devicePixelRatio: Wonder Ink
follows the display scale exactly (dpr 1.5 at 150%), and Chrome is
scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself
and classroom-browser also forces UI_SCALE, a flag from the sway session.

The browser is left as it is. Wonder Ink's pages start at zoom =
UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards
match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window
is not zoomed; Qt scales it once, correctly.

Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click
menu set one level for every tab, remembered as LEVEL= in its own file,
relative to the room. Two actions on one key are ambiguous to Qt and it
then does neither — Ctrl+Minus did nothing until each command became one
action with all its keys, which also fixes Ctrl+Shift+Tab.

The engine profile is built by one function, which the lab's checks now
use: a check with its own profile showed every worksheet blank.

The lab fences 10.10.0.0/16 as well: the development host reaches the
real Elmer over Tailscale, and the lab provisions a queue pointing at it.
Four lab checks written for older code are corrected; one had failed on
every run since 0.2.3.

tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39,
worksheets 24, all passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:04:58 -05:00

347 lines
14 KiB
Bash
Executable File

#!/bin/bash
# Classroom v2 test lab driver.
#
# The development host is a VM with no /dev/kvm, so the desktop under test
# runs inside a systemd container instead of a virtual machine. Nothing this
# script does touches the host outside Docker.
#
# The session is nested: an outer headless sway exists only to give the
# desktop under test a display that grim can capture and that sway's IPC can
# aim a real pointer at. The outer compositor is lab equipment, not part of
# the classroom design.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
IMAGE="${LAB_IMAGE:-classroom-v2-lab:plasma}"
DOCKERFILE="${LAB_DOCKERFILE:-tools/lab/Dockerfile.plasma}"
SHOTS="${LAB_SHOTS:-$ROOT/docs/screenshots}"
OUTER=wayland-1
session_env() {
docker exec -u classroom \
-e XDG_RUNTIME_DIR=/run/user/1000 \
-e HOME=/home/classroom \
-e LANG=C.UTF-8 \
-e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
"$@"
}
# Talk to the outer lab compositor: grim, swaymsg.
outer() {
local sock
sock=$(docker exec "$NAME" sh -c 'ls /run/user/1000/sway-ipc.* 2>/dev/null | head -1')
session_env -e WAYLAND_DISPLAY="$OUTER" -e SWAYSOCK="$sock" "$NAME" "$@"
}
# NOTE ON XDG_CONFIG_DIRS.
#
# This harness used to hand the session
# XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg, and every result it
# produced was therefore a result about a machine somebody had already
# configured by hand. A real session is started by SDDM through PAM, which
# does not read /etc/environment.d, and the classroom's settings were not
# in the cascade at all: the first real upgrade came up as stock Debian
# Plasma with a lock screen. The variable is deliberately not set here any
# more. The package has to put it there, the way it has to on a machine.
# Talk to the Plasma session under test.
inner() {
session_env -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
-e QT_QPA_PLATFORM=wayland "$NAME" "$@"
}
case "${1:-}" in
build)
shift
exec docker build -f "$ROOT/$DOCKERFILE" -t "$IMAGE" "$@" "$ROOT"
;;
up)
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d --name "$NAME" \
--privileged --cgroupns=private \
--tmpfs /run --tmpfs /run/lock \
--shm-size=1g \
-v "$ROOT:/srv/classroom:ro" \
"$IMAGE" >/dev/null
for _ in $(seq 1 90); do
state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true)
[[ "$state" == running || "$state" == degraded ]] && break
sleep 1
done
for _ in $(seq 1 30); do
docker exec "$NAME" test -S /run/user/1000/bus && break
sleep 1
done
# startplasma looks for `qdbus`; Debian ships it as qdbus6.
docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus
# The church's own networks, refused from inside the container.
#
# This is not theoretical. The moment the package began defaulting
# MQTT_HOST to the church broker, the lab connected to it: a test
# machine appearing on the real broker under a container id. Nothing
# was left behind — the broker held no retained status afterwards,
# which was checked — but the lab had no business being there at all,
# and the Parent Call coordinator is on the same networks.
#
# Container-local: this netns belongs to the container alone and
# nothing on the host or the church network is changed by it. Debian's
# own mirrors are untouched, so installs still work.
# 10.10 is the printers. The package provisions Elmer at
# socket://10.10.0.155:9100, and the development host reaches that
# address over Tailscale, so an unfenced lab that printed would have
# put paper in the church's real printer.
#
# A blackhole route rather than a firewall rule: the lab image has no
# iptables, and the rig's guard had been quietly doing nothing because
# of it. Routes need no netfilter modules and cannot silently fail.
docker exec "$NAME" sh -c '
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks"
# And checked with bash, not sh. The probe uses /dev/tcp, which is a
# bash feature: under dash it fails whatever the network is doing, so
# the check passed on a lab that could reach the broker perfectly
# well. Measured.
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1
fi
done
echo " church networks fenced out of the lab"
echo "lab $NAME is up ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null))"
;;
down)
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "lab $NAME is down"
;;
reboot)
# As close to a room being switched off and on as a container gets:
# the same filesystem, a new PID 1, a new user manager, and every
# generator and drop-in read again from scratch. The migration work
# needs this, because half of what an upgrade changes is only read at
# boot.
docker restart "$NAME" >/dev/null
for _ in $(seq 1 90); do
state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true)
[[ "$state" == running || "$state" == degraded ]] && break
sleep 1
done
for _ in $(seq 1 30); do
docker exec "$NAME" test -S /run/user/1000/bus && break
sleep 1
done
docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus
# The fence again. Routes do not survive a container restart, and the
# lab reached the church broker through exactly that gap: four rooms
# named after container ids on the real broker, from sessions that
# had been fenced when they started and were not after a reboot.
docker exec "$NAME" sh -c '
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks"
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1
fi
done
# SDDM really does autologin here: it authenticates the classroom
# through PAM and launches the Plasma session, and then has no display
# hardware to draw it on. That is worth having — it is the real login
# path, not a reading of a configuration file — so the attempt is left
# to happen and its evidence stays in the journal. It is then stopped,
# because the harness runs its own session on the nested compositor
# and two sessions cannot both own the keyboard.
for _ in $(seq 1 30); do
docker exec "$NAME" journalctl -u sddm --no-pager 2>/dev/null \
| grep -q "Session started" && break
sleep 1
done
docker exec "$NAME" systemctl stop sddm >/dev/null 2>&1 || true
docker exec "$NAME" pkill -f sddm-helper >/dev/null 2>&1 || true
# -f, not -x. "startplasma-wayland" is longer than the fifteen
# characters a process name has, so -x silently matches nothing and
# SDDM's session was left winding down underneath the harness's own:
# it tore down graphical-session.target sixteen seconds later and took
# the panel with it, which cost an afternoon to understand.
docker exec "$NAME" pkill -f startplasma-wayland >/dev/null 2>&1 || true
docker exec "$NAME" pkill -f plasma-dbus-run-session >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
state=$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 "$NAME" \
systemctl --user is-active graphical-session.target 2>/dev/null || true)
[[ "$state" != active ]] && break
sleep 1
done
sleep 2
echo "lab $NAME rebooted ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true))"
;;
outer-up)
# A headless compositor has no input devices, and a wl_seat with no
# pointer capability never gives the session under test a pointer to
# click with — sway's cursor commands then report success and move
# nothing. So the lab runs wlroots' libinput backend as well, over the
# devices the privileged container inherits, with seatd standing in for
# the login session it does not have. Clicks in this experiment are
# real pointer events arriving through libinput.
docker exec "$NAME" sh -c 'pgrep -x seatd >/dev/null || \
(SEATD_VTBOUND=0 setsid seatd -g input > /tmp/seatd.log 2>&1 &)'
docker exec "$NAME" usermod -aG input classroom 2>/dev/null || true
sleep 2
session_env \
-e WLR_BACKENDS=headless,libinput -e LIBSEAT_BACKEND=seatd \
-e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \
-d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \
> /tmp/outer-sway.log 2>&1'
for _ in $(seq 1 30); do
docker exec "$NAME" test -S "/run/user/1000/$OUTER" && break
sleep 1
done
echo "outer compositor on $OUTER"
;;
plasma-up)
session_env \
-e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \
-e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \
-e QT_LOGGING_RULES="kwin_scripting.debug=true" \
-d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1'
for _ in $(seq 1 60); do
docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break
sleep 1
done
sleep 5
echo "plasma session up"
;;
plasma-down)
# A logout, not a killing. plasmashell and kwin are both services of
# the user manager, and killing them only makes it start them again —
# the compositor came straight back holding wayland-0 and the next
# session could not start at all. The workspace target has to go down
# first, and this is what a real logout does too.
session_env "$NAME" systemctl --user stop plasma-workspace.target \
>/dev/null 2>&1 || true
docker exec "$NAME" pkill -u classroom -f startplasma-wayland || true
session_env "$NAME" systemctl --user stop graphical-session.target \
>/dev/null 2>&1 || true
for _ in $(seq 1 20); do
docker exec "$NAME" pgrep -x kwin_wayland >/dev/null 2>&1 || break
sleep 1
done
docker exec "$NAME" pkill -u classroom -x kwin_wayland >/dev/null 2>&1 || true
docker exec "$NAME" pkill -u classroom -x plasmashell >/dev/null 2>&1 || true
sleep 2
echo "plasma session down"
;;
session)
"$0" outer-up
"$0" plasma-up
;;
shot)
# shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100"
name="${2:?usage: lab shot NAME}"
if [[ -n "${3:-}" ]]; then
outer grim -g "$3" /tmp/lab-shot.png >/dev/null
else
outer grim /tmp/lab-shot.png >/dev/null
fi
mkdir -p "$SHOTS"
docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png"
echo "$SHOTS/$name.png"
;;
click)
# Absolute, through sway's own seat. This only works because the seat
# has a pointer capability at all, which is what the uinput device in
# `outer-up` is for.
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.4
outer swaymsg seat - cursor press button1 >/dev/null
sleep 0.15
outer swaymsg seat - cursor release button1 >/dev/null
sleep 0.5
;;
rclick)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.4
outer swaymsg seat - cursor press button3 >/dev/null
sleep 0.15
outer swaymsg seat - cursor release button3 >/dev/null
sleep 0.5
;;
move)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
;;
drag)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.3
outer swaymsg seat - cursor press button1 >/dev/null
sleep 0.3
outer swaymsg seat - cursor set "$4" "$5" >/dev/null
sleep 0.3
outer swaymsg seat - cursor release button1 >/dev/null
sleep 0.4
;;
key)
shift
outer wtype "$@"
;;
sh)
shift
exec docker exec -it "$NAME" bash "$@"
;;
run) shift; session_env "$NAME" "$@" ;;
irun) shift; session_env -it "$NAME" "$@" ;;
inner) shift; inner "$@" ;;
inner-d) shift; session_env -d -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
-e QT_QPA_PLATFORM=wayland "$NAME" "$@" ;;
inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;;
outer) shift; outer "$@" ;;
*)
cat <<USAGE
usage: tools/lab/lab COMMAND
build build the lab image
up start the lab container (systemd inside)
down remove it
reboot restart the machine, let SDDM autologin, then stand down
session outer compositor + a Plasma session on top of it
outer-up just the outer compositor
plasma-up just the Plasma session
plasma-down stop the Plasma session
shot NAME capture the screen to docs/screenshots/NAME.png
click X Y press the pointer at a screen position
drag X1 Y1 X2 Y2 drag from one position to another
move X Y move the pointer
key ARGS... wtype, aimed at the session
inner CMD... run CMD as a client of the Plasma session
inner-d CMD... the same, detached
outer CMD... run CMD against the lab compositor
run CMD... run CMD as the classroom user, no display
sh root shell in the lab
USAGE
exit 1
;;
esac