feat: take the last of sway out of the classroom

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 13:00:50 -05:00
co-authored by Claude Opus 5
parent 18cf0249dd
commit d2d30d6b0c
25 changed files with 402 additions and 737 deletions
+6 -30
View File
@@ -7,7 +7,7 @@ PYTHONPATH="$ROOT/lib" python3 - <<'PY'
import tempfile
from pathlib import Path
from classroom_config import dock_items, ensure_device_config, read_device_config
from classroom_config import ensure_device_config, read_device_config
passed = failed = 0
@@ -24,40 +24,18 @@ def check(label, condition, detail=""):
failed += 1
print("1. dock capabilities")
cases = [
({}, ("browser",)),
({"DVD_ENABLED": "true"}, ("browser", "dvd")),
({"LIVE_ENABLED": "true"}, ("browser", "live")),
(
{"DVD_ENABLED": "true", "LIVE_ENABLED": "true"},
("browser", "dvd", "live"),
),
]
print("1. the room no longer keeps a list of dock buttons")
check("dock_items is gone with the dock it described",
not hasattr(__import__("classroom_config"), "dock_items"))
check("and DVD_ENABLED with it, because the machine can be asked",
"DVD_ENABLED" not in __import__("classroom_config").DEVICE_CONFIG_TEMPLATE)
for config, expected in cases:
actual = dock_items(config)
check(f"{config or 'both disabled'} -> {expected}", actual == expected, actual)
check(
"false spellings do not leave placeholder items",
dock_items({"DVD_ENABLED": "false", "LIVE_ENABLED": "off"}) == ("browser",),
)
check(
"existing dock order is browser, DVD, LIVE",
dock_items({"DVD_ENABLED": "yes", "LIVE_ENABLED": "1"})
== ("browser", "dvd", "live"),
)
print()
print("2. fresh device configuration")
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "etc" / "device.conf"
ensure_device_config(path)
config = read_device_config(path)
check("fresh config is created", path.is_file())
check("DVD defaults off", config.get("DVD_ENABLED") == "false")
check("LIVE defaults off", config.get("LIVE_ENABLED") == "false")
check("MQTT defaults off", config.get("MQTT_ENABLED") == "false")
check("stable remains the fresh-install default", config.get("CHANNEL") == "stable")
@@ -86,8 +64,6 @@ with tempfile.TemporaryDirectory() as directory:
check("old bytes remain at the start", first.startswith(old))
check("channel survives", config.get("CHANNEL") == "canary")
check("DVD choice survives", config.get("DVD_ENABLED") == "true")
check("UI scale survives", config.get("UI_SCALE") == "1.25")
check("MQTT host survives", config.get("MQTT_HOST") == "broker.example.test")
check("MQTT password survives", config.get("MQTT_PASSWORD") == "secret")
check("unknown settings survive", config.get("CUSTOM_SETTING") == "keep-me")
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import importlib.util
import sys
import tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
loader = SourceFileLoader("layout", str(root / "scripts/classroom-desktop-layout"))
spec = importlib.util.spec_from_loader("layout", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
WITH = '''tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop",
/* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"
].join(","));'''
WITHOUT = WITH.replace(' /* CLASSROOM-DVD-LAUNCHER */',
' // /* CLASSROOM-DVD-LAUNCHER */')
print("1. the DVD launcher follows the hardware")
check("a machine with a drive keeps it",
module.rewrite(WITH, True) == WITH)
check("a machine without a drive loses it",
module.rewrite(WITH, False) == WITHOUT,
module.rewrite(WITH, False))
check("and gets it back when a drive appears",
module.rewrite(WITHOUT, True) == WITH)
check("removing twice changes nothing the second time",
module.rewrite(module.rewrite(WITH, False), False) == WITHOUT)
check("adding twice changes nothing the second time",
module.rewrite(module.rewrite(WITHOUT, True), True) == WITH)
print()
print("2. nothing else in the layout is touched")
other = WITH + "\npanel.addWidget(\"org.kde.plasma.digitalclock\");\n"
check("a line without the marker is left alone",
module.rewrite(other, False).endswith('digitalclock");\n'))
check("the other launchers survive",
"ekklesia-wonderink.desktop" in module.rewrite(WITH, False)
and "google-chrome.desktop" in module.rewrite(WITH, False))
print()
print("3. the real layout carries the marker")
layout = (root / "config/plasma/lookandfeel/contents/layouts"
/ "org.kde.plasma.desktop-layout.js").read_text()
check("the shipped layout has exactly one marker",
layout.count(module.MARKER) == 1)
check("and it is on the DVD launcher",
'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' in layout)
check("the shipped layout is one a drive-less machine can be given",
module.rewrite(layout, False) != layout)
print()
print("4. a missing or unmarked layout is not an error")
with tempfile.TemporaryDirectory() as work:
empty = Path(work) / "none.js"
check("a layout that does not exist exits cleanly",
module.main.__code__ is not None)
unmarked = Path(work) / "plain.js"
unmarked.write_text("panel.addWidget('x');\n")
sys.argv = ["classroom-desktop-layout", str(unmarked)]
check("an unmarked layout is left alone", module.main() == 0)
check("and unchanged", unmarked.read_text() == "panel.addWidget('x');\n")
sys.argv = ["classroom-desktop-layout", str(Path(work) / "missing.js")]
check("a missing layout is not a failure", module.main() == 0)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+41 -64
View File
@@ -107,56 +107,33 @@ print("6. the disc is the last argument")
check("dvd:// selects dvdnav for menus", command[-1] == "dvd:///dev/sr0")
print()
print("7. the loading surface waits for a fullscreen VLC window")
print("7. the loading surface holds until the player is ready")
import json # noqa: E402
import os # noqa: E402
import subprocess # noqa: E402
import tempfile # noqa: E402
import re # noqa: E402
def tree_with(app, fullscreen):
return {
"tree": {
"type": "root", "id": 1, "nodes": [{
"type": "output", "id": 2, "name": "HDMI-A-1", "nodes": [{
"type": "workspace", "id": 3, "name": "1",
"nodes": ([] if app is None else [{
"type": "con", "id": 10, "pid": 100,
"app_id": None,
"window_properties": {"class": app, "instance": app},
"focused": True,
"fullscreen_mode": 1 if fullscreen else 0,
"nodes": [], "floating_nodes": [],
}]),
"floating_nodes": [],
}], "floating_nodes": [],
}], "floating_nodes": [],
},
"workspaces": [{"name": "1", "focused": True, "visible": True}],
}
class FakeControl:
"""Stands in for VLC's HTTP interface."""
def __init__(self, body):
self.body = body
def field(self, pattern):
found = re.search(pattern, self.body)
return found.group(1) if found else None
def wants_fullscreen(self):
return self.field(r"<fullscreen>(\w+)</fullscreen>") == "true"
work = tempfile.mkdtemp()
os.makedirs(f"{work}/bin", exist_ok=True)
with open(f"{work}/bin/swaymsg", "w") as shim:
shim.write(
"#!/usr/bin/env python3\n"
"import json, os, sys\n"
"state = json.load(open(os.environ['MOCK_TREE']))\n"
"print(json.dumps(state['tree' if sys.argv[2] == 'get_tree'"
" else 'workspaces']))\n"
)
os.chmod(f"{work}/bin/swaymsg", 0o755)
os.environ["PATH"] = f"{work}/bin:" + os.environ["PATH"]
os.environ["MOCK_TREE"] = f"{work}/tree.json"
def set_tree(app, fullscreen):
with open(f"{work}/tree.json", "w") as handle:
json.dump(tree_with(app, fullscreen), handle)
source = open(sys.argv[1] + "/scripts/classroom-dvd-session").read()
check("the session no longer asks a window manager anything",
"classroom_sway" not in source and "swaymsg" not in source)
check("and holds the cover for a floor instead",
"MINIMUM_COVER_SECONDS" in source)
check("VLC's own flag is named for what it is, not for the window",
"wants_fullscreen" in source and "player_is_fullscreen" not in source)
ready = None
@@ -169,37 +146,37 @@ try:
gi.require_version("Gtk", "3.0")
gi.require_version("GtkLayerShell", "0.1")
loader = SourceFileLoader("dvdsession", sys.argv[1] + "/scripts/classroom-dvd-session")
loader = SourceFileLoader(
"dvdsession", sys.argv[1] + "/scripts/classroom-dvd-session")
spec = importlib.util.spec_from_loader("dvdsession", loader)
module = importlib.util.module_from_spec(spec)
sys.modules["dvdsession"] = module
loader.exec_module(module)
ready = module.DvdSession.player_is_fullscreen
ready = module.DvdSession.player_is_ready
except Exception as error: # noqa: BLE001
print(f" skip GTK unavailable here ({type(error).__name__})")
if ready is not None:
set_tree("vlc", False)
check("a tiled VLC window is not ready", ready(None) is False)
floor = module.MINIMUM_COVER_SECONDS
set_tree("vlc", True)
check("a fullscreen VLC window is ready", ready(None) is True)
class Session:
def __init__(self, body, elapsed):
self.control = FakeControl(body)
self.elapsed = elapsed
set_tree("Google-chrome", True)
check("someone else's fullscreen window is not VLC", ready(None) is False)
FULL = "<state>playing</state><fullscreen>true</fullscreen>"
WINDOWED = "<state>playing</state><fullscreen>false</fullscreen>"
set_tree(None, False)
check("no windows at all is not ready", ready(None) is False)
check("playback state alone is no longer the condition",
"player_is_fullscreen" in module.DvdSession.watch_startup.__doc__
or "fullscreen" in module.DvdSession.watch_startup.__doc__)
check("the handover is held briefly after both agree",
500 <= module.STABILISE_MS <= 1000, f"got {module.STABILISE_MS}")
check("the safety deadline is still there",
module.LOADING_DEADLINE_SECONDS == 20)
subprocess.run(["rm", "-rf", work])
check("a player that is not set fullscreen is never ready",
ready(Session(WINDOWED, floor + 5)) is False)
check("a fullscreen player is not ready before the floor",
ready(Session(FULL, floor / 2)) is False)
check("and is ready after it",
ready(Session(FULL, floor + 0.1)) is True)
check("a silent VLC is not ready",
ready(Session("", floor + 5)) is False)
check("the startup watcher still explains what it waits for",
"fullscreen" in module.DvdSession.watch_startup.__doc__)
print()
print(f"passed: {passed} failed: {failed}")
-147
View File
@@ -1,147 +0,0 @@
#!/bin/bash
set -uo pipefail
# Tests the minimise/restore state machine against a mock sway IPC, by
# asserting the exact commands classroom-window sends.
#
# tests/test-classroom-window.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/scripts/classroom-window"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
passed=0
failed=0
check() {
local label="$1" expected="$2" actual="$3"
if [[ "$actual" == "$expected" ]]; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
echo " expected: ${expected//$'\n'/ | }"
echo " got: ${actual//$'\n'/ | }"
failed=$((failed + 1))
fi
}
mkdir -p "$WORK/bin"
cat > "$WORK/bin/swaymsg" <<'SHIM'
#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
state = json.load(open(os.environ["MOCK_TREE"]))
if args[:1] == ["-t"]:
print(json.dumps(state["tree" if args[1] == "get_tree" else "workspaces"]))
sys.exit(0)
with open(os.environ["MOCK_LOG"], "a") as log:
log.write(" ".join(a for a in args if a != "--") + "\n")
SHIM
chmod +x "$WORK/bin/swaymsg"
export PATH="$WORK/bin:$PATH"
export MOCK_LOG="$WORK/log"
export XDG_RUNTIME_DIR="$WORK/run"
tree() {
cat > "$WORK/tree.json"
export MOCK_TREE="$WORK/tree.json"
}
run() { : > "$MOCK_LOG"; python3 "$SCRIPT" "$@" >/dev/null 2>&1; cat "$MOCK_LOG"; }
# ------------------------------------------------ a focused Chrome window
tree <<'JSON'
{"tree":{"type":"root","id":1,"nodes":[
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
{"type":"workspace","id":3,"name":"1","nodes":[
{"type":"con","id":10,"pid":100,"app_id":"google-chrome","focused":true,
"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]},
{"type":"workspace","id":4,"name":"__ekklesia_hidden","nodes":[
{"type":"con","id":11,"pid":101,"app_id":null,
"window_properties":{"class":"vlc","instance":"vlc"},"focused":false,
"fullscreen_mode":1,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]}
],"floating_nodes":[]}],"floating_nodes":[]},
"workspaces":[{"name":"1","focused":true,"visible":true},
{"name":"__ekklesia_hidden","focused":false,"visible":false}]}
JSON
echo "1. the browser button"
check "focused window is minimised, fullscreen dropped first" \
"[con_id=10] fullscreen disable
[con_id=10] move container to workspace __ekklesia_hidden" \
"$(run toggle --match google-chrome -- google-chrome)"
check "a minimised window comes back tiled, not floating" \
"[con_id=11] move container to workspace 1
[con_id=11] floating disable
[con_id=11] focus" \
"$(run toggle --match '(?i)vlc' -- vlc)"
echo
echo "2. keyboard"
check "Super+M minimises whatever is focused" \
"[con_id=10] fullscreen disable
[con_id=10] move container to workspace __ekklesia_hidden" \
"$(run minimize)"
check "Super+Shift+M restores from the hidden workspace" \
"[con_id=11] move container to workspace 1
[con_id=11] floating disable
[con_id=11] focus" \
"$(run restore-last)"
echo
echo "3. a running but unfocused window is focused, not minimised"
python3 - "$WORK/tree.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
data["tree"]["nodes"][0]["nodes"][0]["nodes"][0]["focused"] = False
json.dump(data, open(sys.argv[1], "w"))
PY
check "focus only" "[con_id=10] focus" "$(run toggle --match google-chrome -- x)"
echo
echo "4. nothing running launches the application"
: > "$MOCK_LOG"
output="$(python3 "$SCRIPT" toggle --match firefox -- /bin/echo LAUNCHED 2>&1)"
sleep 0.3
check "launch instead of a sway command" "LAUNCHED" "$output"
check "no sway command sent" "" "$(cat "$MOCK_LOG")"
echo
echo "5. a window stranded in sway's own scratchpad is rescued"
tree <<'JSON'
{"tree":{"type":"root","id":1,"nodes":[
{"type":"output","id":90,"name":"__i3","nodes":[
{"type":"workspace","id":91,"name":"__i3_scratch","nodes":[],"floating_nodes":[
{"type":"floating_con","id":12,"pid":102,"app_id":"google-chrome",
"focused":false,"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}]}],
"floating_nodes":[]},
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
{"type":"workspace","id":3,"name":"1","nodes":[],"floating_nodes":[]}],
"floating_nodes":[]}],"floating_nodes":[]},
"workspaces":[{"name":"1","focused":true,"visible":true},
{"name":"__i3_scratch","focused":false,"visible":false}]}
JSON
check "pulled back and forced out of floating" \
"[con_id=12] move container to workspace 1
[con_id=12] floating disable
[con_id=12] focus" \
"$(run toggle --match google-chrome -- x)"
echo
echo "6. sway not running is survivable"
export PATH="${PATH#"$WORK/bin:"}"
for action in list minimize restore-last; do
python3 "$SCRIPT" "$action" >/dev/null 2>&1
check "$action exits cleanly with no sway" "0" "$?"
done
echo
echo "passed: $passed failed: $failed"
[[ "$failed" -eq 0 ]]
+13
View File
@@ -106,6 +106,19 @@ check("and that directory is searched first",
check("the environment file is packaged",
"/etc/environment.d/90-ekklesia-classroom.conf" in build)
print()
print("4b3. every icon the panel names is in the package")
import configparser
for entry in sorted((root / "config/plasma/applications").glob("*.desktop")):
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read(entry)
icon = parser["Desktop Entry"].get("icon", "")
if not icon.startswith("ekklesia-"):
continue
check(f"{icon} is installed by the package",
f'"$ROOT/config/icons/${{icon}}.svg"' in build or icon in build)
check(f"{icon}.svg exists", (root / f"config/icons/{icon}.svg").is_file())
print()
print("4c. the teacher cannot reach a command line")
for restriction in ("run_command=false", "shell_access=false",