feat: one setting per application, and never the shape of the machine

A laptop is going in for the women's Bible study, and the obvious way to
build that — a laptop profile that turns off the children's church
applications — is the wrong way. A laptop may well take children's church,
and a tower may well be a Bible study. Hardware cannot be allowed to
decide purpose, so every optional application gets a setting of its own:

    WONDERINK_ENABLED       already existed; behaviour unchanged
    DVD_ENABLED             new
    PARENT_CALL_ENABLED     new

and DEVICE_TYPE says only what the hardware IS. classroom-desktop-layout
never reads it, and there is a test that says so, because this is the rule
that will be under pressure the first time somebody adds a device.

All three default to true, so an existing classroom that has never heard
of any of them comes up exactly as it did before. The settings are
appended to device.conf by the same mechanism every other setting has
used, so an upgrade gains them without losing a byte an administrator
wrote.

The disc player is the one place hardware still votes, and it is a veto:
DVD_ENABLED can take the player away from a machine that has a drive,
nothing can put it on a machine that has none. A button that cannot work
is worse than no button.

The layout's launcher markers are replaced by a feature block of three
plain booleans. The old mechanism commented a line out of an array, which
worked for launchers and could not express Parent Call, because that is a
whole widget rather than one entry. Three booleans express both, and the
launcher list is now built from them in one fixed order, so a disabled
application closes the row up rather than leaving a gap, and the same
enabled set produces the same bar on every machine in the church.

Also here, because it is the bar and not a feature: sortingStrategy=0.
Plasma's default is 1, "manually", and the task manager accepts a
reordering drop only in that mode — MouseHandler.qml tests
tasksModel.sortMode === SortManual before it will move anything. The
classroom never wrote the key, so it had the default, and any teacher
could drag the launchers into a different order. This is the applet's own
lever refusing the drop rather than something of ours putting the bar back
afterwards.

The test now runs the layout the package actually ships, in node, with
Plasma's API stubbed, and asserts the launcher string that comes out.
Asserting against a copy of the file's text in the test was how the old
one worked, and a copy is a thing that drifts.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-20 20:55:09 -05:00
co-authored by Claude Opus 5
parent fc764b0621
commit 7f4a122900
8 changed files with 500 additions and 200 deletions
+65 -63
View File
@@ -2,29 +2,33 @@
"""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.
Which applications a device offers is decided here, once, and written into
the three feature lines of the installed layout. The panel is then built
from that layout at the next login.
the disc player a room with no optical drive should not be offered
one. The machine is asked; nothing is remembered.
Each application is its own question, and the shape of the machine is
never one of them:
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.
Wonder Ink WONDERINK_ENABLED. A laptop taking children's church
needs it; a tower used for the women's Bible study does
not. DEVICE_TYPE is not consulted and must not be.
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.
Parent Call PARENT_CALL_ENABLED. Same rule.
the disc DVD_ENABLED *and* a drive being present. The setting can
player take the player away from a machine that has a drive;
nothing can put it on a machine that has none, because a
button that cannot work is worse than no button.
That last one is the only place hardware gets a vote, and it can only ever
remove a launcher. Everything else is configuration, so that moving a
machine between rooms is a matter of saying what it is for.
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.
wrong bar until the next login.
It writes the *layout*, not the running panel. Editing
plasma-org.kde.plasma.desktop-appletsrc underneath a live plasmashell was
@@ -32,7 +36,6 @@ 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
@@ -46,18 +49,21 @@ sys.path.insert(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_config import enabled, read_device_config # noqa: E402
from classroom_config import feature, 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"
# The variable in the layout, and the setting that answers it.
FEATURE_LINES = (
("WONDERINK", "WONDERINK_ENABLED"),
("DVD", "DVD_ENABLED"),
("PARENT_CALL", "PARENT_CALL_ENABLED"),
)
WONDERINK_MARKER = "/* CLASSROOM-WONDERINK-LAUNCHER */"
WONDERINK_ENTRY = "ekklesia-wonderink.desktop"
MARKER = "/* CLASSROOM-FEATURES-BEGIN */"
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and
@@ -67,50 +73,52 @@ 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 say(message):
print(f"classroom-desktop-layout: {message}")
def has_optical_drive():
nodes, blocks = drives()
nodes = sorted(glob.glob(DRIVE_GLOB))
blocks = sorted(glob.glob(SYSFS_GLOB))
# 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'}]")
say(f"/dev [{', '.join(nodes) or 'none'}], "
f"/sys/block [{', '.join(blocks) or 'none'}]")
return bool(nodes and blocks)
def rewrite(text, marker, entry, wanted):
"""Comment one marked launcher in or out, idempotently."""
def wanted(config):
"""What each feature line in the layout should say."""
answers = {}
for variable, setting in FEATURE_LINES:
answer = feature(config, setting)
say(f"{setting} [{config.get(setting, 'unset')}] -> {answer}")
answers[variable] = answer
# A drive is the one thing the machine itself gets a vote on, and it
# is a veto: it can only ever take the player away.
if answers["DVD"] and not has_optical_drive():
say("DVD_ENABLED is true but this machine has no drive")
answers["DVD"] = False
return answers
def rewrite(text, variable, answer):
"""Set one `var NAME = true;` line, idempotently."""
line = re.compile(
r"^(\s*)(//\s*)?(" + re.escape(marker)
+ r'\s*"applications:' + re.escape(entry) + r'")',
r"^(\s*var\s+" + re.escape(variable) + r"\s*=\s*)(true|false)(\s*;)",
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
return line.sub(
lambda m: f"{m.group(1)}{'true' if answer else 'false'}{m.group(3)}",
text,
)
def main():
@@ -123,24 +131,18 @@ def main():
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",
if MARKER not in before:
print("classroom-desktop-layout: no feature block in the layout",
file=sys.stderr)
return 0
after = rewrite(before, DVD_MARKER, DVD_ENTRY, has_optical_drive())
after = before
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)
for variable, answer in wanted(read_device_config()).items():
after = rewrite(after, variable, answer)
if after == before:
print("classroom-desktop-layout: the layout already matches "
"this machine")
say("the layout already matches this machine")
return 0
# Replace the file rather than edit in place, so a full disk or a
@@ -155,7 +157,7 @@ def main():
print(f"classroom-desktop-layout: {error}", file=sys.stderr)
return 1
print("classroom-desktop-layout: the layout now matches this machine")
say("the layout now matches this machine")
return 0