Files
Classroom_Desktop/scripts/classroom-desktop-layout
T
ekklesiaandClaude Opus 5 de5025ad22 feat: give Wonder Ink tabs, and let a device say it has no Wonder Ink
Teachers prepare a lesson across several tabs — the lesson, its video,
the activity — and app mode is the one thing that cannot give them that.

Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs; a
normal window has an address bar and no policy, flag or extension removes
it; tabbed web apps need a manifest on a site that is not ours and push
off-origin links out into a normal browser window, which is the whole of
what this design exists to prevent. So the tab strip is ours, and the
engine is Chromium either way.

QtWebEngine, from Debian, measured before it was chosen rather than
after. The usual and correct objection is codecs: on Debian 13 it reports
H.264, AAC, MP3, VP8 and VP9 all playable, MediaSource supports avc1,
mp4a and vp9, and a 720p H.264 file decoded and played to the end in the
lab. app.wonderink.org loads, and its sign-in is an ordinary email and
password form rather than a Google flow that would mind an embedded
browser. The user agent has the QtWebEngine token stripped so a site
gating on it sees the Chromium this actually is, and Widevine is handed
over from Chrome's own bundle when the machine has Chrome, which a
classroom always does.

    [ Lesson ] [ Video ] [ Activity ]

The strip appears only above one tab, so an ordinary lesson looks exactly
like the application it replaces. A tab can only come from the lesson —
target=_blank, window.open, a middle click, a Ctrl+click — and there is
no address bar, no search and no button that makes a blank tab. No
allowlist of domains either, on purpose: a list would have to be right
about every video service a lesson might ever embed.

Three bugs the lab caught and this fixes. A lesson calling window.print()
did nothing at all, which is worse than no print button because a teacher
concludes the printer is broken; printRequested is now bound to the page
that asked. Leaving fullscreen left the window 200 pixels wide in the
corner — a window that has only ever been maximised has no ordinary
geometry to return to — so the fullscreen bit is cleared rather than
maximised being re-requested. And the Present view video failure written
up in docs/wonder-ink.md is acted on at last: that investigation named
the autoplay gesture chain as the likeliest cause and declined to fix it,
because in Chrome the only lever governed the ordinary browser as well.
Here it does not — this profile is Wonder Ink and nothing else — so a
lesson may start its own video and no other page in the room may.
Reproduced both ways in the lab with an unmuted video started by a script
with no gesture: NotAllowedError at Chrome's default, playing as shipped.
That is not proof Present view is fixed; the lab has no Wonder Ink
account, and candidates 2 and 3 in that document are still open.

Measured on a rebooted machine, from the package: tabs open from a link
and from window.open, switch, and close; closing one leaves the others;
closing the last closes Wonder Ink; H.264 video plays and fills the
display in fullscreen and comes back maximised with its tabs; the print
dialog offers the room's own Elmer queue; a download lands in ~/Downloads;
back and forward work from a right-click menu that is built rather than
filtered; pressing the panel button twice raises one window rather than
starting a second on the same profile; KWin reports the window as
ekklesia-wonderink, which is one panel button and not two.

Separate from the browser as before, in its own profile, with cookies
that live only as long as the window — what the Chrome policy did for
wonderink.org, and the reason closing Wonder Ink ends a teacher's
session. Chrome's old profile directory is left untouched beside it, and
the browser's own policy is not edited by any of this.

Wonder Ink is now optional per device, through WONDERINK_ENABLED in
device.conf, because one future device is a laptop that will not have it.
It rides the mechanism the disc player already uses: one marked line in
the packaged layout, edited by classroom-desktop-layout. False takes the
launcher off the panel and makes the command refuse to start; the
browser, Parent Call and the disc player are untouched and the panel
stays balanced. Absent means yes, so every existing room is unchanged.

One scale, still. Chrome has to be handed UI_SCALE because it was
measured not to follow the compositor; Qt does follow it — devicePixelRatio
2.0 at UI_SCALE=2.0, measured — so Wonder Ink is deliberately not told,
or the room would get it twice.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 21:29:17 -05:00

