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]>
This commit is contained in:
ekklesia
2026-09-12 19:05:09 -05:00
co-authored by Claude Opus 5
parent 926668dc9b
commit 34fd02e08f
59 changed files with 1335 additions and 198 deletions
+9
View File
@@ -24,8 +24,17 @@ set -euo pipefail
# exists to prevent.
HOME_PAGE='https://www.youtube.com/'
# One number for the whole room. Chrome on Wayland was measured not to
# follow the compositor's output scale on its own, so the 4K television
# got a browser at half the size of everything around it. Forcing the
# same number the display is scaled by is a statement of the same fact,
# not a second scaling: Chrome replaces its detected scale with this
# rather than multiplying by it.
SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)"
exec google-chrome \
--ozone-platform=wayland \
--force-device-scale-factor="$SCALE" \
--start-maximized \
--no-first-run \
--no-default-browser-check \
+26 -2
View File
@@ -6,6 +6,13 @@ There is exactly one thing about the bar that depends on hardware: a room
with no optical drive should not be offered a disc player. This edits the
one marked line in the installed layout, and nothing else.
Run before the display manager by ekklesia-classroom-layout.service, and
again by udev when a drive is plugged in or pulled out. That ordering is
the whole point: the panel is built from this layout the moment a session
starts, and a room that is told about its hardware afterwards keeps the
wrong bar until the next login. It was being left to the provisioning
service, which runs at boot alongside the session rather than before it.
It writes the *layout*, not the running panel. Editing
plasma-org.kde.plasma.desktop-appletsrc underneath a live plasmashell was
tried and it destroyed the panel: the shell owns that file and rewrites
@@ -30,12 +37,29 @@ LAYOUT = (
MARKER = "/* CLASSROOM-DVD-LAUNCHER */"
# The kernel names optical drives sr0, sr1, ... regardless of bus.
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and
# /sys/block is the kernel's own list, which cannot be left behind by a
# device node somebody or something created by hand.
DRIVE_GLOB = "/dev/sr[0-9]*"
SYSFS_GLOB = "/sys/block/sr[0-9]*"
def drives():
return sorted(glob.glob(DRIVE_GLOB)), sorted(glob.glob(SYSFS_GLOB))
def has_optical_drive():
return bool(glob.glob(DRIVE_GLOB))
nodes, blocks = drives()
# Said out loud every time. When a room shows a disc player it has no
# drive for, or hides one it has, this line in the journal is the
# difference between an answer and an afternoon.
print("classroom-desktop-layout: /dev "
f"[{', '.join(nodes) or 'none'}], /sys/block "
f"[{', '.join(blocks) or 'none'}]")
return bool(nodes and blocks)
def rewrite(text, wanted):
+52
View File
@@ -133,6 +133,36 @@ if [[ -f "$CONFIG_HOME/powermanagementprofilesrc" ]]; then
say "cleared the user's power settings; the package supplies them"
fi
# THE DISC PLAYER, AND WHETHER THIS MACHINE HAS ONE.
#
# The panel about to be built comes from the packaged layout, and the one
# line in it that depends on hardware is written by
# ekklesia-classroom-layout.service before the display manager starts.
# This cannot write /usr and does not try. It checks, because a room
# showing a disc player it has no drive for is a teacher clicking a button
# that can only disappoint, and the journal should say so at the moment
# the panel is built rather than after somebody notices.
LAYOUT=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
if [[ -r "$LAYOUT" ]]; then
if compgen -G "/dev/sr[0-9]*" >/dev/null; then
drive=yes
else
drive=no
fi
if grep -q '^\s*/\* CLASSROOM-DVD-LAUNCHER \*/' "$LAYOUT"; then
offered=yes
else
offered=no
fi
if [[ "$drive" != "$offered" ]]; then
say "WARNING: this machine has drive=$drive but the layout offers"
say " the disc player=$offered — run classroom-desktop-layout"
fi
fi
CONFIG="$CONFIG_HOME/plasma-org.kde.plasma.desktop-appletsrc"
if [[ -e "$CONFIG" ]]; then
@@ -142,6 +172,28 @@ else
say "no panel configuration to clear"
fi
# A shell package the classroom used to select.
#
# One build chose a shell package of its own so that it could replace the
# volume display. With one selected, plasmashell builds no panel at all —
# and this script deletes the panel at every login, so the room came up
# with an empty screen. The package no longer ships it; this removes the
# choice from a profile that was written while it did.
if [[ -f "$CONFIG_HOME/plasmashellrc" ]] \
&& grep -q "^ShellPackage=" "$CONFIG_HOME/plasmashellrc" 2>/dev/null; then
sed -i "/^ShellPackage=/d" "$CONFIG_HOME/plasmashellrc"
say "removed a shell package choice that leaves the room with no panel"
fi
# The display, like the panel, comes from the package rather than from
# whatever the room last negotiated. KWin remembers a resolution and a
# scale per output in this file, and a television that once came up in the
# wrong mode keeps it for good — the scale service applies UI_SCALE again
# a moment after this, so there is nothing here to preserve. Seen in the
# lab: an output that had been through two scale changes stayed at a
# resolution nothing on the machine had asked for.
rm -f "$CONFIG_HOME/kwinoutputconfig.json" 2>/dev/null || true
# The icon cache remembers a name it once failed to find, which outlives
# the package that later supplies it.
rm -f "$CACHE_HOME/icon-cache.kcache" 2>/dev/null || true
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/python3
"""Hidden maintenance UI. Saving requires an administrator's password."""
import os
import pwd
import subprocess
import sys
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
from classroom_config import read_device_config, ui_scale
def main():
if pwd.getpwuid(os.getuid()).pw_name != "classroom":
sys.exit("Open this window from the classroom display session.")
window = Gtk.Dialog(title="Classroom display size")
window.set_default_size(440, 180)
window.add_button("Close", Gtk.ResponseType.CANCEL)
window.add_button("Save and apply", Gtk.ResponseType.OK)
box = window.get_content_area()
box.set_border_width(20)
box.set_spacing(16)
label = Gtk.Label(label="Display size for this room\n200% is a good starting point for a 4K TV.")
box.add(label)
sizes = Gtk.ComboBoxText()
current = ui_scale(read_device_config())
choices = [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0]
if current not in choices:
choices.append(current)
choices.sort()
for value in choices:
sizes.append(str(value), f"{value * 100:g}%")
sizes.set_active_id(str(current))
box.add(sizes)
box.add(Gtk.Label(label="Saving requires an administrator password."))
window.show_all()
while window.run() == Gtk.ResponseType.OK:
selected = sizes.get_active_id()
if float(selected) == current:
continue
result = subprocess.run(["pkexec", "/usr/local/bin/classroom-set-scale", selected], capture_output=True, text=True)
if result.returncode:
message = "No change saved. Authentication was cancelled or the setting could not be saved."
else:
applied = subprocess.run(["/usr/local/bin/classroom-scale"], capture_output=True, text=True)
current = float(selected)
message = "Display size saved and applied." if applied.returncode == 0 else "Display size saved, but applying it failed. Sign out and back in to retry."
info = Gtk.MessageDialog(transient_for=window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=message)
info.run()
info.destroy()
window.destroy()
if __name__ == "__main__":
main()
+53 -3
View File
@@ -167,16 +167,32 @@ rmdir --ignore-fail-on-non-empty \
# 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")
# Channels that served a desktop this package replaces.
OBSOLETE_CHANNELS = {"hyprland"}
# 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:
@@ -184,6 +200,12 @@ with open(path, "r", encoding="utf-8") as handle:
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 ""
@@ -192,15 +214,43 @@ for line in lines:
changed.append(f"dropped {key}")
continue
if key:
seen.add(key)
if key == "CHANNEL":
value = line.split("=", 1)[1].strip()
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)
+4 -1
View File
@@ -541,7 +541,10 @@ class ParentCall(Gtk.Window):
self.broker_reachable = data.get("reachable")
if not self.broker_reachable:
self._unavailable("Cannot reach the church network")
# Why it is not there decides who the teacher should
# fetch, so the reason the broker thread already knows is
# carried through to the card rather than flattened.
self._unavailable(contract.broker_message(data.get("why")))
else:
self._after(RETAINED_GRACE, self._retained_deadline)
+8 -3
View File
@@ -10,6 +10,7 @@ is the whole of what replaced the compositor-specific version.
import json
import os
import pwd
import subprocess
import sys
@@ -41,11 +42,13 @@ def outputs():
return [
output["name"] for output in found
if output.get("name") and output.get("connected", True)
if output.get("name") and output.get("connected", True) and output.get("enabled", True)
]
def main():
if pwd.getpwuid(os.getuid()).pw_name != "classroom":
return 0
# classroom_config clamps a nonsense or out-of-range value rather than
# obeying it, so a mistyped UI_SCALE cannot leave a room unusable.
scale = ui_scale(read_device_config())
@@ -53,8 +56,9 @@ def main():
if not found:
print("classroom-scale: no outputs", file=sys.stderr)
return 0
return 1
failed = False
for output in found:
result = subprocess.run(
["kscreen-doctor", f"output.{output}.scale.{scale}"],
@@ -64,10 +68,11 @@ def main():
if result.returncode == 0:
print(f"classroom-scale: {output} scale {scale}")
else:
failed = True
print(f"classroom-scale: {output}: {result.stderr.strip()}",
file=sys.stderr)
return 0
return 1 if failed else 0
if __name__ == "__main__":
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/python3 -I
"""Privileged, fixed-purpose writer; called through an auth_admin polkit action."""
import fcntl
import os
from pathlib import Path
import sys
import tempfile
CHOICES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0)
def updated(text, value):
out = []
for line in text.splitlines(keepends=True):
if line.split("=", 1)[0].strip() != "UI_SCALE":
out.append(line)
text = "".join(out)
return text + ("\n" if text and not text.endswith("\n") else "") + f"UI_SCALE={value:g}\n"
def main():
if os.geteuid() != 0:
sys.exit("Administrator authentication is required.")
try:
value = float(sys.argv[1]) if len(sys.argv) == 2 else None
except ValueError:
value = None
if value not in CHOICES:
sys.exit("Choose one of the supported display sizes.")
path = Path("/etc/ekklesia-classroom/device.conf")
with open("/run/lock/ekklesia-display-settings.lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
info = path.stat()
text = updated(path.read_text(), value)
fd, name = tempfile.mkstemp(prefix=".device-scale-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
os.fchown(handle.fileno(), info.st_uid, info.st_gid)
os.fchmod(handle.fileno(), info.st_mode & 0o777)
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
print("Display size saved for this room.")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Print the room's UI scale, clamped, as one number.
There is one scale in a classroom and this is where everything asks for
it: the Plasma session applies it to the display, and the browser and
Wonder Ink are told it explicitly because Chrome was measured not to
follow the compositor on its own. A shell script can read device.conf in
one line, but then there are four places that clamp, default and parse a
setting differently — which is how a room ends up with a panel at one
size and a browser at another.
"""
import os
import sys
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_config import read_device_config, ui_scale # noqa: E402
def main():
scale = ui_scale(read_device_config())
# 2 rather than 2.0: Chrome takes either, and a whole number is what a
# person reads in a journal line without wondering what it means.
if scale == int(scale):
print(int(scale))
else:
print(f"{scale:g}")
return 0
if __name__ == "__main__":
sys.exit(main())
+9
View File
@@ -24,6 +24,14 @@ set -euo pipefail
# browser.
HOME_PAGE='https://app.wonderink.org/'
# One number for the whole room. Chrome on Wayland was measured not to
# follow the compositor's output scale on its own, so the 4K television
# got a browser at half the size of everything around it. Forcing the
# same number the display is scaled by is a statement of the same fact,
# not a second scaling: Chrome replaces its detected scale with this
# rather than multiplying by it.
SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)"
PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink"
mkdir -p "$PROFILE"
@@ -32,6 +40,7 @@ exec google-chrome \
--user-data-dir="$PROFILE" \
--app="$HOME_PAGE" \
--ozone-platform=wayland \
--force-device-scale-factor="$SCALE" \
--start-maximized \
--no-first-run \
--no-default-browser-check \