Files
Classroom_Desktop/scripts/classroom-migrate
T
ekklesiaandClaude Opus 5 34fd02e08f feat: give every room the broker, one scale, and its own panel back
Little Waders took the Plasma build and reported what a container
cannot. A disc player on a machine with no optical drive, a clock nobody
at the back of the room could read, a browser at half the size of the
panel around it, and Parent Call with no broker to call.

The broker is the same for every classroom, so it is a default now and
not something to fill in room by room. classroom-migrate brings an
existing room to the same place without touching its name, its username
or its password, and Parent Call distinguishes a broker it cannot reach
from one that was never set up: those send somebody to look at
completely different things.

The disc player's detection was right and ran too late — alongside the
session rather than before it, and the panel is built from the layout the
moment a session starts. It runs before the display manager now, and
again from udev when a drive is plugged in.

UI_SCALE had a ceiling of 1.5, which cannot express a 4K television; a
clock pinned at 15 points, which is the same size at every scale; and a
browser that was never told the number at all. All three are fixed and
the room doubles together at 200%.

This also brings 0.1.47's work into the repository, where it had only
ever existed in a build directory under /tmp: the hidden display dialog,
its authenticated writer, the polkit action, and the church's own
wallpaper. One piece of it is deliberately left out. The custom volume
display needed a shell package of our own, and with one selected
plasmashell builds no panel at all — measured twice, with that build's
own files, on a classroom that deletes its panel configuration at every
login by design.

The lab connected to the real church broker the moment the package
defaulted to it. Nothing was left behind, which was checked, but the
guard that should have stopped it had never worked: it used iptables,
which the lab image does not have, and probed with /dev/tcp under dash,
where that always fails. Both reported success.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 19:05:09 -05:00

