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 <[email protected]>
This commit is contained in:
ekklesia
2026-09-29 18:05:44 -05:00
co-authored by Claude Opus 5.5
parent 530f6f6563
commit 7b4ac39cd2
6 changed files with 374 additions and 1 deletions
+11 -1
View File
@@ -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/<name> 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
@@ -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];
@@ -0,0 +1,12 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Ekklesia"
}
],
"Id": "EkklesiaClassroom",
"License": "GPLv2+",
"Name": "Ekklesia Classroom"
}
}
+308
View File
@@ -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:]))
@@ -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
+30
View File
@@ -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"