feat: give the classroom a desktop instead of a shell

The whole appliance UI is now configuration for a desktop Debian already
ships: a Plasma 6.3 session with a colour scheme, a look and feel package
that builds the bar, window rules, and KDE's own Kiosk restrictions.

Two things were measured rather than assumed, and both changed a decision.

Plasma restarts a crashed panel three times a minute and then gives up,
which is the failure a classroom has already had. Four lines of systemd
drop-in make it unconditional; the panel then survived eight SIGKILLs a
second apart, back each time in 2.4s.

Plasma draws a minimised task and a background task identically, so the
five states the room needs cannot all be shown. Rather than write a task
manager, the classroom removes minimise: clicking a launcher always means
show me this.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 08:35:12 -05:00
co-authored by Claude Opus 5
parent bfc7caf28f
commit 87b55761c6
51 changed files with 1352 additions and 36 deletions
+20
View File
@@ -50,3 +50,23 @@ CMD ["/sbin/init"]
# sanctioned way to capture a KWin session that has no outer compositor.
RUN apt-get update && apt-get install -y --no-install-recommends kde-spectacle \
&& rm -rf /var/lib/apt/lists/*
# The classroom's browser, from Google's own repository, the same way a
# classroom machine gets it. Testing the browser question on a different
# browser would not answer it.
RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
&& install -d -m 0755 /etc/apt/keyrings \
&& curl -fsS https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor --batch --yes --output /etc/apt/keyrings/google-chrome.gpg \
&& echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main' \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
&& rm -rf /var/lib/apt/lists/*
# VLC, CUPS and PipeWire: the rest of the classroom's runtime, so the
# desktop is judged with the room's real applications in it.
RUN apt-get update && apt-get install -y --no-install-recommends \
vlc vlc-plugin-base cups cups-client cups-filters \
pipewire pipewire-pulse wireplumber pulseaudio-utils \
libnotify-bin \
&& rm -rf /var/lib/apt/lists/*
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Put the classroom desktop onto a machine, from the repository, at the
# paths the package will use. Run as root inside the lab.
#
# Every destination here is a normal system path that Plasma already reads.
# There is no classroom daemon in this list, and nothing is generated: the
# desktop is a set of files.
set -euo pipefail
SRC="${1:-/srv/classroom}"
install -Dm644 "$SRC/config/plasma/xdg/kdeglobals" /etc/xdg/kdeglobals
install -Dm644 "$SRC/config/plasma/xdg/kwinrc" /etc/xdg/kwinrc
install -Dm644 "$SRC/config/plasma/xdg/kwinrulesrc" /etc/xdg/kwinrulesrc
install -Dm644 "$SRC/config/plasma/xdg/kscreenlockerrc" /etc/xdg/kscreenlockerrc
install -Dm644 "$SRC/config/plasma/xdg/ksmserverrc" /etc/xdg/ksmserverrc
install -Dm644 "$SRC/config/plasma/xdg/krunnerrc" /etc/xdg/krunnerrc
install -Dm644 "$SRC/config/plasma/xdg/plasmanotifyrc" /etc/xdg/plasmanotifyrc
install -Dm644 "$SRC/config/plasma/colors/EkklesiaDark.colors" \
/usr/share/color-schemes/EkklesiaDark.colors
install -Dm644 "$SRC/config/plasma/colors/EkklesiaLight.colors" \
/usr/share/color-schemes/EkklesiaLight.colors
LNF=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop
rm -rf "$LNF"
install -Dm644 "$SRC/config/plasma/lookandfeel/metadata.json" "$LNF/metadata.json"
install -Dm644 "$SRC/config/plasma/lookandfeel/contents/defaults" \
"$LNF/contents/defaults"
install -Dm644 \
"$SRC/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" \
"$LNF/contents/layouts/org.kde.plasma.desktop-layout.js"
for entry in "$SRC"/config/plasma/applications/*.desktop; do
install -Dm644 "$entry" "/usr/share/applications/$(basename "$entry")"
done
# Entries that deliberately shadow a vendor one. XDG_DATA_DIRS searches
# /usr/local/share before /usr/share, so these win without touching, and
# without being overwritten by, the package that provides the original.
for entry in "$SRC"/config/plasma/applications-local/*.desktop; do
install -Dm644 "$entry" "/usr/local/share/applications/$(basename "$entry")"
done
install -Dm644 "$SRC/config/plasma/solid/ekklesia-dvd.desktop" \
/usr/share/solid/actions/ekklesia-dvd.desktop
for icon in "$SRC"/config/icons/ekklesia-*.svg; do
[ -e "$icon" ] || continue
install -Dm644 "$icon" \
"/usr/share/icons/hicolor/scalable/apps/$(basename "$icon")"
done
install -Dm644 "$SRC/config/wallpapers/classroom-calm.png" \
/usr/share/backgrounds/ekklesia/classroom-calm.png
install -Dm644 "$SRC/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf" \
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
for script in classroom-browser classroom-wonderink; do
[ -e "$SRC/scripts/$script" ] || continue
install -Dm755 "$SRC/scripts/$script" "/usr/local/bin/$script"
done
command -v gtk-update-icon-cache >/dev/null 2>&1 \
&& gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true
command -v update-desktop-database >/dev/null 2>&1 \
&& update-desktop-database -q /usr/share/applications || true
echo "classroom desktop installed"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Run a KWin script in the session under test and print what it logged.
#
# KWin's scripting interface is the only way to ask the compositor what it
# actually believes about a window — class, caption, desktop file, geometry,
# minimised, keep-above. Everything this experiment claims about window
# behaviour is measured through here rather than inferred from a screenshot.
set -euo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
SCRIPT="${1:?usage: kwin-script FILE}"
ID="labscript$RANDOM"
# /tmp inside the lab is a tmpfs created after the container started, which
# `docker cp` cannot see, so the script is piped in instead.
docker exec -i "$NAME" sh -c "cat > /tmp/$ID.js && chown classroom /tmp/$ID.js" \
< "$SCRIPT"
run() {
docker exec -u classroom \
-e XDG_RUNTIME_DIR=/run/user/1000 \
-e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
-e HOME=/home/classroom \
"$NAME" "$@"
}
since=$(docker exec "$NAME" date '+%Y-%m-%d %H:%M:%S')
number=$(run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript \
"/tmp/$ID.js" "$ID" 2>/dev/null | tr -d '\r')
run qdbus6 org.kde.KWin "/Scripting/Script$number" org.kde.kwin.Script.run >/dev/null 2>&1
sleep 1
run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "$ID" >/dev/null 2>&1 || true
# KWin's print() reaches the journal, tagged by the wrapper process.
docker exec "$NAME" journalctl --since "$since" --no-pager -o cat 2>/dev/null \
| grep -a "LAB:" | sed 's/.*LAB://'
+40 -6
View File
@@ -75,8 +75,19 @@ down)
;;
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 -e WLR_LIBINPUT_NO_DEVICES=1 \
-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'
@@ -91,6 +102,7 @@ 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
@@ -114,27 +126,45 @@ session)
;;
shot)
# shot NAME [--delay SECONDS]
# shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100"
name="${2:?usage: lab shot NAME}"
outer grim /tmp/lab-shot.png >/dev/null
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)
# click X Y — a real pointer press in the session under test
# 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.3
sleep 0.4
outer swaymsg seat - cursor press button1 >/dev/null
sleep 0.1
sleep 0.15
outer swaymsg seat - cursor release button1 >/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 "$@"
@@ -148,6 +178,8 @@ sh)
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 "$@" ;;
@@ -164,9 +196,11 @@ usage: tools/lab/lab COMMAND
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