164 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Match the classroom's panel to the machine it is on.
Two of the bar's launchers depend on the machine rather than on the
release, and this edits the marked line for each of them in the installed
layout, and nothing else.
the disc player a room with no optical drive should not be offered
one. The machine is asked; nothing is remembered.
Wonder Ink WONDERINK_ENABLED in device.conf. One future device
is a laptop that will not have Wonder Ink at all,
and a device that is not a classroom should not
carry a lesson application it cannot use.
The two are deliberately answered differently. A drive is something a
machine can be asked about, and asking beats remembering to edit a file
when a computer moves between rooms. A licence is not, so it is a
setting.
Run before the display manager by ekklesia-classroom-layout.service, and
again by udev when a drive is plugged in or pulled out. That ordering is
the whole point: the panel is built from this layout the moment a session
starts, and a room that is told about its hardware afterwards keeps the
wrong bar until the next login. It was being left to the provisioning
service, which runs at boot alongside the session rather than before it.
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.
"""
import glob
import os
import re
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_config import enabled, read_device_config # noqa: E402
LAYOUT = (
"/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop"
"/contents/layouts/org.kde.plasma.desktop-layout.js"
)
DVD_MARKER = "/* CLASSROOM-DVD-LAUNCHER */"
DVD_ENTRY = "ekklesia-dvd.desktop"
WONDERINK_MARKER = "/* CLASSROOM-WONDERINK-LAUNCHER */"
WONDERINK_ENTRY = "ekklesia-wonderink.desktop"
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and
# /sys/block is the kernel's own list, which cannot be left behind by a
# device node somebody or something created by hand.
DRIVE_GLOB = "/dev/sr[0-9]*"
SYSFS_GLOB = "/sys/block/sr[0-9]*"
def drives():
return sorted(glob.glob(DRIVE_GLOB)), sorted(glob.glob(SYSFS_GLOB))
def has_optical_drive():
nodes, blocks = drives()
# Said out loud every time. When a room shows a disc player it has no
# drive for, or hides one it has, this line in the journal is the
# difference between an answer and an afternoon.
print("classroom-desktop-layout: /dev "
f"[{', '.join(nodes) or 'none'}], /sys/block "
f"[{', '.join(blocks) or 'none'}]")
return bool(nodes and blocks)
def rewrite(text, marker, entry, wanted):
"""Comment one marked launcher in or out, idempotently."""
line = re.compile(
r"^(\s*)(//\s*)?(" + re.escape(marker)
+ r'\s*"applications:' + re.escape(entry) + r'")',
re.M,
)
def swap(match):
indent, _, body = match.groups()
return f"{indent}{'' if wanted else '// '}{body}"
return line.sub(swap, text)
def wonderink_wanted():
"""WONDERINK_ENABLED, defaulting to true.
A classroom that has never heard of the setting is a classroom, so the
absent case is the one every existing room is in and it has to mean
yes. Said out loud for the same reason the drive is.
"""
config = read_device_config()
wanted = enabled(config, "WONDERINK_ENABLED", default=True)
print(f"classroom-desktop-layout: WONDERINK_ENABLED "
f"[{config.get('WONDERINK_ENABLED', 'unset')}]")
return wanted
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 DVD_MARKER not in before:
print("classroom-desktop-layout: no DVD marker in the layout",
file=sys.stderr)
return 0
after = rewrite(before, DVD_MARKER, DVD_ENTRY, has_optical_drive())
if WONDERINK_MARKER in before:
after = rewrite(
after, WONDERINK_MARKER, WONDERINK_ENTRY, wonderink_wanted()
)
else:
print("classroom-desktop-layout: no Wonder Ink marker in the layout",
file=sys.stderr)
if after == before:
print("classroom-desktop-layout: the layout already matches "
"this machine")
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("classroom-desktop-layout: the layout now matches this machine")
return 0
if __name__ == "__main__":
sys.exit(main())