tools/release-gate.sh is the one gate: a fresh install, and the upgrade from what canary and stable serve, each on a room of that channel, plus any older release rooms are still on (RELEASE_GATE_FROM). The releases it upgrades from are fetched over HTTPS and checked against their manifests, so it needs no credential and a CI runner can run it. update_canary.sh VERSION PACKAGE gates and publishes that file without rebuilding it, since a second build is a second, untested file; the hash is taken before the gate and checked after it. update_stable.sh VERSION SHA256 refuses anything but the file that was approved. Co-Authored-By: Claude Opus 5.5 <[email protected]>
617 lines
32 KiB
Bash
Executable File
617 lines
32 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())
|
|
# Wonder Ink is no longer a Chrome window, so it asks in its own words.
|
|
check("Wonder Ink asks for the same", "showMaximized()" 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 drop-ins only",
|
|
xdg_paths == [
|
|
"/etc/xdg/autostart/ekklesia-classroom-display.desktop",
|
|
"/etc/xdg/autostart/ekklesia-classroom-scale.desktop",
|
|
"/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh",
|
|
])
|
|
# Which displays are on is decided before the scale is applied to them,
|
|
# and XDG autostart order is the filename order in practice.
|
|
check("and the displays are decided before they are scaled",
|
|
xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-display.desktop")
|
|
< xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-scale.desktop"))
|
|
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("a notification's Configure button opens nothing",
|
|
"kcm_notifications=false" in restrictions)
|
|
check("and neither does any other settings module a teacher might reach",
|
|
all(f"{m}=false" in restrictions for m in (
|
|
"kcm_pulseaudio", "kcm_clock", "kcm_kscreen", "kcm_users",
|
|
"kcm_sddm", "kcm_keys", "kcm_bluetooth", "kcm_powerdevilprofilesconfig")))
|
|
check("a locked panel offers no widget settings",
|
|
"plasma/allow_configure_when_locked=false" in restrictions)
|
|
check("and the help center a settings window's Help button opens is not installed",
|
|
"khelpcenter" in re.search(r"^Conflicts: (.*)$", control, re.MULTILINE).group(1))
|
|
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 or keep-above chevron",
|
|
"ButtonsOnRight=X" in
|
|
(root / "config/plasma/xdg/kwinrc").read_text())
|
|
|
|
print()
|
|
print("5. bounded Trixie validation")
|
|
# THE PROPERTY, NOT THE NUMBER.
|
|
#
|
|
# These used to pin "720s" and "candidate-install 360" exactly. When
|
|
# 0.2.6 added LibreOffice the budgets had to grow, and the outer-deadline
|
|
# check went on passing anyway — because the commit message explaining
|
|
# the change still contained the string "720s" in a comment. A test that
|
|
# a comment can satisfy is not testing anything. What matters is that
|
|
# nothing in the gate runs unbounded.
|
|
import re as _re
|
|
|
|
outer = _re.search(r"timeout --signal=TERM --kill-after=\S+ (\d+)s", validation_script)
|
|
check("container has an outer deadline", outer is not None)
|
|
check("and it is a real one, not a token",
|
|
outer is not None and int(outer.group(1)) >= 600)
|
|
|
|
installs = _re.findall(r"run_logged (\S+-install\S*) (\d+) ", validation_script)
|
|
check("every package install has a deadline", len(installs) >= 2)
|
|
check("and each is long enough to install a desktop",
|
|
all(int(seconds) >= 600 for _name, seconds in installs))
|
|
check("nothing in the gate runs unbounded",
|
|
"run_logged" in validation_script
|
|
and validation_script.count("run_logged") >= 4)
|
|
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/release-gate.sh"')
|
|
package_copy = canary.index('cp -n "$package_path"')
|
|
first_upload = canary.index("release_upload_package")
|
|
check("the release gate runs before the repository copy", validation < package_copy)
|
|
check("the release gate runs before any upload", validation < first_upload)
|
|
# A version is checked before anything is built for it, and again after
|
|
# the long gate, immediately before the first upload.
|
|
check("a spent version is refused before the build",
|
|
canary.index("release_check_new_version") < canary.index("build-release.sh"))
|
|
check("and again after the gate",
|
|
validation < canary.rindex("release_check_new_version") < first_upload)
|
|
# A package handed in is the one published: a second build is a second,
|
|
# untested file.
|
|
build_at = canary.index('"$ROOT/tools/build-release.sh"')
|
|
check("a package handed in is not rebuilt",
|
|
canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at
|
|
and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at])
|
|
check("and must say it is the version being published",
|
|
'release_package_version "$package_path")" != "$version"' in canary)
|
|
check("the file is hashed before the gate and checked after it",
|
|
canary.index('hash="$(sha256sum "$package_path"') < validation
|
|
< canary.index("changed while it was being gated") < first_upload)
|
|
check("the repository copy is proven to be the gated file",
|
|
package_copy < canary.index("is not the file that passed the gate") < first_upload)
|
|
|
|
gate = (root / "tools/release-gate.sh").read_text()
|
|
gate_code = "\n".join(line for line in gate.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
check("the gate runs a fresh install",
|
|
'"$ROOT/tools/validate-trixie-release.sh" "$candidate"\n' in gate_code)
|
|
check("and the upgrade from what canary and stable serve, as rooms of each",
|
|
"for channel in canary stable" in gate_code
|
|
and 'VALIDATION_CHANNEL="$channel"' in gate_code)
|
|
check("the stable rooms' older releases can be rehearsed too",
|
|
"VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary)
|
|
check("what it upgrades from is checked against its manifest's checksum",
|
|
'if [[ "$got" != "$from_hash" ]]' in gate_code)
|
|
check("the gate needs no credential and writes nothing to the server",
|
|
"release_remote" not in gate_code and "ssh" not in gate_code
|
|
and "rsync" not in gate_code)
|
|
check("the gate stops at the first failure",
|
|
"set -euo pipefail" in gate and "inherit_errexit" in gate)
|
|
check("the published channel is read back afterwards",
|
|
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
|
|
# 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=${channel}\\$"' in validation_script)
|
|
check("and that channel can be stable as well as canary",
|
|
"VALIDATION_CHANNEL" in validation_script
|
|
and "stable|canary)" in validation_script)
|
|
check("the gate checks the installed version is the package's own",
|
|
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
|
|
check("and asks the installed updater about it",
|
|
"ekklesia-classroom-update --check" in validation_script)
|
|
check("and that an upgrade leaves Presentations off",
|
|
"grep -q '^PRESENTATIONS_ENABLED=false$'" 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("and asks the one command for it",
|
|
"classroom-ui-scale" in browser)
|
|
# Wonder Ink is deliberately NOT told. Chrome on Wayland was measured not
|
|
# to follow the compositor's output scale, which is why the browser has to
|
|
# be handed the number; Qt does follow it. Measured in the lab with
|
|
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
|
|
# client on that session reports devicePixelRatio 2.0 without being told
|
|
# anything. Forcing it as well would apply the scale twice.
|
|
wonderink_code = "\n".join(line for line in wonderink.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
check("Wonder Ink is not told twice",
|
|
"force-device-scale-factor" not in wonderink_code
|
|
and "QT_SCALE_FACTOR" not in wonderink_code)
|
|
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
|
|
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
|
|
# read it at in the browser. The window itself is left to Qt.
|
|
# A technician's command, never a teacher's: it runs in a throwaway
|
|
# profile that something owns (an unowned one crashed it), and it never
|
|
# touches the signed-in profile a teacher may have open beside it.
|
|
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
|
|
check("--diagnose uses an owned, throwaway profile",
|
|
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
|
|
and '"--diagnose" in sys.argv' in wonderink_code)
|
|
check("its pages start at the room's scale, which is the browser's size",
|
|
"room_zoom=ui_scale(config)" in wonderink_code
|
|
and "self._room_zoom * self._level" in wonderink_code)
|
|
check("the clock is sized by the bar, not by a number",
|
|
'autoFontAndSize", true' in layout and "fontSize" not in layout)
|
|
|
|
print()
|
|
print("8. a lesson's worksheets come back as tabs")
|
|
# The workflow itself is driven against a running session; these are the
|
|
# properties of it that can be asserted from the source, and each one is
|
|
# something whose absence was a bug at some point in the lab.
|
|
check("worksheets land in Wonder Ink's own directory",
|
|
'DOWNLOADS = Path(' in wonderink and '"Wonder Ink"' in wonderink)
|
|
check("a finished PDF is opened as a tab",
|
|
"def open_worksheet" in wonderink and "def open_pdf" in wonderink)
|
|
check("and only after its contents are checked",
|
|
'PDF_MAGIC = b"%PDF-"' in wonderink and "head != PDF_MAGIC" in wonderink)
|
|
# isFinishedChanged is never emitted for an interrupted download, which
|
|
# was measured: a worksheet that did not arrive said nothing at all.
|
|
check("terminal states are read from stateChanged, not isFinishedChanged",
|
|
"download.stateChanged.connect" in wonderink
|
|
and "isFinishedChanged.connect" not in wonderink)
|
|
check("an interrupted download says so",
|
|
"DownloadInterrupted" in wonderink and "interruptReasonString" in wonderink)
|
|
check("the path is made into a URL rather than concatenated",
|
|
"QUrl.fromLocalFile" in wonderink)
|
|
|
|
print()
|
|
print("8b. and opening them does not open the machine")
|
|
check("a navigation guard decides every local file",
|
|
"def acceptNavigationRequest" in wonderink and "url.isLocalFile()" in wonderink)
|
|
check("which consults a list of paths the window itself opened",
|
|
"def may_open" in wonderink and "self._openable" in wonderink)
|
|
check("local documents may not read other local documents",
|
|
"LocalContentCanAccessFileUrls, False" in wonderink)
|
|
check("nor reach the network",
|
|
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
|
# Qt's print dialog has a Print to File path with a "..." chooser behind
|
|
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
|
|
# printers and nothing else, and no file dialog exists anywhere in it.
|
|
check("there is no file chooser behind printing",
|
|
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
|
|
check("nothing downloaded is executed",
|
|
"subprocess" not in wonderink.split("def notify")[0]
|
|
and "QDesktopServices" not in wonderink
|
|
and "xdg-open" not in wonderink)
|
|
check("and no general file browser was added",
|
|
"QFileDialog" not in wonderink)
|
|
|
|
print()
|
|
print("8c. Wonder Ink and the browser stay apart")
|
|
# The download handler belongs to the Wonder Ink window and is fed by the
|
|
# Wonder Ink profile. Nothing watches a directory, and nothing asks where
|
|
# a file came from by looking at its name.
|
|
check("downloads are handled through Wonder Ink's own profile",
|
|
"profile.downloadRequested.connect(window.on_download)" in wonderink)
|
|
check("no directory watcher",
|
|
"QFileSystemWatcher" not in wonderink and "inotify" not in wonderink)
|
|
check("the browser is not touched by any of it",
|
|
"wonderink" not in browser.lower().replace("wonder ink", "")
|
|
or "Wonder Ink is a separate application" in browser)
|
|
check("and Chrome's download policy is still unmentioned",
|
|
not any(k.lower().startswith("download") for k in chrome_policy))
|
|
|
|
print()
|
|
print("8d. the 0.2.1 behaviour it must not regress")
|
|
check("cookies are still session-only",
|
|
"NoPersistentCookies" in wonderink)
|
|
check("the Present-view autoplay fix is still there",
|
|
"PlaybackRequiresUserGesture, False" in wonderink)
|
|
check("fullscreen still returns to a maximised window",
|
|
"WindowFullScreen" in wonderink)
|
|
# One text box exists, and it is the print preview's page range. Anywhere
|
|
# else a text box in this window is an address bar.
|
|
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
|
|
# The tab strip has one corner widget, and it is the lesson's size and
|
|
# Print — not the new-tab button a browser puts there.
|
|
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
|
|
check("still no new-tab button or address bar",
|
|
wonderink.count("setCornerWidget(") == 1
|
|
and "setCornerWidget(self._controls" in wonderink
|
|
and "open_tab" not in controls and "QLineEdit" not in controls
|
|
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
|
|
|
|
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)
|
|
# One direction only. A layout offering a player on a machine with no
|
|
# drive is a button that cannot work; a machine with a drive and
|
|
# DVD_ENABLED=false is an administrator's choice and not a fault.
|
|
check("the login says so when a player is offered with no drive",
|
|
"has no drive" in reset)
|
|
check("and reads the layout's own feature line to decide",
|
|
"var DVD = true" 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("7c-iii. the permanent download addresses")
|
|
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
|
|
# that never changes, and still no second record of which version is
|
|
# current: the pointer is a symlink to the package the manifest names.
|
|
# Both channels publish through one function, so the order below is the
|
|
# order for both.
|
|
release_lib = (root / "tools/release-lib.sh").read_text()
|
|
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
|
|
publish = publish[:publish.index("\n}\n\n")]
|
|
publish_code = "\n".join(line for line in publish.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
for channel, script in (("canary", "update_canary.sh"),
|
|
("stable", "update_stable.sh")):
|
|
text = (root / script).read_text()
|
|
check(f"{script} publishes {channel} through the shared function",
|
|
f"release_publish_manifest {channel} " in text)
|
|
check("the pointer is moved to the versioned package",
|
|
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
|
|
# Atomic, so nothing downloads a half-written pointer, and after the
|
|
# manifest, so the pointer can lag it but never lead it — it can never
|
|
# offer a package the channel has not published.
|
|
check("it is swapped by rename rather than in place",
|
|
"mv -Tf latest.deb.new latest.deb" in publish_code)
|
|
check("after the manifest goes up",
|
|
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
|
|
check("and refuses to point at a package that is not there",
|
|
"test -f ../packages/$name" in publish_code)
|
|
check("the manifest is refused unless the server holds exactly that package",
|
|
publish_code.index("sha256sum") < publish_code.index("cat >"))
|
|
check("a package upload never replaces a published file",
|
|
"--ignore-existing" in release_lib)
|
|
|
|
check("and neither script writes a version anywhere but the manifest",
|
|
"latest.json" not in (root / "update_canary.sh").read_text()
|
|
and "latest.json" not in (root / "update_stable.sh").read_text())
|
|
|
|
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
|