276 lines
10 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
# Turn a machine that was something else into this classroom.
#
# A room does not arrive new. It arrives as whatever it was before: the
# sway classroom, the Hyprland experiment, a machine a technician tried
# something on. The package can describe the desktop it wants perfectly
# and still lose to one leftover symlink, which is what happened: a real
# room upgraded to the Plasma appliance and came up with the old Waybar
# across the top, mako still catching notifications, and Plasma's own
# stock panel underneath.
#
# So the installation converges the machine instead of assuming it.
# Everything here is something a classroom package created, or state this
# package can identify as belonging to a classroom shell it replaces.
# Nothing removes a file Debian owns: the old bar and the old notification
# daemon stay installed and simply stop being started.
#
# Idempotent, and safe on a machine that has none of this. Run by hand at
# any time:
#
# sudo classroom-migrate
CLASSROOM_USER=classroom
DEVICE_CONFIG=/etc/ekklesia-classroom/device.conf
# The shells this appliance replaces. There is exactly one desktop stack
# now — SDDM, KWin, plasmashell — and every one of these is a component of
# a stack that is gone.
LEGACY_UNITS=(waybar.service mako.service swayidle.service swaybg.service)
LEGACY_PROCESSES=(waybar mako classroom-dock swaybg swayidle)
LEGACY_EXEC_PATTERN='waybar|mako|classroom-dock|classroom-notifications|sway|Hyprland|start-hyprland|classroom-session'
say() {
echo "[classroom-migrate] $1"
}
if [[ "$(id -u)" -ne 0 ]]; then
say "must be run as root"
exit 1
fi
home=$(getent passwd "$CLASSROOM_USER" | cut -d: -f6)
uid=$(id -u "$CLASSROOM_USER" 2>/dev/null || true)
# ---------------------------------------------------------------- units
# Stop what is running now. The upgrade happens underneath a live session,
# so the old bar is on the screen while this runs.
if [[ -n "$uid" && -d "/run/user/$uid" ]]; then
for unit in "${LEGACY_UNITS[@]}"; do
runuser -u "$CLASSROOM_USER" -- env "XDG_RUNTIME_DIR=/run/user/$uid" \
systemctl --user stop "$unit" >/dev/null 2>&1 || true
done
fi
for process in "${LEGACY_PROCESSES[@]}"; do
if pkill -u "$CLASSROOM_USER" -x "$process" 2>/dev/null; then
say "stopped $process"
fi
done
# Remove the activation, wherever it was made. `systemctl --global enable`
# writes into /etc/systemd/user; a session that set XDG_CONFIG_DIRS gets
# /etc/xdg/systemd/user as well; and a unit enabled by hand as the
# classroom user lands in the home directory. The real room had it in the
# second of those, which is why guessing one location would not have been
# enough.
wants_roots=(/etc/systemd/user /etc/xdg/systemd/user /run/systemd/user)
[[ -n "$home" ]] && wants_roots+=("$home/.config/systemd/user")
for root in "${wants_roots[@]}"; do
[[ -d "$root" ]] || continue
for unit in "${LEGACY_UNITS[@]}"; do
while IFS= read -r link; do
rm -f "$link"
say "removed activation ${link#/}"
done < <(find "$root" -name "$unit" -path '*.wants/*' 2>/dev/null)
done
done
# The unit files themselves, where somebody wrote them by hand. Neither
# waybar nor mako ships a systemd unit in trixie, so a file by one of
# those names belongs to the shell this package replaces — but the rule is
# checked rather than assumed: anything a Debian package owns is left
# exactly where it is.
for root in /etc/systemd/user /etc/xdg/systemd/user "${home:+$home/.config/systemd/user}"; do
[[ -n "$root" && -d "$root" ]] || continue
for unit in "${LEGACY_UNITS[@]}"; do
[[ -f "$root/$unit" ]] || continue
if dpkg -S "$root/$unit" >/dev/null 2>&1; then
say "left $root/$unit alone: a Debian package owns it"
continue
fi
rm -f "$root/$unit"
say "removed the hand-written $root/$unit"
done
done
# And then say no once more, at the system level, so that a unit file
# reappearing from anywhere cannot start it. A mask is a symlink to
# /dev/null in /etc/systemd/user: visible, reversible, and it removes
# nothing.
systemctl --global mask "${LEGACY_UNITS[@]}" >/dev/null 2>&1 || true
# And the other way the old notification daemon starts: D-Bus activation.
# The package shadows mako's service file so that activation fails, but a
# session bus that is already running read its service directories at
# startup and has not seen it. Measured during crash recovery on a
# migrated machine: the panel was killed, a notification was sent while
# nothing owned the name, and mako took it.
if [[ -n "$uid" && -S "/run/user/$uid/bus" ]]; then
runuser -u "$CLASSROOM_USER" -- env \
"XDG_RUNTIME_DIR=/run/user/$uid" \
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus" \
dbus-send --session --dest=org.freedesktop.DBus --type=method_call \
/org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig \
>/dev/null 2>&1 || true
fi
# ------------------------------------------------------------- autostart
# XDG autostart entries that start a component of the old shell. Matched on
# what they run, not on their filename, and only in the classroom's own
# directory.
if [[ -n "$home" && -d "$home/.config/autostart" ]]; then
for entry in "$home/.config/autostart"/*.desktop; do
[[ -e "$entry" ]] || continue
if grep -qE "^Exec=.*($LEGACY_EXEC_PATTERN)" "$entry" 2>/dev/null; then
rm -f "$entry"
say "removed autostart entry $(basename "$entry")"
fi
done
fi
# ------------------------------------------------------------ tty1 login
# The sway classroom started its session from the login shell of an
# autologin getty. SDDM owns the session now, and two things owning tty1
# is how a machine ends up with no usable screen at all.
if [[ -n "$home" && -f "$home/.bash_profile" ]]; then
if grep -qE "exec .*($LEGACY_EXEC_PATTERN)" "$home/.bash_profile" 2>/dev/null; then
rm -f "$home/.bash_profile"
say "removed the login shell that used to start the old session"
fi
fi
rm -f /etc/systemd/system/[email protected]/autologin.conf
rmdir --ignore-fail-on-non-empty \
/etc/systemd/system/[email protected] 2>/dev/null || true
# --------------------------------------------------------- device.conf
# The room's own settings file outlives every version of this package, so
# it is edited rather than replaced: the broker password is in it.
#
# COMPOSITOR chose between sway and Hyprland. There is nothing left for it
# to choose, and a stale value is worse than none — it is an instruction
# this package cannot carry out.
#
# CHANNEL decides which manifest the updater fetches. A machine left on the
# hyprland channel would be handed the Hyprland experiment again by the
# next release published there, undoing the upgrade it just took.
#
# The broker is the same for every classroom, so a room that was set up
# before there was one — blank address, switched off — is given the
# address rather than left with Parent Call saying there is no church
# network. A room already pointed somewhere else is left pointed there:
# that is a decision somebody made. The username and the password are
# never read, never written and never printed.
if [[ -f "$DEVICE_CONFIG" ]]; then
python3 - "$DEVICE_CONFIG" <<'PY'
import re
import sys
OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")
# Every channel that serves a desktop this package replaces. A machine
# that has just installed the Plasma classroom must not be left pointed at
# a line that will hand it the sway build back at the next release: stable
# and canary both would. A room already on the v2 channel, or on one
# somebody named deliberately, is left where it is.
OBSOLETE_CHANNELS = {"stable", "canary", "hyprland"}
CHANNEL = "classroom-v2"
# The church broker. One address for every room.
BROKER_HOST = "10.40.0.2"
BROKER_PORT = "1883"
TRUTHY = {"1", "true", "yes", "on", "enabled"}
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
out = []
changed = []
seen = set()
def value_of(line):
return line.split("=", 1)[1].strip()
for line in lines:
key = line.split("=", 1)[0].strip() if "=" in line else ""
if key in OBSOLETE_KEYS:
changed.append(f"dropped {key}")
continue
if key:
seen.add(key)
if key == "CHANNEL":
value = value_of(line)
if value.lower() in OBSOLETE_CHANNELS:
changed.append(f"moved off the {value} channel onto {CHANNEL}")
line = f"CHANNEL={CHANNEL}\n"
elif key == "MQTT_ENABLED":
if value_of(line).lower() not in TRUTHY:
changed.append("switched the broker on")
line = "MQTT_ENABLED=true\n"
elif key == "MQTT_HOST":
if not value_of(line):
changed.append(f"filled in the broker address {BROKER_HOST}")
line = f"MQTT_HOST={BROKER_HOST}\n"
elif key == "MQTT_PORT":
if not value_of(line):
changed.append(f"filled in the broker port {BROKER_PORT}")
line = f"MQTT_PORT={BROKER_PORT}\n"
out.append(line)
# A room old enough to predate a setting has no line to correct.
for key, value in (
("MQTT_ENABLED", "true"),
("MQTT_HOST", BROKER_HOST),
("MQTT_PORT", BROKER_PORT),
):
if key not in seen:
changed.append(f"added {key}")
out.append(f"{key}={value}\n")
if changed:
with open(path, "w", encoding="utf-8") as handle:
handle.writelines(out)
for note in changed:
print(f"[classroom-migrate] device.conf: {note}")
PY
fi
# ------------------------------------------------------- the old session
# Plasma reads the classroom's settings out of a directory that has to be
# in XDG_CONFIG_DIRS, and the environment.d file that used to put it there
# never reached a session started by SDDM. The session script that does is
# installed by the package; this only checks it arrived, because a room
# without it is a room with no lockdown and no panel of ours.
if [[ ! -r /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh ]]; then
say "WARNING: the session environment script is missing;"
say " the desktop will come up as stock Plasma"
fi
say "done"