diff --git a/config/bash_profile b/config/bash_profile deleted file mode 100644 index 6729559..0000000 --- a/config/bash_profile +++ /dev/null @@ -1,3 +0,0 @@ -if [ -z "$WAYLAND_DISPLAY" ] && [ "${XDG_VTNR:-}" = "1" ]; then - exec sway -c /etc/ekklesia-classroom/sway/config -fi diff --git a/config/dock/style.css.in b/config/dock/style.css.in deleted file mode 100644 index 4a6bf87..0000000 --- a/config/dock/style.css.in +++ /dev/null @@ -1,102 +0,0 @@ -/* Ekklesia Classroom dock - * - * A template, not plain CSS. classroom-dock substitutes the bracketed - * measurements at startup from UI_SCALE in device.conf, so a room can - * be tuned for its TV without anyone editing this file. At UI_SCALE=1.0 - * every token resolves to the value it had before scaling existed. - */ - -window#classroom-dock, -window#classroom-clock { - background-color: transparent; - background-image: none; - border: none; - box-shadow: none; -} - -/* Main floating dock */ -.dock { - background-image: none; - background-color: rgba(34, 38, 50, 0.62); - - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: @dock-radius@px; - - padding: @dock-padding-v@px @dock-padding-h@px; - - /* Important: no shadow here. - GTK was clipping it against the rectangular window edge. */ - box-shadow: none; -} - -button.dock-button { - color: white; - - background-image: none; - background-color: rgba(255, 255, 255, 0.05); - - border: none; - border-radius: @button-radius@px; - - padding: @button-padding@px; - margin: 0; - - min-width: @button-size@px; - min-height: @button-size@px; - - box-shadow: none; - outline: none; -} - -button.dock-button:hover { - background-image: none; - background-color: rgba(70, 75, 90, 0.85); - box-shadow: none; -} - -button.dock-button:active { - background-image: none; - background-color: rgba(85, 90, 108, 0.90); - box-shadow: none; -} - -button.dock-button:focus { - background-image: none; - background-color: rgba(255, 255, 255, 0.08); - box-shadow: none; - outline: none; -} - -button.dock-button image { - color: white; - -gtk-icon-effect: none; -} - -/* Shown in place of the icon while an application is starting. */ -button.dock-button spinner { - color: white; - opacity: 1; - - min-width: @button-spinner@px; - min-height: @button-spinner@px; -} - - -/* Clock */ - -.clock { - color: white; - - background-image: none; - background-color: rgba(34, 38, 50, 0.62); - - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: @clock-radius@px; - - padding: @clock-padding-v@px @clock-padding-h@px; - - font-size: @clock-font@px; - font-weight: 600; - - box-shadow: none; -} diff --git a/config/icons/browser.svg b/config/icons/browser.svg deleted file mode 100644 index 51c93ed..0000000 --- a/config/icons/browser.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/config/mako/config.in b/config/mako/config.in deleted file mode 100644 index b8cda50..0000000 --- a/config/mako/config.in +++ /dev/null @@ -1,48 +0,0 @@ -# Ekklesia Classroom notifications -# Managed by the ekklesia-classroom package. -# -# Matched to the dock: same dark slate (#222632), same hairline white -# border, same rounded corners, white text. Kept slightly more opaque -# than the dock so text stays readable over the wallpaper and over video. -# -# A template, not a finished config. classroom-notifications resolves the -# bracketed measurements from UI_SCALE in device.conf and starts mako on -# the result. At UI_SCALE=1.0 they resolve to the values they had before -# scaling existed. - -font=DejaVu Sans @font-size@ - -background-color=#222632e6 -text-color=#ffffffff -border-color=#ffffff21 -progress-color=over #464b5ae6 - -border-size=1 -border-radius=@radius@ - -padding=@padding-v@,@padding-h@ -margin=@margin@ - -width=@width@ -height=@height@ - -anchor=top-right -layer=overlay -sort=-time -max-visible=3 - -markup=1 -format=%s\n%b - -icons=1 -max-icon-size=@icon-size@ - -default-timeout=6000 - -[urgency=low] -default-timeout=4000 - -[urgency=critical] -background-color=#3a2632f0 -border-color=#ff8a8a59 -default-timeout=0 diff --git a/config/sway/config b/config/sway/config deleted file mode 100644 index 37971d7..0000000 --- a/config/sway/config +++ /dev/null @@ -1,62 +0,0 @@ -# Ekklesia Classroom -# Managed by the ekklesia-classroom package. - -set $mod Mod4 - -# Wallpaper -output * bg /usr/share/backgrounds/ekklesia/classroom-default.png fill - -# UI scale first, so every surface below comes up at the right size -# instead of being resized underneath itself. This scales the whole -# session — Chrome, VLC and the dock alike — not just our own windows. -exec --no-startup-id /usr/local/bin/classroom-scale - -# Notifications, started before the dock so nothing can race D-Bus into -# activating an unstyled mako first. The wrapper resolves the styling at -# the room's UI scale and execs mako, so the process is still just mako. -exec --no-startup-id /usr/local/bin/classroom-notifications - -# The first Chrome top-level stays tiled; later indistinguishable Chrome -# containers are floated. Which is which depends on creation order, so this -# has to be stateful Sway IPC logic rather than a broad for_window rule. -# The close affordance for a popup lives in the dock. -exec --no-startup-id /usr/local/bin/classroom-chrome-windows - -# Popups are borderless like the main browser, so they have no title bar to -# drag. Chrome's own tab strip moves them, and Super+drag always works. -floating_modifier $mod normal - -# Ekklesia Classroom dock -exec --no-startup-id /usr/local/bin/classroom-dock - -# Keep the interface intentionally minimal. -# Additional classroom controls are added explicitly by Ekklesia Classroom. - -include /etc/sway/config.d/* - -# Chrome draws its own tab strip with window controls in it, so sway's -# title bar above that is a second, redundant one. Verified on the -# endpoint: Chrome is an xdg_shell client with app_id "google-chrome", -# and sway was giving it border "normal". The class rule covers the case -# where Chrome falls back to XWayland. -# -# This removes the compositor's decoration only. Chrome keeps its tabs -# and address bar, stays a normal window, and still maximises and -# minimises exactly as before. -for_window [app_id="google-chrome"] border none -for_window [class="(?i)google-chrome"] border none - -# The DVD player is an appliance screen, not a desktop window. VLC takes -# the whole display itself; this removes the title bar sway would -# otherwise draw above the video. classroom-dvd-session covers the -# wallpaper with black until VLC is up, so there is never a gap. -for_window [class="(?i)vlc"] border none -for_window [app_id="(?i)vlc"] border none - -# Minimize and restore. -# -# classroom-window parks the window on a hidden workspace as an ordinary -# tiled container, so restoring brings it back maximised instead of as a -# floating, draggable window the way the scratchpad did. -bindsym $mod+m exec /usr/local/bin/classroom-window minimize -bindsym $mod+Shift+m exec /usr/local/bin/classroom-window restore-last diff --git a/config/waybar/config.jsonc b/config/waybar/config.jsonc deleted file mode 100644 index 68b768d..0000000 --- a/config/waybar/config.jsonc +++ /dev/null @@ -1,35 +0,0 @@ -{ - "layer": "top", - "position": "bottom", - "height": 56, - "spacing": 12, - - "modules-left": [ - "custom/browser", - "custom/dvd", - "custom/sanctuary" - ], - - "modules-right": [ - "clock" - ], - - "custom/browser": { - "format": "Browser", - "on-click": "/usr/local/bin/classroom-browser" - }, - - "custom/dvd": { - "format": "DVD", - "on-click": "true" - }, - - "custom/sanctuary": { - "format": "Sanctuary", - "on-click": "true" - }, - - "clock": { - "format": "{:%-I:%M %p}" - } -} diff --git a/config/waybar/style.css b/config/waybar/style.css deleted file mode 100644 index 323e0aa..0000000 --- a/config/waybar/style.css +++ /dev/null @@ -1,20 +0,0 @@ -* { - font-family: sans-serif; - font-size: 18px; -} - -window#waybar { - background: rgba(20, 20, 20, 0.92); - color: white; -} - -#custom-browser, -#custom-dvd, -#custom-sanctuary { - padding: 0 24px; - margin: 6px 4px; -} - -#clock { - padding: 0 18px; -} diff --git a/docs/classroom-v2-experiment.md b/docs/classroom-v2-experiment.md index 1db0570..4ea2b10 100644 --- a/docs/classroom-v2-experiment.md +++ b/docs/classroom-v2-experiment.md @@ -316,26 +316,41 @@ loop goes away. **Not exercised** — the lab has no optical drive. ## Custom code this deletes +Deleted on this branch, and counted rather than estimated: + | | lines | replaced by | | --- | --- | --- | -| `classroom-dock` | 754 | the Plasma panel | -| `classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager | -| `classroom-window` | 208 | the task manager | -| `classroom-notify` | 214 | Plasma notifications | -| `classroom-notifications` | 74 | — | -| `classroom-scale` | 57 | Plasma display scaling | -| `classroom_sway.py` | 127 | — | -| `classroom_icons.py` | 95 | the icon theme | -| `classroom_style.py` | 36 | the colour scheme | -| `classroom_optical.py` | 125 | Solid | -| sway, waybar, mako configs | — | — | -| `config/bash_profile` | 2 | SDDM | -| **total** | **≈ 2 000** | | +| `scripts/classroom-dock` | 754 | the Plasma panel | +| `scripts/classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager | +| `tests/test-classroom-chrome-windows.sh` | 280 | — | +| `config/dock/style.css.in` | 102 | the colour scheme | +| `scripts/classroom-notifications` | 74 | Plasma notifications | +| `config/sway/config` | 62 | `kwinrc` | +| `config/mako/config.in` | 48 | `plasmanotifyrc` | +| `config/waybar/*` | 55 | the Plasma panel | +| `config/bash_profile`, getty autologin | 6 | SDDM | +| `config/icons/browser.svg` | — | Chrome's own icon | +| **total** | **1 695** | | -Kept, because they are the classroom's own subject matter: the DVD session +Rewritten rather than deleted: `classroom-browser` (41 → 30 lines, it no +longer decides launch-or-focus-or-minimise), `classroom-scale` (sway output +scale → `kscreen-doctor`), `classroom-notify` (found the session by looking +for sway and refused to run without mako; now looks for the compositor and +the shell). + +**Still present, and still sway-shaped.** `classroom-window`, +`classroom_sway.py`, `classroom_icons.py`, `classroom_style.py` and +`classroom_optical.py` are all reachable only from the DVD player and the +MQTT notifier, and they were deliberately not rewritten — see *the DVD +player* under unresolved risks. Another ~590 lines go when that is done. + +Kept because they are the classroom's own subject matter: the DVD session and CSS setup, printer setup, provisioning, MQTT, the updater, shutdown. Added: the audio policy and its test, the theme switch, and configuration. +The package was built and its contents checked: every `/usr/local` path any +classroom command names is in it. + ## Alternatives, and why they lost **Stay on sway, add Waybar.** Cheapest migration, and it loses on the thing diff --git a/scripts/classroom-chrome-windows b/scripts/classroom-chrome-windows deleted file mode 100755 index a755b0b..0000000 --- a/scripts/classroom-chrome-windows +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env python3 - -"""Keep Chrome's first window tiled and make later windows usable popups. - -Chrome gives the main browser and its secondary top-level windows the same -app_id, the same process and often nothing else to tell them apart. The only -stable fact is creation order, so this remembers the first Chrome container -and treats every later one as secondary. Page titles are deliberately ignored. - -The close affordance for a secondary window lives in the dock. An earlier -attempt anchored a layer-shell button to the popup's own corner and moved it -whenever the window moved; on real hardware it chased the window and -flickered, because every position change meant tearing a surface down and -building another one. The dock is already on screen, already owns the -classroom's visual language, and does not move. This publishes which window -the dock's Close button should act on, and the dock does the rest. - -Driven by Sway's own window events rather than a poll, so a window that is -being dragged costs nothing. -""" - -import fcntl -import json -import os -import subprocess -import sys -import time -from pathlib import Path - -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_chrome import ChromeRoles, chrome_windows # noqa: E402 -from classroom_sway import command, windows # noqa: E402 - -SECONDARY_SIZE_PERCENT = 76 - -# Chrome renegotiates the size of a popup that was opened with explicit -# dimensions, and does it after the resize returns. Centring before that -# settles leaves the window visibly off to one side, so it is placed -# once more a moment later. Only ever at creation: after that the window -# is the teacher's to move, and nothing here touches it again. -SETTLE_SECONDS = 0.35 - -# Everything else a window event can report — a title change, an urgency -# hint, a mark — cannot change which window is which, and Chrome retitles -# constantly. -INTERESTING = frozenset( - ("new", "close", "focus", "move", "floating", "fullscreen_mode") -) - -RESUBSCRIBE_SECONDS = 2 - -# The system print dialog, matched by its exact title. -# -# This is the one place a title is used, and only to recognise a single -# known dialog. It is not classifying Chrome windows — creation order -# still does that. The dialog and the window that rendered the document -# for it are otherwise identical to sway, and if the render window ends -# up in front the teacher cannot print at all. -PRINT_TITLE = "Print" - -STATE_NAME = "chrome-close-target" - - -def state_directory(): - runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") - directory = runtime / "ekklesia-classroom" - directory.mkdir(parents=True, exist_ok=True) - - return directory - - -def criterion(identifier): - return f"[con_id={identifier}]" - - -def configure_primary(identifier): - """A promoted remaining window becomes the ordinary classroom browser.""" - command(f"{criterion(identifier)} floating disable") - command(f"{criterion(identifier)} border none") - command(f"{criterion(identifier)} focus") - - -def place(target): - command( - f"{target} resize set width {SECONDARY_SIZE_PERCENT} ppt " - f"height {SECONDARY_SIZE_PERCENT} ppt" - ) - command(f"{target} move position center") - - -def configure_secondary(identifier, arriving=True): - """Float one exact container; never apply a broad Chrome float rule.""" - target = criterion(identifier) - command(f"{target} floating enable") - command(f"{target} border none") - - if not arriving: - # Only restoring the floating role to a window that already - # existed. Never move it: that would undo wherever the teacher - # dragged it. Never focus it: that would raise the render window - # over the print dialog sitting above it. - return - - place(target) - command(f"{target} focus") - - # Let Chrome have its say about the size, then place it again. - time.sleep(SETTLE_SECONDS) - place(target) - - -def is_print_dialog(window): - return (window or {}).get("name") == PRINT_TITLE - - -class ActiveSecondary: - """The one secondary window the dock offers to close. - - Deliberately sticky. This used to be whichever secondary happened to - be focused, which made the button impossible to click: moving the - pointer down to the dock moves focus away from the popup, so the - button disappeared on the way to it. Nobody could ever reach it. - - It is remembered instead. Losing focus changes nothing. It only - changes when the window it names goes away, or when another - secondary takes over by being focused. - """ - - def __init__(self): - self.target = None - - def update(self, secondary, chrome): - # A focused secondary takes over. Focus on the dock, the print - # dialog, the main browser or any layer-shell surface is not a - # secondary, so none of them disturb this. - for identifier in secondary: - window = chrome.get(identifier) - - if window and window["focused"] and not window["hidden"]: - self.target = identifier - break - - # Never anything that has closed, and never the main browser: - # `secondary` cannot contain the primary. - if self.target is not None and self.target not in secondary: - self.target = None - - # If the remembered window closed, fall back to another one that - # is still open, so cancelling a print dialog leaves the render - # window still closeable from the dock. - if self.target is None: - live = [ - identifier - for identifier in secondary - if identifier in chrome and not chrome[identifier]["hidden"] - ] - self.target = live[-1] if live else None - - return self.target - - -def publish(target, path): - """Tell the dock which window to offer to close, atomically.""" - temporary = path.with_suffix(".tmp") - - try: - temporary.write_text("" if target is None else f"{target}\n") - os.replace(temporary, path) - except OSError as error: - print(f"[classroom-chrome-windows] could not publish state: {error}", - flush=True) - - -def reconcile(roles, active, path): - current = windows() - chrome = {window["id"]: window for window in chrome_windows(current)} - changes = roles.update(current) - - if changes["promoted"] is not None: - configure_primary(changes["promoted"]) - - # The print dialog is configured last, because configuring a window - # focuses it and whichever is done last ends up in front. Both can - # arrive between two events, and the tree does not promise to list - # them in the order they were created. - arriving = sorted( - changes["new_secondary"], - key=lambda identifier: is_print_dialog(chrome.get(identifier)), - ) - - for identifier in arriving: - configure_secondary(identifier) - - # Reassert the role if something put a secondary back into the tiling - # layout — a restore, or an application-initiated geometry change. - # Hidden windows are left parked quietly. - for identifier in roles.secondary: - window = chrome.get(identifier) - - if window and not window["hidden"] and not window["floating"]: - configure_secondary(identifier, arriving=False) - - keep_print_in_front(roles.secondary, chrome) - - publish(active.update(roles.secondary, chrome), path) - - -def keep_print_in_front(secondary, chrome): - """The print dialog stays the obvious foreground window. - - The window that rendered the document is a secondary like any other - and will raise itself over the dialog given the chance, which leaves - the teacher looking at a document they cannot print. - - Focus is only ever taken back from another Chrome popup. Clicking - the main browser, the dock or anything else is the teacher's - business and is left alone. - """ - dialogs = [ - identifier - for identifier in secondary - if identifier in chrome - and is_print_dialog(chrome[identifier]) - and not chrome[identifier]["hidden"] - ] - - if not dialogs: - return - - dialog = dialogs[-1] - - if chrome[dialog]["focused"]: - return - - intruder = next( - ( - identifier - for identifier in secondary - if identifier in chrome - and chrome[identifier]["focused"] - and identifier != dialog - ), - None, - ) - - if intruder is None: - return - - command(f"[con_id={dialog}] focus") - - -def subscribe(): - try: - return subprocess.Popen( - ["swaymsg", "-t", "subscribe", "-m", "-r", '["window"]'], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - except (OSError, subprocess.SubprocessError): - return None - - -def watch(): - directory = state_directory() - path = directory / STATE_NAME - - with (directory / "chrome-windows.lock").open("w") as lock: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - return 0 - - roles = ChromeRoles() - active = ActiveSecondary() - - try: - while True: - # Whatever happened while there was no subscription. - reconcile(roles, active, path) - - stream = subscribe() - - if stream is None or stream.stdout is None: - time.sleep(RESUBSCRIBE_SECONDS) - continue - - for line in stream.stdout: - try: - event = json.loads(line) - except ValueError: - continue - - if event.get("change") in INTERESTING: - reconcile(roles, active, path) - - # Sway went away or restarted; wait, then pick it back up. - stream.wait() - time.sleep(RESUBSCRIBE_SECONDS) - finally: - publish(None, path) - - -def main(): - return watch() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/classroom-dock b/scripts/classroom-dock deleted file mode 100755 index 44bf967..0000000 --- a/scripts/classroom-dock +++ /dev/null @@ -1,754 +0,0 @@ -#!/usr/bin/env python3 - -import math -import os -import subprocess -import sys -from datetime import datetime -from pathlib import Path - -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("GtkLayerShell", "0.1") - -from gi.repository import Gio, Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell - -sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") - -import classroom_style as style # noqa: E402 -from classroom_config import ( # noqa: E402 - SHELL_SCALE, - dock_items, - read_device_config, - scaled, -) -from classroom_icons import ScalableIcon # noqa: E402 - -# Optional on purpose. The dock is the one thing the classroom cannot -# afford to lose, so a missing DVD helper simply disables disc detection -# instead of taking the whole shell down. -try: - import classroom_optical as optical -except ImportError: - optical = None - -# Only used for the busy feedback on the dock buttons, so a missing -# module costs a nicety rather than the shell. -try: - import classroom_sway as sway -except ImportError: - sway = None - -# Used to draw the throbber. Without it the busy state falls back to a -# stock GtkSpinner, which works but is smaller than the icon language. -try: - import cairo -except ImportError: - cairo = None - - -ICON_DIR = Path("/etc/ekklesia-classroom/icons") - -DVD_POLL_SECONDS = 2 - -# Every measurement in the shell, at UI_SCALE=1.0. The CSS template -# carries the same numbers; these are the ones GTK needs in Python. -SHELL_HEIGHT = 76 -DOCK_SPACING = 10 -CLOCK_EDGE_PADDING = 20 - -BROWSER_ICON_SIZE = 28 -DVD_ICON_SIZE = 40 -SANCTUARY_ICON_SIZE = 38 - -# How Chrome appears in the sway tree, so the dock can tell a launch from -# a restore and stop spinning once the window is really there. -BROWSER_WINDOW = ( - r"google-chrome", - r"chrome", - r"chrome-.*", - r"google-chrome-.*", -) - -# The throbber is drawn at the same size as the icon it stands in for, -# so the button does not change shape when an application is starting. -BUSY_SPINNER_SIZE = 38 -THROBBER_FRAMES = 24 -THROBBER_INTERVAL_MS = 80 - -# Which secondary Chrome window the Close button acts on, published by -# classroom-chrome-windows. A file rather than a socket because the dock -# only ever reads it, and Gio can watch it without a poll. -CLOSE_TARGET_NAME = "chrome-close-target" - -BUSY_POLL_MS = 500 - -# Long enough for a cold Chrome start on classroom hardware, short enough -# that a launch which never happens does not spin all morning. -BUSY_TIMEOUT_SECONDS = 30 - - -def launch(command): - subprocess.Popen(command, start_new_session=True) - - -def notify(title, message): - launch([ - "notify-send", - "--app-name=Ekklesia Classroom", - title, - message, - ]) - - -def themed_icon(*names): - theme = Gtk.IconTheme.get_default() - - for name in names: - if theme.has_icon(name): - return Gtk.Image.new_from_icon_name( - name, - Gtk.IconSize.DIALOG, - ) - - return Gtk.Image.new_from_icon_name( - "application-x-executable", - Gtk.IconSize.DIALOG, - ) - - -def custom_icon(filename, size): - """A classroom SVG icon, drawn at the display's real pixel density.""" - icon = ScalableIcon(filename, size, ICON_DIR) - - return icon if icon.usable else None - - -def throbber_frames(size, scale): - """A white arc, drawn once per frame of one revolution. - - Drawn at the display's real density like the icons, so the busy - state is not the one soft thing in a sharp dock. The surface carries - the device scale, so everything below is in logical units. - """ - if cairo is None: - return None - - stroke = max(2, round(size / 9)) - radius = size / 2 - stroke / 2 - 1 - frames = [] - - for index in range(THROBBER_FRAMES): - surface = cairo.ImageSurface( - cairo.FORMAT_ARGB32, - size * scale, - size * scale, - ) - surface.set_device_scale(scale, scale) - - context = cairo.Context(surface) - - context.set_source_rgba(1, 1, 1, 1) - context.set_line_width(stroke) - context.set_line_cap(cairo.LINE_CAP_ROUND) - - start = index * 2 * math.pi / THROBBER_FRAMES - context.arc(size / 2, size / 2, radius, start, start + math.pi * 1.4) - context.stroke() - - frames.append(surface) - - return frames - - -class Throbber(Gtk.Image): - """The busy state of a dock button.""" - - def __init__(self, size): - super().__init__() - - self.size = size - self.frames = throbber_frames(size, 1) - self.drawn_at = 1 - self.frame = 0 - self.timer = None - - if self.frames: - self.set_from_surface(self.frames[0]) - - self.connect("realize", lambda *_: self.rebuild()) - self.connect("notify::scale-factor", lambda *_: self.rebuild()) - - def rebuild(self): - scale = self.get_scale_factor() or 1 - - if scale == self.drawn_at: - return - - frames = throbber_frames(self.size, scale) - - if not frames: - return - - self.frames = frames - self.drawn_at = scale - self.set_from_surface(self.frames[self.frame]) - - def usable(self): - return bool(self.frames) - - def start(self): - if not self.frames or self.timer is not None: - return - - self.timer = GLib.timeout_add(THROBBER_INTERVAL_MS, self.advance) - - def stop(self): - if self.timer is not None: - GLib.source_remove(self.timer) - self.timer = None - - def advance(self): - self.frame = (self.frame + 1) % len(self.frames) - self.set_from_surface(self.frames[self.frame]) - - return True - - -class ClassroomShell(Gtk.Window): - def __init__(self): - super().__init__() - - screen = self.get_screen() - visual = screen.get_rgba_visual() - - if visual is not None: - self.set_visual(visual) - - self.set_app_paintable(True) - self.set_name("classroom-shell") - self.set_decorated(False) - self.set_resizable(False) - - self.config = read_device_config() - self.scale = SHELL_SCALE - - self.dvd_image = None - self.disc_ready = None - self.buttons = {} - self.close_target = None - self.close_monitor = None - - GtkLayerShell.init_for_window(self) - GtkLayerShell.set_layer( - self, - GtkLayerShell.Layer.TOP, - ) - - GtkLayerShell.set_anchor( - self, - GtkLayerShell.Edge.BOTTOM, - True, - ) - GtkLayerShell.set_anchor( - self, - GtkLayerShell.Edge.LEFT, - True, - ) - GtkLayerShell.set_anchor( - self, - GtkLayerShell.Edge.RIGHT, - True, - ) - - shell_height = scaled(SHELL_HEIGHT, self.scale) - - GtkLayerShell.set_exclusive_zone(self, shell_height) - GtkLayerShell.set_keyboard_interactivity( - self, - False, - ) - - layout = Gtk.Overlay() - layout.set_size_request(-1, shell_height) - - dock = self.build_dock() - - dock_align = Gtk.Alignment( - xalign=0.5, - yalign=0.5, - xscale=0, - yscale=0, - ) - dock_align.add(dock) - layout.add(dock_align) - - self.clock = Gtk.Label() - self.clock.get_style_context().add_class("clock") - - clock_align = Gtk.Alignment( - xalign=1.0, - yalign=0.5, - xscale=0, - yscale=0, - ) - clock_align.set_padding( - 0, - 0, - 0, - scaled(CLOCK_EDGE_PADDING, self.scale), - ) - clock_align.add(self.clock) - - layout.add_overlay(clock_align) - layout.set_overlay_pass_through(clock_align, True) - - self.add(layout) - - self.update_time() - GLib.timeout_add_seconds( - 15, - self.update_time, - ) - - def build_dock(self): - dock = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=scaled(DOCK_SPACING, self.scale), - ) - dock.get_style_context().add_class("dock") - - items = dock_items(self.config) - - dock.pack_start( - self.make_button( - "browser", - self.browser_icon(), - self.open_browser, - ), - False, - False, - 0, - ) - - if "dvd" in items: - dock.pack_start( - self.make_button( - "dvd", - self.dvd_icon(), - lambda *_: launch( - ["/usr/local/bin/classroom-dvd", "toggle"] - ), - ), - False, - False, - 0, - ) - - self.start_disc_watch() - - if "live" in items: - dock.pack_start( - self.make_button( - "sanctuary", - self.sanctuary_icon(), - lambda *_: notify( - "Sanctuary", - "Sanctuary live view is being prepared.", - ), - ), - False, - False, - 0, - ) - - # Only ever on screen while a secondary browser window is focused, - # so the ordinary dock is exactly what it has always been. - close = self.make_button( - "close", - custom_icon("close.svg", scaled(BROWSER_ICON_SIZE, self.scale)) - or themed_icon("window-close-symbolic"), - self.close_secondary, - ) - close.set_no_show_all(True) - close.set_visible(False) - - # show_all() does not recurse into a no-show-all widget, so the - # icon has to be shown by hand or the button appears empty when - # it is finally revealed. - self.buttons["close"]["image"].show() - - dock.pack_start(close, False, False, 0) - - self.watch_close_target() - - return dock - - # -- the close affordance for secondary browser windows --------- - # - # The button lives here rather than on the window because a surface - # anchored to a moving window chases it and flickers. The dock does - # not move. - - def watch_close_target(self): - directory = Path( - os.environ.get("XDG_RUNTIME_DIR") or "/tmp" - ) / "ekklesia-classroom" - - try: - directory.mkdir(parents=True, exist_ok=True) - - self.close_path = directory / CLOSE_TARGET_NAME - - # Watching the directory rather than the file: the state is - # published by rename, so the file itself is replaced. - self.close_monitor = Gio.File.new_for_path( - str(directory) - ).monitor_directory(Gio.FileMonitorFlags.NONE, None) - - self.close_monitor.connect( - "changed", - self.on_close_target_changed, - ) - except (OSError, GLib.Error) as error: - print( - f"classroom-dock: not watching for browser popups: {error}", - file=sys.stderr, - ) - self.close_path = None - return - - self.refresh_close_target() - - def on_close_target_changed(self, monitor, changed, other, event): - if changed is not None and changed.get_basename() != CLOSE_TARGET_NAME: - return - - self.refresh_close_target() - - def refresh_close_target(self): - target = None - - if self.close_path is not None: - try: - text = self.close_path.read_text().strip() - except OSError: - text = "" - - if text.isdigit(): - target = int(text) - - if target == self.close_target: - return - - self.close_target = target - - entry = self.buttons.get("close") - - if entry is not None: - entry["button"].set_visible(target is not None) - - def close_secondary(self, *_): - if self.close_target is None or sway is None: - return - - # Closes exactly this container. Never the main browser, and - # never a Chrome process. - sway.command(f"[con_id={self.close_target}] kill") - - def make_button(self, name, image, callback): - button = Gtk.Button() - button.set_relief(Gtk.ReliefStyle.NONE) - button.get_style_context().add_class( - "dock-button" - ) - - button.add(image) - button.connect("clicked", callback) - - self.buttons[name] = { - "button": button, - "image": image, - "source": None, - "spinner": None, - "waited": 0, - } - - return button - - # -- busy feedback ---------------------------------------------- - # - # A click has to be visibly acknowledged even when the thing it - # starts takes several seconds to appear. The button the finger - # landed on becomes a spinner until the window shows up in sway. - - def window_open(self, patterns): - if sway is None: - return True - - return any( - sway.matches(window, patterns) for window in sway.windows() - ) - - def open_browser(self, *_): - # Only a real launch is worth spinning over. Focusing or - # restoring an open window is immediate and should feel it. - starting = not self.window_open(BROWSER_WINDOW) - - launch(["/usr/local/bin/classroom-browser"]) - - if starting: - self.start_busy( - "browser", - BROWSER_WINDOW, - "Browser", - "The browser did not start. Please try again.", - ) - - def start_busy(self, name, patterns, title, message): - entry = self.buttons.get(name) - - if sway is None or entry is None or entry["source"] is not None: - return - - spinner = Throbber(scaled(BROWSER_ICON_SIZE, self.scale)) - - if not spinner.usable(): - # No cairo: the stock spinner is smaller than the icon - # language would like, but it still says "working". - spinner = Gtk.Spinner() - size = scaled(BUSY_SPINNER_SIZE, self.scale) - spinner.set_size_request(size, size) - - entry["button"].remove(entry["image"]) - entry["button"].add(spinner) - spinner.show() - spinner.start() - - entry["spinner"] = spinner - entry["waited"] = 0 - entry["source"] = GLib.timeout_add( - BUSY_POLL_MS, - self.check_busy, - name, - patterns, - title, - message, - ) - - self.set_busy_cursor(True) - - def check_busy(self, name, patterns, title, message): - entry = self.buttons.get(name) - - if entry is None or entry["source"] is None: - return GLib.SOURCE_REMOVE - - entry["waited"] += BUSY_POLL_MS / 1000 - - if self.window_open(patterns): - self.stop_busy(name) - return GLib.SOURCE_REMOVE - - if entry["waited"] >= BUSY_TIMEOUT_SECONDS: - self.stop_busy(name) - notify(title, message) - return GLib.SOURCE_REMOVE - - return GLib.SOURCE_CONTINUE - - def stop_busy(self, name): - entry = self.buttons.get(name) - - if entry is None or entry["source"] is None: - return - - entry["source"] = None - - spinner = entry["spinner"] - - if spinner is not None: - spinner.stop() - entry["button"].remove(spinner) - entry["spinner"] = None - - entry["button"].add(entry["image"]) - entry["image"].show() - - self.set_busy_cursor( - any(other["source"] is not None for other in self.buttons.values()) - ) - - def set_busy_cursor(self, busy): - """A waiting pointer, for as long as it is over the dock. - - Wayland gives the cursor to whichever client the pointer is over, - and sway offers no way to set one globally, so this is only - honoured on our own surface. That is where the pointer is when a - dock button has just been pressed, which is the moment that - matters. - """ - window = self.get_window() - - if window is None: - return - - cursor = None - - if busy: - cursor = Gdk.Cursor.new_from_name( - window.get_display(), - "progress", - ) - - window.set_cursor(cursor) - - def browser_icon(self): - size = scaled(BROWSER_ICON_SIZE, self.scale) - - image = custom_icon( - "browser.svg", - size, - ) - - if image is not None: - return image - - image = themed_icon( - "web-browser-symbolic", - "internet-web-browser-symbolic", - ) - image.set_pixel_size(size) - - return image - - def dvd_icon(self): - size = scaled(DVD_ICON_SIZE, self.scale) - - image = custom_icon( - "dvd.svg", - size, - ) - - if image is not None: - self.dvd_image = image - return image - - image = themed_icon( - "media-optical-symbolic", - "media-optical", - ) - image.set_pixel_size(size) - - return image - - def sanctuary_icon(self): - is_live = enabled( - self.config, - "SANCTUARY_LIVE", - False, - ) - - filename = ( - "live.svg" - if is_live - else "live-off.svg" - ) - - size = scaled(SANCTUARY_ICON_SIZE, self.scale) - - image = custom_icon( - filename, - size, - ) - - if image is not None: - return image - - image = themed_icon( - "video-display-symbolic", - "video-display", - ) - image.set_pixel_size(size) - - return image - - def start_disc_watch(self): - if optical is None: - return - - self.check_disc() - - GLib.timeout_add_seconds( - DVD_POLL_SECONDS, - self.check_disc, - ) - - def check_disc(self): - ready = optical.drive_status() == optical.CDS_DISC_OK - - # The disc indicator: the DVD logo sits back when the drive is - # empty or unplugged and comes up to full strength once a disc is - # ready, the same gray/colour language the LIVE icon uses. - if self.dvd_image is not None: - self.dvd_image.set_opacity(1.0 if ready else 0.55) - - # The first poll only records what is already in the drive, so a - # disc left in over the week does not announce itself at boot. - if self.disc_ready is not None and ready and not self.disc_ready: - notify( - "DVD Ready", - "A disc is ready to play.", - ) - - self.disc_ready = ready - - return True - - def update_time(self): - self.clock.set_text( - datetime.now().strftime("%-I:%M %p") - ) - return True - - -def load_css(scale): - # Styling is worth losing before the dock is. A stylesheet that will - # not parse leaves an unstyled but working shell rather than no shell. - try: - provider = Gtk.CssProvider() - provider.load_from_data( - style.render(scale).encode("utf-8") - ) - except (OSError, GLib.Error) as error: - print( - f"classroom-dock: stylesheet not applied: {error}", - file=sys.stderr, - ) - return - - screen = Gdk.Screen.get_default() - - Gtk.StyleContext.add_provider_for_screen( - screen, - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - -def main(): - load_css(SHELL_SCALE) - - shell = ClassroomShell() - shell.connect( - "destroy", - Gtk.main_quit, - ) - shell.show_all() - - Gtk.main() - - -if __name__ == "__main__": - main() diff --git a/scripts/classroom-notifications b/scripts/classroom-notifications deleted file mode 100755 index 089bff3..0000000 --- a/scripts/classroom-notifications +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 - -"""Starts mako with the classroom styling at the room's UI scale. - -mako only reads ~/.config, and its measurements have to follow UI_SCALE, -so the packaged template is resolved into the session's runtime directory -and mako is pointed at the result. -""" - -import os -import sys -from pathlib import Path - -sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") - -from classroom_config import SHELL_SCALE, scaled # noqa: E402 - -MAKO = "/usr/bin/mako" -TEMPLATE = Path("/etc/ekklesia-classroom/mako/config.in") - -# The same measurements the template carries, at UI_SCALE=1.0. -MEASUREMENTS = { - "font-size": 12, - "radius": 18, - "padding-v": 14, - "padding-h": 18, - "margin": 18, - "width": 380, - "height": 160, - "icon-size": 48, -} - - -def runtime_directory(): - base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp" - - directory = Path(base) / "ekklesia-classroom" - directory.mkdir(parents=True, exist_ok=True) - - return directory - - -def render(scale): - config = TEMPLATE.read_text() - - for token, value in MEASUREMENTS.items(): - config = config.replace( - f"@{token}@", - str(scaled(value, scale)), - ) - - return config - - -def main(): - scale = SHELL_SCALE - - try: - path = runtime_directory() / "mako.config" - path.write_text(render(scale)) - config_argument = str(path) - except OSError as error: - # Better plain notifications than none at all. - print( - f"classroom-notifications: falling back to the unscaled template: {error}", - file=sys.stderr, - ) - config_argument = str(TEMPLATE) - - os.execv(MAKO, [MAKO, "--config", config_argument]) - - -if __name__ == "__main__": - main() diff --git a/scripts/classroom-notify b/scripts/classroom-notify index 029b2df..5785340 100755 --- a/scripts/classroom-notify +++ b/scripts/classroom-notify @@ -11,12 +11,12 @@ set -euo pipefail # Used by classroom-notify-test to send a sample, and by classroom-mqtt # to put messages from the church broker on the screen. # -# The classroom desktop runs as the classroom user, and mako is attached -# to that user's D-Bus session. An administrator over SSH sits in a -# different session where nothing owns org.freedesktop.Notifications, so -# a plain notify-send there asks D-Bus to start a second mako, which has -# no display to open and times out with NoReply. Find the running desktop -# session and deliver into it instead. +# The classroom desktop runs as the classroom user, and the notification +# service is part of that user's Plasma session. An administrator over SSH +# sits in a different session where nothing owns +# org.freedesktop.Notifications, so a plain notify-send there asks D-Bus to +# start a service that has no display to open, and times out with NoReply. +# Find the running desktop session and deliver into it instead. CLASSROOM_USER=classroom @@ -80,7 +80,7 @@ environ_value() { printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1 } -# sway holds its own wayland-N.lock open, so the display name can be read +# The compositor holds its own wayland-N.lock open, so the display name can be read # back off the process instead of being guessed at. display_from_lock() { local link target @@ -115,17 +115,19 @@ if [[ "$current_uid" == "$classroom_uid" && -n "${WAYLAND_DISPLAY:-}" && -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then # Already inside the classroom desktop session. - sway_pid="n/a" + session_pid="n/a" runtime_dir="${XDG_RUNTIME_DIR:-}" bus="$DBUS_SESSION_BUS_ADDRESS" wayland_display="$WAYLAND_DISPLAY" else session="the $CLASSROOM_USER desktop session" - sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)" + # The compositor is the session: it owns the Wayland display and the + # environment every other part of the session inherited. + session_pid="$(pgrep -u "$CLASSROOM_USER" -x kwin_wayland | head -n 1 || true)" - [[ -n "$sway_pid" ]] || - fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER." + [[ -n "$session_pid" ]] || + fail "no classroom desktop session is running: there is no kwin_wayland process for user $CLASSROOM_USER." # Checked before anything that needs to look inside the session, so # an administrator gets told what to do rather than a permission @@ -134,12 +136,12 @@ else fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user." fi - environment_block="$(read_environ "$sway_pid")" + environment_block="$(read_environ "$session_pid")" if [[ -n "$environment_block" ]]; then - source_note="read from sway (pid $sway_pid)" + source_note="read from the compositor (pid $session_pid)" else - source_note="sway's environment was unreadable; using session defaults" + source_note="the compositor's environment was unreadable; using session defaults" fi runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)" @@ -164,7 +166,7 @@ else wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)" if [[ -z "$wayland_display" ]]; then - wayland_display="$(display_from_lock "$sway_pid")" + wayland_display="$(display_from_lock "$session_pid")" fi environment=( @@ -186,15 +188,15 @@ else fi fi -# Deliver to the mako that is already running. Sending into a session -# without one would have D-Bus start a second mako, which is exactly the -# failure this script exists to avoid. -pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 || - fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako" +# Deliver to the notification service that is already running. Sending into +# a session without one would have D-Bus start something with no display to +# draw on, which is exactly the failure this script exists to avoid. +pgrep -u "$CLASSROOM_USER" -x plasmashell >/dev/null 2>&1 || + fail "the classroom shell is not running, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a plasmashell" if [[ "$dry_run" == true ]]; then echo "Would notify $session:" - echo " sway pid: $sway_pid" + echo " compositor pid: $session_pid" echo " session environment: $source_note" echo " XDG_RUNTIME_DIR: $runtime_dir" echo " DBUS_SESSION_BUS_ADDRESS: $bus" diff --git a/systemd/autologin/autologin.conf b/systemd/autologin/autologin.conf deleted file mode 100644 index a6496e2..0000000 --- a/systemd/autologin/autologin.conf +++ /dev/null @@ -1,3 +0,0 @@ -[Service] -ExecStart= -ExecStart=-/sbin/agetty --autologin classroom --noclear %I $TERM diff --git a/tests/test-classroom-chrome-windows.sh b/tests/test-classroom-chrome-windows.sh deleted file mode 100755 index cbe0a9b..0000000 --- a/tests/test-classroom-chrome-windows.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/bin/bash -set -uo pipefail - -# Secondary Chrome windows: which window is which, how a popup is placed, -# and which one the dock offers to close. -# -# tests/test-classroom-chrome-windows.sh - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' -import importlib.machinery -import importlib.util -import json -import os -import sys -import tempfile -from pathlib import Path - -from classroom_chrome import ChromeRoles, chrome_windows - -root = Path(sys.argv[1]) -path = root / "scripts/classroom-chrome-windows" -loader = importlib.machinery.SourceFileLoader("classroom_chrome_windows", str(path)) -spec = importlib.util.spec_from_loader(loader.name, loader) -script = importlib.util.module_from_spec(spec) -loader.exec_module(script) - -source = path.read_text() -dock_source = (root / "scripts/classroom-dock").read_text() - -passed = failed = 0 - - -def check(label, condition, detail=""): - global passed, failed - if condition: - print(f" ok {label}") - passed += 1 - else: - print(f" FAIL {label}") - if detail: - print(f" {detail}") - failed += 1 - - -def window(identifier, title, floating=False, hidden=False, focused=False): - return { - "id": identifier, - "app_id": "google-chrome", - "class": "", - "instance": "", - "pid": 4242, - "name": title, - "focused": focused, - "floating": floating, - "hidden": hidden, - } - - -print("1. creation order decides which window is the browser") -roles = ChromeRoles() -main = window(5, "Main browser - Google Chrome") -popup = window(8, "about:blank - Google Chrome") - -change = roles.update([main]) -check("the first Chrome window is the browser", change["primary"] == 5) -check("it is not a popup", change["new_secondary"] == []) - -change = roles.update([main, popup]) -check("a later Chrome window is a popup", change["new_secondary"] == [8]) -check("the browser stays the browser", change["primary"] == 5) - -renamed_main = window(5, "Something else entirely") -renamed_popup = window(8, "A different page title") -change = roles.update([renamed_main, renamed_popup]) -check("titles never change a role", - change["primary"] == 5 and not change["new_secondary"]) -check("a shared PID never confuses roles", - len(chrome_windows([renamed_main, renamed_popup])) == 2) - -print() -print("2. several popups at once") -third = window(9, "Third window") -change = roles.update([main, popup, third]) -check("a third top-level is also a popup", change["new_secondary"] == [9]) -change = roles.update([main, popup]) -check("closing one leaves the other a popup", roles.secondary == [8]) -check("the closed one is reported", change["removed"] == [9]) - -print() -print("3. how a popup is placed") -commands = [] -script.command = lambda argument: commands.append(argument) - -script.SETTLE_SECONDS = 0 -script.configure_secondary(8) -check("floats exactly one container", commands[0] == "[con_id=8] floating enable") -check("borderless, like the main browser", commands[1] == "[con_id=8] border none") -check("about three quarters of the output", - "width 76 ppt height 76 ppt" in commands[2], commands) -check("centred", commands[3] == "[con_id=8] move position center") -check("focused", commands[4] == "[con_id=8] focus") -# Chrome resizes a popup after the fact, so the placement is repeated -# once the size has settled or the window ends up off to one side. -check("placed again after Chrome settles", - commands[-1] == "[con_id=8] move position center" - and "width 76 ppt" in commands[-2], commands) -check("placed exactly twice, never on a loop", - commands.count("[con_id=8] move position center") == 2, commands) -check("never matches all Chrome windows at once", - all("app_id" not in item for item in commands)) - -commands.clear() -script.configure_secondary(8, arriving=False) -check("restoring the role never moves the window", - not any("move position" in item for item in commands), commands) -check("restoring the role never raises it over a dialog", - not any("focus" in item for item in commands), commands) -check("it only puts the window back to floating and borderless", - commands == ["[con_id=8] floating enable", "[con_id=8] border none"], - commands) - -commands.clear() -script.configure_primary(8) -check("a promoted window goes back to tiled", - commands[0] == "[con_id=8] floating disable") -check("and borderless", commands[1] == "[con_id=8] border none") - -print() -print("4. the dock's Close target survives losing focus") -# The whole point: the pointer has to travel from the popup down to the -# dock, and that moves focus. If the target followed focus the button -# would vanish before anyone could reach it. -active = script.ActiveSecondary() - -chrome = {5: window(5, "main", focused=True)} -check("nothing to close with only the main browser", - active.update([], chrome) is None) - -chrome = {5: window(5, "main"), 8: window(8, "popup", focused=True)} -check("a focused popup becomes the target", active.update([8], chrome) == 8) - -chrome = {5: window(5, "main"), 8: window(8, "popup")} -check("it stays the target when nothing is focused", - active.update([8], chrome) == 8) - -chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} -check("it stays when focus moves to the main browser", - active.update([8], chrome) == 8) -check("the main browser is never the target", active.target != 5) - -chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog", focused=True)} -check("a newer focused popup takes over", active.update([8, 9], chrome) == 9) - -chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog")} -check("and keeps it after focus moves to the dock", - active.update([8, 9], chrome) == 9) - -chrome = {5: window(5, "main"), 8: window(8, "popup")} -check("closing the dialog falls back to the render window", - active.update([8], chrome) == 8) - -chrome = {5: window(5, "main")} -check("closing the last popup clears it", active.update([], chrome) is None) - -fresh = script.ActiveSecondary() -chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} -check("an unfocused popup is still closeable", - fresh.update([8], chrome) == 8) - -hidden = script.ActiveSecondary() -chrome = {8: window(8, "one", hidden=True)} -check("a minimised popup is not adopted", - hidden.update([8], chrome) is None) - -print() -print("4b. the print dialog stays in front") - -def win(identifier, title, focused=False, hidden=False): - w = window(identifier, title, focused=focused, hidden=hidden) - w["name"] = title - return w - -check("the dialog is recognised by its exact title", - script.is_print_dialog({"name": "Print"})) -check("the render window is not", - not script.is_print_dialog({"name": "Printable Document - Google Chrome"})) -check("nothing else is", not script.is_print_dialog({"name": "Print Preview"})) -check("a missing title is not", not script.is_print_dialog({})) - -commands.clear() -# The render window has raised itself over the dialog. -chrome = {5: win(5, "main"), 8: win(8, "Printable Document", focused=True), - 9: win(9, "Print")} -script.keep_print_in_front([8, 9], chrome) -check("focus is taken back for the dialog", - commands == ["[con_id=9] focus"], commands) - -commands.clear() -chrome = {5: win(5, "main"), 8: win(8, "Printable Document"), 9: win(9, "Print", focused=True)} -script.keep_print_in_front([8, 9], chrome) -check("nothing happens while the dialog already has focus", commands == [], commands) - -commands.clear() -chrome = {5: win(5, "main", focused=True), 8: win(8, "Printable Document"), - 9: win(9, "Print")} -script.keep_print_in_front([8, 9], chrome) -check("focus is never stolen from the main browser", commands == [], commands) - -commands.clear() -chrome = {5: win(5, "main"), 8: win(8, "Printable Document")} -script.keep_print_in_front([8], chrome) -check("nothing happens when there is no dialog", commands == [], commands) - -commands.clear() -chrome = {8: win(8, "Printable Document", focused=True), 9: win(9, "Print", hidden=True)} -script.keep_print_in_front([8, 9], chrome) -check("a minimised dialog is left alone", commands == [], commands) - -# Both arriving together: whichever is configured last ends up in front, -# so the dialog has to be last. -order = sorted([8, 9], key=lambda i: script.is_print_dialog( - {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) -check("the dialog is configured after the render window", order == [8, 9], order) -order = sorted([9, 8], key=lambda i: script.is_print_dialog( - {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) -check("even when the tree lists it first", order == [8, 9], order) - -print() -print("5. the state the dock reads") -work = Path(tempfile.mkdtemp()) -state = work / "chrome-close-target" - -script.publish(9, state) -check("publishes the container id", state.read_text().strip() == "9") -check("published by rename, not left half written", - not (work / "chrome-close-target.tmp").exists()) - -script.publish(None, state) -check("empty when there is nothing to close", state.read_text().strip() == "") - -print() -print("6. the dock owns the button, not the window") -check("no layer-shell surface in the window helper", - "GtkLayerShell" not in source) -check("no second GTK process spawned for a button", - "Gtk.main" not in source and "show_close_button" not in source) -check("no geometry maths chasing a window", - "output_rect" not in source and "button_margins" not in source) -check("the dock has a close action", '"close"' in dock_source) -check("the dock closes exactly one container", - '[con_id={self.close_target}] kill' in dock_source) -check("the dock never kills Chrome processes", - "pkill" not in dock_source and "killall" not in dock_source) -check("the dock watches rather than polls for it", - "monitor_directory" in dock_source) - -print() -print("7. driven by Sway events, not a poll") -check("subscribes to window events", '"-t", "subscribe"' in source) -check("ignores title churn", - "title" not in script.INTERESTING and "new" in script.INTERESTING) -check("reacts to focus and movement", - {"focus", "move", "close"} <= set(script.INTERESTING)) -check("no polling loop constant", "POLL_SECONDS" not in source) - -print() -print("8. the session still starts it") -config = (root / "config/sway/config").read_text() -check("started with the session", - "exec --no-startup-id /usr/local/bin/classroom-chrome-windows" in config) -check("no broad Chrome floating rule", - not any("floating enable" in line and "google-chrome" in line - for line in config.splitlines())) - -print() -print(f"passed: {passed} failed: {failed}") -sys.exit(1 if failed else 0) -PY diff --git a/tests/test-classroom-notify.sh b/tests/test-classroom-notify.sh index 561141c..57c35df 100755 --- a/tests/test-classroom-notify.sh +++ b/tests/test-classroom-notify.sh @@ -3,7 +3,7 @@ set -uo pipefail # Tests the session resolution in classroom-notify-test. # -# sway is stood in for by a real background process started with a known +# The compositor is stood in for by a real background process started with a known # environment, so /proc//environ is genuine rather than mocked. id, # pgrep, runuser and notify-send are shimmed onto PATH so every branch # can be reached from an ordinary account. @@ -14,7 +14,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT="$ROOT/scripts/classroom-notify" WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT +trap 'rm -rf "$WORK"; kill "${FAKE_SESSION_PID:-}" 2>/dev/null' EXIT passed=0 failed=0 @@ -67,12 +67,12 @@ SHIM cat > "$WORK/bin/pgrep" <<'SHIM' #!/bin/bash case "${!#}" in - sway) - [[ -n "${FAKE_SWAY_PID:-}" ]] || exit 1 - echo "$FAKE_SWAY_PID" + kwin_wayland) + [[ -n "${FAKE_SESSION_PID:-}" ]] || exit 1 + echo "$FAKE_SESSION_PID" ;; - mako) - [[ "${FAKE_MAKO:-1}" == "1" ]] || exit 1 + plasmashell) + [[ "${FAKE_SHELL:-1}" == "1" ]] || exit 1 echo 4242 ;; *) @@ -120,27 +120,27 @@ run() { "$SCRIPT" "$@" 2>&1 } -start_fake_sway() { +start_fake_session() { env "$@" sleep 300 & - FAKE_SWAY_PID=$! - export FAKE_SWAY_PID + FAKE_SESSION_PID=$! + export FAKE_SESSION_PID # Give the exec time to land so /proc//environ is populated. - while [[ ! -r "/proc/$FAKE_SWAY_PID/environ" ]]; do sleep 0.05; done + while [[ ! -r "/proc/$FAKE_SESSION_PID/environ" ]]; do sleep 0.05; done } -stop_fake_sway() { - [[ -n "${FAKE_SWAY_PID:-}" ]] && kill "$FAKE_SWAY_PID" 2>/dev/null - wait "${FAKE_SWAY_PID:-}" 2>/dev/null - unset FAKE_SWAY_PID +stop_fake_session() { + [[ -n "${FAKE_SESSION_PID:-}" ]] && kill "$FAKE_SESSION_PID" 2>/dev/null + wait "${FAKE_SESSION_PID:-}" 2>/dev/null + unset FAKE_SESSION_PID } unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS # --------------------------------------------- 1. resolve from environ -echo "1. resolves the session from sway's environment" +echo "1. resolves the session from the compositor's environment" -start_fake_sway \ +start_fake_session \ XDG_RUNTIME_DIR="$RUNTIME" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \ WAYLAND_DISPLAY=wayland-7 @@ -149,9 +149,9 @@ out="$(run)" check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out" log="$(cat "$NOTIFY_LOG")" -check "runtime dir taken from sway" "XDG_RUNTIME_DIR=$RUNTIME" "$log" -check "bus address taken from sway" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log" -check "wayland display from sway" "WAYLAND_DISPLAY=wayland-7" "$log" +check "runtime dir taken from the compositor" "XDG_RUNTIME_DIR=$RUNTIME" "$log" +check "bus address taken from the compositor" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log" +check "wayland display from the compositor" "WAYLAND_DISPLAY=wayland-7" "$log" check "styling preserved" "--app-name=Ekklesia Classroom" "$log" check "default sample title" "DVD Ready" "$log" check "default sample body" "A disc is ready to play." "$log" @@ -161,7 +161,7 @@ check "default sample body" "A disc is ready to play." echo "2. --dry-run reports the resolved session and sends nothing" out="$(run --dry-run)" -check "shows the sway pid" "sway pid: $FAKE_SWAY_PID" "$out" +check "shows the compositor pid" "compositor pid: $FAKE_SESSION_PID" "$out" check "shows the runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out" check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" @@ -196,35 +196,35 @@ check "explains it needs root" "run this as root (sudo classroom-notify)" "$out" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" FAKE_CURRENT_UID=4000 -# ----------------------------------------- 6. mako must already be up +# ------------------------------- 6. the shell must already be up -echo "6. refuses to let D-Bus start a second mako" +echo "6. refuses to let D-Bus start a notification service" -export FAKE_MAKO=0 +export FAKE_SHELL=0 out="$(run)" -check "explains mako is not running" "mako is not running in the classroom session" "$out" +check "explains the shell is not running" "the classroom shell is not running" "$out" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" -unset FAKE_MAKO +unset FAKE_SHELL -stop_fake_sway +stop_fake_session # --------------------------------- 7. display derived from the lock fd -echo "7. derives the display from sway's wayland lock when environ has none" +echo "7. derives the display from the compositor's wayland lock when environ has none" touch "$RUNTIME/wayland-9.lock" env -u WAYLAND_DISPLAY \ XDG_RUNTIME_DIR="$RUNTIME" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \ bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" & -FAKE_SWAY_PID=$! -export FAKE_SWAY_PID -while [[ ! -r "/proc/$FAKE_SWAY_PID/fd/9" ]]; do sleep 0.05; done +FAKE_SESSION_PID=$! +export FAKE_SESSION_PID +while [[ ! -r "/proc/$FAKE_SESSION_PID/fd/9" ]]; do sleep 0.05; done out="$(run --dry-run)" check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out" -stop_fake_sway +stop_fake_session # ------------------------------------------- 8. no session at all @@ -236,9 +236,9 @@ check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" # ------------------------------------- 9. falls back when environ is thin -echo "9. falls back to session defaults when sway's environment is unreadable" +echo "9. falls back to session defaults when the compositor's environment is unreadable" -start_fake_sway XDG_RUNTIME_DIR="$RUNTIME" +start_fake_session XDG_RUNTIME_DIR="$RUNTIME" out="$(run --dry-run)" check "derives the bus from the runtime dir" \ @@ -249,7 +249,7 @@ check "still delivers" "Sent normal notification to the classroom desktop sessio check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \ "$(cat "$NOTIFY_LOG")" -stop_fake_sway +stop_fake_session # ------------------------------------------ 10. missing bus is explained @@ -257,19 +257,19 @@ echo "10. explains a missing session bus instead of hanging" MISSING="$WORK/empty" mkdir -p "$MISSING" -start_fake_sway XDG_RUNTIME_DIR="$MISSING" +start_fake_session XDG_RUNTIME_DIR="$MISSING" out="$(run)" check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" -stop_fake_sway +stop_fake_session # ----------------------------- 11. already inside the classroom session echo "11. sends directly when already inside the classroom session" -# No sway process to find: an in-session caller must not need one. +# No compositor process to find: an in-session caller must not need one. export WAYLAND_DISPLAY=wayland-4 export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" export XDG_RUNTIME_DIR="$RUNTIME" diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 30c06d6..62ecb97 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -16,7 +16,10 @@ provision = (root / "scripts/classroom-provision").read_text() units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service")) canary = (root / "update_canary.sh").read_text() validation_script = (root / "tools/validate-trixie-release.sh").read_text() -sway = (root / "config/sway/config").read_text() +layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text() +rules = (root / "config/plasma/xdg/kwinrulesrc").read_text() +restrictions = (root / "config/plasma/xdg/kdeglobals").read_text() +supervision = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text() build = (root / "tools/build-release.sh").read_text() policy = json.loads( (root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text() @@ -67,14 +70,41 @@ check("printer provisioning is bounded", "--kill-after=5s 30s" in provision) check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True) print() -print("4. secondary Chrome windows") -check("stateful Chrome helper starts with Sway", "/usr/local/bin/classroom-chrome-windows" in sway) -check("Chrome helper is packaged", '"$ROOT/scripts/classroom-chrome-windows"' in build) -check("Chrome role module is packaged", '"$ROOT/lib/classroom_chrome.py"' in build) -check("no broad Chrome floating rule", not any( - "google-chrome" in line and "floating enable" in line - for line in sway.splitlines() -)) +print("4. secondary windows are the window manager's business") +# Chrome gives its main window and every window a page opens the same +# class, so a rule keyed on that class maximises the lesson's popup too. +# It was measured doing exactly that. The browser is told to start +# maximized instead, and no rule mentions it. +check("no window rule claims the browser", "google-chrome" not in rules) +check("the browser asks for its own first window", "--start-maximized" in + (root / "scripts/classroom-browser").read_text()) +check("Wonder Ink asks for the same", "--start-maximized" in + (root / "scripts/classroom-wonderink").read_text()) +check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists()) +check("and is not packaged", "classroom-chrome-windows" not in build) + +print() +print("4b. the panel is never allowed to stay dead") +# Plasma restarts a crashed panel three times a minute and then gives up. +# Measured: dead on the third kill. These lines are what make it +# unconditional, and losing them would lose the room its only way around. +check("restart is unconditional", "Restart=always" in supervision) +check("the burst limit is lifted", "StartLimitIntervalSec=0" in supervision) +check("the drop-in is packaged", "plasma-plasmashell.service.d" in build) + +print() +print("4c. the teacher cannot reach a command line") +for restriction in ("run_command=false", "shell_access=false", + "action/lock_screen=false", + "plasma/plasmashell/unlockedDesktop=false"): + check(f"{restriction}", restriction in restrictions) +check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions) + +print() +print("4d. clicking a launcher never hides an application") +check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout) +check("there is no minimise button", "ButtonsOnRight=FX" in + (root / "config/plasma/xdg/kwinrc").read_text()) print() print("5. bounded Trixie validation") diff --git a/tools/build-release.sh b/tools/build-release.sh index b35a27c..4c9eae8 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -283,6 +283,48 @@ install -Dm644 \ "$ROOT/lib/classroom_audio.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py" +# Still here, and still sway-shaped. +# +# The DVD control surface and the MQTT notifier were deliberately not +# rewritten in this change, because the room's disc playback works and +# breaking it to tidy the tree would be a bad trade. classroom-window and +# classroom_sway speak sway's IPC, so the DVD player's focus handling is +# the one part of the classroom that does NOT yet work on this desktop. +# See docs/classroom-v2-experiment.md. +install -Dm755 \ + "$ROOT/scripts/classroom-window" \ + "$STAGE/usr/local/bin/classroom-window" + +install -Dm755 \ + "$ROOT/scripts/classroom-notify" \ + "$STAGE/usr/local/bin/classroom-notify" + +install -Dm755 \ + "$ROOT/scripts/classroom-notify-test" \ + "$STAGE/usr/local/bin/classroom-notify-test" + +install -Dm644 \ + "$ROOT/lib/classroom_sway.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_sway.py" + +install -Dm644 \ + "$ROOT/lib/classroom_optical.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py" + +# The DVD overlay draws its own control bar as a layer-shell surface, so it +# still needs the icon and stylesheet helpers the dock used to share. +install -Dm644 \ + "$ROOT/lib/classroom_icons.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py" + +install -Dm644 \ + "$ROOT/lib/classroom_style.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py" + +install -Dm644 \ + "$ROOT/config/icons/dvd.svg" \ + "$STAGE/etc/ekklesia-classroom/icons/dvd.svg" + # DVD mode control bar. for icon in media-play media-pause chapter-prev chapter-next \ disc-menu eject close; do