feat: take the last of sway out of the classroom

The DVD player was the only thing still speaking a compositor the room
no longer runs. It asked sway's tree whether VLC's window had really
gone fullscreen, because VLC reports playing while its window is still
the black startup one the loading cover exists to hide.

VLC's own status was measured as a replacement and is not one: it
reports fullscreen true in the same 250ms poll as playing, because the
flag is the option it was started with and not a report about its
window. So the cover is held for a floor and capped by the deadline that
was already there, and the compositor is not asked anything.

Focus-or-launch went the same way. The session already takes an
exclusive lock, so a second one cannot start, and bringing an existing
one to the front is the panel's job.

classroom-window, classroom_sway and classroom_chrome are gone with it —
the last of them was reachable from nothing at all.

DVD also moves out of the status row, where it sat beside the clock as
though a disc were a machine reading. It is an application: it is in the
row with Wonder Ink and the browser, and classroom-provision removes it
from the layout on a machine with no optical drive, which retires
DVD_ENABLED. A computer that moves between rooms takes its drive with
it; nobody remembers to edit a file when it does.

The panel is rebuilt from the package at every login now. Plasma keeps
whatever panel it first built, so an update could change the bar in the
package and never change it in a room — measured, with an icon that
outlived the file it came from. Editing the live panel configuration was
tried instead and destroyed the panel, because the shell owns that file.
Removing it before the shell starts is safe and makes the desktop
stateless.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 13:00:50 -05:00
co-authored by Claude Opus 5
parent 18cf0249dd
commit d2d30d6b0c
25 changed files with 402 additions and 737 deletions
@@ -2,12 +2,13 @@
Type=Application
Name=DVD
Comment=Play the disc in the drive
Exec=/usr/local/bin/classroom-dvd
Exec=/usr/local/bin/classroom-dvd toggle
Icon=media-optical-dvd
Terminal=false
Categories=AudioVideo;Player;
StartupNotify=true
StartupWMClass=vlc
# Never in a menu or a launcher by itself. The DVD appears when a disc is
# in the drive, through Plasma's own device notifier, and goes away with it.
NoDisplay=true
# Indexable, because the panel resolves its launcher through the
# applications index. There is no application menu in this classroom for
# it to clutter.
NoDisplay=false
@@ -73,9 +73,14 @@ leftGap.writeConfig("expanding", true);
// story. This applet is the reason the classroom stops writing a dock.
var tasks = panel.addWidget("org.kde.plasma.icontasks");
tasks.currentConfigGroup = ["General"];
// The DVD line is rewritten by classroom-provision to match the machine:
// a room with no optical drive has it removed, so no teacher is offered a
// disc player that cannot exist. Between the marker comments, and nowhere
// else, so the edit is a line replacement and not a parse.
tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop"
"applications:google-chrome.desktop",
/* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"
].join(","));
// Every window gets its own button. Teachers should never have to open a
// group to find out that a second window exists.
@@ -110,7 +115,7 @@ rightGap.writeConfig("expanding", true);
// RIGHT — status.
//
// Four ordinary applets straight on the bar, and no system tray around
// Three ordinary applets straight on the bar, and no system tray around
// them. A tray is a container that discovers whatever is installed and
// then hides most of it behind an arrow; measured in the lab it collected
// battery, brightness, clipboard, keyboard layout, camera, vaults and
@@ -120,11 +125,6 @@ rightGap.writeConfig("expanding", true);
panel.addWidget("org.kde.plasma.networkmanagement");
panel.addWidget("org.kde.plasma.volume");
// Present only while there is something in a drive. Plasma's own Solid
// device notifier is what makes the DVD appear with the disc and leave
// with it, so nothing in the classroom polls for media.
panel.addWidget("org.kde.plasma.devicenotifier");
panel.addWidget("org.kde.plasma.notifications");
var clock = panel.addWidget("org.kde.plasma.digitalclock");
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 253 KiB

-66
View File
@@ -1,66 +0,0 @@
"""Stable roles for Chrome's otherwise indistinguishable top-level windows."""
from classroom_sway import matches
CHROME_PATTERNS = (
r"google-chrome",
r"chrome",
r"chrome-.*",
r"google-chrome-.*",
)
def chrome_windows(current):
"""Chrome containers in Sway tree order, without using page titles."""
return [window for window in current if matches(window, CHROME_PATTERNS)]
class ChromeRoles:
"""Remember the first Chrome container and classify every later one."""
def __init__(self):
self.primary = None
self.secondary = []
def update(self, current):
identifiers = [window["id"] for window in chrome_windows(current)]
previous_primary = self.primary
previous_secondary = list(self.secondary)
promoted = None
if self.primary not in identifiers:
self.primary = identifiers[0] if identifiers else None
if self.primary is not None and previous_primary is not None:
promoted = self.primary
self.secondary = [
identifier
for identifier in previous_secondary
if identifier in identifiers and identifier != self.primary
]
new_secondary = []
for identifier in identifiers:
if identifier == self.primary or identifier in self.secondary:
continue
self.secondary.append(identifier)
new_secondary.append(identifier)
removed = [
identifier
for identifier in previous_secondary
if identifier not in identifiers
]
return {
"primary": self.primary,
"promoted": promoted,
"new_secondary": new_secondary,
"removed": removed,
"active_secondary": self.secondary[-1] if self.secondary else None,
}
-22
View File
@@ -24,9 +24,6 @@ DEVICE_CONFIG_TEMPLATE = """# Ekklesia Classroom device configuration
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=stable
# Set to true on classrooms that have a DVD drive.
DVD_ENABLED=false
# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not
# implemented yet; this only controls whether the current placeholder appears.
LIVE_ENABLED=false
@@ -61,12 +58,6 @@ MQTT_PASSWORD=
# an upgrade make a setting discoverable without replacing anything an
# administrator has already put in device.conf.
DEVICE_CONFIG_SECTIONS = (
(
"DVD_ENABLED",
"""# Set to true on classrooms that have a DVD drive.
DVD_ENABLED=false
""",
),
(
"LIVE_ENABLED",
"""# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not
@@ -136,19 +127,6 @@ def enabled(config, key, default=False):
return config[key].lower() in TRUTHY
def dock_items(config):
"""Dock items in display order, with disabled capabilities omitted."""
items = ["browser"]
if enabled(config, "DVD_ENABLED"):
items.append("dvd")
if enabled(config, "LIVE_ENABLED"):
items.append("live")
return tuple(items)
def ensure_device_config(path=None):
"""Create device.conf or append any missing settings exactly once.
-127
View File
@@ -1,127 +0,0 @@
"""Talking to sway.
Shared by classroom-window, which moves windows around, and by
classroom-dvd-session, which needs to know when VLC has actually taken
the screen.
"""
import json
import re
import subprocess
HIDDEN_WORKSPACE = "__ekklesia_hidden"
# sway parks scratchpad windows on a workspace of its own. Nothing here
# puts them there, but treating it as hidden means a window left in the
# scratchpad by an older classroom build can still be restored.
HIDDEN_WORKSPACES = {HIDDEN_WORKSPACE, "__i3_scratch"}
def swaymsg(*arguments):
"""Run swaymsg, returning stdout or None when sway is unavailable."""
try:
result = subprocess.run(
["swaymsg", *arguments],
capture_output=True,
text=True,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return result.stdout
def command(argument):
"""Run a sway command, ignoring criteria that match nothing."""
swaymsg("--", argument)
def query(kind):
output = swaymsg("-t", kind, "-r")
if not output:
return None
try:
return json.loads(output)
except ValueError:
return None
def collect(node, workspace, found, floating=False):
"""Flatten the sway tree into the windows the classroom cares about."""
if node.get("type") == "workspace":
workspace = node.get("name")
is_window = node.get("pid") is not None and not node.get("nodes")
if is_window:
properties = node.get("window_properties") or {}
found.append(
{
"id": node.get("id"),
"app_id": node.get("app_id") or "",
"name": node.get("name") or "",
"class": properties.get("class") or "",
"instance": properties.get("instance") or "",
"focused": bool(node.get("focused")),
"fullscreen": bool(node.get("fullscreen_mode")),
"workspace": workspace,
"hidden": workspace in HIDDEN_WORKSPACES,
"floating": floating,
}
)
for child in node.get("nodes", []):
collect(child, workspace, found, floating)
for child in node.get("floating_nodes", []):
collect(child, workspace, found, True)
return found
def windows():
tree = query("get_tree")
if tree is None:
return []
return collect(tree, None, [])
def matches(window, patterns):
"""Match a window against app_id / X11 class / X11 instance."""
identities = [
window["app_id"],
window["class"],
window["instance"],
]
for pattern in patterns:
expression = re.compile(pattern, re.IGNORECASE)
for identity in identities:
if identity and expression.fullmatch(identity):
return True
return False
def visible_workspace():
"""The workspace a restored window should come back to."""
workspaces = query("get_workspaces") or []
for workspace in workspaces:
if workspace.get("focused") and workspace.get("name") not in HIDDEN_WORKSPACES:
return workspace.get("name")
for workspace in workspaces:
if workspace.get("visible") and workspace.get("name") not in HIDDEN_WORKSPACES:
return workspace.get("name")
return "1"
+5
View File
@@ -52,6 +52,11 @@ systemctl enable sddm.service >/dev/null 2>&1 || true
# The room's own user services: the audio policy, and the drop-in that stops
# Plasma giving up on a repeatedly crashing panel.
systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true
# Match the bar to this machine now, so the first login already has the
# right one rather than waiting for the next provisioning run.
/usr/local/bin/classroom-desktop-layout >/dev/null 2>&1 || true
# NetworkManager ships nmtui and deliberately leaves interfaces declared in
# /etc/network/interfaces under ifupdown's control (managed=false). This gives
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Match the classroom's panel to the machine it is on.
There is exactly one thing about the bar that depends on hardware: a room
with no optical drive should not be offered a disc player. This edits the
one marked line in the installed layout, and nothing else.
It writes the *layout*, not the running panel. Editing
plasma-org.kde.plasma.desktop-appletsrc underneath a live plasmashell was
tried and it destroyed the panel: the shell owns that file and rewrites
it, so an edit races with it. The layout is a file the package owns and
the shell only reads, and classroom-desktop-reset makes the shell read it
again at the next login.
DVD_ENABLED is gone. A drive is something the machine can be asked about,
and asking is more reliable than remembering to edit a file when a
computer moves between rooms.
"""
import glob
import os
import re
import sys
LAYOUT = (
"/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop"
"/contents/layouts/org.kde.plasma.desktop-layout.js"
)
MARKER = "/* CLASSROOM-DVD-LAUNCHER */"
# The kernel names optical drives sr0, sr1, ... regardless of bus.
DRIVE_GLOB = "/dev/sr[0-9]*"
def has_optical_drive():
return bool(glob.glob(DRIVE_GLOB))
def rewrite(text, wanted):
"""Comment the DVD launcher in or out, idempotently."""
line = re.compile(
r"^(\s*)(//\s*)?(" + re.escape(MARKER) + r'\s*"applications:ekklesia-dvd\.desktop")',
re.M,
)
def swap(match):
indent, _, body = match.groups()
return f"{indent}{'' if wanted else '// '}{body}"
return line.sub(swap, text)
def main():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
try:
with open(path, "r", encoding="utf-8") as handle:
before = handle.read()
except OSError as error:
print(f"classroom-desktop-layout: {error}", file=sys.stderr)
return 0
if MARKER not in before:
print("classroom-desktop-layout: no DVD marker in the layout",
file=sys.stderr)
return 0
wanted = has_optical_drive()
after = rewrite(before, wanted)
if after == before:
print(f"classroom-desktop-layout: DVD launcher already "
f"{'present' if wanted else 'absent'}")
return 0
# Replace the file rather than edit in place, so a full disk or a
# crash leaves the old layout intact rather than half of a new one.
temporary = f"{path}.new"
try:
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(after)
os.replace(temporary, path)
except OSError as error:
print(f"classroom-desktop-layout: {error}", file=sys.stderr)
return 1
print(f"classroom-desktop-layout: DVD launcher "
f"{'added' if wanted else 'removed'}")
return 0
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
# Make the panel come from the package, every time.
#
# Plasma builds the panel from the classroom's layout script whenever it
# starts with no panel configuration, and keeps whatever it built after
# that. That means an update could change the bar in the package and
# never change it in a room — which is exactly what happened with an icon
# that outlived the file it came from.
#
# So the configuration is removed before the shell starts and the bar is
# rebuilt from the layout at every login. The desktop becomes stateless:
# there is nothing in a teacher's home that an update cannot reach, and a
# panel damaged somehow is repaired by the next login rather than by a
# technician.
#
# Nothing is lost by this. The panel is immutable to teachers by Kiosk
# policy, so there is never anything of theirs in that file.
CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/plasma-org.kde.plasma.desktop-appletsrc"
if [[ -e "$CONFIG" ]]; then
rm -f "$CONFIG"
echo "classroom-desktop-reset: panel will be rebuilt from the layout"
else
echo "classroom-desktop-reset: no panel configuration to clear"
fi
# The icon cache remembers a name it once failed to find, which outlives
# the package that later supplies it.
rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/icon-cache.kcache" 2>/dev/null || true
+9 -15
View File
@@ -22,7 +22,6 @@ sys.path.insert(
import classroom_optical as optical # noqa: E402
from classroom_vlc import PLAYER # noqa: E402
WINDOW_TOOL = "/usr/local/bin/classroom-window"
SESSION_TOOL = "/usr/local/bin/classroom-dvd-session"
@@ -44,13 +43,17 @@ def notify(title, message, urgency="normal"):
def play(device):
"""Hand the disc to DVD mode through the normal window toggle.
"""Hand the disc to DVD mode.
classroom-dvd-session owns the player from here: it puts a black
surface up immediately, starts VLC behind it, and provides the
classroom's own controls. Going through classroom-window keeps the
dock button behaving like every other one — focus if it is already
up, rather than starting a second session.
classroom's own controls.
Started directly, with nothing arranging windows on the way. A second
session cannot happen — the session takes an exclusive lock on
dvd.lock and exits if it cannot get it — and bringing an existing one
back to the front is the panel's job, not this command's. That is
what replaced the window helper.
"""
if shutil.which(PLAYER) is None:
notify(
@@ -59,16 +62,7 @@ def play(device):
)
return 1
subprocess.run(
[
WINDOW_TOOL,
"toggle",
"--match",
"(?i)vlc",
"--",
SESSION_TOOL,
]
)
subprocess.Popen([SESSION_TOOL], start_new_session=True)
return 0
+8 -4
View File
@@ -25,6 +25,7 @@ any of this.
"""
import argparse
import glob
import os
import re
import subprocess
@@ -356,7 +357,7 @@ def status():
print(f"libdvdcss: {'installed' if libdvdcss_installed() else 'missing'}")
config = read_device_config()
print(f"DVD_ENABLED: {enabled(config, 'DVD_ENABLED', False)}")
print(f"optical drive: {', '.join(sorted(glob.glob('/dev/sr[0-9]*'))) or 'none'}")
return 0 if libdvdcss_installed() else 1
@@ -378,7 +379,7 @@ def main():
parser.add_argument(
"--if-enabled",
action="store_true",
help="Do nothing unless DVD_ENABLED is set for this classroom",
help="Do nothing unless this machine has an optical drive",
)
arguments = parser.parse_args()
@@ -386,8 +387,11 @@ def main():
return status()
if arguments.if_enabled:
if not enabled(read_device_config(), "DVD_ENABLED", False):
log("DVD support is not enabled on this classroom; nothing to do")
# Ask the machine rather than a setting. A computer that moves
# between rooms takes its drive with it, and nobody remembers to
# edit a file when it does.
if not glob.glob("/dev/sr[0-9]*"):
log("this classroom has no optical drive; nothing to do")
return 0
# Only the real system needs privilege. Pointed at a fixture tree
+46 -22
View File
@@ -13,7 +13,7 @@ Layout, bottom to top:
loading surface layer-shell OVERLAY, covers the whole display
including the dock while the disc loads, removed
once playback is actually on screen
VLC fullscreen, drawn above the top layer by sway
VLC fullscreen, an ordinary window below both layers
control bar layer-shell OVERLAY, drawn above fullscreen video
"""
@@ -45,7 +45,6 @@ sys.path.insert(
import classroom_optical as optical # noqa: E402
import classroom_style as style # noqa: E402
import classroom_sway as sway # noqa: E402
import classroom_vlc as vlc # noqa: E402
from classroom_config import SHELL_SCALE, read_device_config, scaled # noqa: E402
from classroom_icons import ScalableIcon # noqa: E402
@@ -78,9 +77,21 @@ HOVER_STRIP = 14
IDLE_HIDE_SECONDS = 4
LEAVE_HIDE_SECONDS = 1
# How VLC is recognised in the sway tree. Qt5 puts it on XWayland, so
# the X11 class is what usually matches.
VLC_WINDOW = (r"vlc", r"vlc-.*")
# How long the cover stays up at the very least.
#
# This replaced a query to the window manager. Under sway the session
# walked the tree until VLC's window was really fullscreen, because VLC
# reports playing while its window is still the ordinary one — the black
# startup window the cover exists to hide.
#
# VLC's own status was measured as a replacement and is not one: it
# reports <fullscreen>true</fullscreen> in the same 250ms poll as
# <state>playing</state>, because the flag is the setting it was started
# with and not the state of its window. So the cover is held for a floor
# instead, and LOADING_DEADLINE_SECONDS still caps the wait. On real
# hardware the disc takes far longer than this to open, so the floor is
# almost never what decides.
MINIMUM_COVER_SECONDS = 1.2
# The loading surface goes away only once VLC has genuinely taken the
# screen. The deadline is a safety net: whatever happens, DVD mode must
@@ -188,15 +199,29 @@ class VlcControl:
def state(self):
"""playing, paused, stopped, or None while VLC is still coming up."""
return self.field(r"<state>(\w+)</state>")
def field(self, pattern):
body = self.request()
if not body:
return None
found = re.search(r"<state>(\w+)</state>", body)
found = re.search(pattern, body)
return found.group(1) if found else None
def wants_fullscreen(self):
"""Whether VLC believes it is fullscreen.
Deliberately named for what it is. VLC reports this true from the
moment it starts, because it is the option it was given, not a
report about its window — measured at 250ms resolution against a
real VLC. It is a precondition worth checking and not evidence
that anything is on screen.
"""
return self.field(r"<fullscreen>(\w+)</fullscreen>") == "true"
class LoadingSurface(Gtk.Window):
"""The whole display, while the disc spins up.
@@ -221,9 +246,9 @@ class LoadingSurface(Gtk.Window):
GtkLayerShell.init_for_window(self)
# OVERLAY rather than TOP. The dock is a TOP layer surface, and
# OVERLAY rather than TOP. The panel is a TOP layer surface and
# DVD mode has to cover it; OVERLAY is above it without depending
# on which surface sway happens to have created first. It is
# on which surface the compositor happened to create first. It is
# taken down the moment playback is on screen, so it never sits
# over the video.
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
@@ -290,7 +315,8 @@ class ControlBar(Gtk.Window):
GtkLayerShell.init_for_window(self)
# OVERLAY is the one layer sway draws above a fullscreen window.
# OVERLAY is the one layer a compositor draws above a fullscreen
# window, so the classroom's controls stay over the video.
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
for edge in (
@@ -564,25 +590,23 @@ class DvdSession:
self.pid = None
Gtk.main_quit()
def player_is_fullscreen(self):
"""True once VLC's window exists and has taken the whole output.
def player_is_ready(self):
"""True once there is something worth uncovering.
Playback state alone is not enough. VLC reports playing while its
window is still the ordinary tiled one sway gave it, which is the
black startup window the cover exists to hide. It only takes the
display when it goes fullscreen, and that is what the tree says.
Three things, none of which asks the window manager anything:
VLC is playing or paused, VLC was told to be fullscreen and still
thinks it is, and the cover has been up for its minimum.
"""
for window in sway.windows():
if sway.matches(window, VLC_WINDOW) and window["fullscreen"]:
return True
if not self.control.wants_fullscreen():
return False
return False
return self.elapsed >= MINIMUM_COVER_SECONDS
def watch_startup(self):
"""Hold the cover until VLC is genuinely ready to replace it.
Three things have to line up: VLC says it is playing, its window
is fullscreen, and a moment has passed for the disc menu to draw.
Three things have to line up: VLC says it is playing, it is still
set fullscreen, and a moment has passed for the disc menu to draw.
The deadline is the safety net: DVD mode must never leave a cover
over a screen with nothing behind it, even if the disc never
opens.
@@ -592,7 +616,7 @@ class DvdSession:
# The cheap local check first; the tree is only worth walking
# once VLC claims to be playing something.
playing = self.control.state() in ("playing", "paused")
ready = playing and self.player_is_fullscreen()
ready = playing and self.player_is_ready()
if ready or self.elapsed >= LOADING_DEADLINE_SECONDS:
GLib.timeout_add(STABILISE_MS, self.drop_loading)
+9
View File
@@ -49,6 +49,15 @@ else
log "WARNING: sudo classroom-printer-setup"
fi
# The bar is tailored to the machine before the session is started: a
# room with no optical drive is not offered a disc player. This writes the
# layout only; the shell rebuilds the panel from it at the next login.
if /usr/local/bin/classroom-desktop-layout; then
:
else
log "WARNING: the desktop layout was not matched to this machine."
fi
log "finished"
# Provisioning is best-effort by design.
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""Classroom window control.
Sway has no real minimise concept, so the classroom uses a dedicated
hidden workspace. A minimised window is parked there as an ordinary
tiled container and moved back to the visible workspace on restore,
which is why it returns maximised instead of floating the way the old
scratchpad approach did.
"""
import argparse
import os
import subprocess
import sys
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_sway import ( # noqa: E402
HIDDEN_WORKSPACE,
command,
matches,
visible_workspace,
windows,
)
STATE_DIR = os.path.join(
os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
"ekklesia-classroom",
)
STATE_FILE = os.path.join(STATE_DIR, "minimized")
def read_state():
try:
with open(STATE_FILE, "r", encoding="utf-8") as handle:
return [int(line) for line in handle.read().split() if line.strip()]
except (OSError, ValueError):
return []
def write_state(identifiers):
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as handle:
handle.write("\n".join(str(item) for item in identifiers))
except OSError:
pass
def remember(identifier):
"""Track minimise order so restore-last picks the newest window."""
state = [item for item in read_state() if item != identifier]
state.append(identifier)
write_state(state[-32:])
def forget(identifier):
write_state([item for item in read_state() if item != identifier])
def minimize(window):
# A fullscreen container keeps rendering over everything else if it
# is parked while still fullscreen, so always drop out of it first.
command(f'[con_id={window["id"]}] fullscreen disable')
command(f'[con_id={window["id"]}] move container to workspace {HIDDEN_WORKSPACE}')
remember(window["id"])
def restore(window):
workspace = visible_workspace()
command(f'[con_id={window["id"]}] move container to workspace {workspace}')
# Windows parked by older classroom builds came back floating because
# they had been through the scratchpad. Force them back into the tiling
# layout so a restored browser is maximised like the user expects.
command(f'[con_id={window["id"]}] floating disable')
command(f'[con_id={window["id"]}] focus')
forget(window["id"])
def launch(argv):
if not argv:
return
try:
subprocess.Popen(argv, start_new_session=True)
except OSError as error:
print(f"classroom-window: unable to launch {argv[0]}: {error}", file=sys.stderr)
def action_toggle(patterns, argv):
"""Launch, focus, minimise or restore, in the order a user expects."""
candidates = [window for window in windows() if matches(window, patterns)]
if not candidates:
launch(argv)
return 0
hidden = [window for window in candidates if window["hidden"]]
if hidden:
for window in hidden:
restore(window)
return 0
if any(window["focused"] for window in candidates):
for window in candidates:
minimize(window)
return 0
command(f'[con_id={candidates[-1]["id"]}] focus')
return 0
def action_minimize():
for window in windows():
if window["focused"] and not window["hidden"]:
minimize(window)
return 0
return 0
def action_restore_last():
hidden = {window["id"]: window for window in windows() if window["hidden"]}
if not hidden:
return 0
for identifier in reversed(read_state()):
if identifier in hidden:
restore(hidden[identifier])
return 0
restore(list(hidden.values())[-1])
return 0
def action_list():
for window in windows():
state = "minimized" if window["hidden"] else "visible"
state = "focused" if window["focused"] else state
print(
f'{window["id"]}\t{state}\t'
f'{window["app_id"] or window["class"]}\t{window["workspace"]}'
)
return 0
def main():
parser = argparse.ArgumentParser(
description="Minimise, restore and toggle classroom windows.",
)
subparsers = parser.add_subparsers(dest="action", required=True)
toggle = subparsers.add_parser(
"toggle",
help="Launch, focus, minimise or restore an application",
)
toggle.add_argument(
"--match",
action="append",
required=True,
metavar="REGEX",
help="app_id or X11 class to match (may be repeated)",
)
toggle.add_argument(
"launch",
nargs=argparse.REMAINDER,
help="Command to run when the application is not running",
)
subparsers.add_parser("minimize", help="Minimise the focused window")
subparsers.add_parser("restore-last", help="Restore the newest minimised window")
subparsers.add_parser("list", help="List classroom windows and their state")
arguments = parser.parse_args()
if arguments.action == "toggle":
argv = arguments.launch
if argv and argv[0] == "--":
argv = argv[1:]
return action_toggle(arguments.match, argv)
if arguments.action == "minimize":
return action_minimize()
if arguments.action == "restore-last":
return action_restore_last()
return action_list()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,16 @@
[Unit]
Description=Ekklesia Classroom desktop layout
Documentation=file:///usr/local/bin/classroom-desktop-reset
# Before the shell, because the shell owns the file this removes. Editing
# it underneath a running plasmashell was measured to destroy the panel.
Before=plasma-plasmashell.service
PartOf=graphical-session.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/classroom-desktop-reset
RemainAfterExit=yes
Slice=session.slice
[Install]
WantedBy=plasma-workspace.target
+6 -30
View File
@@ -7,7 +7,7 @@ PYTHONPATH="$ROOT/lib" python3 - <<'PY'
import tempfile
from pathlib import Path
from classroom_config import dock_items, ensure_device_config, read_device_config
from classroom_config import ensure_device_config, read_device_config
passed = failed = 0
@@ -24,40 +24,18 @@ def check(label, condition, detail=""):
failed += 1
print("1. dock capabilities")
cases = [
({}, ("browser",)),
({"DVD_ENABLED": "true"}, ("browser", "dvd")),
({"LIVE_ENABLED": "true"}, ("browser", "live")),
(
{"DVD_ENABLED": "true", "LIVE_ENABLED": "true"},
("browser", "dvd", "live"),
),
]
print("1. the room no longer keeps a list of dock buttons")
check("dock_items is gone with the dock it described",
not hasattr(__import__("classroom_config"), "dock_items"))
check("and DVD_ENABLED with it, because the machine can be asked",
"DVD_ENABLED" not in __import__("classroom_config").DEVICE_CONFIG_TEMPLATE)
for config, expected in cases:
actual = dock_items(config)
check(f"{config or 'both disabled'} -> {expected}", actual == expected, actual)
check(
"false spellings do not leave placeholder items",
dock_items({"DVD_ENABLED": "false", "LIVE_ENABLED": "off"}) == ("browser",),
)
check(
"existing dock order is browser, DVD, LIVE",
dock_items({"DVD_ENABLED": "yes", "LIVE_ENABLED": "1"})
== ("browser", "dvd", "live"),
)
print()
print("2. fresh device configuration")
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "etc" / "device.conf"
ensure_device_config(path)
config = read_device_config(path)
check("fresh config is created", path.is_file())
check("DVD defaults off", config.get("DVD_ENABLED") == "false")
check("LIVE defaults off", config.get("LIVE_ENABLED") == "false")
check("MQTT defaults off", config.get("MQTT_ENABLED") == "false")
check("stable remains the fresh-install default", config.get("CHANNEL") == "stable")
@@ -86,8 +64,6 @@ with tempfile.TemporaryDirectory() as directory:
check("old bytes remain at the start", first.startswith(old))
check("channel survives", config.get("CHANNEL") == "canary")
check("DVD choice survives", config.get("DVD_ENABLED") == "true")
check("UI scale survives", config.get("UI_SCALE") == "1.25")
check("MQTT host survives", config.get("MQTT_HOST") == "broker.example.test")
check("MQTT password survives", config.get("MQTT_PASSWORD") == "secret")
check("unknown settings survive", config.get("CUSTOM_SETTING") == "keep-me")
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import importlib.util
import sys
import tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
loader = SourceFileLoader("layout", str(root / "scripts/classroom-desktop-layout"))
spec = importlib.util.spec_from_loader("layout", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
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
WITH = '''tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop",
/* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"
].join(","));'''
WITHOUT = WITH.replace(' /* CLASSROOM-DVD-LAUNCHER */',
' // /* CLASSROOM-DVD-LAUNCHER */')
print("1. the DVD launcher follows the hardware")
check("a machine with a drive keeps it",
module.rewrite(WITH, True) == WITH)
check("a machine without a drive loses it",
module.rewrite(WITH, False) == WITHOUT,
module.rewrite(WITH, False))
check("and gets it back when a drive appears",
module.rewrite(WITHOUT, True) == WITH)
check("removing twice changes nothing the second time",
module.rewrite(module.rewrite(WITH, False), False) == WITHOUT)
check("adding twice changes nothing the second time",
module.rewrite(module.rewrite(WITHOUT, True), True) == WITH)
print()
print("2. nothing else in the layout is touched")
other = WITH + "\npanel.addWidget(\"org.kde.plasma.digitalclock\");\n"
check("a line without the marker is left alone",
module.rewrite(other, False).endswith('digitalclock");\n'))
check("the other launchers survive",
"ekklesia-wonderink.desktop" in module.rewrite(WITH, False)
and "google-chrome.desktop" in module.rewrite(WITH, False))
print()
print("3. the real layout carries the marker")
layout = (root / "config/plasma/lookandfeel/contents/layouts"
/ "org.kde.plasma.desktop-layout.js").read_text()
check("the shipped layout has exactly one marker",
layout.count(module.MARKER) == 1)
check("and it is on the DVD launcher",
'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' in layout)
check("the shipped layout is one a drive-less machine can be given",
module.rewrite(layout, False) != layout)
print()
print("4. a missing or unmarked layout is not an error")
with tempfile.TemporaryDirectory() as work:
empty = Path(work) / "none.js"
check("a layout that does not exist exits cleanly",
module.main.__code__ is not None)
unmarked = Path(work) / "plain.js"
unmarked.write_text("panel.addWidget('x');\n")
sys.argv = ["classroom-desktop-layout", str(unmarked)]
check("an unmarked layout is left alone", module.main() == 0)
check("and unchanged", unmarked.read_text() == "panel.addWidget('x');\n")
sys.argv = ["classroom-desktop-layout", str(Path(work) / "missing.js")]
check("a missing layout is not a failure", module.main() == 0)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+41 -64
View File
@@ -107,56 +107,33 @@ print("6. the disc is the last argument")
check("dvd:// selects dvdnav for menus", command[-1] == "dvd:///dev/sr0")
print()
print("7. the loading surface waits for a fullscreen VLC window")
print("7. the loading surface holds until the player is ready")
import json # noqa: E402
import os # noqa: E402
import subprocess # noqa: E402
import tempfile # noqa: E402
import re # noqa: E402
def tree_with(app, fullscreen):
return {
"tree": {
"type": "root", "id": 1, "nodes": [{
"type": "output", "id": 2, "name": "HDMI-A-1", "nodes": [{
"type": "workspace", "id": 3, "name": "1",
"nodes": ([] if app is None else [{
"type": "con", "id": 10, "pid": 100,
"app_id": None,
"window_properties": {"class": app, "instance": app},
"focused": True,
"fullscreen_mode": 1 if fullscreen else 0,
"nodes": [], "floating_nodes": [],
}]),
"floating_nodes": [],
}], "floating_nodes": [],
}], "floating_nodes": [],
},
"workspaces": [{"name": "1", "focused": True, "visible": True}],
}
class FakeControl:
"""Stands in for VLC's HTTP interface."""
def __init__(self, body):
self.body = body
def field(self, pattern):
found = re.search(pattern, self.body)
return found.group(1) if found else None
def wants_fullscreen(self):
return self.field(r"<fullscreen>(\w+)</fullscreen>") == "true"
work = tempfile.mkdtemp()
os.makedirs(f"{work}/bin", exist_ok=True)
with open(f"{work}/bin/swaymsg", "w") as shim:
shim.write(
"#!/usr/bin/env python3\n"
"import json, os, sys\n"
"state = json.load(open(os.environ['MOCK_TREE']))\n"
"print(json.dumps(state['tree' if sys.argv[2] == 'get_tree'"
" else 'workspaces']))\n"
)
os.chmod(f"{work}/bin/swaymsg", 0o755)
os.environ["PATH"] = f"{work}/bin:" + os.environ["PATH"]
os.environ["MOCK_TREE"] = f"{work}/tree.json"
def set_tree(app, fullscreen):
with open(f"{work}/tree.json", "w") as handle:
json.dump(tree_with(app, fullscreen), handle)
source = open(sys.argv[1] + "/scripts/classroom-dvd-session").read()
check("the session no longer asks a window manager anything",
"classroom_sway" not in source and "swaymsg" not in source)
check("and holds the cover for a floor instead",
"MINIMUM_COVER_SECONDS" in source)
check("VLC's own flag is named for what it is, not for the window",
"wants_fullscreen" in source and "player_is_fullscreen" not in source)
ready = None
@@ -169,37 +146,37 @@ try:
gi.require_version("Gtk", "3.0")
gi.require_version("GtkLayerShell", "0.1")
loader = SourceFileLoader("dvdsession", sys.argv[1] + "/scripts/classroom-dvd-session")
loader = SourceFileLoader(
"dvdsession", sys.argv[1] + "/scripts/classroom-dvd-session")
spec = importlib.util.spec_from_loader("dvdsession", loader)
module = importlib.util.module_from_spec(spec)
sys.modules["dvdsession"] = module
loader.exec_module(module)
ready = module.DvdSession.player_is_fullscreen
ready = module.DvdSession.player_is_ready
except Exception as error: # noqa: BLE001
print(f" skip GTK unavailable here ({type(error).__name__})")
if ready is not None:
set_tree("vlc", False)
check("a tiled VLC window is not ready", ready(None) is False)
floor = module.MINIMUM_COVER_SECONDS
set_tree("vlc", True)
check("a fullscreen VLC window is ready", ready(None) is True)
class Session:
def __init__(self, body, elapsed):
self.control = FakeControl(body)
self.elapsed = elapsed
set_tree("Google-chrome", True)
check("someone else's fullscreen window is not VLC", ready(None) is False)
FULL = "<state>playing</state><fullscreen>true</fullscreen>"
WINDOWED = "<state>playing</state><fullscreen>false</fullscreen>"
set_tree(None, False)
check("no windows at all is not ready", ready(None) is False)
check("playback state alone is no longer the condition",
"player_is_fullscreen" in module.DvdSession.watch_startup.__doc__
or "fullscreen" in module.DvdSession.watch_startup.__doc__)
check("the handover is held briefly after both agree",
500 <= module.STABILISE_MS <= 1000, f"got {module.STABILISE_MS}")
check("the safety deadline is still there",
module.LOADING_DEADLINE_SECONDS == 20)
subprocess.run(["rm", "-rf", work])
check("a player that is not set fullscreen is never ready",
ready(Session(WINDOWED, floor + 5)) is False)
check("a fullscreen player is not ready before the floor",
ready(Session(FULL, floor / 2)) is False)
check("and is ready after it",
ready(Session(FULL, floor + 0.1)) is True)
check("a silent VLC is not ready",
ready(Session("", floor + 5)) is False)
check("the startup watcher still explains what it waits for",
"fullscreen" in module.DvdSession.watch_startup.__doc__)
print()
print(f"passed: {passed} failed: {failed}")
-147
View File
@@ -1,147 +0,0 @@
#!/bin/bash
set -uo pipefail
# Tests the minimise/restore state machine against a mock sway IPC, by
# asserting the exact commands classroom-window sends.
#
# tests/test-classroom-window.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/scripts/classroom-window"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
passed=0
failed=0
check() {
local label="$1" expected="$2" actual="$3"
if [[ "$actual" == "$expected" ]]; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
echo " expected: ${expected//$'\n'/ | }"
echo " got: ${actual//$'\n'/ | }"
failed=$((failed + 1))
fi
}
mkdir -p "$WORK/bin"
cat > "$WORK/bin/swaymsg" <<'SHIM'
#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
state = json.load(open(os.environ["MOCK_TREE"]))
if args[:1] == ["-t"]:
print(json.dumps(state["tree" if args[1] == "get_tree" else "workspaces"]))
sys.exit(0)
with open(os.environ["MOCK_LOG"], "a") as log:
log.write(" ".join(a for a in args if a != "--") + "\n")
SHIM
chmod +x "$WORK/bin/swaymsg"
export PATH="$WORK/bin:$PATH"
export MOCK_LOG="$WORK/log"
export XDG_RUNTIME_DIR="$WORK/run"
tree() {
cat > "$WORK/tree.json"
export MOCK_TREE="$WORK/tree.json"
}
run() { : > "$MOCK_LOG"; python3 "$SCRIPT" "$@" >/dev/null 2>&1; cat "$MOCK_LOG"; }
# ------------------------------------------------ a focused Chrome window
tree <<'JSON'
{"tree":{"type":"root","id":1,"nodes":[
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
{"type":"workspace","id":3,"name":"1","nodes":[
{"type":"con","id":10,"pid":100,"app_id":"google-chrome","focused":true,
"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]},
{"type":"workspace","id":4,"name":"__ekklesia_hidden","nodes":[
{"type":"con","id":11,"pid":101,"app_id":null,
"window_properties":{"class":"vlc","instance":"vlc"},"focused":false,
"fullscreen_mode":1,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]}
],"floating_nodes":[]}],"floating_nodes":[]},
"workspaces":[{"name":"1","focused":true,"visible":true},
{"name":"__ekklesia_hidden","focused":false,"visible":false}]}
JSON
echo "1. the browser button"
check "focused window is minimised, fullscreen dropped first" \
"[con_id=10] fullscreen disable
[con_id=10] move container to workspace __ekklesia_hidden" \
"$(run toggle --match google-chrome -- google-chrome)"
check "a minimised window comes back tiled, not floating" \
"[con_id=11] move container to workspace 1
[con_id=11] floating disable
[con_id=11] focus" \
"$(run toggle --match '(?i)vlc' -- vlc)"
echo
echo "2. keyboard"
check "Super+M minimises whatever is focused" \
"[con_id=10] fullscreen disable
[con_id=10] move container to workspace __ekklesia_hidden" \
"$(run minimize)"
check "Super+Shift+M restores from the hidden workspace" \
"[con_id=11] move container to workspace 1
[con_id=11] floating disable
[con_id=11] focus" \
"$(run restore-last)"
echo
echo "3. a running but unfocused window is focused, not minimised"
python3 - "$WORK/tree.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
data["tree"]["nodes"][0]["nodes"][0]["nodes"][0]["focused"] = False
json.dump(data, open(sys.argv[1], "w"))
PY
check "focus only" "[con_id=10] focus" "$(run toggle --match google-chrome -- x)"
echo
echo "4. nothing running launches the application"
: > "$MOCK_LOG"
output="$(python3 "$SCRIPT" toggle --match firefox -- /bin/echo LAUNCHED 2>&1)"
sleep 0.3
check "launch instead of a sway command" "LAUNCHED" "$output"
check "no sway command sent" "" "$(cat "$MOCK_LOG")"
echo
echo "5. a window stranded in sway's own scratchpad is rescued"
tree <<'JSON'
{"tree":{"type":"root","id":1,"nodes":[
{"type":"output","id":90,"name":"__i3","nodes":[
{"type":"workspace","id":91,"name":"__i3_scratch","nodes":[],"floating_nodes":[
{"type":"floating_con","id":12,"pid":102,"app_id":"google-chrome",
"focused":false,"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}]}],
"floating_nodes":[]},
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
{"type":"workspace","id":3,"name":"1","nodes":[],"floating_nodes":[]}],
"floating_nodes":[]}],"floating_nodes":[]},
"workspaces":[{"name":"1","focused":true,"visible":true},
{"name":"__i3_scratch","focused":false,"visible":false}]}
JSON
check "pulled back and forced out of floating" \
"[con_id=12] move container to workspace 1
[con_id=12] floating disable
[con_id=12] focus" \
"$(run toggle --match google-chrome -- x)"
echo
echo "6. sway not running is survivable"
export PATH="${PATH#"$WORK/bin:"}"
for action in list minimize restore-last; do
python3 "$SCRIPT" "$action" >/dev/null 2>&1
check "$action exits cleanly with no sway" "0" "$?"
done
echo
echo "passed: $passed failed: $failed"
[[ "$failed" -eq 0 ]]
+13
View File
@@ -106,6 +106,19 @@ check("and that directory is searched first",
check("the environment file is packaged",
"/etc/environment.d/90-ekklesia-classroom.conf" in build)
print()
print("4b3. every icon the panel names is in the package")
import configparser
for entry in sorted((root / "config/plasma/applications").glob("*.desktop")):
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read(entry)
icon = parser["Desktop Entry"].get("icon", "")
if not icon.startswith("ekklesia-"):
continue
check(f"{icon} is installed by the package",
f'"$ROOT/config/icons/${{icon}}.svg"' in build or icon in build)
check(f"{icon}.svg exists", (root / f"config/icons/{icon}.svg").is_file())
print()
print("4c. the teacher cannot reach a command line")
for restriction in ("run_command=false", "shell_access=false",
+16 -21
View File
@@ -133,9 +133,6 @@ install -Dm644 \
"$ROOT/lib/classroom_config.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py"
install -Dm644 \
"$ROOT/lib/classroom_chrome.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_chrome.py"
install -Dm644 \
@@ -257,9 +254,9 @@ install -Dm644 \
"$ROOT/config/plasma/solid/ekklesia-dvd.desktop" \
"$STAGE/usr/share/solid/actions/ekklesia-dvd.desktop"
# Wonder Ink needs a mark of its own. Parent Call does not: it uses
# Breeze's call-outgoing, so there is one fewer drawing to maintain.
for icon in ekklesia-wonderink; do
# Two marks the classroom draws itself, because nothing in Breeze says
# either thing: a lesson, and fetching a child's grown-up.
for icon in ekklesia-wonderink ekklesia-parent-call; do
install -Dm644 \
"$ROOT/config/icons/${icon}.svg" \
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
@@ -275,6 +272,10 @@ install -Dm644 \
"$ROOT/systemd/user/ekklesia-classroom-audio.service" \
"$STAGE/etc/systemd/user/ekklesia-classroom-audio.service"
install -Dm644 \
"$ROOT/systemd/user/ekklesia-classroom-desktop.service" \
"$STAGE/etc/systemd/user/ekklesia-classroom-desktop.service"
install -Dm755 \
"$ROOT/scripts/classroom-wonderink" \
"$STAGE/usr/local/bin/classroom-wonderink"
@@ -287,6 +288,14 @@ install -Dm755 \
"$ROOT/scripts/classroom-parent-call" \
"$STAGE/usr/local/bin/classroom-parent-call"
install -Dm755 \
"$ROOT/scripts/classroom-desktop-layout" \
"$STAGE/usr/local/bin/classroom-desktop-layout"
install -Dm755 \
"$ROOT/scripts/classroom-desktop-reset" \
"$STAGE/usr/local/bin/classroom-desktop-reset"
install -Dm755 \
"$ROOT/scripts/classroom-theme" \
"$STAGE/usr/local/bin/classroom-theme"
@@ -299,18 +308,7 @@ install -Dm644 \
"$ROOT/lib/classroom_parent_call.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_parent_call.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"
# The notifier the MQTT service uses to put a church message on screen.
install -Dm755 \
"$ROOT/scripts/classroom-notify" \
"$STAGE/usr/local/bin/classroom-notify"
@@ -319,9 +317,6 @@ 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" \