[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
+10 -7
View File
@@ -72,7 +72,11 @@ func (t *Tray) applyStatus(st services.Status) {
go t.refreshExitNodes()
}
if daemonVersionChanged && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion))
// The version row lives in the About submenu, which KDE/Plasma caches on
// first open and never re-fetches on a plain SetLabel (see relayoutMenu's
// doc comment). Drive a full relayout so the new version actually paints.
// relayoutMenu repaints the label from the cached lastDaemonVersion.
t.relayoutMenu()
}
if sessionExpiredEnter {
t.handleSessionExpired()
@@ -155,12 +159,11 @@ func (t *Tray) refreshMenuItemsForStatus(st services.Status, connected bool) {
// Refresh the Profiles submenu on every status-text transition: the
// daemon does not emit an active-profile event, so the startup race
// (UI loads profiles before autoconnect picks the persisted profile)
// and a CLI "profile select && up" both surface here. Fired AFTER
// all SetHidden/SetEnabled writes on the static menu items above so
// loadProfiles' SetMenu rebuild (which clearMenu+processMenu the
// entire NSMenu and re-assigns item.impl) cannot race those
// writes — the Wails 3 alpha menu API is not goroutine-safe and
// reads item.disabled/item.hidden at NSMenuItem construction time.
// and a CLI "profile select && up" both surface here. loadProfiles
// fetches the rows and drives a full relayoutMenu (serialised by menuMu),
// so it cannot race the SetHidden/SetEnabled writes on the static items
// above — the Wails 3 alpha menu API is not goroutine-safe and reads
// item.disabled/item.hidden at NSMenuItem construction time.
go t.loadProfiles()
}