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:
co-authored by
Claude Opus 5.5
parent
530f6f6563
commit
7b4ac39cd2
Executable
+308
@@ -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:]))
|
||||
Reference in New Issue
Block a user