Commit 440d086c by PLN (Algolia)

fix(tray): the menu the shell will not draw, and the checks that missed it

"i see the parvagues tray icon, but doesnt react when i click" — twice, with
the unit green both times. Three findings, in the order they mattered:

- The menu WAS exported and populated all along (19 rows over
  com.canonical.dbusmenu). What made it useless is the style: with
  QT_QPA_PLATFORMTHEME=qt5ct and no qt5ct config under the unit, Qt picks
  `qt5ct-style` with a bare #efefef palette — a 2009 grey box on a Yaru
  desktop. Now: Fusion, plus an explicit dark palette when the desktop asks
  for dark (GNOME's color-scheme is the one setting that knows).
- The tray host on GNOME is an EXTENSION, and it registers its
  StatusNotifierWatcher seconds after login. perf-tray printed "No system
  tray available", exited 1, and only Restart= saved it 3 s later. A slower
  login loses that race. Now it waits up to 120 s, polling on a QTimer
  (availability arrives over D-Bus, so the event loop has to be spinning).
- New check `perf-tray reachable`: unit active, item REGISTERED with the live
  pid (a stale item from an old pid looks identical on screen), and the menu
  populated over dbusmenu — the only one of the three that a click needs.
  Negative-tested against a stopped tray.

Also:
- `wireplumber churn` check: starts per hour, and who asked for them. The
  existing checks could only see the wreckage (a failed unit, a dummy sink);
  this sees the cause while audio still works. It FAILs on tonight's 48.
- The kwin midiviz-pin check asked KDE questions on a GNOME session, so it
  warned forever about a file that will never exist. Desktop-aware now: a
  check that cries wolf is one nobody reads on the day it is right.
parent 5818b2b0
...@@ -57,6 +57,7 @@ import launchers as LA # noqa: E402 ...@@ -57,6 +57,7 @@ import launchers as LA # noqa: E402
SERVICE = "perf-tray" # systemd --user unit controlling autostart SERVICE = "perf-tray" # systemd --user unit controlling autostart
REFRESH_MS = 2000 REFRESH_MS = 2000
TRAY_WAIT_S = 120 # how long to wait for the tray host (a GNOME extension) at login
BRIDGE_URL = "http://127.0.0.1:8773/" BRIDGE_URL = "http://127.0.0.1:8773/"
# The unit that IS the sound. Restarting it is the one destructive thing this # The unit that IS the sound. Restarting it is the one destructive thing this
...@@ -819,13 +820,85 @@ class PerfTray: ...@@ -819,13 +820,85 @@ class PerfTray:
a.setText(txt) a.setText(txt)
def prefers_dark() -> bool:
"""Does the desktop ask for a dark UI?
The menu below is drawn by Qt, not by the shell (Qt5 hardcodes
StatusNotifierItem ItemIsMenu=false, so the host will never draw ours), and
under a systemd unit there is no qt5ct config to read a palette from. Left
alone that renders a 2009 grey box on a modern desktop. GNOME's own
color-scheme is the one setting that knows the answer.
"""
for schema, key, dark_values in (
("org.gnome.desktop.interface", "color-scheme", ("prefer-dark",)),
("org.gnome.desktop.interface", "gtk-theme", ("Yaru-dark", "Adwaita-dark")),
):
try:
out = subprocess.run(["gsettings", "get", schema, key],
capture_output=True, text=True, timeout=2).stdout
except (OSError, subprocess.SubprocessError):
continue
value = out.strip().strip("'\"")
if value in dark_values or value.endswith("-dark"):
return True
return False
def apply_style(app) -> None:
"""Fusion + an explicit palette, so the popup looks like the desktop."""
app.setStyle("Fusion")
if not prefers_dark():
return
from PyQt5.QtGui import QPalette
p = QPalette()
ink, ground, raised = QColor("#f6f5f4"), QColor("#353535"), QColor("#2b2b2b")
accent = QColor("#e95420") # Ubuntu orange, the desktop's own accent
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText,
QPalette.ToolTipText, QPalette.BrightText):
p.setColor(role, ink)
for role, colour in ((QPalette.Window, ground), (QPalette.Button, ground),
(QPalette.Base, raised), (QPalette.ToolTipBase, raised),
(QPalette.AlternateBase, ground)):
p.setColor(role, colour)
p.setColor(QPalette.Highlight, accent)
p.setColor(QPalette.HighlightedText, QColor("#ffffff"))
p.setColor(QPalette.Disabled, QPalette.Text, QColor("#9a9996"))
p.setColor(QPalette.Disabled, QPalette.WindowText, QColor("#9a9996"))
app.setPalette(p)
def main(): def main():
app = QApplication([]) app = QApplication([])
app.setQuitOnLastWindowClosed(False) app.setQuitOnLastWindowClosed(False)
if not QSystemTrayIcon.isSystemTrayAvailable(): apply_style(app)
print("No system tray available in this session.")
return 1 # WAIT for the tray host instead of dying on it. On GNOME the tray is not a
PerfTray(app) # session service but an EXTENSION (appindicatorsupport), and it registers
# its StatusNotifierWatcher a few seconds after login — so at 20:44:10 this
# process printed "No system tray available", exited 1, and only systemd's
# Restart= saved it three seconds later. That is a race won by luck, and a
# slower login loses it: Restart=on-failure with a StartLimit would leave
# the rig's only UI permanently absent.
#
# Availability arrives over D-Bus, so it needs the event loop spinning to be
# noticed — hence a QTimer rather than a sleep().
deadline = time.monotonic() + TRAY_WAIT_S
state = {"tray": None}
def attempt():
if QSystemTrayIcon.isSystemTrayAvailable():
state["tray"] = PerfTray(app)
probe.stop()
return
if time.monotonic() > deadline:
print(f"No system tray available after {TRAY_WAIT_S}s "
"(is the appindicator extension enabled?)", file=sys.stderr)
app.exit(1)
probe = QTimer()
probe.timeout.connect(attempt)
probe.start(1000)
attempt()
return app.exec_() return app.exec_()
......
...@@ -550,6 +550,49 @@ def check_pipewire() -> None: ...@@ -550,6 +550,49 @@ def check_pipewire() -> None:
f"{dflt.split('.')[-1] if '.' in dflt else dflt}") f"{dflt.split('.')[-1] if '.' in dflt else dflt}")
def check_wireplumber_churn() -> None:
"""Is the session manager being restarted in a storm?
Found 2026-09-07: 39 WirePlumber restarts in 40 minutes on the idle gig
laptop, one per BlueZ 'Connected' announcement from a paired phone and
headset re-announcing themselves. `bt-audio-fix` restarted the session
manager on every one of them. Two ways that ends badly, and both happened
that evening:
* `failed (start-limit-hit)` — StartLimitBurst=5, and then the box has
no device nodes at all: every sink vanishes, PipeWire invents
`Dummy Output`, and it looks exactly like dead audio hardware.
* every restart recreates the device nodes, so every link into them
dies — including SuperDirt's orbit feed. Silence, no error.
The existing checks only see the WRECKAGE (a failed unit, a dummy sink).
This one sees the CAUSE, while audio still works.
"""
rc, out, _ = run_cmd(["journalctl", "--user", "-u", "wireplumber",
"--since", "-1h", "--no-pager", "-o", "cat"], timeout=15)
if rc != 0:
add("DEVICES", "wireplumber churn", WARN, "could not read the journal")
return
starts = len(re.findall(r"^Started wireplumber", out, re.M))
# `bt-audio-fix` is the only known restarter on this box; naming it saves
# the next hour of wondering who is doing this.
rc2, bt, _ = run_cmd(["journalctl", "--user", "--since", "-1h",
"--no-pager", "-o", "cat", "-t", "bt-audio-fix"], timeout=15)
heals = len(re.findall(r"healing WirePlumber|restarting WirePlumber", bt))
detail = f"{starts} start(s) in the last hour" + (f", {heals} from bt-audio-fix" if heals else "")
if starts >= 10:
add("DEVICES", "wireplumber churn", FAIL, detail + " — a restart storm; "
"this ends in start-limit-hit (no devices at all) and it kills every "
"SuperDirt link on the way",
"journalctl --user -t bt-audio-fix --since -1h # who is restarting it, and why")
elif starts >= 4:
add("DEVICES", "wireplumber churn", WARN, detail + " — more than a boot's "
"worth; a flapping Bluetooth device is the usual cause",
"journalctl --user -t bt-audio-fix --since -1h")
else:
add("DEVICES", "wireplumber churn", PASS, detail)
def check_sof_dsp() -> None: def check_sof_dsp() -> None:
"""Is the Intel SOF audio DSP firmware answering its driver? """Is the Intel SOF audio DSP firmware answering its driver?
...@@ -1345,17 +1388,92 @@ def check_systemd_units() -> None: ...@@ -1345,17 +1388,92 @@ def check_systemd_units() -> None:
def check_kwin_rule() -> None: def check_kwin_rule() -> None:
"""[midiviz-pin] — manual KDE config, nothing installs it. WARN only.""" """[midiviz-pin] — manual KDE config, nothing installs it. WARN only.
It is also a KDE concept, and this rig moved to a GNOME session: asking
kwinrulesrc on GNOME warns forever about a file that will never exist,
which is how a check teaches PLN to stop reading its warnings.
"""
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
if "KDE" not in desktop.upper():
add("INSTALL SURFACE", "midiviz window pin", PASS,
f"n/a — {desktop or 'unknown'} session, not KWin "
"(pin midiviz with the window menu's Always on Top)")
return
f = HOME() / ".config/kwinrulesrc" f = HOME() / ".config/kwinrulesrc"
if f.is_file() and "[midiviz-pin]" in f.read_text(errors="replace"): if f.is_file() and "[midiviz-pin]" in f.read_text(errors="replace"):
add("INSTALL SURFACE", "kwin rule [midiviz-pin]", PASS, "present") add("INSTALL SURFACE", "midiviz window pin", PASS, "kwin rule present")
else: else:
add("INSTALL SURFACE", "kwin rule [midiviz-pin]", WARN, add("INSTALL SURFACE", "midiviz window pin", WARN,
f"not found in {f}", f"[midiviz-pin] not found in {f}",
"add it by hand in KDE: Window Rules > New > match midiviz's " "add it by hand in KDE: Window Rules > New > match midiviz's "
"window class > Above/On all desktops = yes") "window class > Above/On all desktops = yes")
def check_tray() -> None:
"""perf-tray is reachable, not merely running.
PLN, 2026-09-06 and again 2026-09-07: "i see the parvagues tray icon, but
doesnt react when i click". The process was up and the unit was green both
times, which is the whole problem — a tray is three things stacked, and
only the first is a process:
1. the process runs,
2. it is REGISTERED with the session's StatusNotifierWatcher (on GNOME
that watcher is an extension, appindicatorsupport, which registers
several seconds after login — perf-tray used to exit(1) on that race),
3. its menu is EXPORTED and populated over com.canonical.dbusmenu.
Only (3) is what a click needs, so that is what this asks.
"""
unit = _unit_active("perf-tray")
if unit != "active":
add("INSTALL SURFACE", "perf-tray reachable", WARN,
f"perf-tray is {unit} — the gearbox has no UI",
"systemctl --user restart perf-tray")
return
rc, pid_out, _ = run_cmd(["systemctl", "--user", "show", "perf-tray",
"-p", "MainPID", "--value"])
pid = pid_out.strip()
rc, items, _ = run_cmd([
"gdbus", "call", "--session", "--dest", "org.kde.StatusNotifierWatcher",
"--object-path", "/StatusNotifierWatcher", "--method",
"org.freedesktop.DBus.Properties.Get", "org.kde.StatusNotifierWatcher",
"RegisteredStatusNotifierItems"])
if rc != 0:
add("INSTALL SURFACE", "perf-tray reachable", WARN,
"no StatusNotifierWatcher on the bus — no tray host in this session",
"enable the appindicatorsupport GNOME extension, then: "
"systemctl --user restart perf-tray")
return
# The item's own bus name, read from the watcher rather than guessed: Qt
# names it org.kde.StatusNotifierItem-<pid>-<n>, and n is a per-process
# counter, not always 2.
item = re.search(rf"org\.kde\.StatusNotifierItem-{re.escape(pid)}-\d+", items) if pid else None
if not item:
add("INSTALL SURFACE", "perf-tray reachable", FAIL,
f"running (pid {pid}) but NOT registered with the tray host — "
"any icon you see is a stale item from an older pid",
"systemctl --user restart perf-tray")
return
# The menu is what a click actually opens. Qt exports it at /MenuBar.
rc, layout, _ = run_cmd([
"gdbus", "call", "--session", "--dest", item.group(0),
"--object-path", "/MenuBar",
"--method", "com.canonical.dbusmenu.GetLayout", "0", "1", "['label']"])
rows = layout.count("'label'")
if rc != 0 or rows < 3:
add("INSTALL SURFACE", "perf-tray reachable", FAIL,
f"menu not exported ({rows} row(s) over dbusmenu) — clicking the "
"icon can only do nothing",
"systemctl --user restart perf-tray")
return
add("INSTALL SURFACE", "perf-tray reachable", PASS,
f"registered as pid {pid} with {rows} menu rows exported")
def check_launcher_paths() -> None: def check_launcher_paths() -> None:
"""Paths gig-up.sh (repo root, line ~20/271) assumes: the Ardour """Paths gig-up.sh (repo root, line ~20/271) assumes: the Ardour
performing session, and the freebox mount. performing session, and the freebox mount.
...@@ -1413,13 +1531,14 @@ def check_secrets() -> None: ...@@ -1413,13 +1531,14 @@ def check_secrets() -> None:
CHECKS = ( CHECKS = (
check_sc, check_quarks, check_mi_ugens, check_sc_boot_errors, check_sc, check_quarks, check_mi_ugens, check_sc_boot_errors,
check_haskell, check_pulsar, check_haskell, check_pulsar,
check_ardour, check_pipewire, check_sof_dsp, check_failed_units, check_ardour, check_pipewire, check_wireplumber_churn, check_sof_dsp,
check_failed_units,
check_sc_orphan, check_alsa_ffmpeg, check_python_modules, check_sc_orphan, check_alsa_ffmpeg, check_python_modules,
check_scd_sample_roots, check_scd_sample_roots,
check_synthdefs, check_synthdefs,
check_midi_controller, check_audio_interface, check_midi_controller, check_audio_interface,
check_perf_audio, check_protect, check_local_bin_symlinks, check_perf_audio, check_protect, check_local_bin_symlinks,
check_systemd_units, check_kwin_rule, check_launcher_paths, check_systemd_units, check_kwin_rule, check_tray, check_launcher_paths,
check_secrets, check_secrets,
) )
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment