#!/bin/bash # Is this machine the classroom? # # One list of properties, run against whatever the lab is currently # holding. A fresh install and a machine upgraded from the old sway or # Hyprland shell have to answer it identically — that is the whole point # of the migration work, and the reason this is a file rather than a # paragraph in a report. # # tools/lab/converged-check the properties # tools/lab/converged-check --restart and again after a session restart # # Every check reads the running machine. Nothing here consults the # repository, so a check cannot pass by agreeing with the source it came # from. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" LAB="$ROOT/tools/lab/lab" NAME="${LAB_NAME:-classroom-v2-lab}" PASS=0 FAIL=0 in_lab() { docker exec "$NAME" bash -c "$1" 2>/dev/null; } # As the session itself reads configuration: the cascade of the running # shell, not one this script made up. session_config() { local dirs dirs=$(in_lab 'p=$(pgrep -x plasmashell | head -1); tr "\0" "\n" < /proc/$p/environ | grep "^XDG_CONFIG_DIRS=" | cut -d= -f2-') docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 -e HOME=/home/classroom \ -e LANG=C.UTF-8 -e "XDG_CONFIG_DIRS=$dirs" "$NAME" \ kreadconfig6 "$@" 2>/dev/null | tr -d '\r' } running() { in_lab "pgrep -x $1 | wc -l"; } # Which process is actually answering org.freedesktop.Notifications. On a # machine that still has mako installed this is the question that matters: # the name is taken by whoever gets there first. notification_owner() { local pid pid=$(in_lab 'XDG_RUNTIME_DIR=/run/user/1000 runuser -u classroom -- \ busctl --user --no-pager call org.freedesktop.DBus /org/freedesktop/DBus \ org.freedesktop.DBus GetConnectionUnixProcessID s org.freedesktop.Notifications' \ | awk '{print $2}') [[ -n "$pid" ]] && in_lab "cat /proc/$pid/comm" } # How many keys KGlobalAccel has bound to an action, asked of the running # shortcut server rather than read out of a file. This is the question # Ctrl+Alt+Delete actually turns on: a file can say anything, and # kglobalacceld is what answers a key press. shortcut_of() { docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 -e HOME=/home/classroom \ -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus "$NAME" \ busctl --user --no-pager call org.kde.kglobalaccel /kglobalaccel \ org.kde.KGlobalAccel shortcut as 4 "$1" "$2" "" "" 2>/dev/null } # How many keys are bound. "ai 0" is an empty array: nothing answers. shortcut_keys() { shortcut_of "$@" | awk '{print $2}'; } # And which key, as Qt's combined key code, for the chords that have to # keep working. shortcut_key() { shortcut_of "$@" | awk '{print $3}'; } # polkit's own verdict on a running process, which is the exact question # a teacher's session asks when something offers to suspend the machine. # # allowed it would go ahead # challenge it would ask for an administrator's password, which no # teacher has — this is what reboot and shut down already do # denied polkit refuses outright, which is the classroom rule # # Asked about a pid rather than by running pkcheck as somebody, because # the subject polkit judges is the calling process and its session. polkit_verdict() { in_lab "/usr/bin/pkcheck --action-id $2 --process $1 >/dev/null 2>&1; case \$? in 0) echo allowed ;; 1) echo denied ;; *) echo challenge ;; esac" } teacher_session() { in_lab 'pgrep -x plasmashell | head -1'; } # What logind tells the classroom user it may do, which is what Plasma # reads before deciding whether to draw a button. # # yes / challenge the operation is on offer # no policy refuses it # na this machine cannot do it at all # # A teacher must never see the first two. The lab container cannot # hibernate whatever the policy says, so it answers "na" there and "no" on # a real machine; both mean refused, and the polkit checks above are what # actually prove the rule. logind_offers() { case "$(in_lab "runuser -u classroom -- busctl --no-pager call org.freedesktop.login1 \ /org/freedesktop/login1 org.freedesktop.login1.Manager $1" | cut -d'"' -f2)" in yes|challenge) echo offered ;; *) echo refused ;; esac } # logind answers for the running configuration, not for a file on disk. logind_property() { in_lab "busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ org.freedesktop.login1.Manager $1" | cut -d'"' -f2 } APPLETS=/home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc check() { # check DESCRIPTION EXPECTED ACTUAL if [[ "$2" == "$3" ]]; then printf ' \033[32mPASS\033[0m %-52s %s\n' "$1" "$3" PASS=$((PASS + 1)) else printf ' \033[31mFAIL\033[0m %-52s got [%s] want [%s]\n' "$1" "$3" "$2" FAIL=$((FAIL + 1)) fi } section() { printf '\n%s\n' "$1"; } run_checks() { section "the session" check "kwin_wayland running" 1 "$(running kwin_wayland)" check "plasmashell running" 1 "$(running plasmashell)" section "the old shell is gone" check "waybar not running" 0 "$(running waybar)" check "mako not running" 0 "$(running mako)" # The bracket keeps the pattern from matching the command that carries it. check "classroom dock not running" 0 "$(in_lab 'pgrep -f "[c]lassroom-dock" | wc -l')" # The lab's own outer compositor is sway, so the question is whether any # sway is running that is not the harness's. check "no sway but the lab harness's" 0 \ "$(in_lab 'pgrep -x sway | while read -r p; do tr "\0" " " < /proc/$p/cmdline | grep -q outer-sway.conf || echo x; done | grep -c x || true')" check "Hyprland not running" 0 "$(running Hyprland)" check "waybar activation removed" 0 \ "$(in_lab 'find /etc/systemd/user /etc/xdg/systemd/user /home/classroom/.config/systemd/user -name "waybar.service" -path "*.wants/*" 2>/dev/null | wc -l')" check "mako activation removed" 0 \ "$(in_lab 'find /etc/systemd/user /etc/xdg/systemd/user /home/classroom/.config/systemd/user -name "mako.service" -path "*.wants/*" 2>/dev/null | wc -l')" check "waybar masked" masked "$(in_lab 'XDG_RUNTIME_DIR=/run/user/1000 runuser -u classroom -- systemctl --user is-enabled waybar.service || true')" check "mako masked" masked "$(in_lab 'XDG_RUNTIME_DIR=/run/user/1000 runuser -u classroom -- systemctl --user is-enabled mako.service || true')" check "no login shell starting a compositor" 0 \ "$(in_lab 'grep -lE "exec (sway|Hyprland|start-hyprland|classroom-session)" /home/classroom/.bash_profile 2>/dev/null | wc -l')" check "no tty1 autologin drop-in" 0 \ "$(in_lab 'ls /etc/systemd/system/getty@tty1.service.d/autologin.conf 2>/dev/null | wc -l')" check "no autostart entry for the old shell" 0 \ "$(in_lab 'grep -lE "^Exec=.*(waybar|mako|classroom-dock)" /home/classroom/.config/autostart/*.desktop 2>/dev/null | wc -l')" section "device.conf" check "COMPOSITOR removed" 0 "$(in_lab 'grep -c "^COMPOSITOR=" /etc/ekklesia-classroom/device.conf || true')" check "not on the hyprland channel" 0 "$(in_lab 'grep -c "^CHANNEL=hyprland" /etc/ekklesia-classroom/device.conf || true')" section "the configuration cascade" check "classroom settings in the session's cascade" 1 \ "$(in_lab 'p=$(pgrep -x plasmashell | head -1); tr "\0" "\n" < /proc/$p/environ | grep -c "^XDG_CONFIG_DIRS=.*ekklesia-classroom/xdg"')" check "look and feel authoritative" org.ekklesia.classroom.desktop \ "$(session_config --file kdeglobals --group KDE --key LookAndFeelPackage)" check "and immutable" 1 \ "$(in_lab 'grep -c "^LookAndFeelPackage\[\$i\]=" /etc/ekklesia-classroom/xdg/kdeglobals')" check "the palette is the packaged one" 20,28,32 \ "$(session_config --file kdeglobals --group 'Colors:Window' --key BackgroundNormal)" section "the panel" check "applet count" 10 "$(in_lab "grep -c '^plugin=' $APPLETS")" check "Parent Call launcher" 1 \ "$(in_lab "grep -c 'ekklesia-parent-call.desktop' $APPLETS")" check "quick launch, one widget" 1 \ "$(in_lab "grep -c '^plugin=org.kde.plasma.quicklaunch' $APPLETS")" check "the applications are the room's" 1 \ "$(in_lab "grep -c 'launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop' $APPLETS")" check "network, volume, notifications, clock" 4 \ "$(in_lab "grep -cE '^plugin=org.kde.plasma.(networkmanagement|volume|notifications|digitalclock)$' $APPLETS")" check "no kickoff" 0 "$(in_lab "grep -c 'plugin=org.kde.plasma.kickoff' $APPLETS")" check "no pager" 0 "$(in_lab "grep -c 'plugin=org.kde.plasma.pager' $APPLETS")" check "no system tray" 0 "$(in_lab "grep -c 'systemtray' $APPLETS")" check "no discover or system settings" 0 \ "$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")" check "the packaged wallpaper" 1 \ "$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")" section "it will not strand a teacher" check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)" check "no lock on resume" false "$(session_config --file kscreenlockerrc --group Daemon --key LockOnResume)" check "the lock action is forbidden" false \ "$(session_config --file kdeglobals --group 'KDE Action Restrictions' --key action/lock_screen)" check "power button does nothing" 0 \ "$(session_config --file powermanagementprofilesrc --group AC --group HandleButtonEvents --key powerButtonAction)" check "no idle suspend, dim or blank configured" 0 \ "$(in_lab 'grep -cE "^\[(AC|Battery|LowBattery)\]\[(SuspendSession|DimDisplay|DPMSControl)\]" /etc/ekklesia-classroom/xdg/powermanagementprofilesrc || true')" check "and the profiles are immutable" 3 \ "$(in_lab 'grep -cE "^\[(AC|Battery|LowBattery)\]\[\\\$i\]" /etc/ekklesia-classroom/xdg/powermanagementprofilesrc || true')" check "logind ignores the power key" ignore "$(logind_property HandlePowerKey)" check "logind ignores a long press" ignore "$(logind_property HandlePowerKeyLongPress)" check "logind has no idle action" ignore "$(logind_property IdleAction)" check "logind ignores the lid" ignore "$(logind_property HandleLidSwitch)" section "Ctrl+Alt+Delete, and the sleep states" # The chord a teacher used to hibernate a room. check "Ctrl+Alt+Delete is bound to nothing" 0 "$(shortcut_keys ksmserver 'Log Out')" for action in 'Log Out Without Confirmation' LogOut 'Shut Down' Reboot \ 'Reboot Without Confirmation' 'Halt Without Confirmation'; do check "ksmserver/$action unbound" 0 "$(shortcut_keys ksmserver "$action")" done for action in Sleep Hibernate PowerDown PowerOff; do check "powerdevil/$action unbound" 0 "$(shortcut_keys org_kde_powerdevil "$action")" done # And the room's own chords are not collateral damage. The shortcut file # is replaced wholesale at login, so this is the check that says the # maintenance route survived it. 503316555 is Meta+Ctrl+Alt+Shift+K. check "the maintenance chord still answers" 503316555 \ "$(shortcut_key ekklesia-admin-terminal.desktop _launch)" check "the display chord still answers" 503316548 \ "$(shortcut_key ekklesia-display-settings.desktop _launch)" # The greeter itself, with nothing to start it. check "the session screen cannot be activated" 1 \ "$(in_lab 'grep -c "^Exec=/bin/false" /usr/local/share/dbus-1/services/org.kde.LogoutPrompt.service 2>/dev/null || true')" # The policy layer, asked of the teacher's own running session. # # Hybrid sleep and suspend-then-hibernate have no polkit action of their # own in systemd 257: logind authorises both through the hibernate action, # so denying hibernate denies all three. The rule names the other two # spellings anyway, in case a future systemd splits them out. TEACHER="$(teacher_session)" for action in suspend suspend-multiple-sessions suspend-ignore-inhibit \ hibernate hibernate-multiple-sessions hibernate-ignore-inhibit; do check "the teacher's session may not $action" denied \ "$(polkit_verdict "$TEACHER" "org.freedesktop.login1.$action")" done # What the desktop is told, which is why no button is drawn for it. check "logind offers the classroom no suspend" refused "$(logind_offers CanSuspend)" check "logind offers the classroom no hibernate" refused "$(logind_offers CanHibernate)" check "logind offers the classroom no hybrid sleep" refused \ "$(logind_offers CanHybridSleep)" check "logind offers the classroom no suspend-then-hibernate" refused \ "$(logind_offers CanSuspendThenHibernate)" # AND THE MACHINE IS NOT CRIPPLED FOR THE PERSON WHO MAINTAINS IT. # # The rule is scoped to the classroom user by name, so every other account # has to be able to do all of it. pid 1 stands in for a root process. for action in suspend hibernate reboot power-off; do check "a root process may still $action" allowed \ "$(polkit_verdict 1 "org.freedesktop.login1.$action")" done # And the two the classroom user was deliberately NOT denied: ending up at # an administrator's password prompt is the existing behaviour, and it is # not this release's business to change it. for action in reboot power-off; do check "the classroom is not newly denied $action" challenge \ "$(polkit_verdict "$TEACHER" "org.freedesktop.login1.$action")" done check "sleep is not masked machine-wide" 0 \ "$(in_lab 'systemctl is-enabled sleep.target suspend.target hibernate.target 2>/dev/null | grep -c masked || true')" section "lockdown still in force" for key in run_command shell_access logout; do check "$key" false "$(session_config --file kdeglobals --group 'KDE Action Restrictions' --key "$key")" done check "the panel cannot be edited" false \ "$(session_config --file kdeglobals --group 'KDE Action Restrictions' --key plasma/plasmashell/unlockedDesktop)" section "autologin" check "sddm enabled" enabled "$(in_lab 'systemctl is-enabled sddm.service || true')" # Not a reading of the configuration: what SDDM actually did at the last # boot of this machine. It authenticates the classroom through PAM and # launches the session; the lab has no display hardware for the session to # land on, so everything past this point is a hardware test. check "sddm autologged the classroom in at boot" yes \ "$(in_lab 'journalctl -u sddm --no-pager 2>/dev/null | grep -q "Authentication for user .classroom. successful" && echo yes || echo no')" check "and chose the Plasma session" yes \ "$(in_lab 'journalctl -u sddm --no-pager 2>/dev/null | grep -q "wayland-sessions/plasma.desktop. selected" && echo yes || echo no')" check "autologin user" classroom \ "$(in_lab 'grep -A3 "^\[Autologin\]" /etc/sddm.conf.d/ekklesia-classroom.conf | sed -n "s/^User=//p"')" check "autologin session" plasma \ "$(in_lab 'grep -A3 "^\[Autologin\]" /etc/sddm.conf.d/ekklesia-classroom.conf | sed -n "s/^Session=//p"')" check "the session it names exists" 1 \ "$(in_lab 'ls /usr/share/wayland-sessions/plasma.desktop 2>/dev/null | wc -l')" check "the classroom uid is inside the greeter's range" 1 \ "$(in_lab 'uid=$(id -u classroom); min=$(sed -n "s/^MinimumUid=//p" /etc/sddm.conf.d/ekklesia-classroom.conf); max=$(sed -n "s/^MaximumUid=//p" /etc/sddm.conf.d/ekklesia-classroom.conf); [ "$uid" -ge "$min" ] && [ "$uid" -le "$max" ] && echo 1 || echo 0')" section "notifications belong to Plasma" check "the old daemon cannot be activated" 1 \ "$(in_lab 'grep -c "^Exec=/bin/false" /usr/local/share/dbus-1/services/fr.emersion.mako.service 2>/dev/null || true')" check "and Plasma owns the notification service" plasmashell "$(notification_owner)" section "Wonder Ink is an application with tabs" check "the shell is the classroom's, not a Chrome app window" 0 \ "$(in_lab 'grep -c "^exec google-chrome" /usr/local/bin/classroom-wonderink || true')" check "and it is the Qt WebEngine one" 1 \ "$(in_lab 'grep -c "^from PyQt6.QtWebEngineWidgets import" /usr/local/bin/classroom-wonderink || true')" check "the engine is installed" ok \ "$(in_lab 'QT_QPA_PLATFORM=offscreen python3 -c "import PyQt6.QtWebEngineWidgets; print(\"ok\")" 2>/dev/null')" # The panel matches a window to its launcher by this, and a mismatch is # not an error anywhere — it is silently two buttons instead of one. check "the launcher names the shell's app id" ekklesia-wonderink \ "$(in_lab 'sed -n "s/^StartupWMClass=//p" /usr/share/applications/ekklesia-wonderink.desktop')" # Comments stripped first. The shell has a comment saying Ctrl+T is # deliberately absent, and a check that cannot tell that from a binding # is a check that fails for being right. # One text box exists, in the print preview, for the page range. The # check reads the code outside that class; anywhere else a text box in # this window would be an address bar. check "no code offers a new tab button or an address bar" 0 \ "$(in_lab "sed '/^class PrintPreview/,/^def notify/d; /setCornerWidget(self._controls/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")" # Separate from the ordinary browser, which is the whole reason the split # exists: a teacher's Wonder Ink sign-in and the room's shared YouTube # account must never be in one profile. check "its profile is its own" own \ "$(in_lab 'grep -q "wonderink-shell" /usr/local/bin/classroom-wonderink && echo own || echo shared')" # And the Chrome profile is left where it is, so a rollback to 0.2.0 # finds a teacher's Wonder Ink directory intact. check "and Chrome's old one is not reused" 0 \ "$(in_lab 'grep -c "ekklesia-classroom/wonderink\"" /usr/local/bin/classroom-wonderink || true')" # The Present-view fix: a lesson may start its own video, and only this # profile may. The browser's policy is not touched, which the next two # checks are about. check "cookies are still session-only" 1 \ "$(in_lab 'grep -c "NoPersistentCookies" /usr/local/bin/classroom-wonderink || true')" check "a lesson may start its own video" 1 \ "$(in_lab 'grep -c "PlaybackRequiresUserGesture, False" /usr/local/bin/classroom-wonderink || true')" check "and the browser's policy still says nothing about autoplay" 0 \ "$(in_lab 'grep -ci "autoplay" /etc/opt/chrome/policies/managed/ekklesia-classroom.json || true')" check "the browser is still Chrome" 1 \ "$(in_lab 'grep -c "^exec google-chrome" /usr/local/bin/classroom-browser || true')" check "and the browser is untouched by any of this" 0 \ "$(in_lab 'grep -ci "pyqt\|webengine" /usr/local/bin/classroom-browser || true')" section "a lesson's worksheets come back as tabs" check "worksheets have a directory of Wonder Ink's own" 1 \ "$(in_lab 'grep -c ") / \"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')" check "a finished PDF becomes a tab" 1 \ "$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')" check "checked on its contents, not its name" 1 \ "$(in_lab 'grep -c "head != PDF_MAGIC" /usr/local/bin/classroom-wonderink || true')" # Measured: isFinishedChanged is never emitted for an interrupted # download, so a worksheet that did not arrive said nothing at all. check "terminal states come from stateChanged" 0 \ "$(in_lab 'grep -c "isFinishedChanged.connect" /usr/local/bin/classroom-wonderink || true')" check "which local files may be opened is a list, not a directory" 1 \ "$(in_lab 'grep -c "def may_open" /usr/local/bin/classroom-wonderink || true')" check "and a guard consults it on every navigation" 1 \ "$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')" check "a local document cannot read other local documents" 1 \ "$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')" # Qt's print dialog, and its Print to File chooser, are gone altogether: # the preview offers CUPS printers and nothing else. check "no file chooser behind printing" 0 \ "$(in_lab 'grep -cE "QFileDialog|QPrintDialog" /usr/local/bin/classroom-wonderink || true')" check "nothing downloaded is handed to the desktop to run" 0 \ "$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')" check "and no file browser was added" 0 \ "$(in_lab 'grep -c "QFileDialog" /usr/local/bin/classroom-wonderink || true')" # The separation the whole split exists for: Wonder Ink handles its own # downloads because they arrive through its own profile, not because # anything inspects a filename or watches a directory. check "downloads arrive through Wonder Ink's own profile" 1 \ "$(in_lab 'grep -c "profile.downloadRequested.connect(window.on_download)" /usr/local/bin/classroom-wonderink || true')" check "nothing watches a directory" 0 \ "$(in_lab 'grep -cE "QFileSystemWatcher|inotify" /usr/local/bin/classroom-wonderink || true')" check "and the browser still knows nothing about any of it" 0 \ "$(in_lab 'grep -cE "pdf|download" /usr/local/bin/classroom-browser || true')" section "Wonder Ink can be turned off per device" check "device.conf carries the setting" 1 \ "$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')" check "and it defaults to on" true \ "$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')" # One line the setting switches, since 0.2.3's one-setting-per-application # layout: var WONDERINK = true|false. This asked for the older marker # until 0.2.6.1 and so failed on every lab run in between. check "the layout carries a marker the setting can switch" 1 \ "$(in_lab 'grep -cE "^var WONDERINK = (true|false);" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')" check "and it is switched on for this machine" 1 \ "$(in_lab 'grep -c "^var WONDERINK = true;" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')" section "the room's own parts survived" check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')" check "parent call icon installed" 1 \ "$(in_lab 'ls /usr/share/icons/hicolor/scalable/apps/ekklesia-parent-call.svg 2>/dev/null | wc -l')" check "wonderink installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-wonderink 2>/dev/null | wc -l')" check "the maintenance chord is registered" 1 \ "$(in_lab 'grep -c "^X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+K" /usr/share/applications/ekklesia-admin-terminal.desktop || true')" check "no sudo rule for the classroom" 0 \ "$(in_lab 'grep -rl classroom /etc/sudoers /etc/sudoers.d 2>/dev/null | wc -l')" check "the panel is supervised" always \ "$(in_lab 'XDG_RUNTIME_DIR=/run/user/1000 runuser -u classroom -- systemctl --user show plasma-plasmashell.service -p Restart --value')" check "audio policy active" active \ "$(in_lab 'XDG_RUNTIME_DIR=/run/user/1000 runuser -u classroom -- systemctl --user is-active ekklesia-classroom-audio.service || true')" } run_checks if [[ "${1:-}" == "--restart" ]]; then section "and again after logging out and back in" "$LAB" plasma-down >/dev/null "$LAB" plasma-up >/dev/null sleep 3 run_checks fi printf '\n%s passed, %s failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]]