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" \