[verified] merge experimental into nested AT-SPI fix

This commit is contained in:
JakeGreen2145 committed 2026-10-04 17:38:33 -04:00
commit 5e3323c188
143 files changed
+20723 -841

No files matched your search

+143
View File
@@ -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 (<file>.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:]))
+39
View File
@@ -249,8 +249,47 @@ elif [ "$(kreadconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --delete
fi
# KWin draws through zink on the headset's GPU, which vrcompositor needs, so the blur and
# background contrast behind panels and menus and the window animations cost frames for
# little. They're off by default in this desktop. Each is written once, before KWin first
# reads it, and only if this desktop's config doesn't have it yet; the marker keeps it
# from coming back, since System Settings deletes a setting put back to KDE's default.
# docs/reference.md says how to turn them on again.
default() { # file group key value
[ -n "$(kreadconfig6 --file "$1" --group "$2" --key "$3")" ] || kwriteconfig6 --file "$1" --group "$2" --key "$3" "$4"
}
frametoprc=$XDG_CONFIG_HOME/frametoprc
if [ "$(kreadconfig6 --file "$frametoprc" --group Defaults --key effects)" != 1 ]; then
default "$kwinrc" Plugins blurEnabled false
default "$kwinrc" Plugins contrastEnabled false
default "$XDG_CONFIG_HOME/kdeglobals" KDE AnimationDurationFactor 0
kwriteconfig6 --file "$frametoprc" --group Defaults --key effects 1
fi
# System autostart entries this desktop doesn't need, hidden for it alone by a copy with
# Hidden=true in its own autostart folder, once and unless there's a file of that name
# already. Discover's update notifier starts Discover itself to check for updates (about
# 600 MB and a share of a core, with Flatpak's helper and AppStream behind it); updates
# come with SteamOS and from Discover in the stock desktop. IBus can't reach the
# desktop's apps: KWin's input method is ft-textinput, and the session drops the
# variables that point apps at IBus or XIM. Deleting the copy brings an entry back.
if [ "$(kreadconfig6 --file "$frametoprc" --group Defaults --key autostart)" != 1 ]; then
for entry in org.kde.discover.notifier ibus; do
src=/etc/xdg/autostart/$entry.desktop dst=$XDG_CONFIG_HOME/autostart/$entry.desktop
[ -r "$src" ] && [ ! -e "$dst" ] || continue
mkdir -p "$(dirname "$dst")"
sed '/^\[Desktop Entry\]$/a Hidden=true' "$src" > "$dst"
done
kwriteconfig6 --file "$frametoprc" --group Defaults --key autostart 1
fi
# Profiles reopen apps (docs/profiles.md), so Plasma's own session restore stays off here;
# 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
+1 -2
View File
@@ -38,7 +38,7 @@ POINTER_GAZE_SHOW=1 # gaze mode, with POINTER_GAZE_DOT=moving: the dot sh
POINTER_GAZE_MOUSE=precision # gaze mode: the left button holds back its press, the mouse steers, the release clicks (precision) | clicks right away (direct)
POINTER_HEAD_DEADZONE=0.5 # keyboard clicks at the gaze (Meta+J, Meta+K): degrees the head turns before the held dot moves with it
POINTER_KEY_TAP=0.25 # keyboard clicks: let go within this (s) and it clicks where the dot was at the press, and tells the gaze tracker it was right
GAZE_TRACKER=steam # gaze service: steam = SteamVR's eye tracker | own = our own (gaze/tracker: its frame grabber needs gaze/tracker/install.sh; calibrated with Calibrate on Frametop Input Settings' Gaze page)
GAZE_TRACKER=auto # gaze service: auto = our own eye tracker when it's installed (gaze/tracker/install.sh, which install.sh offers), else SteamVR's | own = ours | steam = SteamVR's. Each keeps its own calibration (Calibrate on Frametop Input Settings' Gaze page)
GAZE_EYE=auto # gaze service: eye bias. auto = each eye weighted by how far off it was at your recent nudges | left | right = that eye counts twice
HANDS_SWAP_SIDES=0 # hand tracking (hands/run.sh install): 1 = the side cameras' names are swapped, which some SteamVR restarts cause (hands/tools/check_sides.py --ring tells)
HANDS_CPUS=5,6,7 # hand tracking: the CPUs its model threads run on
@@ -54,7 +54,6 @@ POINTER_PINCH_DEADZONE=1.5 # hands: degrees the pinching hand moves before the p
POINTER_GRIP_GAIN=1 # hands: the pointer moves this times the gripping hand's angle
POINTER_GRIP_BELOW=0.35 # hands: grips beginning more than this (m) below the eyes are ignored (hands on a desk)
POINTER_PINCH_TYPING=1 # hands: no pinch within this many seconds of a key (typing touches thumb to index)
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)
DISPLAY_OFF_MIN=0 # minutes without use before frametop-power turns the displays off, even if the headset seems worn (0 = never)
DISPLAY_MOVE_MM=5 # displays off: the headset or a controller moving this far (mm) within 10 s counts as use
+162
View File
@@ -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()
+105 -15
View File
@@ -11,6 +11,16 @@
# screen placed lower or further right. Instead the VNC screen is the primary's size, and
# the workspace-sized RDP window inside it is shifted so the primary fills it. The pointer
# maps 1:1. When the layout changes, the VNC screen resizes and the RDP client reconnects.
#
# The RDP client runs only while someone watches. While connected, krdp captures and
# H.264-encodes every redraw in software, about 60% of a core, with FreeRDP and Xvnc adding
# about 30% more, even with no VNC viewer. krdp starts its capture per RDP connection and
# stops it when the connection closes, so it idles without one. So FreeRDP starts when a
# VNC client connects (the screen is black for the few seconds that takes) and stops
# VNC_IDLE_SEC (45) seconds after the last one leaves. Xvnc has no hook for clients, so ss
# counts them: whenever Xvnc writes to its log (it logs each connection), every second
# while FreeRDP runs, and every 5 seconds otherwise. The log only wakes this script up;
# what it says doesn't matter.
set -eu
here=$(dirname "$(readlink -f "$0")")
@@ -57,16 +67,51 @@ stop_rdp() { pkill -f "[x]freerdp /v:127.0.0.1:$rdp_port " 2>/dev/null || true;
trap 'stop_rdp; pkill -f "[X]vnc $display " 2>/dev/null || true' EXIT
box bash -c 'vncpasswd -f < "$1/vnc-password" > "$1/vnc-passwd.bin" && chmod 600 "$1/vnc-passwd.bin"' - "$creds"
box Xvnc "$display" -geometry "${w}x${h}" -depth 24 \
exec {xlog}< <(box Xvnc "$display" -geometry "${w}x${h}" -depth 24 \
-interface "$addr" -rfbport "$vnc_port" \
-SecurityTypes VncAuth -PasswordFile "$creds/vnc-passwd.bin" \
-AlwaysShared -desktop "Steam Frame (Frametop)" &
-AlwaysShared -desktop "Steam Frame (Frametop)" 2>&1)
xvnc=$!
sleep 2
# Keep an RDP connection open inside the VNC screen. Reconnect if it drops or the layout changes.
# Wait up to $1 seconds, less if Xvnc logs something; its lines go on to this log.
nap() {
local line rc=0
IFS= read -rt "$1" -u "$xlog" line || rc=$?
if [ $rc -eq 0 ]; then
printf '%s\n' "$line"
while IFS= read -rt 0.1 -u "$xlog" line; do printf '%s\n' "$line"; done
elif [ $rc -le 128 ]; then
sleep 1 # Xvnc's output closed: it's exiting
fi
return 0
}
nap 2
# A connected VNC client: an established TCP connection to Xvnc's port. Any connection
# counts, authenticated or not; it's on the tailnet only.
clients() { [ -n "$(ss -Htn state established "( sport = :$vnc_port )" 2>/dev/null)" ]; }
# ft-screens drops screens you aren't looking at to a low frame rate, and krdp would
# stream that. "watch SECONDS" asks it for full rate on every screen for that long: sent
# when a client connects and renewed every few seconds while one stays, so it lapses by
# itself if this script dies. An older ft-screens just answers that it doesn't know it.
watch() {
if command -v socat >/dev/null; then
printf 'watch 15' | socat -u - ABSTRACT-SENDTO:ft_screens 2>/dev/null
else
python3 -c 'import socket; socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM).sendto(b"watch 15", "\0ft_screens")' 2>/dev/null
fi || true
}
# What remote-view depends on: KWin's saved outputs (positions, scales, primary) and
# Frametop's layout (screen sizes). It's read again only after one of these changes, and
# once a minute in case a change didn't touch them, and only while FreeRDP runs.
layout_files=("$HOME/.config/frametop/kwinoutputconfig.json" "$HOME/.config/frametop-layout.json")
stamp() { stat -c %y "${layout_files[@]}" 2>/dev/null || true; }
# Start FreeRDP inside the VNC screen, sized and shifted for $v.
# /cert:ignore is fine here: the connection never leaves this host.
while kill -0 $xvnc 2>/dev/null; do
start_rdp() {
read -r x y w h ww wh <<< "$v"
box env DISPLAY=$display bash -c '
size=$1 x=$2 y=$3 ww=$4 wh=$5 creds=$6 rdp_port=$7
@@ -80,6 +125,7 @@ while kill -0 $xvnc 2>/dev/null; do
rdp=$!
# FreeRDP takes no negative position, so move its window once it is up.
for _ in $(seq 60); do
kill -0 $rdp 2>/dev/null || break
win=$(xdotool search --class xfreerdp 2>/dev/null | tail -1)
[ -n "$win" ] && break
sleep 0.5
@@ -88,14 +134,58 @@ while kill -0 $xvnc 2>/dev/null; do
wait $rdp
' vnc-rdp "${w}x$h" "$x" "$y" "$ww" "$wh" "$creds" "$rdp_port" || true &
rdp=$!
while kill -0 $rdp 2>/dev/null; do
sleep 5
now=$(view) || continue
[ -n "$now" ] && [ "$now" != "$v" ] || continue
echo "layout changed: $v -> $now"
v=$now
stop_rdp
done
wait $rdp 2>/dev/null || true
sleep 2
}
idle_sec=${VNC_IDLE_SEC:-45}
rdp= # FreeRDP's job while it runs
seen=0 # when a client was last seen ($SECONDS)
watched=-99 # when "watch" was last sent
stamp_v= # stamp() when $v was read
recheck=0 # read the layout every 2 seconds until then
next_view=0 # next time to read it
while kill -0 $xvnc 2>/dev/null; do
if clients; then
if [ $((SECONDS - watched)) -ge 5 ]; then watch; watched=$SECONDS; fi
seen=$SECONDS
if [ -z "$rdp" ]; then
echo "VNC client connected, starting the RDP client"
now=$(view) && [ -n "$now" ] && v=$now
stamp_v=$(stamp) recheck=0 next_view=$((SECONDS + 60))
start_rdp
fi
elif [ "$seen" -ne 0 ]; then
watched=-99
if [ $((SECONDS - seen)) -ge "$idle_sec" ]; then
seen=0
if [ -n "$rdp" ]; then
echo "no VNC client for ${idle_sec}s, stopping the RDP client"
stop_rdp
wait "$rdp" 2>/dev/null || true
rdp=
fi
fi
fi
if [ -n "$rdp" ] && ! kill -0 "$rdp" 2>/dev/null; then
# It dropped, or the layout changed: reconnect next round if a client is still there.
wait "$rdp" 2>/dev/null || true
rdp=
nap 2
continue
fi
if [ -n "$rdp" ]; then
# A layout change shows up in KWin's outputs a few seconds after the files change.
s=$(stamp)
[ "$s" = "$stamp_v" ] || { stamp_v=$s; recheck=$((SECONDS + 10)) next_view=$SECONDS; }
if [ "$SECONDS" -ge "$next_view" ]; then
next_view=$((SECONDS + 60))
[ "$SECONDS" -ge "$recheck" ] || next_view=$((SECONDS + 2))
now=$(view) || now=
if [ -n "$now" ] && [ "$now" != "$v" ]; then
echo "layout changed: $v -> $now"
v=$now
stop_rdp
fi
fi
fi
if [ -n "$rdp" ] || [ "$seen" -ne 0 ]; then nap 1; else nap 5; fi
done