[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
+15 -23
View File
@@ -23,19 +23,17 @@ type exitNodeEntry struct {
Selected bool
}
// rebuildExitNodes paints one clickable row per exit-node candidate into the
// Exit Node submenu. Each row carries the network's NetID and its selected
// state from ListNetworks; clicking toggles it via toggleExitNode. The active
// node is marked with a "✓ " prefix using a plain Add rather than AddCheckbox
// for the same reason as loadProfiles — Wails auto-toggles a checkbox's state
// on click before the OnClick handler runs, so the deselect/select round-trip
// would briefly show two checked rows. Rebuilds via Clear + Add so the row set
// stays in sync; SetMenu on the root menu is required because Wails v3 alpha
// menu Update() builds a detached NSMenu on darwin that never replaces the
// empty submenu attached at initial setup (same workaround as loadProfiles).
// Callers must hold exitNodesRebuildMu so concurrent rebuilds can't race the
// submenu's item slice.
func (t *Tray) rebuildExitNodes(nodes []exitNodeEntry) {
// fillExitNodeSubmenu paints one clickable row per exit-node candidate into
// the (freshly built) Exit Node submenu. Each row carries the network's NetID
// and its selected state from ListNetworks; clicking toggles it via
// toggleExitNode. The active node is marked with a "✓ " prefix using a plain
// Add rather than AddCheckbox for the same reason as fillProfileSubmenu —
// Wails auto-toggles a checkbox's state on click before the OnClick handler
// runs, so the deselect/select round-trip would briefly show two checked rows.
// Pure UI: it never calls SetMenu — relayoutMenu owns the single SetMenu that
// pushes the whole tree. Callers must hold exitNodesRebuildMu so concurrent
// rebuilds can't race the submenu's item slice.
func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) {
if t.exitNodeSubmenu == nil {
return
}
@@ -51,9 +49,6 @@ func (t *Tray) rebuildExitNodes(nodes []exitNodeEntry) {
t.toggleExitNode(id, selected)
})
}
if t.menu != nil {
t.tray.SetMenu(t.menu)
}
}
// refreshExitNodes re-fetches the routed-network list from the daemon and
@@ -93,14 +88,11 @@ func (t *Tray) refreshExitNodes() {
t.exitNodes = nodes
t.exitNodesMu.Unlock()
// Set enablement before rebuildExitNodes' SetMenu so the rebuild reads the
// updated state at NSMenuItem construction time (Wails v3 alpha reads
// item.disabled at build time, not lazily).
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected && len(nodes) > 0)
}
// relayoutMenu rebuilds the whole tree (allocating a fresh exitNodeItem) and
// repaints the parent's enablement from the cached entries we just stored,
// so there is no need to poke the old exitNodeItem here.
if changed {
t.rebuildExitNodes(nodes)
t.relayoutMenu()
}
}