#!/usr/bin/env python3 """Match the classroom's panel to the machine it is on. Which applications a device offers is decided here, once, and written into the feature lines of the installed layout. The panel is then built from that layout at the next login. Each application is its own question, and the shape of the machine is never one of them: 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. Parent Call PARENT_CALL_ENABLED. Same rule. Presentations PRESENTATIONS_ENABLED. Same rule again, and it asks the machine nothing at all: a presentation needs a screen, which every device has. Alone among these it defaults to OFF, because no existing room has it. 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 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 json 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 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" ) # The variable in the layout, and the setting that answers it. FEATURE_LINES = ( ("WONDERINK", "WONDERINK_ENABLED"), ("DVD", "DVD_ENABLED"), ("PARENT_CALL", "PARENT_CALL_ENABLED"), ("PRESENTATIONS", "PRESENTATIONS_ENABLED"), ) MARKER = "/* CLASSROOM-FEATURES-BEGIN */" # WHAT THE MANUAL IS ALLOWED TO KNOW ABOUT THIS DEVICE. # # This command already decides what the device offers, so it is also the # place to write that answer down where the classroom account can read # it. A list of setting NAMES, and nothing else: no values, no broker # password, no Wi-Fi, no update address, none of the rest of device.conf # — which the manual has no use for and the room should not be handed. # # In /run rather than /etc because it is derived, not configured: it is # rewritten from device.conf at every boot by the same service that # builds the bar, so it cannot go stale behind a setting somebody # changed. MANIFEST = "/run/ekklesia-classroom/features.json" # 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 say(message): print(f"classroom-desktop-layout: {message}") def has_optical_drive(): 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. say(f"/dev [{', '.join(nodes) or 'none'}], " f"/sys/block [{', '.join(blocks) or 'none'}]") return bool(nodes and blocks) 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*var\s+" + re.escape(variable) + r"\s*=\s*)(true|false)(\s*;)", re.M, ) return line.sub( lambda m: f"{m.group(1)}{'true' if answer else 'false'}{m.group(3)}", text, ) def publish(answers, path=MANIFEST): """Leave the enabled features where classroom-manual can read them. Best effort on purpose. A room whose manual shows a page for something it has not got is a small confusion; a panel that failed to build because a directory was read-only is a room with no way of getting anywhere. """ enabled = sorted( setting for variable, setting in FEATURE_LINES if answers.get(variable) ) try: os.makedirs(os.path.dirname(path), exist_ok=True) temporary = f"{path}.new" with open(temporary, "w", encoding="utf-8") as handle: json.dump(enabled, handle) os.replace(temporary, path) os.chmod(path, 0o644) say(f"the manual will show {', '.join(enabled) or 'no optional pages'}") except OSError as error: say(f"could not write {path}: {error}") 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 feature block in the layout", file=sys.stderr) return 0 after = before answers = wanted(read_device_config()) publish(answers) for variable, answer in answers.items(): after = rewrite(after, variable, answer) if after == before: say("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 say("the layout now matches this machine") return 0 if __name__ == "__main__": sys.exit(main())