0.1.50 was taken to the church and the things a container cannot answer were answered on the hardware: boot, autologin, the panel after a reboot, panel and browser scaling, Wonder Ink, Parent Call opening in the corner and reaching the sanctuary with a typed name, readable and silent notifications, HDMI audio and the volume keys. The promotion is a fast-forward. The experiment was cut from the published stable source and never diverged from it, so master reaches this work without a merge and there is one history. One thing changed for the release. The channel rules turn around: classroom-v2 and hyprland are the obsolete channels now and a room on either comes home to stable, while canary — where the next development release goes — and any channel somebody named by hand stay where they were put. A fresh installation has always defaulted to stable. Nothing else moved. This is the 0.1.50 that was verified, with a version number and a channel rule. Co-Authored-By: Claude Opus 5 <[email protected]>
389 lines
19 KiB
Bash
Executable File
389 lines
19 KiB
Bash
Executable File
#!/bin/bash
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
python3 - "$ROOT" <<'PY'
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
root = Path(sys.argv[1])
|
|
control = (root / "packaging/debian/control.template").read_text()
|
|
postinst = (root / "packaging/debian/postinst").read_text()
|
|
migrate = (root / "scripts/classroom-migrate").read_text()
|
|
provision = (root / "scripts/classroom-provision").read_text()
|
|
units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service"))
|
|
canary = (root / "update_canary.sh").read_text()
|
|
validation_script = (root / "tools/validate-trixie-release.sh").read_text()
|
|
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
|
|
rules = (root / "config/plasma/xdg/kwinrulesrc").read_text()
|
|
restrictions = (root / "config/plasma/xdg/kdeglobals").read_text()
|
|
supervision = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
|
|
build = (root / "tools/build-release.sh").read_text()
|
|
chrome_policy = json.loads(
|
|
(root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text()
|
|
)
|
|
|
|
passed = failed = 0
|
|
|
|
|
|
def check(label, condition):
|
|
global passed, failed
|
|
if condition:
|
|
print(f" ok {label}")
|
|
passed += 1
|
|
else:
|
|
print(f" FAIL {label}")
|
|
failed += 1
|
|
|
|
|
|
depends = re.search(r"^Depends: (.*)$", control, re.MULTILINE).group(1).split(", ")
|
|
|
|
print("1. Trixie package contract")
|
|
for package in (
|
|
"network-manager",
|
|
"cups",
|
|
"cups-client",
|
|
"cups-filters",
|
|
"foomatic-db-compressed-ppds",
|
|
):
|
|
check(f"depends on {package}", package in depends)
|
|
|
|
print()
|
|
print("2. networking service behavior")
|
|
check("NetworkManager is explicitly enabled", "systemctl enable NetworkManager.service || true" in postinst)
|
|
check("NetworkManager start is best effort", "systemctl start NetworkManager.service || true" in postinst)
|
|
check("online waiter is disabled", "systemctl disable NetworkManager-wait-online.service || true" in postinst)
|
|
check("no classroom NetworkManager config overrides ifupdown", not (root / "config/network").exists())
|
|
|
|
print()
|
|
print("3. printing service behavior")
|
|
check("CUPS service and socket are enabled", "systemctl enable cups.socket cups.service || true" in postinst)
|
|
check("CUPS start is best effort", "systemctl start cups.socket || true" in postinst)
|
|
check("automatic remote queue browsing is disabled", "systemctl disable --now cups-browsed.service || true" in postinst)
|
|
check("Avahi service and socket are disabled", "systemctl disable --now avahi-daemon.socket avahi-daemon.service || true" in postinst)
|
|
check("Avahi service and socket are masked", "systemctl mask avahi-daemon.socket avahi-daemon.service || true" in postinst)
|
|
check("printer setup is not a blocking boot dependency", "classroom-printer-setup" not in units)
|
|
check("printer setup runs automatically in provisioning", "/usr/local/bin/classroom-printer-setup" in provision)
|
|
check("printer provisioning is bounded", "--kill-after=5s 30s" in provision)
|
|
check("Chrome uses the system print dialog",
|
|
chrome_policy.get("DisablePrintPreview") is True)
|
|
|
|
print()
|
|
print("4. secondary windows are the window manager's business")
|
|
# Chrome gives its main window and every window a page opens the same
|
|
# class, so a rule keyed on that class maximises the lesson's popup too.
|
|
# It was measured doing exactly that. The browser is told to start
|
|
# maximized instead, and no rule mentions it.
|
|
check("no window rule claims the browser", "google-chrome" not in rules)
|
|
check("the browser asks for its own first window", "--start-maximized" in
|
|
(root / "scripts/classroom-browser").read_text())
|
|
check("Wonder Ink asks for the same", "--start-maximized" in
|
|
(root / "scripts/classroom-wonderink").read_text())
|
|
check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists())
|
|
check("and is not packaged", "classroom-chrome-windows" not in build)
|
|
|
|
print()
|
|
print("4b. the panel is never allowed to stay dead")
|
|
# Plasma restarts a crashed panel three times a minute and then gives up.
|
|
# Measured: dead on the third kill. These lines are what make it
|
|
# unconditional, and losing them would lose the room its only way around.
|
|
check("restart is unconditional", "Restart=always" in supervision)
|
|
check("the burst limit is lifted", "StartLimitIntervalSec=0" in supervision)
|
|
check("the drop-in is packaged", "plasma-plasmashell.service.d" in build)
|
|
|
|
print()
|
|
print("4b2. the session settings do not fight another package")
|
|
# plasma-workspace owns /etc/xdg/kdeglobals and friends. dpkg refused the
|
|
# package when the classroom tried to own them too, so the room's copies
|
|
# live in a directory of their own, in front of /etc/xdg.
|
|
check("settings install to the classroom's own directory",
|
|
"$STAGE/etc/ekklesia-classroom/xdg/" in build)
|
|
# One file of ours does live under /etc/xdg, and it is not a settings
|
|
# file: it is the session environment drop-in that puts the directory
|
|
# above into the cascade at all. A drop-in directory is built to be
|
|
# added to; a name plasma-workspace already owns is not.
|
|
xdg_paths = sorted(re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build))
|
|
check("what the classroom puts under /etc/xdg is two drop-ins",
|
|
xdg_paths == [
|
|
"/etc/xdg/autostart/ekklesia-classroom-scale.desktop",
|
|
"/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh",
|
|
])
|
|
check("and no settings file takes a name plasma-workspace owns",
|
|
not any(path.rsplit("/", 1)[0] == "/etc/xdg" for path in xdg_paths))
|
|
check("and that directory is searched first",
|
|
"XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg" in
|
|
(root / "config/environment/90-ekklesia-classroom.conf").read_text())
|
|
check("the environment file is packaged",
|
|
"/etc/environment.d/90-ekklesia-classroom.conf" in build)
|
|
|
|
print()
|
|
print("4b3. every icon the panel names is in the package")
|
|
import configparser
|
|
for entry in sorted((root / "config/plasma/applications").glob("*.desktop")):
|
|
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
|
parser.read(entry)
|
|
icon = parser["Desktop Entry"].get("icon", "")
|
|
if not icon.startswith("ekklesia-"):
|
|
continue
|
|
check(f"{icon} is installed by the package",
|
|
f'"$ROOT/config/icons/${{icon}}.svg"' in build or icon in build)
|
|
check(f"{icon}.svg exists", (root / f"config/icons/{icon}.svg").is_file())
|
|
|
|
print()
|
|
print("4b4. the power button does nothing, at both layers")
|
|
logind = (root / "config/systemd/logind-ekklesia-classroom.conf").read_text()
|
|
power = (root / "config/plasma/xdg/powermanagementprofilesrc").read_text()
|
|
# PowerDevil holds logind's handle-power-key inhibitor while a session is
|
|
# running, so one layer without the other is a button that still works.
|
|
for key in ("HandlePowerKey=ignore", "HandleSuspendKey=ignore",
|
|
"HandleHibernateKey=ignore", "HandleLidSwitch=ignore"):
|
|
check(f"logind: {key}", key in logind)
|
|
check("PowerDevil: no action on the power button",
|
|
power.count("powerButtonAction=0") == 3)
|
|
check("PowerDevil: no action on a lid either",
|
|
power.count("lidAction=0") == 3)
|
|
# Six: the three button groups, and the three profiles around them.
|
|
# PowerDevil loads an idle action when it finds a group for it, so a
|
|
# writable profile is one a leftover user file can add a suspend to.
|
|
check("and the session cannot put an action back",
|
|
power.count("[$i]") == 6)
|
|
check("no idle action in any profile",
|
|
not any(f"[{profile}][{action}]" in power
|
|
for profile in ("AC", "Battery", "LowBattery")
|
|
for action in ("SuspendSession", "DimDisplay", "DPMSControl")))
|
|
check("the logind drop-in is packaged",
|
|
"/etc/systemd/logind.conf.d/ekklesia-classroom.conf" in build)
|
|
check("the PowerDevil profile is packaged",
|
|
"powermanagementprofilesrc" in build)
|
|
check("the long hold is left alone on purpose",
|
|
"firmware" in logind)
|
|
|
|
print()
|
|
print("4b5. the maintenance terminal is hidden and reachable")
|
|
admin = (root / "config/plasma/applications/ekklesia-admin-terminal.desktop").read_text()
|
|
konsole = (root / "config/plasma/applications-local/org.kde.konsole.desktop").read_text()
|
|
wrapper = (root / "scripts/classroom-admin-terminal").read_text()
|
|
check("the chord is the obscure one",
|
|
"X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+K" in admin)
|
|
check("its entry is in no application list", "NoDisplay=true" in admin)
|
|
check("Konsole's own entry is shadowed and hidden",
|
|
"NoDisplay=true" in konsole)
|
|
check("the shadow is in /usr/local/share, not on top of Konsole's file",
|
|
"$STAGE/usr/local/share/applications/" in build)
|
|
check("the terminal is launched outside the classroom's restrictions",
|
|
"XDG_CONFIG_DIRS=/etc/xdg" in wrapper)
|
|
check("and grants no privilege of its own",
|
|
"sudo" not in wrapper.replace("no sudo rule", ""))
|
|
check("the wrapper is packaged", "classroom-admin-terminal" in build)
|
|
check("Konsole is a dependency, so the route exists on every machine",
|
|
"konsole" in (root / "packaging/debian/control.template").read_text())
|
|
check("no terminal is pinned to the panel",
|
|
"konsole" not in (root / "config/plasma/lookandfeel/contents/layouts"
|
|
/ "org.kde.plasma.desktop-layout.js").read_text().lower())
|
|
|
|
print()
|
|
print("4c. the teacher cannot reach a command line")
|
|
for restriction in ("run_command=false", "shell_access=false",
|
|
"action/lock_screen=false",
|
|
"plasma/plasmashell/unlockedDesktop=false"):
|
|
check(f"{restriction}", restriction in restrictions)
|
|
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
|
|
|
|
print()
|
|
print("4d. clicking a launcher never hides an application")
|
|
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
|
|
check("there is no minimise button", "ButtonsOnRight=FX" in
|
|
(root / "config/plasma/xdg/kwinrc").read_text())
|
|
|
|
print()
|
|
print("5. bounded Trixie validation")
|
|
check("container has an outer deadline", "720s" in validation_script)
|
|
check("package installs have deadlines", "candidate-install 360" in validation_script)
|
|
check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in validation_script and "exit 101" in validation_script)
|
|
|
|
print()
|
|
print("6. canary release gate")
|
|
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"')
|
|
package_copy = canary.index('cp "$package_path"')
|
|
first_rsync = canary.index("rsync -av")
|
|
check("Trixie validation runs before the repository copy", validation < package_copy)
|
|
check("Trixie validation runs before any upload", validation < first_rsync)
|
|
# The gate checks convergence, not just that the package installs: an
|
|
# upgraded room ends on the v2 channel with every setting this release
|
|
# has, exactly once, and the command a technician runs is safe to run on
|
|
# a machine that is already converged.
|
|
check("the gate checks a deliberate channel survives",
|
|
"'^CHANNEL=canary$'" in validation_script)
|
|
check("and that the experiment's channel comes home",
|
|
"'^CHANNEL=stable$'" in validation_script)
|
|
check("and that every new setting arrived exactly once",
|
|
"for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE" in validation_script)
|
|
check("and that running the migration by hand is safe",
|
|
"classroom-migrate >/tmp/migrate-by-hand.log" in validation_script)
|
|
check("and that it never prints the broker password",
|
|
"! grep -q 'preserve-me' /tmp/migrate-by-hand.log" in validation_script)
|
|
|
|
print()
|
|
print("7. one scale, applied everywhere")
|
|
autostart = (root / "config/plasma/autostart/ekklesia-classroom-scale.desktop").read_text()
|
|
policy = (root / "config/polkit/org.ekklesia.classroom.display.policy").read_text()
|
|
settings_entry = (root / "config/plasma/applications/ekklesia-display-settings.desktop").read_text()
|
|
browser = (root / "scripts/classroom-browser").read_text()
|
|
wonderink = (root / "scripts/classroom-wonderink").read_text()
|
|
config_module = (root / "lib/classroom_config.py").read_text()
|
|
# UI_SCALE was packaged, clamped and documented, and nothing ever ran the
|
|
# command that applies it. A 4K television showed a 1080p interface at
|
|
# half size and every part of this section is a piece of that.
|
|
check("the scale is applied at every login",
|
|
"Exec=/usr/local/bin/classroom-scale" in autostart)
|
|
check("by the session, which is where kscreen-doctor can be asked",
|
|
"OnlyShowIn=KDE" in autostart)
|
|
check("and the package installs it",
|
|
"/etc/xdg/autostart/ekklesia-classroom-scale.desktop" in build)
|
|
check("changing it needs an administrator password",
|
|
"auth_admin" in policy and "classroom-set-scale" in policy)
|
|
check("and there is no way into it from the room",
|
|
"NoDisplay=true" in settings_entry
|
|
and "X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+D" in settings_entry)
|
|
check("2.0 is inside the range a room may ask for",
|
|
"UI_SCALE_MAX = 3.0" in config_module)
|
|
check("the browser is told the same number",
|
|
'--force-device-scale-factor="$SCALE"' in browser)
|
|
check("so is Wonder Ink",
|
|
'--force-device-scale-factor="$SCALE"' in wonderink)
|
|
check("both ask the one command for it",
|
|
"classroom-ui-scale" in browser and "classroom-ui-scale" in wonderink)
|
|
check("the clock is sized by the bar, not by a number",
|
|
'autoFontAndSize", true' in layout and "fontSize" not in layout)
|
|
|
|
print()
|
|
print("7b. the disc player is decided before a session starts")
|
|
layout_unit = (root / "systemd/layout/ekklesia-classroom-layout.service").read_text()
|
|
udev = (root / "config/udev/60-ekklesia-classroom-optical.rules").read_text()
|
|
reset = (root / "scripts/classroom-desktop-reset").read_text()
|
|
check("before the display manager", "Before=display-manager.service" in layout_unit)
|
|
check("started at every boot", "WantedBy=graphical.target" in layout_unit)
|
|
check("enabled by the package", "enable ekklesia-classroom-layout.service" in postinst)
|
|
check("a drive plugged in later is noticed", 'ENV{ID_CDROM}=="1"' in udev)
|
|
check("and the rules are reloaded on install", "udevadm control --reload-rules" in postinst)
|
|
check("the login says so when the layout and the hardware disagree",
|
|
"the disc player=" in reset)
|
|
|
|
print()
|
|
print("7c. the room's own popups")
|
|
style = (root / "config/plasma/desktoptheme/dialogs/background.svg").read_text()
|
|
plasmarc = (root / "config/plasma/xdg/plasmarc").read_text()
|
|
notify = (root / "config/plasma/xdg/plasmanotifyrc").read_text()
|
|
policy_notifications = chrome_policy.get("DefaultNotificationsSetting")
|
|
# A Plasma style is a set of pictures with a per-file fallback to Breeze.
|
|
# Replacing the notification plasmoid's QML was the other route and a QML
|
|
# error there is a room with no notifications at all.
|
|
for piece in ("topleft", "top", "topright", "left", "center", "right",
|
|
"bottomleft", "bottom", "bottomright"):
|
|
check(f"the card has a {piece}", f'id="{piece}"' in style)
|
|
check("the style is chosen, immutably", "name[$i]=ekklesia" in plasmarc)
|
|
check("and the package installs it", "desktoptheme/ekklesia" in build)
|
|
check("the colour still comes from the session's scheme",
|
|
"ColorScheme-Background" in style)
|
|
check("notifications stay long enough to read across a room",
|
|
"PopupTimeout=8000" in notify)
|
|
# The volume display is the one thing the classroom's shell package
|
|
# overrides. Plasma 6.3 loads the on-screen display from the shell
|
|
# package and from nowhere else — the look and feel is not consulted,
|
|
# which was measured — so this is the supported way to replace it. The
|
|
# fallback declaration is what keeps the package this small: without it a
|
|
# shell package loses the desktop's own defaults, and the wallpaper with
|
|
# them, which was also measured.
|
|
# The volume display is Plasma's own. Replacing it needs a shell package
|
|
# — 6.3 reads the on-screen display from there and nowhere else — and a
|
|
# shell package with one selected builds no panel at all. Measured twice,
|
|
# and the panel is rebuilt from the layout at every login on purpose, so
|
|
# the room would have come up empty. The scale makes Plasma's own display
|
|
# the right size on a television, which is what was actually wanted.
|
|
check("no shell package of our own", "plasma/shells" not in build)
|
|
check("and nothing selects one", "ShellPackage" not in build)
|
|
check("the volume chime is off", 'volumeFeedback", false' in layout)
|
|
check("and off in the setting Plasma 6 actually reads",
|
|
"AudioFeedback[$i]=false"
|
|
in (root / "config/plasma/xdg/plasmaparc").read_text())
|
|
check("but the display itself is on", 'volumeOsd", true' in layout)
|
|
check("web pages cannot put a notification on the television",
|
|
policy_notifications == 2)
|
|
check("and cannot ask to",
|
|
chrome_policy.get("NotificationsAllowedForUrls") == []
|
|
and chrome_policy.get("NotificationsBlockedForUrls") == ["*"])
|
|
|
|
print()
|
|
print("7c-i. the room does not introduce itself")
|
|
# A teacher does not need the name of the software. Every visible name is
|
|
# the thing it does — Parent Call, DVD, Browser, Classroom — and the
|
|
# product name stays where it belongs: package names, unit names, paths
|
|
# and topics, none of which anybody in the room reads.
|
|
visible = {}
|
|
|
|
for entry in sorted((root / "config/plasma").rglob("*.desktop")):
|
|
for line in entry.read_text().splitlines():
|
|
if line.split("=", 1)[0] in ("Name", "GenericName", "Comment"):
|
|
visible[f"{entry.name}:{line}"] = line
|
|
|
|
for script in sorted((root / "scripts").iterdir()):
|
|
if not script.is_file():
|
|
continue
|
|
for line in script.read_text().splitlines():
|
|
if "--app-name" in line:
|
|
visible[f"{script.name}:{line.strip()}"] = line
|
|
|
|
check("there are visible names to check", len(visible) > 10)
|
|
|
|
offenders = [where for where, line in visible.items() if "Ekklesia" in line]
|
|
|
|
if offenders:
|
|
for where in offenders:
|
|
print(f" {where}")
|
|
|
|
check(f"none of them says the product name ({len(visible)} checked)",
|
|
not offenders)
|
|
|
|
check("the notification card is from the room",
|
|
'--app-name="Classroom"'
|
|
in (root / "scripts/classroom-notify").read_text())
|
|
|
|
print()
|
|
print("7c-ii. the v2 channel is its own")
|
|
v2 = (root / "update_classroom_v2.sh").read_text()
|
|
# A publish script that can reach another channel's manifest is one
|
|
# mistake away from putting the Plasma build on a sway room.
|
|
code = "\n".join(line for line in v2.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
check("it publishes to classroom-v2", "CHANNEL=classroom-v2" in code)
|
|
check("and names no other channel anywhere it acts",
|
|
"stable" not in code and "canary" not in code)
|
|
check("the same Trixie gate runs first",
|
|
code.index("validate-trixie-release.sh") < code.index("rsync"))
|
|
check("and the manifest goes up last",
|
|
code.rindex("manifest.json") > code.index("packages/"))
|
|
|
|
print()
|
|
print("7d. the broker every room shares")
|
|
mqtt_unit = (root / "systemd/mqtt/ekklesia-classroom-mqtt.service").read_text()
|
|
parent_call = (root / "lib/classroom_parent_call.py").read_text()
|
|
check("the link is restarted whatever it exits with",
|
|
"Restart=always" in mqtt_unit)
|
|
check("it is enabled by the package",
|
|
"enable ekklesia-classroom-mqtt.service" in postinst)
|
|
check("a fresh room points at the church broker",
|
|
"MQTT_HOST=10.40.0.2" in config_module)
|
|
check("an upgraded one is brought to the same place",
|
|
'BROKER_HOST = "10.40.0.2"' in migrate)
|
|
check("Parent Call tells a missing broker from an unreachable one",
|
|
"has no church network set up" in parent_call
|
|
and "Cannot reach the church network" in parent_call)
|
|
|
|
print()
|
|
print(f"passed: {passed} failed: {failed}")
|
|
raise SystemExit(1 if failed else 0)
|
|
PY
|