[client/ui] Fix tray submenu not updating on KDE/Plasma

The Profiles and Exit Node submenus (and the About version/Update rows)
stopped reflecting changes on KDE/Plasma: after the first profile switch
the menu froze on its initial snapshot, and "Manage Profiles" — plus the
profile rows themselves — stopped responding to clicks entirely.

Root cause (confirmed via dbus-monitor): Plasma's StatusNotifierItem host
caches a submenu's layout the first time it is opened (GetLayout for that
submenu id) and never re-fetches it on a LayoutUpdated(parent=0) signal.
The old submenu.Clear()+Add() repaint allocated fresh monotonic item ids
each time but reused the same submenu container id, so Plasma kept showing
the stale snapshot and, on click, sent the stale ids back — which the
rebuilt itemMap no longer knew, silently no-op'ing the click.

Fix: route every dynamic tray-menu change through a new relayoutMenu that
rebuilds the whole tree (buildMenu + repaint cached state + a single
SetMenu), allocating brand-new submenu container ids. Plasma treats those
as unseen and re-queries them on next open, fixing both the stale paint
and the dead clicks. loadProfiles/refreshExitNodes now cache their rows
and drive relayoutMenu; the update row goes through a new onMenuChange
hook; the daemon-version row relayouts too. relayoutMenu is serialised by
menuMu and the fill*Submenu helpers are pure UI (no fetch, no SetMenu) so
it never recurses. The whole-tree SetMenu also subsumes the prior darwin
detached-NSMenu workaround.
This commit is contained in:
Zoltán Papp
2026-06-04 18:28:30 +02:00
parent 1412b06999
commit db371a0263
6 changed files with 140 additions and 74 deletions
+19 -2
View File
@@ -32,6 +32,13 @@ type trayUpdater struct {
// transitions, so the tray can repaint its icon (the small badge
// overlay differs between has-update / no-update).
onIconChange func()
// onMenuChange drives a full tray relayout (Tray.relayoutMenu) after an
// event-driven update-state change. The update row lives in the About
// submenu, which KDE/Plasma caches on first open and never re-fetches on a
// plain SetLabel/SetHidden — so a newly-available update would never paint
// there. relayoutMenu rebuilds the whole tree (fresh submenu ids) and
// re-attaches this item from the cached state via attach → refreshMenuItem.
onMenuChange func()
mu sync.Mutex
item *application.MenuItem
@@ -40,7 +47,7 @@ type trayUpdater struct {
progressWindowOpen bool // last installing value we acted on
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
@@ -48,6 +55,7 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd
notifier: notifier,
loc: loc,
onIconChange: onIconChange,
onMenuChange: onMenuChange,
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from the cached state so we don't miss an event that fired
@@ -142,7 +150,16 @@ func (u *trayUpdater) applyState(st updater.State) {
}
u.mu.Unlock()
u.refreshMenuItem(st)
// Drive a full relayout rather than mutating u.item in place: on KDE the
// About submenu is layout-cached, so a direct SetLabel/SetHidden here would
// not paint the newly-available update. relayoutMenu re-attaches the item
// from u.state, which re-runs refreshMenuItem. Fall back to the in-place
// refresh if no relayout hook was wired (defensive — always set today).
if u.onMenuChange != nil {
u.onMenuChange()
} else {
u.refreshMenuItem(st)
}
if prev.Available != st.Available && u.onIconChange != nil {
u.onIconChange()
}