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
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Generate the light colour scheme from the dark one.
There is one set of design tokens, not two themes. The light scheme is the
same file with the roles swapped, so a colour can only be changed in one
place and the two can never drift apart.
"""
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parent.parent
COLORS = ROOT / "config" / "plasma" / "colors"
# dark value -> light value
ROLES = {
"20,28,32": "242,245,244", # window background
"25,35,40": "233,238,237", # alternate
"15,23,26": "255,255,255", # view background
"18,26,30": "236,241,240", # header
"16,23,26": "228,234,233", # header, inactive
"30,42,47": "225,231,230", # button alternate
"230,237,239": "22,35,42", # normal text
"147,165,170": "96,116,122", # inactive text
"170,186,191": "80,99,105", # inactive header text
"125,142,147": "120,138,144",
"79,179,168": "46,140,130", # accent
"126,196,255": "26,110,180", # link
"227,106,106": "192,57,57", # negative
"224,177,92": "166,116,24", # neutral
"111,207,151": "38,142,86", # positive
"156,138,222": "104,80,180", # visited
"14,21,24": "255,255,255", # text on a selection
"10,16,18": "255,255,255",
}
HEADER = (
"# Ekklesia Classroom — light.\n"
"#\n"
"# GENERATED from EkklesiaDark.colors by tools/build-light-scheme.\n"
"# Edit the dark scheme and regenerate; do not edit this file by hand."
)
def main():
dark = (COLORS / "EkklesiaDark.colors").read_text()
light = dark
for old, new in sorted(ROLES.items(), key=lambda item: -len(item[0])):
light = re.sub(rf"(?<![\d,]){re.escape(old)}(?![\d,])", "\0" + new, light)
light = light.replace("\0", "")
light = light.replace("Ekklesia Classroom Dark", "Ekklesia Classroom Light")
light = light.replace("ColorScheme=EkklesiaDark", "ColorScheme=EkklesiaLight")
light = light.replace("# Ekklesia Classroom — dark. The default.", HEADER)
(COLORS / "EkklesiaLight.colors").write_text(light)
print("wrote", COLORS / "EkklesiaLight.colors")
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Render the classroom's idle desktop background.
This is a STAND-IN. The classroom's chosen wallpaper is an illustrated
landscape that is not in this repository; when it arrives it replaces the
output of this script at the same path and nothing else changes.
It exists so the desktop can be judged in the right palette rather than
against the bright confetti background it is replacing: sage, teal, blue
and lavender, a river leading into the picture, and a large quiet area in
the upper middle where nothing happens.
No image library is installed on the development host, so the PNG is
written by hand — zlib and struct are enough for a truecolour image.
"""
import math
import pathlib
import struct
import zlib
WIDTH = 1920
HEIGHT = 1080
OUT = pathlib.Path(__file__).resolve().parent.parent / "config" / "wallpapers"
def mix(a, b, t):
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return tuple(a[i] + (b[i] - a[i]) * t for i in range(3))
def ease(t):
"""Smoothstep. Bands in a gradient are the one thing that would read as
a rendering fault rather than as a picture."""
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return t * t * (3 - 2 * t)
# The palette, top of the sky downwards.
SKY = [
(0.00, (34, 38, 58)), # deep lavender-slate
(0.30, (58, 68, 96)), # lavender
(0.55, (86, 108, 130)), # blue
(0.78, (139, 165, 168)), # sage haze at the horizon
(1.00, (178, 198, 192)),
]
HORIZON = 0.56 # where the land starts, as a fraction of the height
GLOW_X, GLOW_Y = 0.56, 0.545
GLOW = (232, 232, 214)
RIDGES = [
# (base height, amplitude, wavelength, phase, colour, haze)
(0.575, 0.028, 2.1, 0.4, (74, 96, 110), 0.55),
(0.615, 0.040, 1.5, 2.2, (60, 84, 92), 0.34),
(0.665, 0.052, 1.1, 4.1, (48, 72, 74), 0.18),
]
RIVER_TOP = 0.585
RIVER_COLOUR = (96, 140, 146)
RIVER_LIGHT = (186, 212, 202)
BANK = (34, 52, 48) # the sage foreground the river runs between
def sky_colour(y):
t = y / HEIGHT
for i in range(len(SKY) - 1):
t0, c0 = SKY[i]
t1, c1 = SKY[i + 1]
if t <= t1 or i == len(SKY) - 2:
return mix(c0, c1, ease((t - t0) / (t1 - t0)))
return SKY[-1][1]
def river_edges(y):
"""The river narrows towards the horizon and opens towards the viewer,
which is what makes it lead into the picture rather than across it."""
t = (y / HEIGHT - RIVER_TOP) / (1 - RIVER_TOP)
if t < 0:
return None
t = ease(t)
centre = 0.56 - 0.14 * t * t
half = 0.004 + 0.30 * t * t
return (centre - half) * WIDTH, (centre + half) * WIDTH
def render():
rows = []
for y in range(HEIGHT):
base = sky_colour(y)
row = bytearray()
edges = river_edges(y)
yn = y / HEIGHT
# Ridge heights for this column are cheap to evaluate, so the land
# is computed per pixel rather than filled as polygons.
for x in range(WIDTH):
xn = x / WIDTH
r, g, b = base
# A soft light low in the sky, off centre. It is the only
# bright thing in the picture.
dx = (xn - GLOW_X) * 1.7
dy = yn - GLOW_Y
d = math.sqrt(dx * dx + dy * dy)
glow = math.exp(-(d * d) / 0.055) * 0.55
if glow > 0.002:
r, g, b = mix((r, g, b), GLOW, glow)
land = None
for height, amp, wavelength, phase, colour, haze in RIDGES:
ridge = height - amp * (
0.6 * math.sin(xn * math.pi * 2 * wavelength + phase)
+ 0.4 * math.sin(xn * math.pi * 2 * wavelength * 2.3 + phase * 1.7)
)
if yn >= ridge:
# Nearer ridges are darker and less hazy, which is the
# whole of the depth in this picture.
shade = mix(colour, (r, g, b), haze)
depth = ease((yn - ridge) / 0.16)
land = mix(shade, tuple(c * 0.82 for c in shade), depth)
if land is not None:
r, g, b = land
if edges is not None:
left, right = edges
if left <= x <= right:
width = max(right - left, 1.0)
across = (x - left) / width
# Light gathers down the middle of the water and the
# banks stay dark, so the river reads as a surface.
sheen = math.exp(-((across - 0.5) ** 2) / 0.06)
ripple = 0.5 + 0.5 * math.sin(
yn * 130 + math.sin(xn * 18) * 1.4
)
water = mix(RIVER_COLOUR, RIVER_LIGHT, 0.30 * sheen + 0.10 * ripple)
water = mix(water, (r, g, b), max(0.0, 0.45 - 0.45 * ease((yn - RIVER_TOP) / 0.25)))
edge = min(across, 1 - across)
r, g, b = mix(BANK, water, ease(edge / 0.05))
elif yn > RIVER_TOP + 0.10:
near = ease((yn - RIVER_TOP - 0.10) / 0.34)
r, g, b = mix((r, g, b), BANK, near * 0.85)
# The bottom of the screen carries the bar. Settling it down
# keeps the panel legible without putting a band across the
# picture.
if yn > 0.78:
r, g, b = mix((r, g, b), (16, 24, 28), ease((yn - 0.78) / 0.24) * 0.62)
row += bytes((int(r + 0.5) & 255, int(g + 0.5) & 255, int(b + 0.5) & 255))
rows.append(bytes(row))
return rows
def write_png(path, rows):
raw = b"".join(b"\x00" + row for row in rows)
def chunk(tag, data):
payload = tag + data
return (
struct.pack(">I", len(data))
+ payload
+ struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
path.write_bytes(png)
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
target = OUT / "classroom-calm.png"
write_png(target, render())
print(f"wrote {target} ({target.stat().st_size} bytes)")
+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