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