diff --git a/docs/design.md b/docs/design.md index ae6a0ac..404393a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -185,6 +185,8 @@ KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompos The nested session also runs the system's XDG autostart entries, being a KDE session. Discover's update notifier started `plasma-discover --mode update` in it (520 to 620 MB resident and about 9% of a core, plus `flatpak-system-helper` and AppStream downloads), and IBus started a daemon, the kimpanel panel and its GTK extension that nothing can use: KWin hands text input to the one input method it starts (`ft-textinput`), and the session drops `QT_IM_MODULE`, `GTK_IM_MODULE` and `XMODIFIERS`. The session hides both for this desktop only, with `Hidden=true` copies in its own autostart folder. The geoclue demo agent stays: it's what answers apps' location requests to Geoclue outside GNOME, and it costs nothing while idle. Orca's entry only starts in GNOME-family desktops. +Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. The file is backed up to `.ft-bak` first. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens. + Remote desktop is a chain (krdp, then FreeRDP inside Xvnc) because nothing on SteamOS serves KWin over VNC directly. Kept connected all the time, it cost about a core with nobody watching: krdpserver 55 to 78% (it encodes H.264 in software with openh264: VA-API finds no driver for the Frame's GPU in the container), FreeRDP 16 to 27%, Xvnc 6 to 11%, and the bridge's layout check every 5 seconds another 4%. krdp creates its screencast session per RDP connection and drops it when the connection closes (`SessionController::onNewConnection` in krdp 6.7), so an idle krdpserver costs nothing and can stay up; only the RDP connection has to go. The bridge connects FreeRDP when a VNC client appears and disconnects 45 seconds after the last one leaves. Xvnc has no hook for its clients, so the bridge counts established connections to its port with `ss`, woken early by Xvnc's log output; looking with `ss` once a second cost about 0.9% of a core in bash, against about 0.1% this way. `Xvnc -inetd` from a systemd socket would start a server per connection and lose sharing between viewers. The layout check (`ft-layout remote-view`, which scans `/proc` for plasmashell and runs `kscreen-doctor -j`) now runs only while FreeRDP runs, and then only after `kwinoutputconfig.json` or `frametop-layout.json` changes, with one check a minute in case a change touched neither. Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`. @@ -224,6 +226,5 @@ On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-stea - A controller button that shows the screens during a game. Games own the controllers, so this needs SteamVR input actions for ft-screens. - Drawing KWin's cursor on the screens. -- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back. - Frame pacing and GPU cost with several busy screens haven't been measured. - Real standby on a stand, with rendering and tracking paused, not just the backlight off. SteamVR has no call for it, and its activity level follows the proximity sensor. diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 96ca755..4ac9244 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -36,6 +36,10 @@ check "distrobox" on_frame 'test -x ~/.local/bin/distrobox && ~/.local/bin/distr check "container $FRAME_BOX" on_frame "podman ps -a --filter name=^$FRAME_BOX\$ --format '{{.Image}} {{.Status}}' | grep ." check "repo on the Frame" on_frame 'pwd' check "free space in ~" on_frame "df -h ~ | awk 'NR==2{print \$4\" free\"}'" +# A taskbar saved on a screen the desktop doesn't have is hidden; the desktop's next start +# moves it to the first screen (session/fix-panels.py). +check "taskbar on a screen" on_frame 'set -o pipefail; [ -f session/fix-panels.py ] || { echo "not checked (older checkout)"; exit 0; } + python3 session/fix-panels.py --check | paste -sd ";" | sed "s/;/; /g"' echo "what Frametop needs from SteamOS:" if [ "$FRAME_LOCAL" = 1 ]; then diff --git a/scripts/report.sh b/scripts/report.sh index 8164eb0..ea031e1 100755 --- a/scripts/report.sh +++ b/scripts/report.sh @@ -42,6 +42,25 @@ for i, s in enumerate(d.get("screens", []), 1): print("visibility:", d.get("visibility")) PY +section "Plasma panels and outputs" +python3 "$repo/session/fix-panels.py" --check 2>&1 || true +python3 - "$repo" <<'PY' 2>&1 || true +import json, subprocess, sys +sys.path.insert(0, sys.argv[1] + "/layout") +import ft_layout +env = ft_layout.nested_env() +if not env: + sys.exit(print("desktop not running: no live outputs or panels")) +outs = json.loads(subprocess.run(["kscreen-doctor", "-j"], capture_output=True, text=True, env=env, timeout=10).stdout or "{}") +for o in outs.get("outputs", []): + size = o.get("size") or {} + print(f"output {o.get('name')}: enabled={o.get('enabled')} priority={o.get('priority')} {size.get('width')}x{size.get('height')}") +js = "print(JSON.stringify(panels().map(p => ({id: p.id, screen: p.screen, location: p.location}))))" +r = subprocess.run(["qdbus6", "org.kde.plasmashell", "/PlasmaShell", "org.kde.PlasmaShell.evaluateScript", js], + capture_output=True, text=True, env=env, timeout=10) +print("live panels:", (r.stdout or r.stderr).strip()) +PY + section "Input relay (last 60 lines)" journalctl --user -u frametop-input-relay -n 60 --no-pager -o short 2>/dev/null section "Pointer helper (last 60 lines)" diff --git a/session/fix-panels.py b/session/fix-panels.py new file mode 100755 index 0000000..d251708 --- /dev/null +++ b/session/fix-panels.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Bring back a taskbar that Plasma saved against a screen this desktop doesn't have. + +Plasma 6.2.5 ties each panel to a screen number (lastScreen in its containment), and the +numbers rank the enabled outputs by priority: 0 is the primary screen. A panel whose +number is past the screen count gets no view and stays hidden; Plasma never moves it, +even on a later start (sanitizeScreenLayout() only remaps a panel whose number has no +desktop, and the Frametop desktop keeps a desktop for every spare output it has seen). +So a taskbar saved on a spare output (issue #18), or on a screen that a smaller layout +dropped, was lost until the config was deleted. + +The session runs this before Plasma starts, so nothing races Plasma for the file. Each +panel numbered at or past the screen count moves to screen 0, with its system tray's +containment, and keeps its widgets and settings. A panel is left where it is when screen +0 already has a panel on that edge: it comes back by itself if the screens do. The file +is backed up once per repair (.ft-bak), and the changes go through kwriteconfig6. + + fix-panels.py [--screens N] [--file APPLETSRC] [--check] + --screens the desktop's screen count (default: the configured layout's) + --file default: $XDG_CONFIG_HOME/plasma-org.kde.plasma.desktop-appletsrc, with + XDG_CONFIG_HOME defaulting to the Frametop desktop's ~/.config/frametop + --check change nothing; list the panels, and exit 1 if one is lost +""" +import argparse +import os +import re +import shutil +import subprocess +import sys + +EDGES = {3: "top", 4: "bottom", 5: "left", 6: "right"} # Plasma::Types::Location +SYSTRAY = "org.kde.plasma.private.systemtray" +GROUP = re.compile(r"\[([^\]]*)\]") + + +def parse(text): + """KConfig text -> {(group, subgroup, ...): {key: value}}.""" + groups, cur = {}, None + for line in text.splitlines(): + line = line.strip() + if line.startswith("["): + cur = tuple(GROUP.findall(line)) + groups.setdefault(cur, {}) + elif cur is not None and "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + groups[cur][re.sub(r"\[\$.*\]$", "", k.strip())] = v.strip() + return groups + + +def number(v, default=-1): + try: + return int(v) + except (TypeError, ValueError): + return default + + +def panels(groups): + """The panels, by containment id: location, lastScreen, and the ids of their system + trays' own containments (which sit on the same edge and screen as the panel).""" + trays, found = {}, {} + for g, keys in groups.items(): + if len(g) == 5 and g[0] == "Containments" and g[2] == "Applets" and g[4] == "Configuration": + tray = keys.get("SystrayContainmentId") + if tray: + trays.setdefault(g[1], []).append(tray) + owned = {t for ts in trays.values() for t in ts} + for g, keys in groups.items(): + if len(g) != 2 or g[0] != "Containments" or number(g[1]) <= 0 or g[1] in owned: + continue + loc = number(keys.get("location"), 0) + if loc in EDGES and keys.get("plugin") != SYSTRAY: + found[g[1]] = {"location": loc, "screen": number(keys.get("lastScreen")), + "plugin": keys.get("plugin", "?"), "trays": trays.get(g[1], [])} + return dict(sorted(found.items(), key=lambda kv: number(kv[0]))) + + +def plan(groups, screens): + """(moves, kept): moves are (panel id, from screen, containment ids to put on screen 0); + kept are (panel id, from screen, why) for lost panels left alone.""" + found = panels(groups) + taken = {(p["screen"], p["location"]) for p in found.values() if 0 <= p["screen"] < screens} + moves, kept = [], [] + for pid, p in found.items(): + if p["screen"] < screens: + continue # on a screen this desktop has (Plasma puts -1 on the first one itself) + if (0, p["location"]) in taken: + kept.append((pid, p["screen"], f"the first screen already has a {EDGES[p['location']]} panel")) + continue + taken.add((0, p["location"])) + moves.append((pid, p["screen"], [pid, *p["trays"]])) + return moves, kept + + +def default_screens(): + sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "layout")) + import ft_layout + return ft_layout.screen_count() + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--screens", type=int) + ap.add_argument("--file") + ap.add_argument("--check", action="store_true") + a = ap.parse_args(argv) + config = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config/frametop") + path = a.file or os.path.join(config, "plasma-org.kde.plasma.desktop-appletsrc") + screens = a.screens if a.screens else default_screens() + try: + with open(path) as f: + groups = parse(f.read()) + except FileNotFoundError: + if a.check: + print("no Plasma config yet (Plasma makes the default taskbar)") + return 0 + moves, kept = plan(groups, screens) + + if a.check: + found = panels(groups) + for pid, p in found.items(): + lost = " (lost: no such screen)" if p["screen"] >= screens else "" + print(f"panel {pid}: screen {p['screen']}, {EDGES[p['location']]}{lost}") + if not found: + print("no panels") + print(f"{screens} screen(s)") + return 1 if moves or kept else 0 + + if moves: + shutil.copy2(path, path + ".ft-bak") + for pid, was, ids in moves: + for cid in ids: + subprocess.run(["kwriteconfig6", "--file", os.path.abspath(path), "--group", "Containments", + "--group", cid, "--key", "lastScreen", "0"], check=True) + print(f"frametop: panel {pid} was saved on screen {was}, which this desktop doesn't have " + f"({screens} screen(s)); moved it to the first screen (backup: {path}.ft-bak)", file=sys.stderr) + for pid, was, why in kept: + print(f"frametop: panel {pid} is saved on screen {was}, which this desktop doesn't have; " + f"left there: {why}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/session/frametop-session.sh b/session/frametop-session.sh index aec1fe9..404fa36 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -272,4 +272,9 @@ fi # with both, apps would open twice. kwriteconfig6 --file "$XDG_CONFIG_HOME/ksmserverrc" --group General --key loginMode emptySession +# Plasma keeps a panel on a screen number, and never moves one whose screen this desktop +# doesn't have, like a spare output or a screen a smaller layout dropped. Put such a panel +# back on the first (primary) screen before Plasma reads the file (session/fix-panels.py). +python3 "$here/fix-panels.py" --screens "$screens" || true + dbus-run-session startplasma-wayland diff --git a/session/tests/test_fix_panels.py b/session/tests/test_fix_panels.py new file mode 100644 index 0000000..29e576d --- /dev/null +++ b/session/tests/test_fix_panels.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Tests for session/fix-panels.py: issue #18's config (the taskbar saved on spare output 8 +of a 3-screen desktop), panels that are fine, an edge that's taken, and a real run of +kwriteconfig6 on a copy. Nothing here touches the running desktop or its config. + + python3 session/tests/test_fix_panels.py +""" +import importlib.util +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.realpath(__file__)) +SCRIPT = os.path.join(HERE, "..", "fix-panels.py") +spec = importlib.util.spec_from_file_location("fix_panels", SCRIPT) +fp = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fp) + + +def desktop(cid, screen): + return f"""[Containments][{cid}] +activityId=7e1d1a3c-0000-4000-8000-000000000000 +formfactor=0 +immutability=1 +lastScreen={screen} +location=0 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image +""" + + +def panel(cid, screen, location=4, tray=None): + text = f"""[Containments][{cid}] +activityId= +formfactor=2 +immutability=1 +lastScreen={screen} +location={location} +plugin=org.kde.panel +wallpaperplugin=org.kde.image + +[Containments][{cid}][Applets][{cid}1] +immutability=1 +plugin=org.kde.plasma.kickoff +""" + if tray: + text += f""" +[Containments][{cid}][Applets][{cid}2] +immutability=1 +plugin=org.kde.plasma.systemtray + +[Containments][{cid}][Applets][{cid}2][Configuration] +PreloadWeight=90 +SystrayContainmentId={tray} + +[Containments][{tray}] +activityId= +formfactor=2 +immutability=1 +lastScreen={screen} +location={location} +plugin=org.kde.plasma.private.systemtray +popupHeight=432 +""" + return text + + +ISSUE_18 = "\n".join([desktop(1, 0), desktop(18, 1), desktop(19, 2)] + [desktop(20 + i, 3 + i) for i in range(8)] + + [panel(10, 8, tray=11), "[ScreenMapping]\nitemsOnDisabledScreens=\n"]) + + +class Plan(unittest.TestCase): + def test_issue_18(self): + moves, kept = fp.plan(fp.parse(ISSUE_18), 3) + self.assertEqual(moves, [("10", 8, ["10", "11"])]) + self.assertEqual(kept, []) + + def test_panel_on_a_screen_stays(self): + for screen in (0, 2, -1): + moves, kept = fp.plan(fp.parse(desktop(1, 0) + panel(2, screen, tray=8)), 3) + self.assertEqual((moves, kept), ([], []), screen) + + def test_tray_is_not_a_panel(self): + found = fp.panels(fp.parse(panel(2, 0, tray=8))) + self.assertEqual(list(found), ["2"]) + self.assertEqual(found["2"]["trays"], ["8"]) + + def test_desktops_never_move(self): + moves, _ = fp.plan(fp.parse(ISSUE_18), 1) + self.assertEqual([m[0] for m in moves], ["10"]) + + def test_edge_taken_on_first_screen(self): + text = panel(2, 0, tray=8) + panel(10, 8, tray=11) + moves, kept = fp.plan(fp.parse(text), 3) + self.assertEqual(moves, []) + self.assertEqual([k[:2] for k in kept], [("10", 8)]) + + def test_other_edge_is_free(self): + moves, kept = fp.plan(fp.parse(panel(2, 0) + panel(10, 4, location=3)), 3) + self.assertEqual(moves, [("10", 4, ["10"])]) + self.assertEqual(kept, []) + + def test_two_lost_on_one_edge(self): + moves, kept = fp.plan(fp.parse(panel(10, 5) + panel(12, 8)), 3) + self.assertEqual([m[0] for m in moves], ["10"]) + self.assertEqual([k[0] for k in kept], ["12"]) + + def test_screen_count_went_down(self): + moves, _ = fp.plan(fp.parse(panel(2, 1, location=3)), 1) + self.assertEqual(moves, [("2", 1, ["2"])]) + + +class Run(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "plasma-org.kde.plasma.desktop-appletsrc") + with open(self.path, "w") as f: + f.write(ISSUE_18) + + def tearDown(self): + self.dir.cleanup() + + def run_script(self, *args): + return subprocess.run([sys.executable, SCRIPT, "--file", self.path, "--screens", "3", *args], + capture_output=True, text=True) + + def test_check_reports_and_changes_nothing(self): + r = self.run_script("--check") + self.assertEqual(r.returncode, 1) + self.assertIn("panel 10: screen 8, bottom (lost", r.stdout) + with open(self.path) as f: + self.assertEqual(f.read(), ISSUE_18) + + def test_repair(self): + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("moved it to the first screen", r.stderr) + with open(self.path) as f: + groups = fp.parse(f.read()) + self.assertEqual(groups[("Containments", "10")]["lastScreen"], "0") + self.assertEqual(groups[("Containments", "11")]["lastScreen"], "0") + self.assertEqual(groups[("Containments", "10", "Applets", "101")]["plugin"], "org.kde.plasma.kickoff") + self.assertEqual(groups[("Containments", "25")]["lastScreen"], "8") # a spare's desktop + with open(self.path + ".ft-bak") as f: + self.assertEqual(f.read(), ISSUE_18) + # A second run finds nothing to do and leaves the backup alone. + os.remove(self.path + ".ft-bak") + r = self.run_script() + self.assertEqual((r.returncode, r.stderr), (0, "")) + self.assertFalse(os.path.exists(self.path + ".ft-bak")) + self.assertEqual(self.run_script("--check").returncode, 0) + + def test_no_config_yet(self): + os.remove(self.path) + self.assertEqual(self.run_script().returncode, 0) + self.assertEqual(self.run_script("--check").returncode, 0) + + +if __name__ == "__main__": + unittest.main()