diff --git a/config/chrome/policies/managed/ekklesia-classroom.json b/config/chrome/policies/managed/ekklesia-classroom.json index 7402b50..98d2701 100644 --- a/config/chrome/policies/managed/ekklesia-classroom.json +++ b/config/chrome/policies/managed/ekklesia-classroom.json @@ -1,21 +1,26 @@ { - "DefaultBrowserSettingEnabled": false, - "PromotionalTabsEnabled": false, - "MetricsReportingEnabled": false, - "BrowserSignin": 0, - "SyncDisabled": true, - "SigninInterceptionEnabled": false, + "BackgroundModeEnabled": false, "BrowserAddPersonEnabled": false, "BrowserGuestModeEnabled": false, - "ProfilePickerOnStartupAvailability": 1, - "PasswordManagerEnabled": false, - "PasswordLeakDetectionEnabled": false, - "BackgroundModeEnabled": false, - "DisablePrintPreview": true, + "BrowserSignin": 0, "CookiesSessionOnlyForUrls": [ "[*.]wonderink.org" ], - "ShowHomeButton": true, + "DefaultBrowserSettingEnabled": false, + "DefaultNotificationsSetting": 2, + "DisablePrintPreview": true, "HomepageIsNewTabPage": false, - "HomepageLocation": "https://www.youtube.com/" + "HomepageLocation": "https://www.youtube.com/", + "MetricsReportingEnabled": false, + "NotificationsAllowedForUrls": [], + "NotificationsBlockedForUrls": [ + "*" + ], + "PasswordLeakDetectionEnabled": false, + "PasswordManagerEnabled": false, + "ProfilePickerOnStartupAvailability": 1, + "PromotionalTabsEnabled": false, + "ShowHomeButton": true, + "SigninInterceptionEnabled": false, + "SyncDisabled": true } diff --git a/config/icons/ekklesia-wonderink.svg b/config/icons/ekklesia-wonderink.svg index 27b5e40..32132e9 100644 --- a/config/icons/ekklesia-wonderink.svg +++ b/config/icons/ekklesia-wonderink.svg @@ -1,33 +1,16 @@ - - - - - - - - - - - - - - - - - + + Wonder Ink + + + diff --git a/config/plasma/applications/ekklesia-display-settings.desktop b/config/plasma/applications/ekklesia-display-settings.desktop new file mode 100644 index 0000000..723e7a7 --- /dev/null +++ b/config/plasma/applications/ekklesia-display-settings.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Classroom display size +Exec=/usr/local/bin/classroom-display-settings +Icon=preferences-desktop-display +Terminal=false +NoDisplay=true +X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+D diff --git a/config/plasma/autostart/ekklesia-classroom-scale.desktop b/config/plasma/autostart/ekklesia-classroom-scale.desktop new file mode 100644 index 0000000..34d8f4b --- /dev/null +++ b/config/plasma/autostart/ekklesia-classroom-scale.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Type=Application +Name=Classroom display size +Exec=/usr/local/bin/classroom-scale +OnlyShowIn=KDE; +NoDisplay=true diff --git a/config/plasma/desktoptheme/dialogs/background.svg b/config/plasma/desktoptheme/dialogs/background.svg new file mode 100644 index 0000000..7d616c4 --- /dev/null +++ b/config/plasma/desktoptheme/dialogs/background.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/config/plasma/desktoptheme/metadata.json b/config/plasma/desktoptheme/metadata.json new file mode 100644 index 0000000..db89d14 --- /dev/null +++ b/config/plasma/desktoptheme/metadata.json @@ -0,0 +1,15 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Ekklesia" + } + ], + "Description": "Cards for a room read from across it", + "Id": "ekklesia", + "License": "GPLv2+", + "Name": "Ekklesia Classroom", + "Website": "https://riverofglorymn.org" + }, + "X-Plasma-API": "5.0" +} diff --git a/config/plasma/lookandfeel/contents/defaults b/config/plasma/lookandfeel/contents/defaults index 08cde14..713ee60 100644 --- a/config/plasma/lookandfeel/contents/defaults +++ b/config/plasma/lookandfeel/contents/defaults @@ -15,7 +15,7 @@ AccentColor=79,179,168 Theme=breeze-dark [plasmarc][Theme] -name=default +name=ekklesia [kcminputrc][Mouse] cursorTheme=breeze_cursors diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js index 090c251..4f2766c 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -123,7 +123,23 @@ rightGap.writeConfig("expanding", true); // about. Naming the four the classroom actually uses is both smaller and // deterministic, and it leaves nothing for a teacher to expand. panel.addWidget("org.kde.plasma.networkmanagement"); -panel.addWidget("org.kde.plasma.volume"); + +var volume = panel.addWidget("org.kde.plasma.volume"); +volume.currentConfigGroup = ["General"]; +// No chime when the volume changes. A classroom is often listening to +// something when somebody adjusts it, and a beep over a lesson is worse +// than no feedback at all — the on-screen display is the feedback, and it +// is in the look and feel package where a television can read it. +// +// Plasma 6 moved this setting out of the applet and into a global one, +// and the applet migrates a false written here into it on first run. So +// this is both the setting and the instruction to carry it across. +volume.writeConfig("volumeFeedback", false); +volume.writeConfig("volumeOsd", true); +volume.writeConfig("muteOsd", true); +volume.writeConfig("outputChangeOsd", true); +// A teacher pressing the volume key expects a step they can hear. +volume.writeConfig("volumeStep", 5); panel.addWidget("org.kde.plasma.notifications"); @@ -134,8 +150,13 @@ clock.writeConfig("dateFormat", "custom"); clock.writeConfig("customDateFormat", "ddd d MMM"); clock.writeConfig("showSeconds", 0); clock.writeConfig("dateDisplayFormat", "BelowTime"); -// Left to itself the clock grows to the height of the bar and becomes the -// largest text in the room. It is status, not a headline. -clock.writeConfig("autoFontAndSize", false); -clock.writeConfig("fontSize", 15); +// Sized by the bar, not by a number written here. +// +// It used to be pinned at 15 points to stop it becoming the largest text +// in the room. On a 4K television that was a clock a teacher could not +// read from the back of the room, and it was the one thing in the +// classroom that did not follow UI_SCALE, because a point size written +// into a layout is the same point size at every scale. Plasma's own +// sizing follows the bar, and the bar follows the scale. +clock.writeConfig("autoFontAndSize", true); clock.writeConfig("fontWeight", 500); diff --git a/config/plasma/xdg/plasmanotifyrc b/config/plasma/xdg/plasmanotifyrc index 06e4006..680b63f 100644 --- a/config/plasma/xdg/plasmanotifyrc +++ b/config/plasma/xdg/plasmanotifyrc @@ -1,9 +1,16 @@ -# Small cards, top right, out of the way of the bottom bar and of anything -# a teacher is reading. Plasma's own notification service does this; the -# classroom does not run a notification daemon of its own. +# Top right, out of the way of the bottom bar and of anything a teacher is +# reading. Plasma's own notification service does this; the classroom does +# not run a notification daemon of its own, and replacing the daemon was +# never on the table — what the room changes is how the card looks, in the +# Plasma style, and how large it is, which follows UI_SCALE like the rest +# of the session. [Notifications][$i] PopupPosition=TopRight -PopupTimeout=6000 +# Eight seconds. Six is comfortable at a desk and short at the back of a +# classroom, where the card has to be noticed before it can be read. +# Critical notifications ignore this and stay until they are dismissed, +# which is Plasma's behaviour and the reason it is not changed here. +PopupTimeout=8000 LowPriorityPopups=false LowPriorityHistory=false diff --git a/config/plasma/xdg/plasmaparc b/config/plasma/xdg/plasmaparc new file mode 100644 index 0000000..952960e --- /dev/null +++ b/config/plasma/xdg/plasmaparc @@ -0,0 +1,5 @@ +[General] +AudioFeedback[$i]=false +RaiseMaximumVolume[$i]=false +VolumeOsd=true +MuteOsd=true diff --git a/config/plasma/xdg/plasmarc b/config/plasma/xdg/plasmarc new file mode 100644 index 0000000..a31a074 --- /dev/null +++ b/config/plasma/xdg/plasmarc @@ -0,0 +1,12 @@ +# Which Plasma style draws the room's popups. +# +# The style is a set of pictures — see +# /usr/share/plasma/desktoptheme/ekklesia — and it is what gives a +# notification and the volume display a rounded card instead of Breeze's. +# Anything the classroom's style does not provide comes from Breeze, so +# this cannot take a popup away, only change how it looks. +# +# Immutable on the key rather than the group, because the rest of this +# file is Plasma's own working state. +[Theme] +name[$i]=ekklesia diff --git a/config/polkit/org.ekklesia.classroom.display.policy b/config/polkit/org.ekklesia.classroom.display.policy new file mode 100644 index 0000000..ae6528a --- /dev/null +++ b/config/polkit/org.ekklesia.classroom.display.policy @@ -0,0 +1,14 @@ + + + + + Change the classroom display size + Enter an administrator password to save this room's display size. + + no + no + auth_admin + + /usr/local/bin/classroom-set-scale + + diff --git a/config/udev/60-ekklesia-classroom-optical.rules b/config/udev/60-ekklesia-classroom-optical.rules new file mode 100644 index 0000000..69ac036 --- /dev/null +++ b/config/udev/60-ekklesia-classroom-optical.rules @@ -0,0 +1,12 @@ +# A drive plugged in later. +# +# The panel's disc player follows the machine's hardware rather than a +# setting, so a USB drive connected after the room was set up has to be +# noticed. This re-runs the same detection the boot service runs; the +# button appears at the next login, because the panel is built at login +# and rewriting a running one destroys it — measured. +# +# --no-block so udev never waits for systemd, and a match narrow enough +# that nothing but an optical drive can trigger it. +ACTION=="add|remove", SUBSYSTEM=="block", ENV{ID_CDROM}=="1", \ + RUN+="/usr/bin/systemctl start --no-block ekklesia-classroom-layout.service" diff --git a/config/wallpapers/classroom-calm.png b/config/wallpapers/classroom-calm.png index 04f8463..a1e0ff9 100644 Binary files a/config/wallpapers/classroom-calm.png and b/config/wallpapers/classroom-calm.png differ diff --git a/docs/classroom-v2-experiment.md b/docs/classroom-v2-experiment.md index 34a547a..0570912 100644 --- a/docs/classroom-v2-experiment.md +++ b/docs/classroom-v2-experiment.md @@ -1096,6 +1096,111 @@ 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. +## The rooms themselves: 0.1.48 + +The first real classroom, Little Waders, took the Plasma build and then +reported what a lab cannot: a disc player on a machine with no drive, a +clock nobody could read, a browser at half the size of the panel around +it, and Parent Call with no broker to call. + +### The broker every room shares + +There is one, at 10.40.0.2, and every classroom uses it. It is a default +now rather than something to fill in room by room: a fresh machine is +pointed at it, and `classroom-migrate` brings an existing one to the same +place — switched on if it was off, addressed if it was blank, and left +exactly as it is if somebody deliberately pointed it elsewhere. The room +name, the username and the password are read by none of it and printed by +none of it, which `tests/test-classroom-migrate-config.sh` checks against +the real migration code rather than a copy of it. + +The link restarts on `always` rather than `on-failure`, because it exits +cleanly when it finds no broker configured and a machine set up before +the church had one would otherwise never pick it up. + +Parent Call now says which of the two problems a teacher has. "Cannot +reach the church network" and "This room has no church network set up" +send somebody to look at completely different things, and saying the +first when the truth is the second costs an hour at a router. + +### A disc player on a machine with no drive + +The detection was right and it ran too late. `classroom-provision` runs at +boot *alongside* the session rather than before it, and the panel is built +from the layout the moment a session starts: a room told about its own +hardware afterwards keeps the wrong bar until the next login. +`ekklesia-classroom-layout.service` runs it before the display manager +instead, a udev rule re-runs it when a drive is plugged in or pulled out, +and the login says so in the journal when the layout and the hardware +disagree. Measured in a mount namespace with no `/dev/sr*`: the launcher +is removed and the rebuilt panel has ten applets and no disc. + +### One scale, and everything that was not following it + +`UI_SCALE` had been packaged, clamped, documented — and applied by an +autostart entry that only appeared in 0.1.47. Three things were still +outside it: + +* **The ceiling was 1.5.** A 4K television wants 2.0, so a room asking for + it silently got 1.5. It is 3.0 now, and the hidden display dialog + offers up to it. +* **The clock was pinned at 15 points**, which is the same size at every + scale. It is sized by the bar now, which follows the scale like + everything else. +* **Chrome was not told.** On Wayland it did not follow the compositor's + output scale, so the browser was half the size of the panel around it. + Both launchers pass `--force-device-scale-factor`, from the one command + that owns the number. + +Measured at 200% in the lab: the panel, the clock, the notification card, +Chrome's tab strip and the page itself all doubled together. + +### The notification card + +A Plasma style, which is the supported way and the reason no QML was +replaced. A style is a set of pictures and anything the classroom does not +provide comes from Breeze, so the worst this can do is look wrong — never +fail to appear. Replacing the notification plasmoid's QML was the other +route and a QML error there is a room with no notifications at all. + +Web pages are blocked from notifying at the policy level, with no prompt: +a permission dialog in a classroom is a dialog a child can agree to. + +### The volume display, and a shell package that costs the panel + +The 0.1.47 build replaced Plasma's on-screen volume display with its own, +through a shell package of its own. Plasma 6.3 does read the display from +there and from nowhere else — the look and feel is not consulted, which +was measured — so the mechanism was right. + +**With that shell package selected, plasmashell builds no panel at all.** +Not ours, not the stock one. Measured twice, with the 0.1.47 package's own +files. The classroom deletes its panel configuration at every login on +purpose, so every login was one step away from a television showing a +wallpaper and nothing else. + +So it is gone, and the volume display is Plasma's own. It is drawn at the +room's scale like everything else, which is what the television actually +needed. `classroom-desktop-reset` also strips a `ShellPackage` line out of +a profile written while the old build was installed. + +The chime stays off, in `plasmaparc`, which is the file Plasma 6 actually +reads for it. + +### What the lab was allowed to reach + +Pointing the package at the real broker had an immediate consequence: the +lab connected to it, under a container id, as a room. Nothing was left +behind — the broker held no retained status for it afterwards, which was +checked — but the lab had no business being there. + +`tools/lab/lab up` now blackholes both church networks before anything +starts, and refuses to continue if either is still reachable. The rig had +carried a guard for this since it was written and it had never once +worked: it used `iptables`, which is not installed in the lab image, and +it probed with `/dev/tcp` under `sh`, which is a bash feature and fails +under dash whatever the network is doing. Both were reported as success. + ## Unresolved risks In the order I would worry about them. @@ -1160,7 +1265,17 @@ In the order I would worry about them. 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 +15. **Nothing in this pass has been on the classroom machines.** The + development host has no route to Little Waders — its SSH refuses this + machine's key, and the control computer cannot reach it either — so + every result above is the lab's. The package is built and the deploy + commands are written down; the room itself is unverified. +16. **The v2 channel still has no manifest.** `update_classroom_v2.sh` + exists now and has not been run. Until it is, a v2 room takes no + updates at all, and `classroom-migrate` moves every upgraded machine + onto that channel — deliberately, because stable and canary would + both hand a Plasma room the sway build back. +17. **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 @@ -1260,29 +1375,44 @@ The existing Debian classroom test VM should be kept. 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. +* **0.1.47's work brought into the repository**, where it had only ever + existed in a build directory: the hidden display dialog behind + Meta+Ctrl+Alt+Shift+D, its authenticated writer, the polkit action, the + autostart entry that applies the scale, and the church's own wallpaper. +* **The broker given to every room**, the disc player decided before the + session starts, the clock and the browser brought under the one scale, + the notification card given a Plasma style of its own, and the shell + package that cost the panel taken back out — 0.1.48. **Next**, in order: -0. **The thirty-second Escape test**, before anything is built on the +0. **Deploy 0.1.48 to Little Waders and run the list.** Everything in + *The rooms themselves* is lab work: the development host cannot reach + the classroom machines at all. The deploy and rollback commands are in + the report that accompanies this build, and the checks that matter are + the panel after a reboot, the clock and the browser at the room's + scale, Parent Call with a typed name, the broker, HDMI audio and the + volume keys. +1. **The thirty-second Escape test**, before anything is built on the gesture: bind a probe entry to `Escape`, press it in a fullscreen video, and see whether the video leaves fullscreen. That settles the one thing this pass could not measure. -1. **On the bench, tonight.** Type a name into Parent Call with a real +2. **On the bench, tonight.** Type a name into Parent Call with a real keyboard. Switch a television on after the computer and listen. Play a 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 the v2 channel. `classroom-migrate` already moves a migrated +3. 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: +4. 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 +5. 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 +6. 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*. -6. Live View, when the rest is quiet. +7. Live View, when the rest is quiet. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 55fde83..dd1ffeb 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -87,6 +87,20 @@ container lab. See [../classroom-v2-experiment.md](../classroom-v2-experiment.md | `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. | +## The rooms themselves + +| | | +| --- | --- | +| `v2-from-package` | the classroom with the church's own wallpaper and the real Wonder Ink mark | +| `wonderink-icon` | the same mark as a running application's button | +| `display-settings` | the hidden display-size dialog, saying that saving needs an administrator | +| `scale-200`, `scale-200-notification`, `scale-200-chrome` | the room at 200%: the panel, a notification card, and the browser, all doubled together | +| `notification-style` | the notification card on the classroom's own Plasma style | +| `osd-volume` | Plasma's volume display, which the room keeps | +| `osd-shellpackage` | the custom one, which rendered correctly and cost the panel | +| `pc-unreachable`, `pc-not-configured` | Parent Call telling a broker it cannot reach from one that was never set up | +| `dvd-absent-bar` | the bar on a machine with no optical drive | + ## Failures, kept | | | diff --git a/docs/screenshots/display-settings.png b/docs/screenshots/display-settings.png new file mode 100644 index 0000000..348f04e Binary files /dev/null and b/docs/screenshots/display-settings.png differ diff --git a/docs/screenshots/dvd-absent-bar.png b/docs/screenshots/dvd-absent-bar.png new file mode 100644 index 0000000..c4734ef Binary files /dev/null and b/docs/screenshots/dvd-absent-bar.png differ diff --git a/docs/screenshots/dvd-absent.png b/docs/screenshots/dvd-absent.png new file mode 100644 index 0000000..8b51912 Binary files /dev/null and b/docs/screenshots/dvd-absent.png differ diff --git a/docs/screenshots/notification-style.png b/docs/screenshots/notification-style.png new file mode 100644 index 0000000..729c146 Binary files /dev/null and b/docs/screenshots/notification-style.png differ diff --git a/docs/screenshots/osd-shellpackage.png b/docs/screenshots/osd-shellpackage.png new file mode 100644 index 0000000..7ff60ae Binary files /dev/null and b/docs/screenshots/osd-shellpackage.png differ diff --git a/docs/screenshots/osd-volume-test.png b/docs/screenshots/osd-volume-test.png new file mode 100644 index 0000000..39f8f88 Binary files /dev/null and b/docs/screenshots/osd-volume-test.png differ diff --git a/docs/screenshots/osd-volume.png b/docs/screenshots/osd-volume.png new file mode 100644 index 0000000..15d77ea Binary files /dev/null and b/docs/screenshots/osd-volume.png differ diff --git a/docs/screenshots/pc-not-configured.png b/docs/screenshots/pc-not-configured.png new file mode 100644 index 0000000..fa2de46 Binary files /dev/null and b/docs/screenshots/pc-not-configured.png differ diff --git a/docs/screenshots/pc-unreachable.png b/docs/screenshots/pc-unreachable.png new file mode 100644 index 0000000..31102c4 Binary files /dev/null and b/docs/screenshots/pc-unreachable.png differ diff --git a/docs/screenshots/scale-200-chrome.png b/docs/screenshots/scale-200-chrome.png new file mode 100644 index 0000000..1d9a606 Binary files /dev/null and b/docs/screenshots/scale-200-chrome.png differ diff --git a/docs/screenshots/scale-200-notification.png b/docs/screenshots/scale-200-notification.png new file mode 100644 index 0000000..9d88522 Binary files /dev/null and b/docs/screenshots/scale-200-notification.png differ diff --git a/docs/screenshots/scale-200.png b/docs/screenshots/scale-200.png new file mode 100644 index 0000000..a129bd8 Binary files /dev/null and b/docs/screenshots/scale-200.png differ diff --git a/docs/screenshots/shellpackage-desktop.png b/docs/screenshots/shellpackage-desktop.png new file mode 100644 index 0000000..126b587 Binary files /dev/null and b/docs/screenshots/shellpackage-desktop.png differ diff --git a/docs/screenshots/v2-dark-bar.png b/docs/screenshots/v2-dark-bar.png index 51ff4d3..4dd9e36 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 f0362be..13ceb04 100644 Binary files a/docs/screenshots/v2-from-package.png and b/docs/screenshots/v2-from-package.png differ diff --git a/docs/screenshots/wonderink-icon.png b/docs/screenshots/wonderink-icon.png new file mode 100644 index 0000000..bf90088 Binary files /dev/null and b/docs/screenshots/wonderink-icon.png differ diff --git a/docs/ui-scale.md b/docs/ui-scale.md index 3400a8c..323a064 100644 --- a/docs/ui-scale.md +++ b/docs/ui-scale.md @@ -1,106 +1,69 @@ -# UI scale +# Classroom display size (Plasma v2) -Every classroom gets the same design at a size that suits its TV. A 32" -panel a few feet from the children and a 65" set across a hall want -different sizes of the same room, not different rooms. +Each room keeps its own `UI_SCALE` in `/etc/ekklesia-classroom/device.conf`. +Package upgrades preserve the value. Plasma applies it at every classroom login. +Native display resolution is unchanged. -## Changing it +## Administrator setting -Edit `/etc/ekklesia-classroom/device.conf` on that endpoint: +On the classroom keyboard press **Meta+Ctrl+Alt+Shift+D** to open Display size. +It is hidden from application lists and the teacher's panel. Saving requires +administrator authentication through polkit; the keyboard shortcut grants no +privilege. Choose a percentage and press Save and apply. Canceling the password +prompt leaves the saved setting unchanged. + +Start at **200% for a 4K TV**, which gives 1920×1080 logical space while retaining +3840×2160 output. The offered values are 100, 125, 150, 175, 200, 225, 250 and 300%. +The choice applies to all connected, enabled displays on that classroom computer. + +For SSH administration, the fixed-purpose helper can also be run as root: ``` -UI_SCALE=1.25 +sudo /usr/local/bin/classroom-set-scale 2 ``` -Then restart the classroom shell: +It writes only UI_SCALE, preserving room configuration, broker credentials and +file ownership and permissions. The new size takes effect at the next classroom +login, or by running `classroom-scale` inside the classroom's graphical session. +The hidden window saves and applies in one operation. -``` -sudo systemctl restart getty@tty1 -``` +## Implementation and validation limits -That ends the session and the autologin brings it straight back at the -new size. A reboot does the same. Nothing else needs touching, and no -CSS or source file is ever edited to change a room's size. +`classroom-scale` uses `kscreen-doctor`; the login autostart runs only in KDE and +the script applies only to the classroom account. Values read from older/manual +configurations remain clamped to 0.8–3.0; invalid/nonfinite values become 1.0. +The helper accepts only the window's predefined choices, takes no paths, and +writes the root-owned settings file atomically. Polkit requires auth_admin for +every save, with no retained authorization. -## What it scales +Hardware checks required: shortcut registration, administrator authentication, +200% at 4K, logout/reboot persistence, and Chrome/Parent Call/DVD sizing. XWayland +applications may be softer than native Wayland applications when scaled. -**Everything.** UI_SCALE is a property of the television, not of one -program, so it is applied where it reaches the whole session: sway's -output scale. +## Wallpaper in the v2 package -- Chrome's tabs, omnibox, toolbar icons and menus -- VLC, and any other application's own interface -- The dock, the clock, notifications and the DVD control bar -- Mouse hit targets, because they scale with the widgets +The approved background source is `config/wallpapers/classroom-calm.png`. The +package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the +path used by the packaged Plasma layout. The layout is rebuilt at login, so +upgraded classrooms receive the package wallpaper without per-room copying. -The display mode is **not** changed. The panel stays at its native -1920x1080. Only the logical coordinate space shrinks, which is what -makes everything drawn in it larger: +## What the one number reaches -| UI_SCALE | Logical space | Roughly | -| --- | --- | --- | -| 0.8 | 2400x1350 | Small or close-up panel | -| 1.0 | 1920x1080 | Default. 32–50" classroom TV | -| 1.25 | 1536x864 | Large room, or a set viewed from further back | -| 1.5 | 1280x720 | 65"+ | +`classroom-ui-scale` prints it, clamped, and everything that cannot read +the display scale for itself asks that command rather than the file: -Anything outside 0.8–1.5 is clamped, and anything that is not a number -falls back to 1.0, so a typo in device.conf cannot produce an unusable -shell. +* **Plasma** — the panel, the clock, notifications, the volume display and + every dialog, because `classroom-scale` sets it as the display's scale + through `kscreen-doctor` at every login. +* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because + Chrome on Wayland was measured not to follow the compositor's output + scale on its own. It is the same number, not a second scaling: Chrome + replaces its detected scale with it. +* **The disc player's own surfaces** — they are measured in the logical + pixels the compositor is already scaling, so they follow without being + told. -## Measured - -Against 1920x1080 under sway, with Chrome 152: - -| | 1.0 | 1.25 | 1.5 | -| --- | --- | --- | --- | -| Chrome tab strip | 40px | 49px (x1.23) | 59px (x1.48) | -| sway title bar | 25px | 31px (x1.24) | 38px (x1.52) | -| Classroom dock | 74px | 92px (x1.24) | 110px (x1.49) | - -Everything scales once, together. - -## Why our own surfaces do not scale themselves - -`classroom-scale` runs first in the sway config and sets the output -scale. From then on the dock, the notifications and the DVD control bar -are measured in the same logical pixels sway is already scaling, so they -must render **unscaled** — `SHELL_SCALE` in `classroom_config.py` is -`1.0`. Scaling them again would apply UI_SCALE twice and the dock would -come out half again too big. - -The stylesheet templates still take a scale, and `SHELL_SCALE` is the -one place it comes from. If compositor scaling ever has to be abandoned, -setting it back to `ui_scale(config)` restores per-surface scaling -without touching a template. - -## Tradeoffs - -**GTK 3 has no fractional scaling.** At UI_SCALE=1.5 sway advertises an -integer scale of 2 to GTK, which renders at 2x, and sway scales the -result down to 1.5x. The dock is the right size but very slightly softer -than at 1.0. On a TV at classroom distance this is not visible. - -Icons have to be drawn for that themselves. `new_from_file_at_scale` -rasterises an SVG at its *logical* size, and the compositor then -enlarges that finished raster — which made icons visibly soft on a 24" -monitor while the clock text beside them stayed sharp. `classroom_icons` -renders each SVG at `size x scale_factor` physical pixels and hands GTK -a cairo surface carrying that device scale, so the artwork lands at the -display's real density and the layout is unchanged. Measured on the -Chrome icon, peak edge contrast at UI_SCALE 1.5 went from 49 to 83 out -of 255, and at 2.0 from 37 to 82; at 1.0 it is identical to before. - -**XWayland is softer at fractional scale.** X11 clients have no concept -of a fractional output scale, so sway renders them at the logical size -and scales the buffer. Measured on terminal text, scale 1.5 produced -about 50% more intermediate-tone pixels than scale 1.0 — real, modest -softening. - -VLC is the only XWayland application in the classroom, and in DVD mode -none of its interface is on screen: only video, from a 720x480 disc -being upscaled regardless. Chrome runs on Wayland natively and is sharp -at every scale, which is what matters for reading. - -At UI_SCALE=1.0 nothing is scaled at all and the session is identical to -one with no scaling. +The clock is the one thing that used to be left behind. It was pinned at +15 points, which is the same 15 points at every scale, and on a 4K +television it was a clock nobody at the back of the room could read. It +is sized by the bar now. diff --git a/lib/classroom_config.py b/lib/classroom_config.py index 7c523b8..8a39ebe 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -12,11 +12,15 @@ CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf") TRUTHY = {"1", "true", "yes", "on", "enabled"} -# The dock is tuned for a 1080p classroom TV at 1.0. Smaller rooms with a -# 32" panel sit near the bottom of this range, a 65" set near the top. +# Tuned for a 1080p classroom television at 1.0. A 4K set wants 2.0, +# which is the same interface at the same physical size on a display with +# twice the pixels. UI_SCALE_DEFAULT = 1.0 UI_SCALE_MIN = 0.8 -UI_SCALE_MAX = 1.5 +# 2.0 is a 4K television drawn at 1080p sizes, which is the setting the +# large classroom screens actually need. The old ceiling of 1.5 could not +# express it, so a room asking for 2.0 silently got 1.5. +UI_SCALE_MAX = 3.0 DEVICE_CONFIG_TEMPLATE = """# Ekklesia Classroom device configuration @@ -40,14 +44,22 @@ LIVE_ENABLED=false # and sound that is already playing is moved across to it. AUDIO_OUTPUT=auto -# Size of the dock, clock and notifications, 0.8 to 1.5. -# Larger rooms and larger TVs want a larger number. +# How large everything in the room is drawn: the panel, the clock, the +# browser, Wonder Ink, the disc player, notifications and the volume +# display, all from this one number. 1.0 to 3.0. +# +# 1.0 suits a 1080p television at ordinary classroom distance. 2.0 is +# right for a 4K set, where everything is otherwise half the size it +# looks. This is an administrator's setting: there is no way to reach it +# from the room, on purpose. UI_SCALE=1.0 -# Church message broker. The room is this machine's hostname unless ROOM -# is set. Leave MQTT_ENABLED=false until a broker exists. -MQTT_ENABLED=false -MQTT_HOST= +# Church message broker. Every classroom uses the same one, so the address +# is a default rather than something to fill in room by room. The room is +# this machine's hostname unless ROOM is set. A username and password are +# only needed if the broker asks for them. +MQTT_ENABLED=true +MQTT_HOST=10.40.0.2 MQTT_PORT=1883 MQTT_USERNAME= MQTT_PASSWORD= @@ -82,17 +94,25 @@ AUDIO_OUTPUT=auto ), ( "UI_SCALE", - """# Size of the dock, clock and notifications, 0.8 to 1.5. -# Larger rooms and larger TVs want a larger number. + """# How large everything in the room is drawn: the panel, the clock, the +# browser, Wonder Ink, the disc player, notifications and the volume +# display, all from this one number. 1.0 to 3.0. +# +# 1.0 suits a 1080p television at ordinary classroom distance. 2.0 is +# right for a 4K set, where everything is otherwise half the size it +# looks. This is an administrator's setting: there is no way to reach it +# from the room, on purpose. UI_SCALE=1.0 """, ), ( "MQTT_ENABLED", - """# Church message broker. The room is this machine's hostname unless ROOM -# is set. Leave MQTT_ENABLED=false until a broker exists. -MQTT_ENABLED=false -MQTT_HOST= + """# Church message broker. Every classroom uses the same one, so the address +# is a default rather than something to fill in room by room. The room is +# this machine's hostname unless ROOM is set. A username and password are +# only needed if the broker asks for them. +MQTT_ENABLED=true +MQTT_HOST=10.40.0.2 MQTT_PORT=1883 MQTT_USERNAME= MQTT_PASSWORD= diff --git a/lib/classroom_parent_call.py b/lib/classroom_parent_call.py index bb9b843..04612ba 100644 --- a/lib/classroom_parent_call.py +++ b/lib/classroom_parent_call.py @@ -268,7 +268,26 @@ def read_status(payload, request_id=None): } -def availability(mode, coordinator_online, broker_reachable=True): +# Why the room has no broker, in the teacher's words. +# +# "Cannot reach" and "is not set up" are different problems with different +# people to fetch: the first is a network or a broker that is down and +# will come back, the second is a machine that was never told where the +# church network is and needs an administrator. Saying the first when the +# truth is the second sends somebody to look at a router for an hour. +BROKER_UNREACHABLE = "Cannot reach the church network" +BROKER_WORDING = { + "not configured": "This room has no church network set up", + "no broker library": "This room has no church network set up", +} + + +def broker_message(why=None): + """What to tell the teacher about a broker that is not there.""" + return BROKER_WORDING.get(why, BROKER_UNREACHABLE) + + +def availability(mode, coordinator_online, broker_reachable=True, why=None): """Whether a teacher may send a call now, and what to say if not. Returns (can_send, message). The wording for an off mode is the @@ -276,7 +295,7 @@ def availability(mode, coordinator_online, broker_reachable=True): would. """ if not broker_reachable: - return False, "Cannot reach the church network" + return False, broker_message(why) if not coordinator_online: return False, "The sanctuary system is not running" diff --git a/packaging/debian/control.template b/packaging/debian/control.template index 1417910..c17cca7 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -4,7 +4,7 @@ Section: misc Priority: optional Architecture: amd64 Maintainer: Ekklesia -Depends: plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt +Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt Recommends: vlc Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Description: Ekklesia managed classroom environment diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 0ca531f..4d322b3 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -55,11 +55,21 @@ systemctl reload systemd-logind >/dev/null 2>&1 || true # Autologin into the classroom, and back into it if the session ever ends. systemctl enable sddm.service >/dev/null 2>&1 || true -# The room's own user services: the audio policy, and the drop-in that stops -# Plasma giving up on a repeatedly crashing panel. +# The room's own user services: the audio policy, and the drop-in that +# stops Plasma giving up on a repeatedly crashing panel. The display scale +# is applied by an XDG autostart entry instead, which is already how it +# reaches a session on the classroom machines. systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true +# The panel's disc player is decided before the display manager starts, not +# at the same time as it. Enabled rather than started: this install has +# just run the same command directly, below. +systemctl enable ekklesia-classroom-layout.service >/dev/null 2>&1 || true + +# So a drive plugged in later is noticed without a reboot. +udevadm control --reload-rules >/dev/null 2>&1 || true + # Match the bar to this machine now, so the first login already has the # right one rather than waiting for the next provisioning run. /usr/local/bin/classroom-desktop-layout >/dev/null 2>&1 || true diff --git a/scripts/classroom-browser b/scripts/classroom-browser index 9e3f2cb..864f8fa 100755 --- a/scripts/classroom-browser +++ b/scripts/classroom-browser @@ -24,8 +24,17 @@ set -euo pipefail # exists to prevent. HOME_PAGE='https://www.youtube.com/' +# One number for the whole room. Chrome on Wayland was measured not to +# follow the compositor's output scale on its own, so the 4K television +# got a browser at half the size of everything around it. Forcing the +# same number the display is scaled by is a statement of the same fact, +# not a second scaling: Chrome replaces its detected scale with this +# rather than multiplying by it. +SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)" + exec google-chrome \ --ozone-platform=wayland \ + --force-device-scale-factor="$SCALE" \ --start-maximized \ --no-first-run \ --no-default-browser-check \ diff --git a/scripts/classroom-desktop-layout b/scripts/classroom-desktop-layout index 8810be5..775becb 100755 --- a/scripts/classroom-desktop-layout +++ b/scripts/classroom-desktop-layout @@ -6,6 +6,13 @@ There is exactly one thing about the bar that depends on hardware: a room with no optical drive should not be offered a disc player. This edits the one marked line in the installed layout, and nothing else. +Run before the display manager by ekklesia-classroom-layout.service, and +again by udev when a drive is plugged in or pulled out. That ordering is +the whole point: the panel is built from this layout the moment a session +starts, and a room that is told about its hardware afterwards keeps the +wrong bar until the next login. It was being left to the provisioning +service, which runs at boot alongside the session rather than before it. + It writes the *layout*, not the running panel. Editing plasma-org.kde.plasma.desktop-appletsrc underneath a live plasmashell was tried and it destroyed the panel: the shell owns that file and rewrites @@ -30,12 +37,29 @@ LAYOUT = ( MARKER = "/* CLASSROOM-DVD-LAUNCHER */" -# The kernel names optical drives sr0, sr1, ... regardless of bus. +# The kernel names optical drives sr0, sr1, ... regardless of bus. Both +# views are checked: /dev is what anything opening the drive will use, and +# /sys/block is the kernel's own list, which cannot be left behind by a +# device node somebody or something created by hand. DRIVE_GLOB = "/dev/sr[0-9]*" +SYSFS_GLOB = "/sys/block/sr[0-9]*" + + +def drives(): + return sorted(glob.glob(DRIVE_GLOB)), sorted(glob.glob(SYSFS_GLOB)) def has_optical_drive(): - return bool(glob.glob(DRIVE_GLOB)) + nodes, blocks = drives() + + # Said out loud every time. When a room shows a disc player it has no + # drive for, or hides one it has, this line in the journal is the + # difference between an answer and an afternoon. + print("classroom-desktop-layout: /dev " + f"[{', '.join(nodes) or 'none'}], /sys/block " + f"[{', '.join(blocks) or 'none'}]") + + return bool(nodes and blocks) def rewrite(text, wanted): diff --git a/scripts/classroom-desktop-reset b/scripts/classroom-desktop-reset index a47875e..96bac07 100755 --- a/scripts/classroom-desktop-reset +++ b/scripts/classroom-desktop-reset @@ -133,6 +133,36 @@ if [[ -f "$CONFIG_HOME/powermanagementprofilesrc" ]]; then say "cleared the user's power settings; the package supplies them" fi +# THE DISC PLAYER, AND WHETHER THIS MACHINE HAS ONE. +# +# The panel about to be built comes from the packaged layout, and the one +# line in it that depends on hardware is written by +# ekklesia-classroom-layout.service before the display manager starts. +# This cannot write /usr and does not try. It checks, because a room +# showing a disc player it has no drive for is a teacher clicking a button +# that can only disappoint, and the journal should say so at the moment +# the panel is built rather than after somebody notices. +LAYOUT=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js + +if [[ -r "$LAYOUT" ]]; then + if compgen -G "/dev/sr[0-9]*" >/dev/null; then + drive=yes + else + drive=no + fi + + if grep -q '^\s*/\* CLASSROOM-DVD-LAUNCHER \*/' "$LAYOUT"; then + offered=yes + else + offered=no + fi + + if [[ "$drive" != "$offered" ]]; then + say "WARNING: this machine has drive=$drive but the layout offers" + say " the disc player=$offered — run classroom-desktop-layout" + fi +fi + CONFIG="$CONFIG_HOME/plasma-org.kde.plasma.desktop-appletsrc" if [[ -e "$CONFIG" ]]; then @@ -142,6 +172,28 @@ else say "no panel configuration to clear" fi +# A shell package the classroom used to select. +# +# One build chose a shell package of its own so that it could replace the +# volume display. With one selected, plasmashell builds no panel at all — +# and this script deletes the panel at every login, so the room came up +# with an empty screen. The package no longer ships it; this removes the +# choice from a profile that was written while it did. +if [[ -f "$CONFIG_HOME/plasmashellrc" ]] \ + && grep -q "^ShellPackage=" "$CONFIG_HOME/plasmashellrc" 2>/dev/null; then + sed -i "/^ShellPackage=/d" "$CONFIG_HOME/plasmashellrc" + say "removed a shell package choice that leaves the room with no panel" +fi + +# The display, like the panel, comes from the package rather than from +# whatever the room last negotiated. KWin remembers a resolution and a +# scale per output in this file, and a television that once came up in the +# wrong mode keeps it for good — the scale service applies UI_SCALE again +# a moment after this, so there is nothing here to preserve. Seen in the +# lab: an output that had been through two scale changes stayed at a +# resolution nothing on the machine had asked for. +rm -f "$CONFIG_HOME/kwinoutputconfig.json" 2>/dev/null || true + # The icon cache remembers a name it once failed to find, which outlives # the package that later supplies it. rm -f "$CACHE_HOME/icon-cache.kcache" 2>/dev/null || true diff --git a/scripts/classroom-display-settings b/scripts/classroom-display-settings new file mode 100755 index 0000000..c244403 --- /dev/null +++ b/scripts/classroom-display-settings @@ -0,0 +1,54 @@ +#!/usr/bin/python3 +"""Hidden maintenance UI. Saving requires an administrator's password.""" +import os +import pwd +import subprocess +import sys +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +from classroom_config import read_device_config, ui_scale + +def main(): + if pwd.getpwuid(os.getuid()).pw_name != "classroom": + sys.exit("Open this window from the classroom display session.") + window = Gtk.Dialog(title="Classroom display size") + window.set_default_size(440, 180) + window.add_button("Close", Gtk.ResponseType.CANCEL) + window.add_button("Save and apply", Gtk.ResponseType.OK) + box = window.get_content_area() + box.set_border_width(20) + box.set_spacing(16) + label = Gtk.Label(label="Display size for this room\n200% is a good starting point for a 4K TV.") + box.add(label) + sizes = Gtk.ComboBoxText() + current = ui_scale(read_device_config()) + choices = [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0] + if current not in choices: + choices.append(current) + choices.sort() + for value in choices: + sizes.append(str(value), f"{value * 100:g}%") + sizes.set_active_id(str(current)) + box.add(sizes) + box.add(Gtk.Label(label="Saving requires an administrator password.")) + window.show_all() + while window.run() == Gtk.ResponseType.OK: + selected = sizes.get_active_id() + if float(selected) == current: + continue + result = subprocess.run(["pkexec", "/usr/local/bin/classroom-set-scale", selected], capture_output=True, text=True) + if result.returncode: + message = "No change saved. Authentication was cancelled or the setting could not be saved." + else: + applied = subprocess.run(["/usr/local/bin/classroom-scale"], capture_output=True, text=True) + current = float(selected) + message = "Display size saved and applied." if applied.returncode == 0 else "Display size saved, but applying it failed. Sign out and back in to retry." + info = Gtk.MessageDialog(transient_for=window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=message) + info.run() + info.destroy() + window.destroy() + +if __name__ == "__main__": + main() diff --git a/scripts/classroom-migrate b/scripts/classroom-migrate index d39122c..733728d 100755 --- a/scripts/classroom-migrate +++ b/scripts/classroom-migrate @@ -167,16 +167,32 @@ rmdir --ignore-fail-on-non-empty \ # CHANNEL decides which manifest the updater fetches. A machine left on the # hyprland channel would be handed the Hyprland experiment again by the # next release published there, undoing the upgrade it just took. +# +# The broker is the same for every classroom, so a room that was set up +# before there was one — blank address, switched off — is given the +# address rather than left with Parent Call saying there is no church +# network. A room already pointed somewhere else is left pointed there: +# that is a decision somebody made. The username and the password are +# never read, never written and never printed. if [[ -f "$DEVICE_CONFIG" ]]; then python3 - "$DEVICE_CONFIG" <<'PY' import re import sys OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE") -# Channels that served a desktop this package replaces. -OBSOLETE_CHANNELS = {"hyprland"} +# Every channel that serves a desktop this package replaces. A machine +# that has just installed the Plasma classroom must not be left pointed at +# a line that will hand it the sway build back at the next release: stable +# and canary both would. A room already on the v2 channel, or on one +# somebody named deliberately, is left where it is. +OBSOLETE_CHANNELS = {"stable", "canary", "hyprland"} CHANNEL = "classroom-v2" +# The church broker. One address for every room. +BROKER_HOST = "10.40.0.2" +BROKER_PORT = "1883" +TRUTHY = {"1", "true", "yes", "on", "enabled"} + path = sys.argv[1] with open(path, "r", encoding="utf-8") as handle: @@ -184,6 +200,12 @@ with open(path, "r", encoding="utf-8") as handle: out = [] changed = [] +seen = set() + + +def value_of(line): + return line.split("=", 1)[1].strip() + for line in lines: key = line.split("=", 1)[0].strip() if "=" in line else "" @@ -192,15 +214,43 @@ for line in lines: changed.append(f"dropped {key}") continue + if key: + seen.add(key) + if key == "CHANNEL": - value = line.split("=", 1)[1].strip() + value = value_of(line) if value.lower() in OBSOLETE_CHANNELS: changed.append(f"moved off the {value} channel onto {CHANNEL}") line = f"CHANNEL={CHANNEL}\n" + elif key == "MQTT_ENABLED": + if value_of(line).lower() not in TRUTHY: + changed.append("switched the broker on") + line = "MQTT_ENABLED=true\n" + + elif key == "MQTT_HOST": + if not value_of(line): + changed.append(f"filled in the broker address {BROKER_HOST}") + line = f"MQTT_HOST={BROKER_HOST}\n" + + elif key == "MQTT_PORT": + if not value_of(line): + changed.append(f"filled in the broker port {BROKER_PORT}") + line = f"MQTT_PORT={BROKER_PORT}\n" + out.append(line) +# A room old enough to predate a setting has no line to correct. +for key, value in ( + ("MQTT_ENABLED", "true"), + ("MQTT_HOST", BROKER_HOST), + ("MQTT_PORT", BROKER_PORT), +): + if key not in seen: + changed.append(f"added {key}") + out.append(f"{key}={value}\n") + if changed: with open(path, "w", encoding="utf-8") as handle: handle.writelines(out) diff --git a/scripts/classroom-parent-call b/scripts/classroom-parent-call index 2fb3159..a41b63f 100755 --- a/scripts/classroom-parent-call +++ b/scripts/classroom-parent-call @@ -541,7 +541,10 @@ class ParentCall(Gtk.Window): self.broker_reachable = data.get("reachable") if not self.broker_reachable: - self._unavailable("Cannot reach the church network") + # Why it is not there decides who the teacher should + # fetch, so the reason the broker thread already knows is + # carried through to the card rather than flattened. + self._unavailable(contract.broker_message(data.get("why"))) else: self._after(RETAINED_GRACE, self._retained_deadline) diff --git a/scripts/classroom-scale b/scripts/classroom-scale index f82d3d5..51bd3be 100755 --- a/scripts/classroom-scale +++ b/scripts/classroom-scale @@ -10,6 +10,7 @@ is the whole of what replaced the compositor-specific version. import json import os +import pwd import subprocess import sys @@ -41,11 +42,13 @@ def outputs(): return [ output["name"] for output in found - if output.get("name") and output.get("connected", True) + if output.get("name") and output.get("connected", True) and output.get("enabled", True) ] def main(): + if pwd.getpwuid(os.getuid()).pw_name != "classroom": + return 0 # classroom_config clamps a nonsense or out-of-range value rather than # obeying it, so a mistyped UI_SCALE cannot leave a room unusable. scale = ui_scale(read_device_config()) @@ -53,8 +56,9 @@ def main(): if not found: print("classroom-scale: no outputs", file=sys.stderr) - return 0 + return 1 + failed = False for output in found: result = subprocess.run( ["kscreen-doctor", f"output.{output}.scale.{scale}"], @@ -64,10 +68,11 @@ def main(): if result.returncode == 0: print(f"classroom-scale: {output} scale {scale}") else: + failed = True print(f"classroom-scale: {output}: {result.stderr.strip()}", file=sys.stderr) - return 0 + return 1 if failed else 0 if __name__ == "__main__": diff --git a/scripts/classroom-set-scale b/scripts/classroom-set-scale new file mode 100755 index 0000000..219d300 --- /dev/null +++ b/scripts/classroom-set-scale @@ -0,0 +1,48 @@ +#!/usr/bin/python3 -I +"""Privileged, fixed-purpose writer; called through an auth_admin polkit action.""" +import fcntl +import os +from pathlib import Path +import sys +import tempfile + +CHOICES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0) + +def updated(text, value): + out = [] + for line in text.splitlines(keepends=True): + if line.split("=", 1)[0].strip() != "UI_SCALE": + out.append(line) + text = "".join(out) + return text + ("\n" if text and not text.endswith("\n") else "") + f"UI_SCALE={value:g}\n" + +def main(): + if os.geteuid() != 0: + sys.exit("Administrator authentication is required.") + try: + value = float(sys.argv[1]) if len(sys.argv) == 2 else None + except ValueError: + value = None + if value not in CHOICES: + sys.exit("Choose one of the supported display sizes.") + path = Path("/etc/ekklesia-classroom/device.conf") + with open("/run/lock/ekklesia-display-settings.lock", "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + info = path.stat() + text = updated(path.read_text(), value) + fd, name = tempfile.mkstemp(prefix=".device-scale-", dir=path.parent) + try: + with os.fdopen(fd, "w") as handle: + os.fchown(handle.fileno(), info.st_uid, info.st_gid) + os.fchmod(handle.fileno(), info.st_mode & 0o777) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) + print("Display size saved for this room.") + +if __name__ == "__main__": + main() diff --git a/scripts/classroom-ui-scale b/scripts/classroom-ui-scale new file mode 100755 index 0000000..5d0c022 --- /dev/null +++ b/scripts/classroom-ui-scale @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +"""Print the room's UI scale, clamped, as one number. + +There is one scale in a classroom and this is where everything asks for +it: the Plasma session applies it to the display, and the browser and +Wonder Ink are told it explicitly because Chrome was measured not to +follow the compositor on its own. A shell script can read device.conf in +one line, but then there are four places that clamp, default and parse a +setting differently — which is how a room ends up with a panel at one +size and a browser at another. +""" + +import os +import sys + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +from classroom_config import read_device_config, ui_scale # noqa: E402 + + +def main(): + scale = ui_scale(read_device_config()) + + # 2 rather than 2.0: Chrome takes either, and a whole number is what a + # person reads in a journal line without wondering what it means. + if scale == int(scale): + print(int(scale)) + else: + print(f"{scale:g}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index d222a42..3c25cb6 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -24,6 +24,14 @@ set -euo pipefail # browser. HOME_PAGE='https://app.wonderink.org/' + +# One number for the whole room. Chrome on Wayland was measured not to +# follow the compositor's output scale on its own, so the 4K television +# got a browser at half the size of everything around it. Forcing the +# same number the display is scaled by is a statement of the same fact, +# not a second scaling: Chrome replaces its detected scale with this +# rather than multiplying by it. +SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)" PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink" mkdir -p "$PROFILE" @@ -32,6 +40,7 @@ exec google-chrome \ --user-data-dir="$PROFILE" \ --app="$HOME_PAGE" \ --ozone-platform=wayland \ + --force-device-scale-factor="$SCALE" \ --start-maximized \ --no-first-run \ --no-default-browser-check \ diff --git a/systemd/layout/ekklesia-classroom-layout.service b/systemd/layout/ekklesia-classroom-layout.service new file mode 100644 index 0000000..3435e51 --- /dev/null +++ b/systemd/layout/ekklesia-classroom-layout.service @@ -0,0 +1,24 @@ +[Unit] +Description=Ekklesia Classroom panel layout for this machine +Documentation=file:///usr/local/bin/classroom-desktop-layout +# Before the display manager, because the panel is built from the layout +# the moment a session starts and there is no second chance until the next +# login. This used to be left to the provisioning service, which runs at +# boot alongside the session rather than before it: a room with no optical +# drive could be shown a disc player anyway, and it stayed there all day. +Before=display-manager.service sddm.service +# Not ordered after anything that waits for the network. The question is +# about hardware in this machine and must never hold a boot up. +After=local-fs.target +ConditionPathExists=/usr/local/bin/classroom-desktop-layout + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/classroom-desktop-layout +RemainAfterExit=no +# A layout that cannot be written is not a reason to refuse to start the +# classroom. It is a reason to say so in the journal. +SuccessExitStatus=0 1 + +[Install] +WantedBy=graphical.target diff --git a/systemd/mqtt/ekklesia-classroom-mqtt.service b/systemd/mqtt/ekklesia-classroom-mqtt.service index ff3b27d..76ebf70 100644 --- a/systemd/mqtt/ekklesia-classroom-mqtt.service +++ b/systemd/mqtt/ekklesia-classroom-mqtt.service @@ -14,7 +14,12 @@ Type=simple User=classroom Group=classroom ExecStart=/usr/local/bin/classroom-mqtt -Restart=on-failure +# always, not on-failure. The link exits cleanly when it finds no broker +# configured, and on-failure means a machine that was set up before the +# church had a broker would never pick one up until somebody rebooted it. +# Reconnection while it is running is paho's own, with a backoff; this is +# only for the case where the process is not running at all. +Restart=always RestartSec=10 [Install] diff --git a/tests/test-classroom-config.sh b/tests/test-classroom-config.sh index e887fc8..e911042 100755 --- a/tests/test-classroom-config.sh +++ b/tests/test-classroom-config.sh @@ -36,7 +36,16 @@ with tempfile.TemporaryDirectory() as directory: ensure_device_config(path) config = read_device_config(path) check("fresh config is created", path.is_file()) - check("MQTT defaults off", config.get("MQTT_ENABLED") == "false") + # Every classroom talks to the same broker, so a fresh machine is + # pointed at it rather than left switched off waiting for somebody to + # remember. The rooms that were set up before there was a broker are + # brought to the same place by classroom-migrate. + check("the broker is on by default", config.get("MQTT_ENABLED") == "true") + check("and points at the church broker", + config.get("MQTT_HOST") == "10.40.0.2") + check("on the usual port", config.get("MQTT_PORT") == "1883") + check("with no credentials invented for it", + config.get("MQTT_USERNAME") == "" and config.get("MQTT_PASSWORD") == "") check("stable remains the fresh-install default", config.get("CHANNEL") == "stable") print() diff --git a/tests/test-classroom-migrate-config.sh b/tests/test-classroom-migrate-config.sh new file mode 100755 index 0000000..421c748 --- /dev/null +++ b/tests/test-classroom-migrate-config.sh @@ -0,0 +1,153 @@ +#!/bin/bash +set -uo pipefail + +# What an upgrade does to a room's own settings file. +# +# device.conf is the one file in the classroom that belongs to the church +# rather than to the package: the room's name, and the broker password. +# An upgrade edits it, so an upgrade can also ruin it. These are the cases +# that matter, run against the real code — the migration's own Python is +# lifted out of the script rather than copied here, so a change to one +# cannot pass by disagreeing with the other. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +root = Path(sys.argv[1]) +script = (root / "scripts/classroom-migrate").read_text() + +block = re.search(r'python3 - "\$DEVICE_CONFIG" <<\'PY\'\n(.*?)\nPY\n', + script, re.S) + +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 + + +check("the migration's device.conf code was found", block is not None) + +if block is None: + sys.exit(1) + +migration = block.group(1) + + +def migrate(text): + """Run the real migration over a device.conf and return it.""" + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "migrate.py" + source.write_text(migration) + config = Path(directory) / "device.conf" + config.write_text(text) + + result = subprocess.run( + [sys.executable, str(source), str(config)], + capture_output=True, text=True, check=False, + ) + + return config.read_text(), result.stdout + result.stderr + + +print("1. a room from the Hyprland experiment") + +BEFORE = """# Ekklesia Classroom device configuration + +UPDATE_URL=https://updates.riverofglorymn.org +CHANNEL=hyprland +LIVE_ENABLED=false +AUDIO_OUTPUT=auto +UI_SCALE=2.0 +ROOM=little-waders +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME=classroom +MQTT_PASSWORD=hunter2-not-a-real-one +COMPOSITOR=hyprland +HYPR_APPEARANCE=visual +""" + +after, output = migrate(BEFORE) + +check("the broker is switched on", "MQTT_ENABLED=true" in after) +check("and given the church address", "MQTT_HOST=10.40.0.2" in after) +check("the port is left as it was", "MQTT_PORT=1883" in after) +check("the room keeps its name", "ROOM=little-waders" in after) +check("the username is untouched", "MQTT_USERNAME=classroom" in after) +check("the password is untouched", + "MQTT_PASSWORD=hunter2-not-a-real-one" in after) +check("and is never printed", "hunter2" not in output) +check("the compositor setting is gone", "COMPOSITOR" not in after) +check("and the appearance setting with it", "HYPR_APPEARANCE" not in after) +check("the channel moves off the experiment", + "CHANNEL=classroom-v2" in after) +check("the room's scale is preserved", "UI_SCALE=2.0" in after) +check("and everything else is left alone", + "UPDATE_URL=https://updates.riverofglorymn.org" in after + and "AUDIO_OUTPUT=auto" in after) + +print() +print("2. a room already pointed at a broker of its own") + +OWN = """CHANNEL=a-channel-somebody-named +MQTT_ENABLED=true +MQTT_HOST=broker.example.invalid +MQTT_PORT=8883 +MQTT_PASSWORD=leave-me +""" + +after, output = migrate(OWN) + +check("a deliberate address is not overwritten", + "MQTT_HOST=broker.example.invalid" in after) +check("nor a deliberate port", "MQTT_PORT=8883" in after) +check("a channel nobody here named is left alone", + "CHANNEL=a-channel-somebody-named" in after) +check("and its password is still there", "MQTT_PASSWORD=leave-me" in after) + +print() +print("3. a room old enough to have no broker settings at all") + +OLD = """CHANNEL=canary +UI_SCALE=1.0 +""" + +after, output = migrate(OLD) + +check("the settings are added", "MQTT_ENABLED=true" in after + and "MQTT_HOST=10.40.0.2" in after and "MQTT_PORT=1883" in after) +check("no credentials are invented", + "MQTT_USERNAME" not in after and "MQTT_PASSWORD" not in after) +# stable and canary are both the sway line. A machine that has just taken +# the Plasma package and is left pointed at one of them is handed the old +# desktop back by the next release there: the upgrade undoing itself. +check("canary moves onto the v2 channel", "CHANNEL=classroom-v2" in after) + +after, _ = migrate("CHANNEL=stable\nUI_SCALE=1.0\n") +check("and so does stable", "CHANNEL=classroom-v2" in after) + +print() +print("4. running it twice changes nothing the second time") + +once, _ = migrate(BEFORE) +twice, _ = migrate(once) +check("the second run is a no-op", once == twice) + +print() +print(f"{passed} passed, {failed} failed") +sys.exit(1 if failed else 0) +PY diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 70d3a3f..d58f2b5 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -12,6 +12,7 @@ from pathlib import Path root = Path(sys.argv[1]) control = (root / "packaging/debian/control.template").read_text() postinst = (root / "packaging/debian/postinst").read_text() +migrate = (root / "scripts/classroom-migrate").read_text() provision = (root / "scripts/classroom-provision").read_text() units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service")) canary = (root / "update_canary.sh").read_text() @@ -21,7 +22,7 @@ rules = (root / "config/plasma/xdg/kwinrulesrc").read_text() restrictions = (root / "config/plasma/xdg/kdeglobals").read_text() supervision = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text() build = (root / "tools/build-release.sh").read_text() -policy = json.loads( +chrome_policy = json.loads( (root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text() ) @@ -67,7 +68,8 @@ check("Avahi service and socket are masked", "systemctl mask avahi-daemon.socket check("printer setup is not a blocking boot dependency", "classroom-printer-setup" not in units) check("printer setup runs automatically in provisioning", "/usr/local/bin/classroom-printer-setup" in provision) check("printer provisioning is bounded", "--kill-after=5s 30s" in provision) -check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True) +check("Chrome uses the system print dialog", + chrome_policy.get("DisablePrintPreview") is True) print() print("4. secondary windows are the window manager's business") @@ -103,10 +105,13 @@ check("settings install to the classroom's own directory", # 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", +xdg_paths = sorted(re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build)) +check("what the classroom puts under /etc/xdg is two drop-ins", + xdg_paths == [ + "/etc/xdg/autostart/ekklesia-classroom-scale.desktop", + "/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh", + ]) +check("and no settings file takes a name plasma-workspace owns", not any(path.rsplit("/", 1)[0] == "/etc/xdg" for path in xdg_paths)) check("and that directory is searched first", "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg" in @@ -207,6 +212,127 @@ first_rsync = canary.index("rsync -av") check("Trixie validation runs before the repository copy", validation < package_copy) check("Trixie validation runs before any upload", validation < first_rsync) +print() +print("7. one scale, applied everywhere") +autostart = (root / "config/plasma/autostart/ekklesia-classroom-scale.desktop").read_text() +policy = (root / "config/polkit/org.ekklesia.classroom.display.policy").read_text() +settings_entry = (root / "config/plasma/applications/ekklesia-display-settings.desktop").read_text() +browser = (root / "scripts/classroom-browser").read_text() +wonderink = (root / "scripts/classroom-wonderink").read_text() +config_module = (root / "lib/classroom_config.py").read_text() +# UI_SCALE was packaged, clamped and documented, and nothing ever ran the +# command that applies it. A 4K television showed a 1080p interface at +# half size and every part of this section is a piece of that. +check("the scale is applied at every login", + "Exec=/usr/local/bin/classroom-scale" in autostart) +check("by the session, which is where kscreen-doctor can be asked", + "OnlyShowIn=KDE" in autostart) +check("and the package installs it", + "/etc/xdg/autostart/ekklesia-classroom-scale.desktop" in build) +check("changing it needs an administrator password", + "auth_admin" in policy and "classroom-set-scale" in policy) +check("and there is no way into it from the room", + "NoDisplay=true" in settings_entry + and "X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+D" in settings_entry) +check("2.0 is inside the range a room may ask for", + "UI_SCALE_MAX = 3.0" in config_module) +check("the browser is told the same number", + '--force-device-scale-factor="$SCALE"' in browser) +check("so is Wonder Ink", + '--force-device-scale-factor="$SCALE"' in wonderink) +check("both ask the one command for it", + "classroom-ui-scale" in browser and "classroom-ui-scale" in wonderink) +check("the clock is sized by the bar, not by a number", + 'autoFontAndSize", true' in layout and "fontSize" not in layout) + +print() +print("7b. the disc player is decided before a session starts") +layout_unit = (root / "systemd/layout/ekklesia-classroom-layout.service").read_text() +udev = (root / "config/udev/60-ekklesia-classroom-optical.rules").read_text() +reset = (root / "scripts/classroom-desktop-reset").read_text() +check("before the display manager", "Before=display-manager.service" in layout_unit) +check("started at every boot", "WantedBy=graphical.target" in layout_unit) +check("enabled by the package", "enable ekklesia-classroom-layout.service" in postinst) +check("a drive plugged in later is noticed", 'ENV{ID_CDROM}=="1"' in udev) +check("and the rules are reloaded on install", "udevadm control --reload-rules" in postinst) +check("the login says so when the layout and the hardware disagree", + "the disc player=" in reset) + +print() +print("7c. the room's own popups") +style = (root / "config/plasma/desktoptheme/dialogs/background.svg").read_text() +plasmarc = (root / "config/plasma/xdg/plasmarc").read_text() +notify = (root / "config/plasma/xdg/plasmanotifyrc").read_text() +policy_notifications = chrome_policy.get("DefaultNotificationsSetting") +# A Plasma style is a set of pictures with a per-file fallback to Breeze. +# Replacing the notification plasmoid's QML was the other route and a QML +# error there is a room with no notifications at all. +for piece in ("topleft", "top", "topright", "left", "center", "right", + "bottomleft", "bottom", "bottomright"): + check(f"the card has a {piece}", f'id="{piece}"' in style) +check("the style is chosen, immutably", "name[$i]=ekklesia" in plasmarc) +check("and the package installs it", "desktoptheme/ekklesia" in build) +check("the colour still comes from the session's scheme", + "ColorScheme-Background" in style) +check("notifications stay long enough to read across a room", + "PopupTimeout=8000" in notify) +# The volume display is the one thing the classroom's shell package +# overrides. Plasma 6.3 loads the on-screen display from the shell +# package and from nowhere else — the look and feel is not consulted, +# which was measured — so this is the supported way to replace it. The +# fallback declaration is what keeps the package this small: without it a +# shell package loses the desktop's own defaults, and the wallpaper with +# them, which was also measured. +# The volume display is Plasma's own. Replacing it needs a shell package +# — 6.3 reads the on-screen display from there and nowhere else — and a +# shell package with one selected builds no panel at all. Measured twice, +# and the panel is rebuilt from the layout at every login on purpose, so +# the room would have come up empty. The scale makes Plasma's own display +# the right size on a television, which is what was actually wanted. +check("no shell package of our own", "plasma/shells" not in build) +check("and nothing selects one", "ShellPackage" not in build) +check("the volume chime is off", 'volumeFeedback", false' in layout) +check("and off in the setting Plasma 6 actually reads", + "AudioFeedback[$i]=false" + in (root / "config/plasma/xdg/plasmaparc").read_text()) +check("but the display itself is on", 'volumeOsd", true' in layout) +check("web pages cannot put a notification on the television", + policy_notifications == 2) +check("and cannot ask to", + chrome_policy.get("NotificationsAllowedForUrls") == [] + and chrome_policy.get("NotificationsBlockedForUrls") == ["*"]) + +print() +print("7c-ii. the v2 channel is its own") +v2 = (root / "update_classroom_v2.sh").read_text() +# A publish script that can reach another channel's manifest is one +# mistake away from putting the Plasma build on a sway room. +code = "\n".join(line for line in v2.splitlines() + if not line.lstrip().startswith("#")) +check("it publishes to classroom-v2", "CHANNEL=classroom-v2" in code) +check("and names no other channel anywhere it acts", + "stable" not in code and "canary" not in code) +check("the same Trixie gate runs first", + code.index("validate-trixie-release.sh") < code.index("rsync")) +check("and the manifest goes up last", + code.rindex("manifest.json") > code.index("packages/")) + +print() +print("7d. the broker every room shares") +mqtt_unit = (root / "systemd/mqtt/ekklesia-classroom-mqtt.service").read_text() +parent_call = (root / "lib/classroom_parent_call.py").read_text() +check("the link is restarted whatever it exits with", + "Restart=always" in mqtt_unit) +check("it is enabled by the package", + "enable ekklesia-classroom-mqtt.service" in postinst) +check("a fresh room points at the church broker", + "MQTT_HOST=10.40.0.2" in config_module) +check("an upgraded one is brought to the same place", + 'BROKER_HOST = "10.40.0.2"' in migrate) +check("Parent Call tells a missing broker from an unreachable one", + "has no church network set up" in parent_call + and "Cannot reach the church network" in parent_call) + print() print(f"passed: {passed} failed: {failed}") raise SystemExit(1 if failed else 0) diff --git a/tests/test-display-settings.sh b/tests/test-display-settings.sh new file mode 100755 index 0000000..02bb300 --- /dev/null +++ b/tests/test-display-settings.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$ROOT" PYTHONPATH="$ROOT/lib" python3 - <<'PY' +import os, pathlib, runpy, subprocess, sys, tempfile +from classroom_config import ui_scale +root = pathlib.Path(os.environ['ROOT']) +helper = runpy.run_path(str(root/'scripts/classroom-set-scale')) +original = '# keep this\nCHANNEL=classroom-v2\nMQTT_PASSWORD=literal $ and = characters\nUI_SCALE=1.25\nCUSTOM=x\nUI_SCALE=1.5\n' +result = helper['updated'](original, 2.0) +assert 'MQTT_PASSWORD=literal $ and = characters\n' in result +assert result.count('UI_SCALE=') == 1 +assert helper['updated'](result, 2.0) == result +assert 'CUSTOM=x\n' in result +assert ui_scale({'UI_SCALE': '2'}) == 2 +assert ui_scale({'UI_SCALE': '3'}) == 3 +for bad in ('nan', 'inf', '-inf', 'oops'): + assert ui_scale({'UI_SCALE': bad}) == 1 +assert ui_scale({'UI_SCALE': '999'}) == 3 +assert ui_scale({'UI_SCALE': '-1'}) == 0.8 +# Run the actual migration's config code in a temporary directory: no services. +script = (root/'scripts/classroom-migrate').read_text() +code = script.split("<<'PY'\n", 1)[1].split('\nPY\n', 1)[0] +with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp)/'device.conf' + for channel in ('stable', 'canary', 'hyprland', 'classroom-v2', 'custom'): + path.write_text(original.replace('CHANNEL=classroom-v2', 'CHANNEL='+channel)) + subprocess.run([sys.executable, '-c', code, str(path)], check=True, capture_output=True) + expected = channel if channel in ('classroom-v2', 'custom') else 'classroom-v2' + assert 'CHANNEL='+expected+'\n' in path.read_text() + assert 'MQTT_PASSWORD=literal $ and = characters\n' in path.read_text() +print('Display settings: scale bounds, config preservation, idempotence and channel migration passed.') +PY diff --git a/tests/test-migration.sh b/tests/test-migration.sh index 8caa312..5f828b2 100755 --- a/tests/test-migration.sh +++ b/tests/test-migration.sh @@ -158,8 +158,8 @@ check( 'OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")' in migrate, ) check( - "and comes off a channel that would undo the upgrade", - 'OBSOLETE_CHANNELS = {"hyprland"}' in migrate, + "and comes off any channel that would undo the upgrade", + 'OBSOLETE_CHANNELS = {"stable", "canary", "hyprland"}' in migrate, ) check( "the rest of device.conf is rewritten line by line, not regenerated", diff --git a/tools/build-release.sh b/tools/build-release.sh index 12bbaa1..85ec0f7 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -207,7 +207,8 @@ 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 kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ - krunnerrc plasmanotifyrc powermanagementprofilesrc; do + krunnerrc plasmanotifyrc powermanagementprofilesrc plasmarc \ + plasmaparc; do install -Dm644 \ "$ROOT/config/plasma/xdg/${setting}" \ "$STAGE/etc/ekklesia-classroom/xdg/${setting}" @@ -258,6 +259,32 @@ install -Dm644 \ "$ROOT/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" \ "$LOOK/contents/layouts/org.kde.plasma.desktop-layout.js" +# NO SHELL PACKAGE OF OUR OWN. +# +# Plasma 6.3 loads the volume display from the shell package and from +# nowhere else — the look and feel is not consulted, which was measured — +# so a shell package is the only supported way to replace it, and a +# classroom-sized one was built and tried. With it selected, plasmashell +# builds no panel at all: not the stock one, not ours. Measured twice, and +# the room's panel is deleted at every login on purpose, so every login +# would have come up with nothing on the screen. +# +# The volume display is Plasma's own instead. UI_SCALE makes it the right +# size on a television, which is what the room actually needed. + +# The room's Plasma style: the card a notification and the volume display +# are drawn on. Only the pieces the classroom changes; everything else +# comes from Breeze, so this can never leave a popup undrawn. +STYLE="$STAGE/usr/share/plasma/desktoptheme/ekklesia" + +install -Dm644 \ + "$ROOT/config/plasma/desktoptheme/metadata.json" \ + "$STYLE/metadata.json" + +install -Dm644 \ + "$ROOT/config/plasma/desktoptheme/dialogs/background.svg" \ + "$STYLE/dialogs/background.svg" + for entry in "$ROOT"/config/plasma/applications/*.desktop; do install -Dm644 "$entry" \ "$STAGE/usr/share/applications/$(basename "$entry")" @@ -339,6 +366,38 @@ install -Dm755 \ "$ROOT/scripts/classroom-migrate" \ "$STAGE/usr/local/bin/classroom-migrate" +# One number, one place to ask for it. +install -Dm755 \ + "$ROOT/scripts/classroom-ui-scale" \ + "$STAGE/usr/local/bin/classroom-ui-scale" + +# The room's display size: a hidden dialog behind a chord, a writer that +# asks for an administrator password, and the one thing that applies the +# answer at every login. +for command in classroom-display-settings classroom-set-scale; do + install -Dm755 \ + "$ROOT/scripts/${command}" \ + "$STAGE/usr/local/bin/${command}" +done + +install -Dm644 \ + "$ROOT/config/polkit/org.ekklesia.classroom.display.policy" \ + "$STAGE/usr/share/polkit-1/actions/org.ekklesia.classroom.display.policy" + +install -Dm644 \ + "$ROOT/config/plasma/autostart/ekklesia-classroom-scale.desktop" \ + "$STAGE/etc/xdg/autostart/ekklesia-classroom-scale.desktop" + +# The panel's disc player follows this machine's hardware, and the +# detection has to have happened before a session starts. +install -Dm644 \ + "$ROOT/systemd/layout/ekklesia-classroom-layout.service" \ + "$STAGE/etc/systemd/system/ekklesia-classroom-layout.service" + +install -Dm644 \ + "$ROOT/config/udev/60-ekklesia-classroom-optical.rules" \ + "$STAGE/etc/udev/rules.d/60-ekklesia-classroom-optical.rules" + install -Dm644 \ "$ROOT/lib/classroom_audio.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py" diff --git a/tools/lab/install-desktop b/tools/lab/install-desktop index 9916fc0..d305bf0 100755 --- a/tools/lab/install-desktop +++ b/tools/lab/install-desktop @@ -13,7 +13,8 @@ SRC="${1:-/srv/classroom}" # XDG_CONFIG_DIRS. plasma-workspace owns files by these names in /etc/xdg # and two packages cannot own one path. for setting in kwinrc kwinrulesrc kscreenlockerrc ksmserverrc \ - krunnerrc plasmanotifyrc powermanagementprofilesrc; do + krunnerrc plasmanotifyrc powermanagementprofilesrc plasmarc \ + plasmaparc; do install -Dm644 "$SRC/config/plasma/xdg/$setting" \ "/etc/ekklesia-classroom/xdg/$setting" rm -f "/etc/xdg/$setting.ekklesia-old" @@ -48,6 +49,20 @@ install -Dm644 "$SRC/config/plasma/lookandfeel/contents/defaults" \ install -Dm644 \ "$SRC/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" \ "$LNF/contents/layouts/org.kde.plasma.desktop-layout.js" +install -Dm644 "$SRC/config/polkit/org.ekklesia.classroom.display.policy" \ + /usr/share/polkit-1/actions/org.ekklesia.classroom.display.policy +install -Dm644 "$SRC/config/plasma/autostart/ekklesia-classroom-scale.desktop" \ + /etc/xdg/autostart/ekklesia-classroom-scale.desktop + +install -Dm644 "$SRC/config/plasma/desktoptheme/metadata.json" \ + /usr/share/plasma/desktoptheme/ekklesia/metadata.json +install -Dm644 "$SRC/config/plasma/desktoptheme/dialogs/background.svg" \ + /usr/share/plasma/desktoptheme/ekklesia/dialogs/background.svg + +for script in classroom-ui-scale classroom-scale classroom-desktop-layout \ + classroom-display-settings classroom-set-scale; do + install -Dm755 "$SRC/scripts/$script" "/usr/local/bin/$script" +done for entry in "$SRC"/config/plasma/applications/*.desktop; do install -Dm644 "$entry" "/usr/share/applications/$(basename "$entry")" diff --git a/tools/lab/lab b/tools/lab/lab index 0ef3869..ca0facf 100755 --- a/tools/lab/lab +++ b/tools/lab/lab @@ -77,6 +77,38 @@ up) done # startplasma looks for `qdbus`; Debian ships it as qdbus6. docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus + + # The church's own networks, refused from inside the container. + # + # This is not theoretical. The moment the package began defaulting + # MQTT_HOST to the church broker, the lab connected to it: a test + # machine appearing on the real broker under a container id. Nothing + # was left behind — the broker held no retained status afterwards, + # which was checked — but the lab had no business being there at all, + # and the Parent Call coordinator is on the same networks. + # + # Container-local: this netns belongs to the container alone and + # nothing on the host or the church network is changed by it. Debian's + # own mirrors are untouched, so installs still work. + # A blackhole route rather than a firewall rule: the lab image has no + # iptables, and the rig's guard had been quietly doing nothing because + # of it. Routes need no netfilter modules and cannot silently fail. + docker exec "$NAME" sh -c ' + for net in 10.30.0.0/16 10.40.0.0/16; do + ip route replace blackhole "$net" || exit 1 + done' || echo " WARNING: could not fence the church networks" + + # And checked with bash, not sh. The probe uses /dev/tcp, which is a + # bash feature: under dash it fails whatever the network is doing, so + # the check passed on a lab that could reach the broker perfectly + # well. Measured. + for target in 10.40.0.2/1883 10.30.0.2/1025; do + if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then + echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 + exit 1 + fi + done + echo " church networks fenced out of the lab" echo "lab $NAME is up ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null))" ;; diff --git a/tools/lab/parent-call-rig b/tools/lab/parent-call-rig index 4d8f11d..a03aaf0 100755 --- a/tools/lab/parent-call-rig +++ b/tools/lab/parent-call-rig @@ -24,14 +24,19 @@ inlab() { docker exec "$NAME" "$@"; } guard() { # Container-local. This netns belongs to the container alone, so # nothing on the host or the church network is changed by it. + # Blackhole routes, not iptables: the lab image has no iptables at + # all, so these two lines had been failing silently since they were + # written. `tools/lab/lab up` now does the same thing at start-up, so + # this is a second statement of it rather than the only one. inlab sh -c ' - iptables -C OUTPUT -d 10.30.0.0/16 -j REJECT 2>/dev/null || - iptables -I OUTPUT -d 10.30.0.0/16 -j REJECT - iptables -C OUTPUT -d 10.40.0.0/16 -j REJECT 2>/dev/null || - iptables -I OUTPUT -d 10.40.0.0/16 -j REJECT' + for net in 10.30.0.0/16 10.40.0.0/16; do + ip route replace blackhole "$net" || exit 1 + done' + # bash, not sh. /dev/tcp is a bash feature and this probe reported + # every target unreachable under dash, whatever the network was doing. for target in 10.40.0.2/1883 10.30.0.2/1025; do - if inlab timeout 4 sh -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then + if inlab timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then echo "REFUSING TO CONTINUE: ${target} is still reachable" >&2 exit 1 fi diff --git a/update_classroom_v2.sh b/update_classroom_v2.sh new file mode 100755 index 0000000..e912419 --- /dev/null +++ b/update_classroom_v2.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -euo pipefail + +# Publish a build to the classroom-v2 channel. +# +# The v2 rooms are on a channel of their own, and classroom-migrate moves +# a machine onto it as soon as it takes this package: stable and canary +# are both the sway line, and a v2 room left on either is handed the old +# desktop back by the next release there. +# +# Nothing here can touch stable or canary. It writes one manifest, in one +# directory, named after the one channel this file is about. +# +# ./update_classroom_v2.sh 0.1.48 +# +# The package goes up first and the manifest last, so a room that checks +# for an update in the middle of a publish is offered nothing rather than +# a manifest pointing at a file that is not there yet. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +CHANNEL=classroom-v2 + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 VERSION" + echo "For example: $0 0.1.48" + exit 1 +fi + +version="$1" + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid version: $version" + exit 1 +fi + +package_name="ekklesia-classroom_${version}_amd64.deb" +package_path="$ROOT/dist/$package_name" + +if [[ ! -f "$package_path" ]]; then + echo "No such package: $package_path" + echo "Build it first: tools/build-release.sh $version" + exit 1 +fi + +echo "Publishing $version to $CHANNEL" +echo + +# The same gate canary uses, against the exact artifact being published. +validation_args=("$package_path") + +if [[ -f "$ROOT/repo/$CHANNEL/manifest.json" ]]; then + previous_name="$(python3 -c \ + "import json; print(json.load(open('repo/$CHANNEL/manifest.json'))['package'].rsplit('/', 1)[-1])")" + previous="$ROOT/repo/packages/$previous_name" + + if [[ -f "$previous" && "$previous" != "$package_path" ]]; then + validation_args+=("$previous") + fi +fi + +"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}" + +hash="$(sha256sum "$package_path" | awk '{print $1}')" + +mkdir -p "$ROOT/repo/packages" "$ROOT/repo/$CHANNEL" +cp "$package_path" "$ROOT/repo/packages/$package_name" + +cat > "$ROOT/repo/$CHANNEL/manifest.json" <