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]>
309 lines
10 KiB
Python
Executable File
309 lines
10 KiB
Python
Executable File
#!/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:]))
|