diff --git a/config/dbus/fr.emersion.mako.service b/config/dbus/fr.emersion.mako.service new file mode 100644 index 0000000..1c2783c --- /dev/null +++ b/config/dbus/fr.emersion.mako.service @@ -0,0 +1,22 @@ +[D-BUS Service] +# The old notification daemon, made unstartable. +# +# Removing mako's activation from systemd was not enough, and this was +# measured rather than guessed: during the crash-recovery test on a +# migrated machine, the panel was killed, something in the session sent a +# notification while nothing owned org.freedesktop.Notifications, and +# D-Bus started mako from the file mako-notifier ships. The room would +# then have had the old shell's popups on a Plasma desktop, from a daemon +# with no configuration, until the next reboot. +# +# This file shadows that one. The session bus searches XDG_DATA_DIRS in +# order and /usr/local/share comes before /usr/share, which is the same +# mechanism the classroom already uses to shadow a desktop entry — no +# Debian file is touched, and mako-notifier can be upgraded or removed +# without any of this needing to change. +# +# /bin/false is what a machine with no notification daemon installed does +# anyway: the activation fails and the caller is told so. Plasma's own +# service takes the name at startup and never comes through here. +Name=org.freedesktop.Notifications +Exec=/bin/false diff --git a/config/plasma/env/zz-ekklesia-classroom.sh b/config/plasma/env/zz-ekklesia-classroom.sh new file mode 100644 index 0000000..6126f04 --- /dev/null +++ b/config/plasma/env/zz-ekklesia-classroom.sh @@ -0,0 +1,33 @@ +# Put the classroom's settings into the session's configuration cascade. +# +# This is the file the migration failure turned on. Everything the room is +# — the panel layout, the lock screen that must never appear, the idle +# behaviour, the Kiosk restrictions — lives in /etc/ekklesia-classroom/xdg +# and reaches Plasma only if that directory is in XDG_CONFIG_DIRS. It was +# set in /etc/environment.d, which systemd's user manager reads. Measured +# on a real machine and then reproduced in the lab: that is not enough. +# +# startplasma sources /etc/xdg/plasma-workspace/env/*.sh at session start, +# and Debian's env.sh there sets XDG_CONFIG_DIRS from scratch when the +# variable is empty — which it is, because SDDM starts the session through +# PAM, and PAM does not read /etc/environment.d. startplasma then pushes +# its own environment into the user manager, so the value from +# /etc/environment.d was not merely bypassed, it was overwritten for every +# user service as well. The room came up as stock Debian Plasma: kickoff, +# a pager, a system tray, the Debian wallpaper, and a lock screen. +# +# So the classroom sets it where Plasma itself looks. The name sorts after +# env.sh, so this appends to Debian's value rather than replacing it, and +# the whole thing is a no-op if the directory is already there. +# +# The /etc/environment.d file stays: it covers user services that start +# before, or without, a Plasma session. + +case ":${XDG_CONFIG_DIRS:-}:" in + *:/etc/ekklesia-classroom/xdg:*) + ;; + *) + XDG_CONFIG_DIRS="/etc/ekklesia-classroom/xdg:${XDG_CONFIG_DIRS:-/etc/xdg}" + export XDG_CONFIG_DIRS + ;; +esac diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals index b65d87d..7bcfda5 100644 --- a/config/plasma/xdg/kdeglobals +++ b/config/plasma/xdg/kdeglobals @@ -48,7 +48,18 @@ XftSubPixel=rgb [KDE] widgetStyle=Breeze -LookAndFeelPackage=org.ekklesia.classroom.desktop +# One key, marked immutable on its own rather than by freezing the group. +# +# This is the name plasmashell reads to decide which layout script builds a +# panel for a session that has none, so it decides whether the room comes +# up as the classroom or as stock Plasma. A migrated machine reached this +# experiment with a user profile that had been through other desktops, and +# a user's own kdeglobals sits ahead of every system directory in the +# cascade. Immutable here means no leftover profile, and no stray write by +# a settings dialog that does not exist in this room anyway, can answer +# that question differently. The palette below stays writable, because +# classroom-theme is allowed to change it. +LookAndFeelPackage[$i]=org.ekklesia.classroom.desktop SingleClick=false AnimationDurationFactor=0.5 diff --git a/config/plasma/xdg/powermanagementprofilesrc b/config/plasma/xdg/powermanagementprofilesrc index 13fc8fa..f497f3a 100644 --- a/config/plasma/xdg/powermanagementprofilesrc +++ b/config/plasma/xdg/powermanagementprofilesrc @@ -1,25 +1,47 @@ -# The desktop half of "the power button does nothing". +# The classroom never sleeps, never dims, and never locks. # -# PowerDevil takes logind's handle-power-key inhibitor while a session is -# running, so the logind drop-in on its own would be overruled by -# whatever this says. 0 is no action. +# Two failures on real hardware are behind this file. The power button put +# a room to sleep; and a machine left alone came back to a password prompt +# on an account that is meant to have no password, which strands the +# television until somebody arrives with SSH. # -# Immutable, so nothing in the session can put an action back. +# PowerDevil decides all of it. It also takes logind's handle-power-key +# inhibitor while a session is running, so the logind drop-in on its own +# would be overruled by whatever this says. +# +# Every profile is marked immutable at the profile level, not just on the +# subgroups. PowerDevil loads an idle action only when the profile has a +# group for it, so what matters here is as much what is absent — no +# DimDisplay, no DPMSControl, no SuspendSession — as what is present, and +# a writable profile is one a leftover user file can add those back to. +# classroom-desktop-reset removes the user's copy at every login for the +# same reason. # # Every profile gets the same answer. A classroom machine has no battery, # but a profile that exists and disagrees is worse than one that does not # exist at all. +# +# 0 is no action. + +[AC][$i] +icon=battery-charging [AC][HandleButtonEvents][$i] powerButtonAction=0 lidAction=0 triggerLidActionWhenExternalMonitorPresent=false +[Battery][$i] +icon=battery-060 + [Battery][HandleButtonEvents][$i] powerButtonAction=0 lidAction=0 triggerLidActionWhenExternalMonitorPresent=false +[LowBattery][$i] +icon=battery-low + [LowBattery][HandleButtonEvents][$i] powerButtonAction=0 lidAction=0 diff --git a/config/sddm/ekklesia-classroom.conf b/config/sddm/ekklesia-classroom.conf index 1516fde..8a0d0e7 100644 --- a/config/sddm/ekklesia-classroom.conf +++ b/config/sddm/ekklesia-classroom.conf @@ -19,6 +19,11 @@ DisplayServer=wayland InputMethod= [Users] -MaximumUid=1000 +# Wide enough to include a classroom account that was created after +# another user. The first migrated room had uid 1001 and would not have +# appeared in the greeter at all; autologin looks the name up directly and +# still worked, but a room that ever falls back to the greeter must show +# the account it is meant to log in as. MinimumUid=1000 +MaximumUid=1999 HideShells=/usr/sbin/nologin,/bin/false diff --git a/config/systemd/logind-ekklesia-classroom.conf b/config/systemd/logind-ekklesia-classroom.conf index de1a2b4..7339e35 100644 --- a/config/systemd/logind-ekklesia-classroom.conf +++ b/config/systemd/logind-ekklesia-classroom.conf @@ -32,3 +32,9 @@ HandleRebootKeyLongPress=ignore HandleLidSwitch=ignore HandleLidSwitchExternalPower=ignore HandleLidSwitchDocked=ignore + +# logind's own idle timer, which is separate from PowerDevil's and runs +# whether or not a session is up. ignore is the default, and a classroom +# is not a machine to leave a default of this importance implicit on. +IdleAction=ignore +IdleActionSec=0 diff --git a/docs/classroom-v2-experiment.md b/docs/classroom-v2-experiment.md index 828da07..34a547a 100644 --- a/docs/classroom-v2-experiment.md +++ b/docs/classroom-v2-experiment.md @@ -937,6 +937,165 @@ ekklesia-classroom-tv.service small, supervised, ours adapters arrive today; the commands get measured before anything is built on them. +## The upgrade that failed + +The clean-install result was real and the migration was not. A classroom +machine that had been through the sway build and then the Hyprland +experiment took the 0.1.45 package and came up as **stock Debian Plasma**: +kickoff, a pager, a full system tray, the Debian wallpaper, the old Waybar +still across the top, mako still catching notifications — and then a lock +screen, on an account that has no password. Somebody had to give the +appliance account a password over SSH to get back in. + +None of that was visible here, because the lab had been answering an +easier question than the one a room asks. + +### Reproducing it + +One line of the harness: + +``` +tools/lab/lab plasma-up: -e XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg +``` + +The lab had been handing the session under test the very setting whose +delivery was the thing in doubt. Removing it and starting the session the +way a machine does reproduced the failure exactly: 23 applets, kickoff, +pager, system tray, Debian wallpaper — `docs/screenshots/migration-before.png` +is that screen, and it is the teacher's screen. + +### Root causes + +**1. The classroom's settings never reached the session.** Everything the +room is — the panel layout, the lock screen that must never appear, the +idle behaviour, every Kiosk restriction — lives in +`/etc/ekklesia-classroom/xdg`, and reaches Plasma only if that directory +is in `XDG_CONFIG_DIRS`. It was set in `/etc/environment.d`, which +systemd's **user manager** reads. SDDM starts the session through PAM, and +PAM does not read `/etc/environment.d`. Debian's own +`/etc/xdg/plasma-workspace/env/env.sh` then set the variable from scratch, +because it was empty, and `startplasma` pushed its own environment back +into the user manager — so the value was not merely bypassed, it was +overwritten for every user service too. Measured in the session that came +up: + +``` +XDG_CONFIG_DIRS=/home/classroom/.config/kdedefaults:/etc/xdg:/usr/share/desktop-base/kf5-settings +LookAndFeelPackage = org.debian.desktop +Autolock = (unset, so on) +shell_access = (unset, so allowed) +``` + +The stock panel, the lock screen and the absence of every Kiosk +restriction are all that one line. + +**2. Deleting the panel configuration made it worse.** The desktop reset +removes `plasma-org.kde.plasma.desktop-appletsrc` before the shell starts +so that the bar always comes from the package. With the look and feel +unresolvable, Plasma rebuilt the **Debian** default layout instead. The +reset was correct and was pointed at the wrong answer. + +**3. Naming a colour scheme does not produce one.** `ColorScheme=EkklesiaDark` +is a label; KConfig reads the actual colours from `[Colors:*]` groups in +kdeglobals, and nothing was putting them there except a technician having +run `classroom-theme` once by hand. The clean-install screenshots looked +right for a reason the package did not own. A machine with no user +profile got Breeze light — confirmed here the moment a profile was +cleared. + +**4. A stale user profile outranks the package.** `~/.config/kdeglobals` +sits ahead of every system directory, and `~/.config/kdedefaults` ahead of +ours. A migrated machine carried a Breeze-light palette in both. + +**5. The old shell was still being started.** `waybar.service` was +activated through `graphical-session.target.wants` — and, found only by +running the crash-recovery suite on a migrated machine, **mako came back +through D-Bus**: the panel was killed, something sent a notification while +nothing owned `org.freedesktop.Notifications`, and D-Bus started mako from +the file `mako-notifier` ships. Deactivating the systemd unit would not +have been enough. + +And one more that had not bitten yet: `device.conf` still said +`CHANNEL=hyprland`, so the next release published on that channel would +have handed the room the Hyprland experiment back. + +### The fix + +| | | +| --- | --- | +| `config/plasma/env/zz-ekklesia-classroom.sh` | new. `/etc/xdg/plasma-workspace/env/` is sourced by `startplasma` for every session; the name sorts after Debian's `env.sh`, so it appends rather than replaces, and it is a no-op if the directory is already there. This is the root fix. | +| `config/plasma/xdg/kdeglobals` | `LookAndFeelPackage[$i]` — per-entry immutability, so no leftover profile and no stray write can answer that question differently. The palette stays writable, because `classroom-theme` may change it. | +| `tools/build-kdeglobals` | new. Composes the shipped kdeglobals from the session policy **and** `EkklesiaDark.colors`, so the room is dark because the package says so. One source of truth: it composes, it does not duplicate. | +| `scripts/classroom-desktop-reset` | asserts the cascade and repairs it for the shell about to start if it is wrong; removes another desktop's look and feel defaults and a foreign palette; keeps an Ekklesia one; clears a user power profile. | +| `scripts/classroom-migrate` | new. Stops, deactivates and masks the old shell's units, removes activation from all three directories it can live in, removes hand-written unit files that no Debian package owns, clears old autostart entries, removes the login shell that used to start sway, and migrates `device.conf`. Run from `postinst` on every install. | +| `config/dbus/fr.emersion.mako.service` | new. Shadows mako's D-Bus activation from `/usr/local/share`, where the session bus looks first. `Exec=/bin/false`, which is what a machine with no notification daemon does anyway. | +| `config/plasma/xdg/powermanagementprofilesrc` | profiles immutable at the profile level, not just on the button subgroups: 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. | +| `config/systemd/logind-ekklesia-classroom.conf` | `IdleAction=ignore` as well. | +| `config/sddm/ekklesia-classroom.conf` | uid range 1000–1999. The first migrated room's classroom account was uid 1001 and would not have appeared in the greeter at all. | + +### What device.conf means now + +`COMPOSITOR` and `HYPR_APPEARANCE` are removed on upgrade. There is one +desktop stack and nothing left for them to choose; a stale value is an +instruction this package cannot carry out. + +`CHANNEL=hyprland` becomes `CHANNEL=classroom-v2`. Not `stable` and not +`canary`: both are the sway line, and a release published there would +convert a v2 machine back. **That channel is not published yet**, so a +migrated machine takes no updates until it is — which is the honest state +of an unpublished experiment, and better than being quietly reverted. +Everything else in the file, the broker password included, is rewritten +line by line and byte-for-byte. + +### The tests that would have caught it + +- `tools/lab/lab plasma-up` no longer sets `XDG_CONFIG_DIRS`. The package + has to put it there, the way it has to on a machine. +- `tools/lab/lab reboot` — a real restart of the machine under test: new + PID 1, new user manager, every drop-in and generator read again. **SDDM + autologin genuinely runs here**: it authenticates the classroom through + PAM and launches the Plasma session, and then has no display hardware to + draw on. Both scenarios now go through it. +- `tools/lab/converged-check` — 60 properties of a converged machine, + asked of the running system and never of the repository. Both scenarios + run the same list, because a fresh install and a migrated one have to be + indistinguishable from a teacher's chair. +- `tools/lab/dirty-upgrade` — the Hyprland package actually installed, + with sway, waybar and mako behind it; Waybar activated in all three + directories; an autostart entry; a device.conf asking for a compositor + that is gone; a Breeze-light profile in both places that outrank the + package; a stock panel already built; and a user power profile that + would suspend the room. Then the new package, a reboot, and the + questions. +- `tests/test-migration.sh` — 41 source-level checks, one per reason the + failure happened. + +### Results + +| | | +| --- | --- | +| dirty upgrade → converged | 60 / 60 | +| installed a second time over itself | 60 / 60, device.conf unchanged | +| clean install → converged | 60 / 60 | +| panel killed eight times | back every time, 2.2–7.4s | +| compositor killed twice | 0.1–0.4s | +| audio policy killed three times | active every time | +| the room's light theme across a logout | survived | +| every test script | 11 / 11 | + +`docs/screenshots/migration-after.png` and `v2-from-package.png` are the +migrated machine and the fresh one. They are the same desktop. + +### One regression this pass made and caught + +The first version of the reset deleted `~/.config/kdedefaults` +unconditionally. Plasma treats that directory as the record of which look +and feel is in force, so deleting it made Plasma re-apply the look and +feel at every login — which throws away the room's chosen palette. +Measured: `classroom-theme light`, log out, and the bar came back dark. +The rule is now whose the defaults are: the classroom's are kept, another +desktop's are removed. + ## Unresolved risks In the order I would worry about them. @@ -960,10 +1119,13 @@ In the order I would worry about them. tries disturbs the session enough to close the card. The name was typed through a lab-only `--child` argument. **Type a name on the bench before a Sunday.** -4. **SDDM is configured and untested.** It cannot be exercised in a - container, and it is the thing that starts the session. If the config is - wrong the room boots to nothing. This is the first thing to check on a - VM or a bench machine, and it is why the checklist below starts there. +4. **SDDM has never put a picture on a screen.** More is known than + before: in the lab it really does autologin, authenticating the + classroom through PAM and launching the Plasma session — that is now + one of the checks. What it cannot do in a container is find a display + to draw on, so everything from the compositor's first frame onwards is + still a bench test. It remains the first thing to check on a VM or a + bench machine. 5. **No real audio hardware has been through the policy.** The decision is well tested; applying it is not. A wrong `pactl set-card-profile` on real hardware is a silent room. @@ -988,7 +1150,17 @@ In the order I would worry about them. registered from the packaged entry and its action opens a shell — verified over D-Bus — but key injection cannot reach the nested session, so `Meta+Ctrl+Alt+Shift+K` itself is unpressed. -13. **Ctrl+Alt+T is still registered.** Konsole's own entry declares it +13. **Idle behaviour is configured and unobserved.** No suspend, no dim, + no blank and no lock are all now in the package, immutable, and read + back correctly from a migrated session. Nothing in a container goes + idle, so none of it has been watched not happening. **Leave a bench + machine alone for an hour.** +14. **The v2 channel does not exist yet.** A migrated machine is moved to + `CHANNEL=classroom-v2` so that a stable or Hyprland release cannot + take it back, and nothing is published there. Until it is, a v2 + machine takes no updates at all. That is deliberate; it is also a + thing to remember before leaving one in a room. +15. **Ctrl+Alt+T is still registered.** Konsole's own entry declares it and the registration survives being shadowed. It is inert, because Konsole refuses to open a shell under the classroom's `shell_access=false` — measured — but it rests on that one @@ -1078,6 +1250,16 @@ The existing Debian classroom test VM should be kept. package, hidden from every application list, granting no privilege. * **Long-Escape investigated and recommended against**, with the two clean alternatives written down. +* **The migration failure found on a real room and fixed in the package**: + the settings directory now reaches the session the way Plasma delivers + it, the look and feel cannot be answered for by a stale profile, the + palette is in the package rather than in somebody's home directory, and + the old shell is stopped, deactivated, masked and — the one that was + only found by killing the panel — unable to come back through D-Bus. +* **The lab stopped flattering the package**: it no longer sets the + session's configuration path by hand, it restarts the machine, and it + asks a fresh install and a machine upgraded from the Hyprland + experiment the same sixty questions. Both answer them identically. **Next**, in order: @@ -1090,11 +1272,17 @@ The existing Debian classroom test VM should be kept. disc. These three are the only things left that a container cannot answer, and each of them is a Sunday-morning path. Add the power button and the maintenance chord to that list. -2. Publish `0.1.45~plasma1` to a channel of its own, the way the Hyprland - milestone did, so a stable or canary room can never be offered it. -3. Bench-test SDDM: autologin, and that ending the session brings the - classroom back rather than a shell. -4. When the CEC adapters have been exercised, replace the computer volume +2. Publish the v2 channel. `classroom-migrate` already moves a migrated + room onto `CHANNEL=classroom-v2`, and nothing serves that manifest + yet, so those machines are taking no updates at all. This is now the + first packaging job rather than a tidy-up. +3. Take the upgraded VM through the same list by hand: + `tools/lab/converged-check` is the shape of it, and the things a + container cannot answer are the lock screen never appearing after an + hour alone, and the machine still being there on Monday. +4. Bench-test SDDM on hardware: autologin all the way to a picture, and + that ending the session brings the classroom back rather than a shell. +5. When the CEC adapters have been exercised, replace the computer volume applet with a television control, behind the service boundary in *Television control, and where CEC will fit*. -5. Live View, when the rest is quiet. +6. Live View, when the rest is quiet. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 97b8b31..55fde83 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -77,6 +77,16 @@ container lab. See [../classroom-v2-experiment.md](../classroom-v2-experiment.md | `lock-panel-rmb` | right-clicking the bar: no menu | | `lock-krunner` | KRunner asked for, and not there | +## The migration + +| | | +| --- | --- | +| `migration-before` | what a real room got when it upgraded: stock Debian Plasma, kickoff, a pager, a system tray, the Debian wallpaper. Reproduced in the lab by taking one line out of the harness. | +| `migration-after` | the same machine after the fix — a Hyprland-era install, upgraded, rebooted | +| `migration-after-bar` | its bar, at the size a teacher sees it | +| `migration-parent-call` | Parent Call on the migrated machine, saying it cannot reach the church network rather than pretending it can | +| `theme-light-live`, `theme-light-after-logout` | the room's own light theme, and the same room after logging out and back in. The first attempt at this pass lost it. | + ## Failures, kept | | | diff --git a/docs/screenshots/migration-after-bar.png b/docs/screenshots/migration-after-bar.png new file mode 100644 index 0000000..852242d Binary files /dev/null and b/docs/screenshots/migration-after-bar.png differ diff --git a/docs/screenshots/migration-after.png b/docs/screenshots/migration-after.png new file mode 100644 index 0000000..cc2dd10 Binary files /dev/null and b/docs/screenshots/migration-after.png differ diff --git a/docs/screenshots/migration-before.png b/docs/screenshots/migration-before.png new file mode 100644 index 0000000..8f1bac9 Binary files /dev/null and b/docs/screenshots/migration-before.png differ diff --git a/docs/screenshots/migration-parent-call.png b/docs/screenshots/migration-parent-call.png new file mode 100644 index 0000000..9187a14 Binary files /dev/null and b/docs/screenshots/migration-parent-call.png differ diff --git a/docs/screenshots/theme-light-after-logout.png b/docs/screenshots/theme-light-after-logout.png new file mode 100644 index 0000000..d5ca59c Binary files /dev/null and b/docs/screenshots/theme-light-after-logout.png differ diff --git a/docs/screenshots/theme-light-bar.png b/docs/screenshots/theme-light-bar.png new file mode 100644 index 0000000..9681155 Binary files /dev/null and b/docs/screenshots/theme-light-bar.png differ diff --git a/docs/screenshots/theme-light-live.png b/docs/screenshots/theme-light-live.png new file mode 100644 index 0000000..df1980f Binary files /dev/null and b/docs/screenshots/theme-light-live.png differ diff --git a/docs/screenshots/v2-dark-bar.png b/docs/screenshots/v2-dark-bar.png index c5f342c..51ff4d3 100644 Binary files a/docs/screenshots/v2-dark-bar.png and b/docs/screenshots/v2-dark-bar.png differ diff --git a/docs/screenshots/v2-from-package.png b/docs/screenshots/v2-from-package.png index be8bb50..f0362be 100644 Binary files a/docs/screenshots/v2-from-package.png and b/docs/screenshots/v2-from-package.png differ diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 7f17619..0ca531f 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -8,13 +8,6 @@ if ! id classroom >/dev/null 2>&1; then classroom fi -# The session is started by SDDM now. An upgrade from a sway-era room has -# to remove the login shell that used to start it, or the machine has two -# things trying to own tty1. -rm -f /home/classroom/.bash_profile -rm -f /etc/systemd/system/getty@tty1.service.d/autologin.conf -rmdir --ignore-fail-on-non-empty \ - /etc/systemd/system/getty@tty1.service.d 2>/dev/null || true # Commands the room's own parts install. Listing them by name went wrong # once already, when a command that had been removed was still chmod'ed @@ -44,6 +37,13 @@ PY chown root:classroom /etc/ekklesia-classroom/device.conf 2>/dev/null || true chmod 0640 /etc/ekklesia-classroom/device.conf +# Converge a machine that was something else: the old bar and notification +# daemon stopped and deactivated, the login shell that used to start sway +# removed, and device.conf moved off settings this package cannot carry +# out. Runs on every install, does nothing on a machine that is already +# this classroom. +/usr/local/bin/classroom-migrate || true + systemctl daemon-reload || true # The power button stops working immediately rather than at the next diff --git a/scripts/classroom-desktop-reset b/scripts/classroom-desktop-reset index dcf189c..a47875e 100755 --- a/scripts/classroom-desktop-reset +++ b/scripts/classroom-desktop-reset @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -# Make the panel come from the package, every time. +# Make the desktop come from the package, every time. # # Plasma builds the panel from the classroom's layout script whenever it # starts with no panel configuration, and keeps whatever it built after @@ -17,16 +17,131 @@ set -euo pipefail # # Nothing is lost by this. The panel is immutable to teachers by Kiosk # policy, so there is never anything of theirs in that file. +# +# This runs before plasmashell, as a user service ordered Before it. -CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/plasma-org.kde.plasma.desktop-appletsrc" +CLASSROOM_XDG=/etc/ekklesia-classroom/xdg +CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" + +say() { + echo "classroom-desktop-reset: $1" +} + +# THE CASCADE. +# +# Everything below this line is pointless if Plasma is not reading the +# classroom's settings directory at all: without it the room gets stock +# Plasma, no Kiosk restrictions and a lock screen. The session environment +# is set by /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh, which +# is the mechanism Plasma itself provides. This is the assertion, not the +# mechanism: if the variable arrives here wrong, say so out loud and fix it +# for the shell that is about to start rather than build a wrong desktop +# quietly. +case ":${XDG_CONFIG_DIRS:-}:" in + *:"$CLASSROOM_XDG":*) + ;; + *) + say "XDG_CONFIG_DIRS did not contain $CLASSROOM_XDG — repairing it" + export XDG_CONFIG_DIRS="$CLASSROOM_XDG:${XDG_CONFIG_DIRS:-/etc/xdg}" + systemctl --user set-environment \ + "XDG_CONFIG_DIRS=$XDG_CONFIG_DIRS" 2>/dev/null || true + ;; +esac + +# FOREIGN APPEARANCE STATE. +# +# A classroom machine that has been through another desktop arrives with a +# profile that answers questions the package means to answer. Two places +# sit ahead of the classroom's own directory in KConfig's cascade: +# +# ~/.config/kdedefaults what the last look and feel applied wrote. +# Plasma also treats it as the record of which +# look and feel is in force: remove it and the +# next session applies ours again from scratch, +# which resets the palette to the package +# default. That is right for a machine arriving +# from another desktop and wrong for a room that +# chose the light theme, so whose it is decides. +# +# ~/.config/kdeglobals the user's own file, which beats every system +# directory. plasma-apply-colorscheme expands a +# whole palette into it, so a light Breeze scheme +# from a previous desktop would keep the room +# light no matter what the package said. +# +# The colour scheme is the one piece of appearance a room is allowed to +# choose, through classroom-theme, so an Ekklesia scheme is kept and +# anything else is removed. This is not a wipe of the home directory: two +# named pieces of appearance state, neither of them a teacher's work. + +# The [General] ColorScheme of a KConfig file, or nothing. +scheme_of() { + awk -F= ' + /^\[/ { general = ($0 == "[General]"); next } + general && $1 == "ColorScheme" { print $2; exit } + ' "$1" 2>/dev/null || true +} + +defaults_are_ours=no + +if [[ -d "$CONFIG_HOME/kdedefaults" ]]; then + case "$(scheme_of "$CONFIG_HOME/kdedefaults/kdeglobals")" in + Ekklesia*) + defaults_are_ours=yes + ;; + *) + rm -rf "$CONFIG_HOME/kdedefaults" + say "removed another desktop's look and feel defaults" + ;; + esac +fi + +if [[ -f "$CONFIG_HOME/kdeglobals" ]]; then + scheme=$(scheme_of "$CONFIG_HOME/kdeglobals") + + case "$scheme" in + Ekklesia*) + say "keeping the room's colour scheme ($scheme)" + ;; + "") + # No scheme named. That is what Plasma leaves behind after it + # applies a look and feel — it writes a hash instead — so the + # palette belongs to whichever look and feel last ran, and the + # directory above has just said whose that was. + if [[ "$defaults_are_ours" == yes ]]; then + say "keeping the palette the classroom's look and feel left" + else + rm -f "$CONFIG_HOME/kdeglobals" + say "removed an unnamed palette from another desktop" + fi + ;; + *) + rm -f "$CONFIG_HOME/kdeglobals" + say "removed a foreign palette ($scheme); the package supplies one" + ;; + esac +fi + +# The room's power behaviour is policy, not preference: never sleep, never +# dim, never lock. PowerDevil loads an idle action when it finds a group +# for it, and the merged view of this file is where such a group could +# come from, so the user's copy goes at every login. Nothing of a +# teacher's is in it — there is no settings application in this room. +if [[ -f "$CONFIG_HOME/powermanagementprofilesrc" ]]; then + rm -f "$CONFIG_HOME/powermanagementprofilesrc" + say "cleared the user's power settings; the package supplies them" +fi + +CONFIG="$CONFIG_HOME/plasma-org.kde.plasma.desktop-appletsrc" if [[ -e "$CONFIG" ]]; then rm -f "$CONFIG" - echo "classroom-desktop-reset: panel will be rebuilt from the layout" + say "panel will be rebuilt from the layout" else - echo "classroom-desktop-reset: no panel configuration to clear" + say "no panel configuration to clear" fi # The icon cache remembers a name it once failed to find, which outlives # the package that later supplies it. -rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/icon-cache.kcache" 2>/dev/null || true +rm -f "$CACHE_HOME/icon-cache.kcache" 2>/dev/null || true diff --git a/scripts/classroom-migrate b/scripts/classroom-migrate new file mode 100755 index 0000000..d39122c --- /dev/null +++ b/scripts/classroom-migrate @@ -0,0 +1,225 @@ +#!/bin/bash +set -uo pipefail + +# Turn a machine that was something else into this classroom. +# +# A room does not arrive new. It arrives as whatever it was before: the +# sway classroom, the Hyprland experiment, a machine a technician tried +# something on. The package can describe the desktop it wants perfectly +# and still lose to one leftover symlink, which is what happened: a real +# room upgraded to the Plasma appliance and came up with the old Waybar +# across the top, mako still catching notifications, and Plasma's own +# stock panel underneath. +# +# So the installation converges the machine instead of assuming it. +# Everything here is something a classroom package created, or state this +# package can identify as belonging to a classroom shell it replaces. +# Nothing removes a file Debian owns: the old bar and the old notification +# daemon stay installed and simply stop being started. +# +# Idempotent, and safe on a machine that has none of this. Run by hand at +# any time: +# +# sudo classroom-migrate + +CLASSROOM_USER=classroom +DEVICE_CONFIG=/etc/ekklesia-classroom/device.conf + +# The shells this appliance replaces. There is exactly one desktop stack +# now — SDDM, KWin, plasmashell — and every one of these is a component of +# a stack that is gone. +LEGACY_UNITS=(waybar.service mako.service swayidle.service swaybg.service) +LEGACY_PROCESSES=(waybar mako classroom-dock swaybg swayidle) +LEGACY_EXEC_PATTERN='waybar|mako|classroom-dock|classroom-notifications|sway|Hyprland|start-hyprland|classroom-session' + +say() { + echo "[classroom-migrate] $1" +} + +if [[ "$(id -u)" -ne 0 ]]; then + say "must be run as root" + exit 1 +fi + +home=$(getent passwd "$CLASSROOM_USER" | cut -d: -f6) +uid=$(id -u "$CLASSROOM_USER" 2>/dev/null || true) + +# ---------------------------------------------------------------- units + +# Stop what is running now. The upgrade happens underneath a live session, +# so the old bar is on the screen while this runs. +if [[ -n "$uid" && -d "/run/user/$uid" ]]; then + for unit in "${LEGACY_UNITS[@]}"; do + runuser -u "$CLASSROOM_USER" -- env "XDG_RUNTIME_DIR=/run/user/$uid" \ + systemctl --user stop "$unit" >/dev/null 2>&1 || true + done +fi + +for process in "${LEGACY_PROCESSES[@]}"; do + if pkill -u "$CLASSROOM_USER" -x "$process" 2>/dev/null; then + say "stopped $process" + fi +done + +# Remove the activation, wherever it was made. `systemctl --global enable` +# writes into /etc/systemd/user; a session that set XDG_CONFIG_DIRS gets +# /etc/xdg/systemd/user as well; and a unit enabled by hand as the +# classroom user lands in the home directory. The real room had it in the +# second of those, which is why guessing one location would not have been +# enough. +wants_roots=(/etc/systemd/user /etc/xdg/systemd/user /run/systemd/user) +[[ -n "$home" ]] && wants_roots+=("$home/.config/systemd/user") + +for root in "${wants_roots[@]}"; do + [[ -d "$root" ]] || continue + + for unit in "${LEGACY_UNITS[@]}"; do + while IFS= read -r link; do + rm -f "$link" + say "removed activation ${link#/}" + done < <(find "$root" -name "$unit" -path '*.wants/*' 2>/dev/null) + done +done + +# The unit files themselves, where somebody wrote them by hand. Neither +# waybar nor mako ships a systemd unit in trixie, so a file by one of +# those names belongs to the shell this package replaces — but the rule is +# checked rather than assumed: anything a Debian package owns is left +# exactly where it is. +for root in /etc/systemd/user /etc/xdg/systemd/user "${home:+$home/.config/systemd/user}"; do + [[ -n "$root" && -d "$root" ]] || continue + + for unit in "${LEGACY_UNITS[@]}"; do + [[ -f "$root/$unit" ]] || continue + + if dpkg -S "$root/$unit" >/dev/null 2>&1; then + say "left $root/$unit alone: a Debian package owns it" + continue + fi + + rm -f "$root/$unit" + say "removed the hand-written $root/$unit" + done +done + +# And then say no once more, at the system level, so that a unit file +# reappearing from anywhere cannot start it. A mask is a symlink to +# /dev/null in /etc/systemd/user: visible, reversible, and it removes +# nothing. +systemctl --global mask "${LEGACY_UNITS[@]}" >/dev/null 2>&1 || true + +# And the other way the old notification daemon starts: D-Bus activation. +# The package shadows mako's service file so that activation fails, but a +# session bus that is already running read its service directories at +# startup and has not seen it. Measured during crash recovery on a +# migrated machine: the panel was killed, a notification was sent while +# nothing owned the name, and mako took it. +if [[ -n "$uid" && -S "/run/user/$uid/bus" ]]; then + runuser -u "$CLASSROOM_USER" -- env \ + "XDG_RUNTIME_DIR=/run/user/$uid" \ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus" \ + dbus-send --session --dest=org.freedesktop.DBus --type=method_call \ + /org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig \ + >/dev/null 2>&1 || true +fi + +# ------------------------------------------------------------- autostart + +# XDG autostart entries that start a component of the old shell. Matched on +# what they run, not on their filename, and only in the classroom's own +# directory. +if [[ -n "$home" && -d "$home/.config/autostart" ]]; then + for entry in "$home/.config/autostart"/*.desktop; do + [[ -e "$entry" ]] || continue + + if grep -qE "^Exec=.*($LEGACY_EXEC_PATTERN)" "$entry" 2>/dev/null; then + rm -f "$entry" + say "removed autostart entry $(basename "$entry")" + fi + done +fi + +# ------------------------------------------------------------ tty1 login + +# The sway classroom started its session from the login shell of an +# autologin getty. SDDM owns the session now, and two things owning tty1 +# is how a machine ends up with no usable screen at all. +if [[ -n "$home" && -f "$home/.bash_profile" ]]; then + if grep -qE "exec .*($LEGACY_EXEC_PATTERN)" "$home/.bash_profile" 2>/dev/null; then + rm -f "$home/.bash_profile" + say "removed the login shell that used to start the old session" + fi +fi + +rm -f /etc/systemd/system/getty@tty1.service.d/autologin.conf +rmdir --ignore-fail-on-non-empty \ + /etc/systemd/system/getty@tty1.service.d 2>/dev/null || true + +# --------------------------------------------------------- device.conf + +# The room's own settings file outlives every version of this package, so +# it is edited rather than replaced: the broker password is in it. +# +# COMPOSITOR chose between sway and Hyprland. There is nothing left for it +# to choose, and a stale value is worse than none — it is an instruction +# this package cannot carry out. +# +# CHANNEL decides which manifest the updater fetches. A machine left on the +# hyprland channel would be handed the Hyprland experiment again by the +# next release published there, undoing the upgrade it just took. +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"} +CHANNEL = "classroom-v2" + +path = sys.argv[1] + +with open(path, "r", encoding="utf-8") as handle: + lines = handle.readlines() + +out = [] +changed = [] + +for line in lines: + key = line.split("=", 1)[0].strip() if "=" in line else "" + + if key in OBSOLETE_KEYS: + changed.append(f"dropped {key}") + continue + + if key == "CHANNEL": + value = line.split("=", 1)[1].strip() + + if value.lower() in OBSOLETE_CHANNELS: + changed.append(f"moved off the {value} channel onto {CHANNEL}") + line = f"CHANNEL={CHANNEL}\n" + + out.append(line) + +if changed: + with open(path, "w", encoding="utf-8") as handle: + handle.writelines(out) + + for note in changed: + print(f"[classroom-migrate] device.conf: {note}") +PY +fi + +# ------------------------------------------------------- the old session + +# Plasma reads the classroom's settings out of a directory that has to be +# in XDG_CONFIG_DIRS, and the environment.d file that used to put it there +# never reached a session started by SDDM. The session script that does is +# installed by the package; this only checks it arrived, because a room +# without it is a room with no lockdown and no panel of ours. +if [[ ! -r /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh ]]; then + say "WARNING: the session environment script is missing;" + say " the desktop will come up as stock Plasma" +fi + +say "done" diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index de6b184..70d3a3f 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -99,7 +99,15 @@ print("4b2. the session settings do not fight another package") # 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) -check("nothing is installed into /etc/xdg", "$STAGE/etc/xdg/" not 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 = re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build) +check("the only file under /etc/xdg is the session env drop-in", + xdg_paths == ["/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh"]) +check("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()) @@ -132,8 +140,15 @@ 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]") == 3) + 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", diff --git a/tests/test-migration.sh b/tests/test-migration.sh new file mode 100755 index 0000000..8caa312 --- /dev/null +++ b/tests/test-migration.sh @@ -0,0 +1,208 @@ +#!/bin/bash +set -uo pipefail + +# The upgrade path, as far as source can be checked without a machine. +# +# A real room upgraded to the Plasma classroom and came up as stock Debian +# Plasma with the old Waybar still across the top and a lock screen waiting +# for a password the account does not have. Every check here is one of the +# reasons that happened. The lab scenario in tools/lab/dirty-upgrade is the +# other half: this one guards the package, that one guards the machine. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +import sys +from pathlib import Path + +root = Path(sys.argv[1]) + +env_script = (root / "config/plasma/env/zz-ekklesia-classroom.sh").read_text() +build = (root / "tools/build-release.sh").read_text() +postinst = (root / "packaging/debian/postinst").read_text() +migrate = (root / "scripts/classroom-migrate").read_text() +reset = (root / "scripts/classroom-desktop-reset").read_text() +kdeglobals = (root / "config/plasma/xdg/kdeglobals").read_text() +power = (root / "config/plasma/xdg/powermanagementprofilesrc").read_text() +locker = (root / "config/plasma/xdg/kscreenlockerrc").read_text() +logind = (root / "config/systemd/logind-ekklesia-classroom.conf").read_text() +sddm = (root / "config/sddm/ekklesia-classroom.conf").read_text() +lab = (root / "tools/lab/lab").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 + + +print("the session environment") + +# The root cause. /etc/environment.d is read by systemd's user manager; +# SDDM starts the session through PAM, which is not systemd, so the +# classroom's settings directory never reached Plasma. startplasma sources +# /etc/xdg/plasma-workspace/env, which is why the file lives there. +check( + "the classroom directory is set where startplasma looks", + "/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh" in build, +) +check( + "and it is in the package, not only in the lab", + 'config/plasma/env/zz-ekklesia-classroom.sh' in build, +) +check( + "it prepends rather than replaces", + "XDG_CONFIG_DIRS=\"/etc/ekklesia-classroom/xdg:${XDG_CONFIG_DIRS:-/etc/xdg}\"" + in env_script, +) +check( + "and does nothing when the directory is already there", + "*:/etc/ekklesia-classroom/xdg:*" in env_script, +) +check( + "the name sorts after Debian's own env.sh", + Path("config/plasma/env/zz-ekklesia-classroom.sh").name > "env.sh", +) +check( + "environment.d is still shipped for services outside a session", + "/etc/environment.d/90-ekklesia-classroom.conf" in build, +) +check( + "the lab no longer sets the variable for the session under test", + "-e XDG_CONFIG_DIRS=" not in lab, +) + +print("\nthe desktop cannot be answered for by a stale profile") + +check( + "the look and feel package is immutable on its own key", + "LookAndFeelPackage[$i]=org.ekklesia.classroom.desktop" in kdeglobals, +) +check( + "the palette is composed into the shipped kdeglobals", + "tools/build-kdeglobals" in build, +) +check( + "another desktop's look and feel defaults are removed at login", + "removed another desktop's look and feel defaults" in reset, +) +check( + "and the classroom's own are kept, so a chosen theme survives", + "defaults_are_ours=yes" in reset, +) +check( + "a foreign palette is removed and an Ekklesia one kept", + "Ekklesia*)" in reset and 'rm -f "$CONFIG_HOME/kdeglobals"' in reset, +) +check( + "the panel is still rebuilt from the packaged layout every login", + "plasma-org.kde.plasma.desktop-appletsrc" in reset, +) +check( + "and the reset says so out loud if the cascade is wrong", + "XDG_CONFIG_DIRS did not contain" in reset, +) + +print("\nthe old shell converges away") + +check("the migration runs on every install", "classroom-migrate" in postinst) +check("it is in the package", "scripts/classroom-migrate" in build) + +for unit in ("waybar.service", "mako.service"): + check(f"{unit} is deactivated", unit in migrate) + +check( + "activation is removed from all three places it can live", + "/etc/systemd/user" in migrate + and "/etc/xdg/systemd/user" in migrate + and ".config/systemd/user" in migrate, +) +check("and then masked, so a stray unit file cannot start it", "--global mask" in migrate) +check( + "autostart entries are matched on what they run", + "^Exec=" in migrate and "autostart" in migrate, +) +check( + "the login shell that started sway is removed", + ".bash_profile" in migrate, +) +check( + "and the tty1 autologin drop-in with it", + "getty@tty1.service.d/autologin.conf" in migrate, +) +check( + "the old notification daemon cannot be D-Bus activated", + "/usr/local/share/dbus-1/services/fr.emersion.mako.service" in build, +) +check( + "the shadow makes activation fail rather than hang", + "Exec=/bin/false" + in (root / "config/dbus/fr.emersion.mako.service").read_text(), +) +check( + "and a running session bus is told to re-read its services", + "ReloadConfig" in migrate, +) +check( + "nothing removes a Debian package", + "apt-get remove" not in migrate and "apt remove" not in migrate, +) +check( + "device.conf loses the compositor it can no longer choose", + 'OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")' in migrate, +) +check( + "and comes off a channel that would undo the upgrade", + 'OBSOLETE_CHANNELS = {"hyprland"}' in migrate, +) +check( + "the rest of device.conf is rewritten line by line, not regenerated", + "handle.writelines(out)" in migrate, +) + +print("\nnothing puts a password prompt in front of a teacher") + +check("no automatic lock", "Autolock=false" in locker) +check("no lock on resume", "LockOnResume=false" in locker) +check("the lock settings are immutable", "[Daemon][$i]" in locker) +check( + "no idle suspend, dim or blank in any profile", + not any( + f"[{profile}][{action}]" in power + for profile in ("AC", "Battery", "LowBattery") + for action in ("SuspendSession", "DimDisplay", "DPMSControl") + ), +) +check( + "the profiles themselves are immutable, so none can be added back", + all(f"[{profile}][$i]" in power for profile in ("AC", "Battery", "LowBattery")), +) +check( + "a leftover user power profile is cleared at login", + "powermanagementprofilesrc" in reset, +) +check("logind takes no idle action", "IdleAction=ignore" in logind) +check("the power button is still ignored", "HandlePowerKey=ignore" in logind) +check( + "and a long press is still the firmware's business", + "HandlePowerKeyLongPress=ignore" in logind, +) +check( + "the package holds no account credentials", + "passwd" not in postinst and "chpasswd" not in postinst, +) +check( + "a classroom account created after another user still appears", + "MaximumUid=1999" in sddm, +) + +print() +print(f"{passed} passed, {failed} failed") +sys.exit(1 if failed else 0) +PY diff --git a/tools/build-kdeglobals b/tools/build-kdeglobals new file mode 100755 index 0000000..0168017 --- /dev/null +++ b/tools/build-kdeglobals @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Compose the classroom's kdeglobals, colours included. + +The panel came up light on a machine that had never had a colour scheme +applied to it, and dark on one that had. That is the whole bug in one +sentence: `ColorScheme=EkklesiaDark` is a label. KConfig reads the actual +colours out of [Colors:*] groups in kdeglobals, and nothing was putting +them there except a technician running classroom-theme once, by hand, on +a machine that then looked right for reasons the package did not own. + +So the package ships the expanded palette in its own kdeglobals, at the +bottom of the cascade, where a room with no user configuration at all +finds it. One source of truth stays EkklesiaDark.colors: this composes, +it does not duplicate. + +classroom-theme still works. plasma-apply-colorscheme writes the chosen +palette into the user's own kdeglobals, which sits ahead of this file, and +classroom-desktop-reset keeps a user palette whose name is an Ekklesia one. + + tools/build-kdeglobals > somewhere/kdeglobals +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +POLICY = ROOT / "config/plasma/xdg/kdeglobals" +SCHEME = ROOT / "config/plasma/colors/EkklesiaDark.colors" + +# Name belongs to the scheme file, not to a session's configuration, and +# ColorScheme is already stated in the policy file above. +SKIP_IN_GENERAL = {"Name", "ColorScheme"} + + +def groups(text): + """Yield (header, [lines]) for each group in a KConfig file.""" + header = None + body = [] + + for line in text.splitlines(): + if line.startswith("["): + if header is not None: + yield header, body + header, body = line, [] + elif header is not None: + body.append(line) + + if header is not None: + yield header, body + + +def policy_with(extra_general): + """The policy file, with the scheme's stray [General] keys folded in. + + Two [General] headers in one file is asking a parser a question it + should not be asked, so the keys go into the group that is already + there. + """ + lines = POLICY.read_text().rstrip("\n").splitlines() + out = [] + inside = False + + for line in lines: + if line.startswith("["): + if inside: + out.extend(extra_general) + inside = False + + inside = line == "[General]" + + out.append(line) + + if inside: + out.extend(extra_general) + + return "\n".join(out) + + +def main(): + extra_general = [] + + for header, body in groups(SCHEME.read_text()): + if header != "[General]": + continue + + extra_general = [ + line + for line in body + if line.strip() + and not line.startswith("#") + and line.split("=", 1)[0].strip() not in SKIP_IN_GENERAL + ] + + out = [policy_with(extra_general), ""] + + out.append( + "# ------------------------------------------------------------------\n" + "# Generated by tools/build-kdeglobals from EkklesiaDark.colors.\n" + "# Do not edit here: edit the scheme file and rebuild the package.\n" + "#\n" + "# These are the colours themselves. Without them a room with no user\n" + "# configuration gets KDE's built-in light palette however the scheme\n" + "# above is named.\n" + "# ------------------------------------------------------------------" + ) + + for header, body in groups(SCHEME.read_text()): + lines = [line for line in body if line.strip() and not line.startswith("#")] + + if header == "[General]": + # Already folded into the policy file's own [General] above. + continue + + if not lines: + continue + + out.append("") + out.append(header) + out.extend(lines) + + sys.stdout.write("\n".join(out) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/build-release.sh b/tools/build-release.sh index 2c263d5..12bbaa1 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -206,17 +206,32 @@ install -Dm644 \ # Not /etc/xdg: plasma-workspace already owns files by these names there. # The classroom's directory goes in front of it in XDG_CONFIG_DIRS instead. -for setting in kdeglobals kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ +for setting in kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ krunnerrc plasmanotifyrc powermanagementprofilesrc; do install -Dm644 \ "$ROOT/config/plasma/xdg/${setting}" \ "$STAGE/etc/ekklesia-classroom/xdg/${setting}" done +# kdeglobals is composed rather than copied: the session policy plus the +# dark palette itself. Naming a colour scheme does not produce one, and a +# room whose user profile had never had a scheme applied came up light. +install -d "$STAGE/etc/ekklesia-classroom/xdg" +"$ROOT/tools/build-kdeglobals" > "$STAGE/etc/ekklesia-classroom/xdg/kdeglobals" +chmod 0644 "$STAGE/etc/ekklesia-classroom/xdg/kdeglobals" + install -Dm644 \ "$ROOT/config/environment/90-ekklesia-classroom.conf" \ "$STAGE/etc/environment.d/90-ekklesia-classroom.conf" +# The one that actually reaches a session started by a display manager. +# startplasma sources this directory; PAM does not read environment.d, so +# without this file none of the settings above are in the cascade at all +# and the room comes up as stock Plasma. Measured, on a real machine. +install -Dm644 \ + "$ROOT/config/plasma/env/zz-ekklesia-classroom.sh" \ + "$STAGE/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh" + # The power button does nothing. Two layers, because while a session is # running PowerDevil holds logind's inhibitor and would overrule it. install -Dm644 \ @@ -260,6 +275,14 @@ install -Dm644 \ "$ROOT/config/plasma/solid/ekklesia-dvd.desktop" \ "$STAGE/usr/share/solid/actions/ekklesia-dvd.desktop" +# Shadows the old notification daemon's D-Bus activation. Deactivating its +# systemd unit is not enough: D-Bus starts it on demand from a file +# mako-notifier owns, and the crash-recovery test on a migrated machine +# caught it doing exactly that while the panel was down. +install -Dm644 \ + "$ROOT/config/dbus/fr.emersion.mako.service" \ + "$STAGE/usr/local/share/dbus-1/services/fr.emersion.mako.service" + # Two marks the classroom draws itself, because nothing in Breeze says # either thing: a lesson, and fetching a child's grown-up. for icon in ekklesia-wonderink ekklesia-parent-call; do @@ -310,6 +333,12 @@ install -Dm755 \ "$ROOT/scripts/classroom-theme" \ "$STAGE/usr/local/bin/classroom-theme" +# Converges a machine that used to run something else. Called from +# postinst on every install, and by hand by a technician. +install -Dm755 \ + "$ROOT/scripts/classroom-migrate" \ + "$STAGE/usr/local/bin/classroom-migrate" + install -Dm644 \ "$ROOT/lib/classroom_audio.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py" diff --git a/tools/lab/converged-check b/tools/lab/converged-check new file mode 100755 index 0000000..e02544f --- /dev/null +++ b/tools/lab/converged-check @@ -0,0 +1,205 @@ +#!/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" +} + +# 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 "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 "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 ]] diff --git a/tools/lab/dirty-upgrade b/tools/lab/dirty-upgrade new file mode 100755 index 0000000..be5eb34 --- /dev/null +++ b/tools/lab/dirty-upgrade @@ -0,0 +1,236 @@ +#!/bin/bash +# The upgrade that failed, reproduced on purpose. +# +# A real classroom does not arrive new, and the clean-install test was +# passing on a machine that had never been anything else. This builds a +# machine that has: the Hyprland experiment package actually installed, +# with sway, waybar and mako behind it, a Waybar activated through +# graphical-session, an autostart entry, a device.conf still asking for a +# compositor that is gone, and a Plasma profile belonging to a different +# desktop. Then it installs the classroom package over the top and asks +# whether the room converged. +# +# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb] +# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side +# of a logout +# +# The container is restarted between the install and the session, because +# a room takes its upgrade and reboots. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +LAB="$ROOT/tools/lab/lab" +NAME="${LAB_NAME:-classroom-v2-lab}" + +NEW="${1:-$(ls -t "$ROOT"/dist/ekklesia-classroom_*_amd64.deb | grep -v '~' | head -1)}" +OLD="${2:-$ROOT/dist/ekklesia-classroom_0.1.45~hypr3_amd64.deb}" + +step() { printf '\n\033[1m== %s\033[0m\n' "$1"; } + +step "a fresh machine" +"$LAB" up >/dev/null + +docker exec "$NAME" sh -c ' + printf "#!/bin/sh\nexit 101\n" > /usr/sbin/policy-rc.d + chmod +x /usr/sbin/policy-rc.d + apt-get update -qq >/dev/null 2>&1 + # The old package schedules a provisioning run that would go to + # backports for Hyprland. The lab is not testing that. + systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1' + +step "the old classroom: $(basename "$OLD")" +docker exec "$NAME" sh -c " + DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1 + DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1 + DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1 + true" || true +docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom + +step "and everything a used machine accumulates" +docker exec -i "$NAME" bash <<'DIRT' +set -e + +# Waybar and mako, activated the way the real room had them. Neither +# package ships a unit in trixie, so the units themselves are part of what +# somebody added — and one of the three activation directories is the one +# the broken room actually used. +for unit in waybar mako; do + cat > "/etc/systemd/user/$unit.service" < /home/classroom/.config/autostart/waybar.desktop <<'ENTRY' +[Desktop Entry] +Type=Application +Name=Waybar +Exec=waybar +ENTRY + +# The room still asking for a compositor that is gone, with a broker +# password in the same file to prove the migration does not rewrite it. +sed -i 's/^CHANNEL=.*/CHANNEL=hyprland/' /etc/ekklesia-classroom/device.conf +{ + echo "COMPOSITOR=hyprland" + echo "HYPR_APPEARANCE=visual" +} >> /etc/ekklesia-classroom/device.conf +sed -i 's/^MQTT_HOST=.*/MQTT_HOST=broker.example.invalid/' /etc/ekklesia-classroom/device.conf +sed -i 's/^MQTT_PASSWORD=.*/MQTT_PASSWORD=keep-me-exactly/' /etc/ekklesia-classroom/device.conf + +# A Plasma profile from a different desktop: someone else's palette, in +# both of the places that outrank the package. +mkdir -p /home/classroom/.config/kdedefaults +cat > /home/classroom/.config/kdedefaults/kdeglobals <<'KDE' +[General] +ColorScheme=BreezeLight + +[Icons] +Theme=breeze + +[KDE] +LookAndFeelPackage=org.kde.breeze.desktop +KDE +cat > /home/classroom/.config/kdeglobals <<'KDE' +[General] +ColorScheme=BreezeLight + +[Colors:Window] +BackgroundNormal=239,240,241 +ForegroundNormal=35,38,41 + +[KDE] +LookAndFeelPackage=org.kde.breeze.desktop +KDE + +# And a stock panel, already built, of the kind the broken upgrade made. +cat > /home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc <<'PANEL' +[Containments][1] +plugin=org.kde.plasma.folder + +[Containments][2] +plugin=org.kde.panel + +[Containments][2][Applets][3] +plugin=org.kde.plasma.kickoff + +[Containments][2][Applets][4] +plugin=org.kde.plasma.pager + +[Containments][2][Applets][5] +plugin=org.kde.plasma.icontasks + +[Containments][2][Applets][5][Configuration][General] +launchers=applications:systemsettings.desktop,applications:org.kde.discover.desktop,preferred://browser + +[Containments][2][Applets][6] +plugin=org.kde.plasma.systemtray +PANEL + +# A user-level power profile that would suspend the room. +cat > /home/classroom/.config/powermanagementprofilesrc <<'POWER' +[AC][SuspendSession] +idleTime=1800000 +suspendType=1 + +[AC][DPMSControl] +idleTime=600 +POWER + +chown -R classroom:classroom /home/classroom/.config +echo " dirt in place" +DIRT + +step "the new classroom: $(basename "$NEW")" +docker exec "$NAME" sh -c ' + systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true' +docker exec "$NAME" sh -c " + DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' 2>&1 \ + | grep -E '^\[classroom-migrate\]' || true + DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1 + DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1 + rm -f /usr/sbin/policy-rc.d + true" || true +docker exec "$NAME" dpkg-query -W -f=' installed: ${Version} ${db:Status-Abbrev}\n' ekklesia-classroom + +step "and the room reboots" +"$LAB" reboot + +step "the session" +"$LAB" session >/dev/null +sleep 6 + +step "did it converge?" +converged=0 +"$ROOT/tools/lab/converged-check" ${CHECK_ARGS:-} || converged=1 + +step "and again, installed a second time over itself" +# An update that changes nothing must change nothing. The migration runs +# again here, over a machine that is already converged. +docker exec "$NAME" sh -c " + DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1 + true" || true +before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum') +"$LAB" plasma-down >/dev/null +"$LAB" plasma-up >/dev/null +sleep 4 +after=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum') +[[ "$before" == "$after" ]] \ + && echo " device.conf unchanged by the second install" \ + || { echo " DEVICE.CONF CHANGED ON REINSTALL"; converged=1; } +"$ROOT/tools/lab/converged-check" || converged=1 + +step "and the room's own theme choice survives a logout" +# Deleting the look and feel defaults every login was measured to make +# Plasma re-apply the look and feel and throw the room's chosen palette +# away with it. This is the check that stops that coming back. +# With the session's own cascade. plasma-apply-colorscheme writes the +# chosen name into ~/.config/kdedefaults, which is in XDG_CONFIG_DIRS for +# a session and not for a bare command, so asking without it reports +# nothing and means nothing. +theme() { + local dirs + dirs=$(docker exec "$NAME" bash -c '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 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + -e WAYLAND_DISPLAY=wayland-0 -e "XDG_CONFIG_DIRS=$dirs" "$NAME" \ + /usr/local/bin/classroom-theme "$1" 2>/dev/null | tail -1 +} +theme light >/dev/null +"$LAB" plasma-down >/dev/null +"$LAB" plasma-up >/dev/null +sleep 4 +chosen=$(theme show) +if [[ "$chosen" == EkklesiaLight ]]; then + echo " light survived the logout" +else + echo " THE ROOM'S THEME CHOICE WAS LOST: $chosen" + converged=1 +fi +theme dark >/dev/null +echo " back to $(theme show)" + +step "and it still recovers" +"$ROOT/tools/lab/recovery-check" || converged=1 + +exit "$converged" diff --git a/tools/lab/from-package b/tools/lab/from-package index b349fbe..615d407 100755 --- a/tools/lab/from-package +++ b/tools/lab/from-package @@ -27,9 +27,11 @@ docker exec "$NAME" sh -c " state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom) echo "package state: $state" -# environment.d is read when the user manager starts, so it starts after. -docker exec "$NAME" systemctl restart user@1000.service -sleep 4 +# And then the machine is restarted, the way a room takes an update: a +# new PID 1, a new user manager, SDDM autologging the classroom in. The +# session environment, the logind drop-in and the user services are all +# things that are only read at boot. +"$LAB" reboot "$LAB" session >/dev/null sleep 8 @@ -50,3 +52,8 @@ check "panel restart policy" "$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run check "audio policy" "$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 \ -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus "$NAME" \ systemctl --user is-active ekklesia-classroom-audio.service || true)" + +# The same list of properties the upgraded machine has to satisfy. A +# fresh install and a migrated one are supposed to be indistinguishable +# from a teacher's chair, so they are asked the same questions. +"$ROOT/tools/lab/converged-check" ${CHECK_ARGS:-} diff --git a/tools/lab/install-desktop b/tools/lab/install-desktop index c6a9a5c..9916fc0 100755 --- a/tools/lab/install-desktop +++ b/tools/lab/install-desktop @@ -12,16 +12,26 @@ SRC="${1:-/srv/classroom}" # A directory of the classroom's own, ahead of /etc/xdg in # XDG_CONFIG_DIRS. plasma-workspace owns files by these names in /etc/xdg # and two packages cannot own one path. -for setting in kdeglobals kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ +for setting in kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ krunnerrc plasmanotifyrc powermanagementprofilesrc; do install -Dm644 "$SRC/config/plasma/xdg/$setting" \ "/etc/ekklesia-classroom/xdg/$setting" rm -f "/etc/xdg/$setting.ekklesia-old" done +# Composed, not copied — the policy and the palette. See tools/build-kdeglobals. +install -d /etc/ekklesia-classroom/xdg +"$SRC/tools/build-kdeglobals" > /etc/ekklesia-classroom/xdg/kdeglobals +chmod 0644 /etc/ekklesia-classroom/xdg/kdeglobals + install -Dm644 "$SRC/config/environment/90-ekklesia-classroom.conf" \ /etc/environment.d/90-ekklesia-classroom.conf +# What actually puts that directory into a session started by a display +# manager. startplasma sources this; PAM never reads environment.d. +install -Dm644 "$SRC/config/plasma/env/zz-ekklesia-classroom.sh" \ + /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh + install -Dm644 "$SRC/config/systemd/logind-ekklesia-classroom.conf" \ /etc/systemd/logind.conf.d/ekklesia-classroom.conf @@ -53,6 +63,9 @@ done install -Dm644 "$SRC/config/plasma/solid/ekklesia-dvd.desktop" \ /usr/share/solid/actions/ekklesia-dvd.desktop +install -Dm644 "$SRC/config/dbus/fr.emersion.mako.service" \ + /usr/local/share/dbus-1/services/fr.emersion.mako.service + for icon in "$SRC"/config/icons/ekklesia-*.svg; do [ -e "$icon" ] || continue install -Dm644 "$icon" \ diff --git a/tools/lab/lab b/tools/lab/lab index d941039..0ef3869 100755 --- a/tools/lab/lab +++ b/tools/lab/lab @@ -35,6 +35,17 @@ outer() { session_env -e WAYLAND_DISPLAY="$OUTER" -e SWAYSOCK="$sock" "$NAME" "$@" } +# NOTE ON XDG_CONFIG_DIRS. +# +# This harness used to hand the session +# XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg, and every result it +# produced was therefore a result about a machine somebody had already +# configured by hand. A real session is started by SDDM through PAM, which +# does not read /etc/environment.d, and the classroom's settings were not +# in the cascade at all: the first real upgrade came up as stock Debian +# Plasma with a lock screen. The variable is deliberately not set here any +# more. The package has to put it there, the way it has to on a machine. + # Talk to the Plasma session under test. inner() { session_env -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \ @@ -74,6 +85,55 @@ down) echo "lab $NAME is down" ;; +reboot) + # As close to a room being switched off and on as a container gets: + # the same filesystem, a new PID 1, a new user manager, and every + # generator and drop-in read again from scratch. The migration work + # needs this, because half of what an upgrade changes is only read at + # boot. + docker restart "$NAME" >/dev/null + for _ in $(seq 1 90); do + state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true) + [[ "$state" == running || "$state" == degraded ]] && break + sleep 1 + done + for _ in $(seq 1 30); do + docker exec "$NAME" test -S /run/user/1000/bus && break + sleep 1 + done + docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus + + # SDDM really does autologin here: it authenticates the classroom + # through PAM and launches the Plasma session, and then has no display + # hardware to draw it on. That is worth having — it is the real login + # path, not a reading of a configuration file — so the attempt is left + # to happen and its evidence stays in the journal. It is then stopped, + # because the harness runs its own session on the nested compositor + # and two sessions cannot both own the keyboard. + for _ in $(seq 1 30); do + docker exec "$NAME" journalctl -u sddm --no-pager 2>/dev/null \ + | grep -q "Session started" && break + sleep 1 + done + docker exec "$NAME" systemctl stop sddm >/dev/null 2>&1 || true + docker exec "$NAME" pkill -f sddm-helper >/dev/null 2>&1 || true + # -f, not -x. "startplasma-wayland" is longer than the fifteen + # characters a process name has, so -x silently matches nothing and + # SDDM's session was left winding down underneath the harness's own: + # it tore down graphical-session.target sixteen seconds later and took + # the panel with it, which cost an afternoon to understand. + docker exec "$NAME" pkill -f startplasma-wayland >/dev/null 2>&1 || true + docker exec "$NAME" pkill -f plasma-dbus-run-session >/dev/null 2>&1 || true + for _ in $(seq 1 20); do + state=$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 "$NAME" \ + systemctl --user is-active graphical-session.target 2>/dev/null || true) + [[ "$state" != active ]] && break + sleep 1 + done + sleep 2 + echo "lab $NAME rebooted ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true))" + ;; + outer-up) # A headless compositor has no input devices, and a wl_seat with no # pointer capability never gives the session under test a pointer to @@ -103,7 +163,6 @@ plasma-up) -e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \ -e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \ -e QT_LOGGING_RULES="kwin_scripting.debug=true" \ - -e XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg \ -d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1' for _ in $(seq 1 60); do docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break @@ -114,9 +173,22 @@ plasma-up) ;; plasma-down) + # A logout, not a killing. plasmashell and kwin are both services of + # the user manager, and killing them only makes it start them again — + # the compositor came straight back holding wayland-0 and the next + # session could not start at all. The workspace target has to go down + # first, and this is what a real logout does too. + session_env "$NAME" systemctl --user stop plasma-workspace.target \ + >/dev/null 2>&1 || true docker exec "$NAME" pkill -u classroom -f startplasma-wayland || true - docker exec "$NAME" pkill -u classroom -x plasmashell || true - docker exec "$NAME" pkill -u classroom -x kwin_wayland || true + session_env "$NAME" systemctl --user stop graphical-session.target \ + >/dev/null 2>&1 || true + for _ in $(seq 1 20); do + docker exec "$NAME" pgrep -x kwin_wayland >/dev/null 2>&1 || break + sleep 1 + done + docker exec "$NAME" pkill -u classroom -x kwin_wayland >/dev/null 2>&1 || true + docker exec "$NAME" pkill -u classroom -x plasmashell >/dev/null 2>&1 || true sleep 2 echo "plasma session down" ;; @@ -200,6 +272,7 @@ usage: tools/lab/lab COMMAND build build the lab image up start the lab container (systemd inside) down remove it + reboot restart the machine, let SDDM autologin, then stand down session outer compositor + a Plasma session on top of it outer-up just the outer compositor plasma-up just the Plasma session