diff --git a/config/plasma/applications/ekklesia-dvd.desktop b/config/plasma/applications/ekklesia-dvd.desktop index cda48f8..28be77b 100644 --- a/config/plasma/applications/ekklesia-dvd.desktop +++ b/config/plasma/applications/ekklesia-dvd.desktop @@ -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 diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js index 4bb96d6..090c251 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -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"); diff --git a/docs/screenshots/dvd-click.png b/docs/screenshots/dvd-click.png new file mode 100644 index 0000000..7a5f576 Binary files /dev/null and b/docs/screenshots/dvd-click.png differ diff --git a/docs/screenshots/dvd-cover.png b/docs/screenshots/dvd-cover.png new file mode 100644 index 0000000..01114b4 Binary files /dev/null and b/docs/screenshots/dvd-cover.png differ diff --git a/docs/screenshots/dvd-live.png b/docs/screenshots/dvd-live.png new file mode 100644 index 0000000..ea4e5f9 Binary files /dev/null and b/docs/screenshots/dvd-live.png differ diff --git a/docs/screenshots/dvd-panel.png b/docs/screenshots/dvd-panel.png new file mode 100644 index 0000000..980ec72 Binary files /dev/null and b/docs/screenshots/dvd-panel.png differ diff --git a/docs/screenshots/v2-idle-desktop.png b/docs/screenshots/v2-idle-desktop.png index 8d2c7a2..c0b5338 100644 Binary files a/docs/screenshots/v2-idle-desktop.png and b/docs/screenshots/v2-idle-desktop.png differ diff --git a/lib/classroom_chrome.py b/lib/classroom_chrome.py deleted file mode 100644 index 1b0fc08..0000000 --- a/lib/classroom_chrome.py +++ /dev/null @@ -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, - } diff --git a/lib/classroom_config.py b/lib/classroom_config.py index 137e2b7..0c77d7b 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -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. diff --git a/lib/classroom_sway.py b/lib/classroom_sway.py deleted file mode 100644 index aaa685e..0000000 --- a/lib/classroom_sway.py +++ /dev/null @@ -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" diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 29d323e..617f2a9 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -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 diff --git a/scripts/classroom-desktop-layout b/scripts/classroom-desktop-layout new file mode 100755 index 0000000..8810be5 --- /dev/null +++ b/scripts/classroom-desktop-layout @@ -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()) diff --git a/scripts/classroom-desktop-reset b/scripts/classroom-desktop-reset new file mode 100755 index 0000000..dcf189c --- /dev/null +++ b/scripts/classroom-desktop-reset @@ -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 diff --git a/scripts/classroom-dvd b/scripts/classroom-dvd index d0097a5..5ffc653 100755 --- a/scripts/classroom-dvd +++ b/scripts/classroom-dvd @@ -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 diff --git a/scripts/classroom-dvd-css-setup b/scripts/classroom-dvd-css-setup index 6000e76..a826c62 100755 --- a/scripts/classroom-dvd-css-setup +++ b/scripts/classroom-dvd-css-setup @@ -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 diff --git a/scripts/classroom-dvd-session b/scripts/classroom-dvd-session index ae849f0..118c605 100755 --- a/scripts/classroom-dvd-session +++ b/scripts/classroom-dvd-session @@ -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 true in the same 250ms poll as +# playing, 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"(\w+)") + + def field(self, pattern): body = self.request() if not body: return None - found = re.search(r"(\w+)", 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"(\w+)") == "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) diff --git a/scripts/classroom-provision b/scripts/classroom-provision index 5476ed5..850a74b 100755 --- a/scripts/classroom-provision +++ b/scripts/classroom-provision @@ -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. diff --git a/scripts/classroom-window b/scripts/classroom-window deleted file mode 100755 index 0597f18..0000000 --- a/scripts/classroom-window +++ /dev/null @@ -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()) diff --git a/systemd/user/ekklesia-classroom-desktop.service b/systemd/user/ekklesia-classroom-desktop.service new file mode 100644 index 0000000..0bebb9a --- /dev/null +++ b/systemd/user/ekklesia-classroom-desktop.service @@ -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 diff --git a/tests/test-classroom-config.sh b/tests/test-classroom-config.sh index dd7ae55..e887fc8 100755 --- a/tests/test-classroom-config.sh +++ b/tests/test-classroom-config.sh @@ -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") diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh new file mode 100755 index 0000000..b98d34f --- /dev/null +++ b/tests/test-classroom-desktop-layout.sh @@ -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 diff --git a/tests/test-classroom-dvd-session.sh b/tests/test-classroom-dvd-session.sh index 3efe622..4cad836 100755 --- a/tests/test-classroom-dvd-session.sh +++ b/tests/test-classroom-dvd-session.sh @@ -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"(\w+)") == "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 = "playingtrue" + WINDOWED = "playingfalse" - 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}") diff --git a/tests/test-classroom-window.sh b/tests/test-classroom-window.sh deleted file mode 100755 index 635139f..0000000 --- a/tests/test-classroom-window.sh +++ /dev/null @@ -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 ]] diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index d5a4949..79a285e 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -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", diff --git a/tools/build-release.sh b/tools/build-release.sh index 1bb79b6..0db072b 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -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" \