From 7b4ac39cd2ddf0756bef0fd0c099b21aafa7676c Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 29 Sep 2026 18:05:44 -0500 Subject: [PATCH] fix: a desktop Plasma makes after the layout has run gets the classroom's wallpaper Tiny Pebbles came through the 0.2.2 upgrade on Plasma's own purple wallpaper: its desktop was containment 10, made after the layout had run (most likely the television arriving after a night-time reboot), with no wallpaper configured. The look and feel's fallback could not catch it, because it named a file and Plasma only accepts a wallpaper package there. The fallback is now the EkklesiaClassroom package, whose one image links to the classroom's picture. classroom-desktop-wallpaper, run before every plasmashell start, writes the picture into every desktop containment the file already has: found by plugin, never by number; panels and everything else byte for byte; a file that is already right not written at all. Co-Authored-By: Claude Opus 5.5 --- config/plasma/lookandfeel/contents/defaults | 12 +- .../layouts/org.kde.plasma.desktop-layout.js | 7 + .../EkklesiaClassroom/metadata.json | 12 + scripts/classroom-desktop-wallpaper | 308 ++++++++++++++++++ .../ekklesia-classroom.conf | 6 + tools/build-release.sh | 30 ++ 6 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 config/wallpapers/EkklesiaClassroom/metadata.json create mode 100755 scripts/classroom-desktop-wallpaper diff --git a/config/plasma/lookandfeel/contents/defaults b/config/plasma/lookandfeel/contents/defaults index 713ee60..4668896 100644 --- a/config/plasma/lookandfeel/contents/defaults +++ b/config/plasma/lookandfeel/contents/defaults @@ -24,8 +24,18 @@ cursorTheme=breeze_cursors library=org.kde.breeze theme=Breeze +# The picture a desktop gets when nothing has said which. That is every +# desktop Plasma makes on its own after the layout has run: a television +# switched on after the computer, a second screen, a new activity. +# +# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it +# up as wallpapers/ and nothing else. This line was a path until +# 0.2.6.6, which Plasma cannot load as a package, so every such desktop +# fell back to Plasma's own purple one — measured, and it is what Tiny +# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose +# one image is a link to the classroom's file; see build-release.sh. [Wallpaper] -Image=/usr/share/backgrounds/ekklesia/classroom-calm.png +Image=EkklesiaClassroom [KSplash] Theme=None 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 b1b0ddb..b16bf53 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 @@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"; var PANEL_HEIGHT = 76; // The idle desktop: the wallpaper, and nothing on it. +// +// Only the desktops that exist right now. A screen that appears later — +// the television switched on after a reboot at three in the morning, which +// is what Tiny Pebbles had — gets a desktop Plasma makes without this +// file. That one takes the look and feel's default wallpaper, which is the +// classroom's package, and classroom-desktop-wallpaper writes it in by +// name before the shell next starts. var screens = desktops(); for (var i = 0; i < screens.length; i++) { var desktop = screens[i]; diff --git a/config/wallpapers/EkklesiaClassroom/metadata.json b/config/wallpapers/EkklesiaClassroom/metadata.json new file mode 100644 index 0000000..5e371d6 --- /dev/null +++ b/config/wallpapers/EkklesiaClassroom/metadata.json @@ -0,0 +1,12 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Ekklesia" + } + ], + "Id": "EkklesiaClassroom", + "License": "GPLv2+", + "Name": "Ekklesia Classroom" + } +} diff --git a/scripts/classroom-desktop-wallpaper b/scripts/classroom-desktop-wallpaper new file mode 100755 index 0000000..2bac1ea --- /dev/null +++ b/scripts/classroom-desktop-wallpaper @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 + +"""Put the classroom's wallpaper on every desktop the shell has. + +Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own +purple wallpaper, with the right panel and the right package installed. +Its desktop was containment 10, with no Wallpaper group at all: + + [Containments][10] + plugin=org.kde.plasma.folder + wallpaperplugin=org.kde.image + [Containments][10][General] + positions={"1920x1080":[]} + +The layout script writes the wallpaper, but only onto the desktops that +exist while it runs. Plasma makes a desktop for every screen, and every +activity, that appears afterwards — and it makes them without the layout. +0.2.2's updater rebooted at three in the morning with the television +switched off at the set, so the layout ran with no screen and no desktop +at all, and the desktop Plasma made when the television came on was +bare. Reproduced in the lab through a new activity, which takes the same +path through the shell: containment 11, the same four lines, Plasma's +wallpaper. + +Two things now close that. The look and feel's default wallpaper is a +real wallpaper package, so a desktop Plasma makes on its own comes up +with the classroom's picture — that is the fix, and it needs nothing to +run. And this, which writes the picture into every desktop the file +already has, so that the configuration says what the screen shows. + +WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its +number: a top-level containment whose plugin is one of Plasma's desktop +containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the +panel's number is whatever was free when it was built. + +ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a +desktop's wallpaper is written back byte for byte, and a file that is +already right is not written at all. + +WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre, +because the shell owns this file: editing it underneath a live shell was +measured to destroy the panel. That is every start — a login, a reboot, +and the restart after a crash — and at a login it usually finds nothing, +because classroom-desktop-reset has just removed the file for the layout +to rebuild. Run by hand, it refuses while the shell is running and says +how to apply it. Live D-Bus scripting is not an option: the classroom +locks the desktop and forbids the scripting console, and Plasma answers +"Widgets are locked" — measured. + + classroom-desktop-wallpaper repair, if the shell is stopped + classroom-desktop-wallpaper --check say what is wrong, change nothing + classroom-desktop-wallpaper FILE work on FILE instead +""" + +import os +import re +import sys + +WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png" + +WALLPAPER_PLUGIN = "org.kde.image" + +# Plasma's two desktop containments: Folder View, which is what the +# classroom's desktops are, and the plain Desktop. Panels, the system tray +# a 0.2.2-era profile may still carry and anything else are not desktops. +DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment") + +# What the layout writes onto a desktop, so a repaired desktop and a +# freshly built one say the same thing. +WALLPAPER_KEYS = ( + ("FillMode", "2"), + ("Image", f"file://{WALLPAPER}"), +) + +APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc" + +CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$") + +# key=value, with KConfig's optional [$i]-style flags or [locale] after +# the key. The flags are kept when a value is replaced. +ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$") + + +def say(message): + print(f"classroom-desktop-wallpaper: {message}", flush=True) + + +def groups(lines): + """[(name, header, end)]: lines[header] is the group's header line, or + -1 for the lines before the first group, and end is exclusive.""" + found = [] + name, header = None, -1 + + for index, line in enumerate(lines): + stripped = line.strip() + + if stripped.startswith("[") and stripped.endswith("]"): + found.append((name, header, index)) + name, header = stripped, index + + found.append((name, header, len(lines))) + return found + + +def find_group(lines, name): + for group, header, end in groups(lines): + if group == name: + return header, end + + return None + + +def entry(line): + match = ENTRY.match(line.rstrip("\n")) + return (match.group(1), match.group(2), match.group(3)) if match else None + + +def desktops(lines): + """The ids of the desktop containments, whatever they are numbered.""" + ids = [] + + for name, header, end in groups(lines): + match = CONTAINMENT.match(name or "") + + if not match: + continue + + for line in lines[header + 1:end]: + parsed = entry(line) + + if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS: + ids.append(match.group(1)) + break + + return ids + + +def set_entry(lines, group, key, value): + """Make `key=value` true in an existing group. Returns what changed, + or None when it already was.""" + header, end = find_group(lines, group) + last = header + + for index in range(header + 1, end): + parsed = entry(lines[index]) + + if lines[index].strip(): + last = index + + if parsed and parsed[0] == key: + if parsed[2] == value: + return None + + lines[index] = f"{key}{parsed[1]}={value}\n" + return f"{key} was [{parsed[2]}]" + + lines.insert(last + 1, f"{key}={value}\n") + return f"{key} was missing" + + +def add_group(lines, containment, group): + """A new group, placed after the last of its containment's own.""" + prefix = f"[Containments][{containment}]" + after = None + + for name, header, end in groups(lines): + if name == prefix or (name or "").startswith(prefix + "["): + after = end + + # Back over the blank lines that separate it from the next group, so + # the new one sits with its containment and the spacing stays as + # KConfig writes it. + while after > 0 and not lines[after - 1].strip(): + after -= 1 + + if after > 0 and not lines[after - 1].endswith("\n"): + lines[after - 1] += "\n" + + block = ["\n", f"{group}\n"] + block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS] + lines[after:after] = block + + +def converge(text): + """The file as it should be, and what was changed to make it so.""" + lines = text.splitlines(keepends=True) + changes = [] + + for containment in desktops(lines): + top = f"[Containments][{containment}]" + change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN) + + if change: + changes.append(f"desktop {containment}: {change}") + + group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]" + + if find_group(lines, group) is None: + add_group(lines, containment, group) + changes.append(f"desktop {containment}: had no wallpaper configured") + continue + + for key, value in WALLPAPER_KEYS: + change = set_entry(lines, group, key, value) + + if change: + changes.append(f"desktop {containment}: {change}") + + return "".join(lines), changes + + +def default_path(): + home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(home, APPLETSRC) + + +def shell_running(proc="/proc"): + """plasmashell running as this user. Read from /proc rather than asked + of systemd, so a shell started any way at all counts, and rather than + of pgrep, which nothing guarantees is installed.""" + uid = os.getuid() + + for pid in os.listdir(proc): + if not pid.isdigit(): + continue + + try: + with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle: + name = handle.read().strip() + + if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid: + return True + except OSError: + # Gone while it was being looked at. + continue + + return False + + +def write(path, text): + """Replace the file whole, so a full disk or a power cut leaves the old + one rather than half of a new one.""" + temporary = f"{path}.classroom-new" + mode = os.stat(path).st_mode & 0o7777 + + with open(temporary, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + + os.chmod(temporary, mode) + os.replace(temporary, path) + + +def main(argv): + check = "--check" in argv + paths = [arg for arg in argv if not arg.startswith("--")] + path = paths[0] if paths else default_path() + + try: + with open(path, "r", encoding="utf-8") as handle: + before = handle.read() + except FileNotFoundError: + say("no desktop configuration yet; the layout writes the wallpaper") + return 0 + except OSError as error: + say(f"cannot read {path}: {error}") + return 0 + + after, changes = converge(before) + found = desktops(before.splitlines(keepends=True)) + + if not found: + say("no desktop in the configuration yet; Plasma's default is the " + "classroom's wallpaper package") + return 0 + + if not changes: + say(f"desktop {', '.join(found)}: the classroom wallpaper already") + return 0 + + for change in changes: + say(change) + + if check: + say("not changed (--check)") + return 1 + + # The shell owns the live file. Anything else is just a file. + if not paths and shell_running(): + say("plasmashell is running, and editing its file underneath it") + say("destroys the panel. Apply it with:") + say(" systemctl --user restart plasma-plasmashell.service") + say("which repairs the file before the shell starts again.") + return 1 + + try: + write(path, after) + except OSError as error: + say(f"cannot write {path}: {error}") + return 1 + + say(f"the wallpaper is now configured on desktop {', '.join(found)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf index 240e77f..ef9e4ac 100644 --- a/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf +++ b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf @@ -16,3 +16,9 @@ StartLimitIntervalSec=0 [Service] Restart=always RestartSec=2 +# Every desktop the configuration already has gets the classroom's +# wallpaper written into it, in the one moment the shell is not running to +# own the file — at a login, and at every restart after a crash. See +# classroom-desktop-wallpaper. The "-" is because a panel matters more than +# a wallpaper: nothing that script can do is allowed to stop the shell. +ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper diff --git a/tools/build-release.sh b/tools/build-release.sh index 90d32f0..94e342e 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -176,6 +176,32 @@ install -Dm644 \ "$ROOT/config/wallpapers/classroom-calm.png" \ "$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png" +# THE SAME PICTURE, AS A WALLPAPER PACKAGE. +# +# The look and feel names this as the default for any desktop Plasma makes +# without the layout, and Plasma only accepts a package there, never a +# file. Its one image is a link to the file above rather than a copy, so +# there is still exactly one picture, and every desktop the layout wrote +# keeps pointing at the path it always has. The name says the image's +# size, because that is how Plasma chooses between a package's images. +WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/EkklesiaClassroom" + +install -Dm644 \ + "$ROOT/config/wallpapers/EkklesiaClassroom/metadata.json" \ + "$WALLPAPER_PACKAGE/metadata.json" + +wallpaper_size="$(python3 -c ' +import struct, sys +with open(sys.argv[1], "rb") as png: + header = png.read(24) +assert header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR" +print("%dx%d" % struct.unpack(">II", header[16:24])) +' "$ROOT/config/wallpapers/classroom-calm.png")" + +install -d -m755 "$WALLPAPER_PACKAGE/contents" "$WALLPAPER_PACKAGE/contents/images" +ln -s /usr/share/backgrounds/ekklesia/classroom-calm.png \ + "$WALLPAPER_PACKAGE/contents/images/${wallpaper_size}.png" + @@ -466,6 +492,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-desktop-reset" \ "$STAGE/usr/local/bin/classroom-desktop-reset" +install -Dm755 \ + "$ROOT/scripts/classroom-desktop-wallpaper" \ + "$STAGE/usr/local/bin/classroom-desktop-wallpaper" + install -Dm755 \ "$ROOT/scripts/classroom-theme" \ "$STAGE/usr/local/bin/classroom-theme"