feat: give every room the broker, one scale, and its own panel back
Little Waders took the Plasma build and reported what a container cannot. A disc player on a machine with no optical drive, a clock nobody at the back of the room could read, a browser at half the size of the panel around it, and Parent Call with no broker to call. The broker is the same for every classroom, so it is a default now and not something to fill in room by room. classroom-migrate brings an existing room to the same place without touching its name, its username or its password, and Parent Call distinguishes a broker it cannot reach from one that was never set up: those send somebody to look at completely different things. The disc player's detection was right and ran too late — alongside the session rather than before it, and the panel is built from the layout the moment a session starts. It runs before the display manager now, and again from udev when a drive is plugged in. UI_SCALE had a ceiling of 1.5, which cannot express a 4K television; a clock pinned at 15 points, which is the same size at every scale; and a browser that was never told the number at all. All three are fixed and the room doubles together at 200%. This also brings 0.1.47's work into the repository, where it had only ever existed in a build directory under /tmp: the hidden display dialog, its authenticated writer, the polkit action, and the church's own wallpaper. One piece of it is deliberately left out. The custom volume display needed a shell package of our own, and with one selected plasmashell builds no panel at all — measured twice, with that build's own files, on a classroom that deletes its panel configuration at every login by design. The lab connected to the real church broker the moment the package defaulted to it. Nothing was left behind, which was checked, but the guard that should have stopped it had never worked: it used iptables, which the lab image does not have, and probed with /dev/tcp under dash, where that always fails. Both reported success. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
926668dc9b
commit
34fd02e08f
@@ -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 \
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+54
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Executable
+48
@@ -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()
|
||||
Executable
+40
@@ -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())
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user