build: keep the DVD and MQTT helpers, and count the deletion honestly

Building the package showed what reading the tree did not: classroom-dvd
still calls classroom-window, classroom-mqtt still calls classroom-notify,
and the DVD overlay still imports the icon and stylesheet helpers the dock
shared. They go back in, and the write-up now counts what was actually
removed rather than what could be.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 09:03:26 -05:00
co-authored by Claude Opus 5
parent 6e4786f448
commit 4c3f19d599
17 changed files with 171 additions and 1778 deletions
-3
View File
@@ -1,3 +0,0 @@
if [ -z "$WAYLAND_DISPLAY" ] && [ "${XDG_VTNR:-}" = "1" ]; then
exec sway -c /etc/ekklesia-classroom/sway/config
fi
-102
View File
@@ -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;
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 640 640"><path fill="#ffffff" d="M161.423 279.11L69.816 120.474C128.458 47.068 218.708-.012 319.976-.012c117.168 0 219.57 62.965 275.33 156.888H334.175c-4.69-.39-9.438-.638-14.245-.638-76.158 0-140.352 52.288-158.553 122.848l.048.024zm273.121-75.958H617.96c14.197 36.202 22.04 75.64 22.04 116.872C640 495.55 498.668 638.04 323.626 640l130.88-226.692c18.449-26.492 29.244-58.642 29.244-93.284 0-45.71-18.839-87.12-49.205-116.872zM203.742 320.024c0-64.099 52.158-116.234 116.234-116.234 64.087 0 116.246 52.159 116.246 116.234 0 64.088-52.158 116.246-116.246 116.246-64.076 0-116.234-52.158-116.234-116.246zm160.112 157.762l-91.596 158.695C118.17 613.438 0 480.515 0 320.024c0-56.988 14.918-110.505 41.044-156.876l130.643 226.278c26.162 55.678 82.808 94.372 148.277 94.372 15.19 0 29.87-2.091 43.879-5.965l.012-.047z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-48
View File
@@ -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=<b>%s</b>\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
-62
View File
@@ -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
-35
View File
@@ -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}"
}
}
-20
View File
@@ -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;
}
+29 -14
View File
@@ -316,26 +316,41 @@ loop goes away. **Not exercised** — the lab has no optical drive.
## Custom code this deletes ## Custom code this deletes
Deleted on this branch, and counted rather than estimated:
| | lines | replaced by | | | lines | replaced by |
| --- | --- | --- | | --- | --- | --- |
| `classroom-dock` | 754 | the Plasma panel | | `scripts/classroom-dock` | 754 | the Plasma panel |
| `classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager | | `scripts/classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager |
| `classroom-window` | 208 | the task manager | | `tests/test-classroom-chrome-windows.sh` | 280 | — |
| `classroom-notify` | 214 | Plasma notifications | | `config/dock/style.css.in` | 102 | the colour scheme |
| `classroom-notifications` | 74 | — | | `scripts/classroom-notifications` | 74 | Plasma notifications |
| `classroom-scale` | 57 | Plasma display scaling | | `config/sway/config` | 62 | `kwinrc` |
| `classroom_sway.py` | 127 | — | | `config/mako/config.in` | 48 | `plasmanotifyrc` |
| `classroom_icons.py` | 95 | the icon theme | | `config/waybar/*` | 55 | the Plasma panel |
| `classroom_style.py` | 36 | the colour scheme | | `config/bash_profile`, getty autologin | 6 | SDDM |
| `classroom_optical.py` | 125 | Solid | | `config/icons/browser.svg` | — | Chrome's own icon |
| sway, waybar, mako configs | — | — | | **total** | **1 695** | |
| `config/bash_profile` | 2 | SDDM |
| **total** | **≈ 2 000** | |
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. and CSS setup, printer setup, provisioning, MQTT, the updater, shutdown.
Added: the audio policy and its test, the theme switch, and configuration. 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 ## Alternatives, and why they lost
**Stay on sway, add Waybar.** Cheapest migration, and it loses on the thing **Stay on sway, add Waybar.** Cheapest migration, and it loses on the thing
-314
View File
@@ -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())
-754
View File
@@ -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()
-74
View File
@@ -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()
+23 -21
View File
@@ -11,12 +11,12 @@ set -euo pipefail
# Used by classroom-notify-test to send a sample, and by classroom-mqtt # Used by classroom-notify-test to send a sample, and by classroom-mqtt
# to put messages from the church broker on the screen. # to put messages from the church broker on the screen.
# #
# The classroom desktop runs as the classroom user, and mako is attached # The classroom desktop runs as the classroom user, and the notification
# to that user's D-Bus session. An administrator over SSH sits in a # service is part of that user's Plasma session. An administrator over SSH
# different session where nothing owns org.freedesktop.Notifications, so # sits in a different session where nothing owns
# a plain notify-send there asks D-Bus to start a second mako, which has # org.freedesktop.Notifications, so a plain notify-send there asks D-Bus to
# no display to open and times out with NoReply. Find the running desktop # start a service that has no display to open, and times out with NoReply.
# session and deliver into it instead. # Find the running desktop session and deliver into it instead.
CLASSROOM_USER=classroom CLASSROOM_USER=classroom
@@ -80,7 +80,7 @@ environ_value() {
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1 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. # back off the process instead of being guessed at.
display_from_lock() { display_from_lock() {
local link target local link target
@@ -115,17 +115,19 @@ if [[ "$current_uid" == "$classroom_uid" &&
-n "${WAYLAND_DISPLAY:-}" && -n "${WAYLAND_DISPLAY:-}" &&
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
# Already inside the classroom desktop session. # Already inside the classroom desktop session.
sway_pid="n/a" session_pid="n/a"
runtime_dir="${XDG_RUNTIME_DIR:-}" runtime_dir="${XDG_RUNTIME_DIR:-}"
bus="$DBUS_SESSION_BUS_ADDRESS" bus="$DBUS_SESSION_BUS_ADDRESS"
wayland_display="$WAYLAND_DISPLAY" wayland_display="$WAYLAND_DISPLAY"
else else
session="the $CLASSROOM_USER desktop session" 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" ]] || [[ -n "$session_pid" ]] ||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER." 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 # Checked before anything that needs to look inside the session, so
# an administrator gets told what to do rather than a permission # 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." fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user."
fi fi
environment_block="$(read_environ "$sway_pid")" environment_block="$(read_environ "$session_pid")"
if [[ -n "$environment_block" ]]; then if [[ -n "$environment_block" ]]; then
source_note="read from sway (pid $sway_pid)" source_note="read from the compositor (pid $session_pid)"
else else
source_note="sway's environment was unreadable; using session defaults" source_note="the compositor's environment was unreadable; using session defaults"
fi fi
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)" runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
@@ -164,7 +166,7 @@ else
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)" wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
if [[ -z "$wayland_display" ]]; then if [[ -z "$wayland_display" ]]; then
wayland_display="$(display_from_lock "$sway_pid")" wayland_display="$(display_from_lock "$session_pid")"
fi fi
environment=( environment=(
@@ -186,15 +188,15 @@ else
fi fi
fi fi
# Deliver to the mako that is already running. Sending into a session # Deliver to the notification service that is already running. Sending into
# without one would have D-Bus start a second mako, which is exactly the # a session without one would have D-Bus start something with no display to
# failure this script exists to avoid. # draw on, which is exactly the failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 || pgrep -u "$CLASSROOM_USER" -x plasmashell >/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" 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 if [[ "$dry_run" == true ]]; then
echo "Would notify $session:" echo "Would notify $session:"
echo " sway pid: $sway_pid" echo " compositor pid: $session_pid"
echo " session environment: $source_note" echo " session environment: $source_note"
echo " XDG_RUNTIME_DIR: $runtime_dir" echo " XDG_RUNTIME_DIR: $runtime_dir"
echo " DBUS_SESSION_BUS_ADDRESS: $bus" echo " DBUS_SESSION_BUS_ADDRESS: $bus"
-3
View File
@@ -1,3 +0,0 @@
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin classroom --noclear %I $TERM
-280
View File
@@ -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
+38 -38
View File
@@ -3,7 +3,7 @@ set -uo pipefail
# Tests the session resolution in classroom-notify-test. # 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/<pid>/environ is genuine rather than mocked. id, # environment, so /proc/<pid>/environ is genuine rather than mocked. id,
# pgrep, runuser and notify-send are shimmed onto PATH so every branch # pgrep, runuser and notify-send are shimmed onto PATH so every branch
# can be reached from an ordinary account. # can be reached from an ordinary account.
@@ -14,7 +14,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/scripts/classroom-notify" SCRIPT="$ROOT/scripts/classroom-notify"
WORK="$(mktemp -d)" 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 passed=0
failed=0 failed=0
@@ -67,12 +67,12 @@ SHIM
cat > "$WORK/bin/pgrep" <<'SHIM' cat > "$WORK/bin/pgrep" <<'SHIM'
#!/bin/bash #!/bin/bash
case "${!#}" in case "${!#}" in
sway) kwin_wayland)
[[ -n "${FAKE_SWAY_PID:-}" ]] || exit 1 [[ -n "${FAKE_SESSION_PID:-}" ]] || exit 1
echo "$FAKE_SWAY_PID" echo "$FAKE_SESSION_PID"
;; ;;
mako) plasmashell)
[[ "${FAKE_MAKO:-1}" == "1" ]] || exit 1 [[ "${FAKE_SHELL:-1}" == "1" ]] || exit 1
echo 4242 echo 4242
;; ;;
*) *)
@@ -120,27 +120,27 @@ run() {
"$SCRIPT" "$@" 2>&1 "$SCRIPT" "$@" 2>&1
} }
start_fake_sway() { start_fake_session() {
env "$@" sleep 300 & env "$@" sleep 300 &
FAKE_SWAY_PID=$! FAKE_SESSION_PID=$!
export FAKE_SWAY_PID export FAKE_SESSION_PID
# Give the exec time to land so /proc/<pid>/environ is populated. # Give the exec time to land so /proc/<pid>/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() { stop_fake_session() {
[[ -n "${FAKE_SWAY_PID:-}" ]] && kill "$FAKE_SWAY_PID" 2>/dev/null [[ -n "${FAKE_SESSION_PID:-}" ]] && kill "$FAKE_SESSION_PID" 2>/dev/null
wait "${FAKE_SWAY_PID:-}" 2>/dev/null wait "${FAKE_SESSION_PID:-}" 2>/dev/null
unset FAKE_SWAY_PID unset FAKE_SESSION_PID
} }
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
# --------------------------------------------- 1. resolve from environ # --------------------------------------------- 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" \ XDG_RUNTIME_DIR="$RUNTIME" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
WAYLAND_DISPLAY=wayland-7 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" check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out"
log="$(cat "$NOTIFY_LOG")" log="$(cat "$NOTIFY_LOG")"
check "runtime dir taken from sway" "XDG_RUNTIME_DIR=$RUNTIME" "$log" check "runtime dir taken from the compositor" "XDG_RUNTIME_DIR=$RUNTIME" "$log"
check "bus address taken from sway" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log" check "bus address taken from the compositor" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log"
check "wayland display from sway" "WAYLAND_DISPLAY=wayland-7" "$log" check "wayland display from the compositor" "WAYLAND_DISPLAY=wayland-7" "$log"
check "styling preserved" "--app-name=Ekklesia Classroom" "$log" check "styling preserved" "--app-name=Ekklesia Classroom" "$log"
check "default sample title" "DVD Ready" "$log" check "default sample title" "DVD Ready" "$log"
check "default sample body" "A disc is ready to play." "$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" echo "2. --dry-run reports the resolved session and sends nothing"
out="$(run --dry-run)" 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 runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out"
check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out" check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" 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")" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
FAKE_CURRENT_UID=4000 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)" 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")" 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 # --------------------------------- 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" touch "$RUNTIME/wayland-9.lock"
env -u WAYLAND_DISPLAY \ env -u WAYLAND_DISPLAY \
XDG_RUNTIME_DIR="$RUNTIME" \ XDG_RUNTIME_DIR="$RUNTIME" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" & bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" &
FAKE_SWAY_PID=$! FAKE_SESSION_PID=$!
export FAKE_SWAY_PID export FAKE_SESSION_PID
while [[ ! -r "/proc/$FAKE_SWAY_PID/fd/9" ]]; do sleep 0.05; done while [[ ! -r "/proc/$FAKE_SESSION_PID/fd/9" ]]; do sleep 0.05; done
out="$(run --dry-run)" out="$(run --dry-run)"
check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out" check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out"
stop_fake_sway stop_fake_session
# ------------------------------------------- 8. no session at all # ------------------------------------------- 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 # ------------------------------------- 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)" out="$(run --dry-run)"
check "derives the bus from the runtime dir" \ 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" \ check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \
"$(cat "$NOTIFY_LOG")" "$(cat "$NOTIFY_LOG")"
stop_fake_sway stop_fake_session
# ------------------------------------------ 10. missing bus is explained # ------------------------------------------ 10. missing bus is explained
@@ -257,19 +257,19 @@ echo "10. explains a missing session bus instead of hanging"
MISSING="$WORK/empty" MISSING="$WORK/empty"
mkdir -p "$MISSING" mkdir -p "$MISSING"
start_fake_sway XDG_RUNTIME_DIR="$MISSING" start_fake_session XDG_RUNTIME_DIR="$MISSING"
out="$(run)" out="$(run)"
check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out" check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")" check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
stop_fake_sway stop_fake_session
# ----------------------------- 11. already inside the classroom session # ----------------------------- 11. already inside the classroom session
echo "11. sends directly when 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 WAYLAND_DISPLAY=wayland-4
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
export XDG_RUNTIME_DIR="$RUNTIME" export XDG_RUNTIME_DIR="$RUNTIME"
+39 -9
View File
@@ -16,7 +16,10 @@ provision = (root / "scripts/classroom-provision").read_text()
units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service")) units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service"))
canary = (root / "update_canary.sh").read_text() canary = (root / "update_canary.sh").read_text()
validation_script = (root / "tools/validate-trixie-release.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() build = (root / "tools/build-release.sh").read_text()
policy = json.loads( policy = json.loads(
(root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text() (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) check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True)
print() print()
print("4. secondary Chrome windows") print("4. secondary windows are the window manager's business")
check("stateful Chrome helper starts with Sway", "/usr/local/bin/classroom-chrome-windows" in sway) # Chrome gives its main window and every window a page opens the same
check("Chrome helper is packaged", '"$ROOT/scripts/classroom-chrome-windows"' in build) # class, so a rule keyed on that class maximises the lesson's popup too.
check("Chrome role module is packaged", '"$ROOT/lib/classroom_chrome.py"' in build) # It was measured doing exactly that. The browser is told to start
check("no broad Chrome floating rule", not any( # maximized instead, and no rule mentions it.
"google-chrome" in line and "floating enable" in line check("no window rule claims the browser", "google-chrome" not in rules)
for line in sway.splitlines() 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()
print("5. bounded Trixie validation") print("5. bounded Trixie validation")
+42
View File
@@ -283,6 +283,48 @@ install -Dm644 \
"$ROOT/lib/classroom_audio.py" \ "$ROOT/lib/classroom_audio.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/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. # DVD mode control bar.
for icon in media-play media-pause chapter-prev chapter-next \ for icon in media-play media-pause chapter-prev chapter-next \
disc-menu eject close; do disc-menu eject close; do