mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 20:49:56 +00:00
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.
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"github.com/netbirdio/netbird/client/ui/services"
|
|
)
|
|
|
|
// exitNodeEntry is one selectable row in the Exit Node submenu. ID is the
|
|
// network's NetID — both the row label and the argument the Select/Deselect
|
|
// RPCs take; Selected drives the ✓ prefix.
|
|
type exitNodeEntry struct {
|
|
ID string
|
|
Selected bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
t.exitNodeSubmenu.Clear()
|
|
for _, n := range nodes {
|
|
id := n.ID
|
|
selected := n.Selected
|
|
label := id
|
|
if selected {
|
|
label = "✓ " + id
|
|
}
|
|
t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) {
|
|
t.toggleExitNode(id, selected)
|
|
})
|
|
}
|
|
}
|
|
|
|
// refreshExitNodes re-fetches the routed-network list from the daemon and
|
|
// repaints the Exit Node submenu. Sourcing the rows from Networks.List() (not
|
|
// the Status stream) is what makes them selectable: the stream only ships peer
|
|
// FQDNs, whereas ListNetworks returns the NetID + selected state the
|
|
// Select/Deselect RPCs need. Serialized by exitNodesRebuildMu so overlapping
|
|
// Status pushes can't race the submenu rebuild. Owns the parent item's
|
|
// enablement: greyed unless the tunnel is up and at least one candidate exists.
|
|
func (t *Tray) refreshExitNodes() {
|
|
t.exitNodesRebuildMu.Lock()
|
|
defer t.exitNodesRebuildMu.Unlock()
|
|
|
|
t.statusMu.Lock()
|
|
connected := t.connected
|
|
t.statusMu.Unlock()
|
|
|
|
var nodes []exitNodeEntry
|
|
if connected {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
list, err := t.svc.Networks.List(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
log.Debugf("tray list networks: %v", err)
|
|
return
|
|
}
|
|
nodes = exitNodesFromNetworks(list)
|
|
}
|
|
|
|
log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes))
|
|
for _, n := range nodes {
|
|
log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected)
|
|
}
|
|
|
|
t.exitNodesMu.Lock()
|
|
changed := !equalExitNodes(nodes, t.exitNodes)
|
|
t.exitNodes = nodes
|
|
t.exitNodesMu.Unlock()
|
|
|
|
// 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.relayoutMenu()
|
|
}
|
|
}
|
|
|
|
// toggleExitNode activates or deactivates one exit node by NetID. Exit nodes
|
|
// are mutually exclusive, so Select uses append=false to clear any other
|
|
// active node before turning this one on; deselecting an active node turns
|
|
// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs
|
|
// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the
|
|
// new selection.
|
|
func (t *Tray) toggleExitNode(id string, selected bool) {
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false}
|
|
var err error
|
|
if selected {
|
|
err = t.svc.Networks.Deselect(ctx, params)
|
|
} else {
|
|
err = t.svc.Networks.Select(ctx, params)
|
|
}
|
|
if err != nil {
|
|
log.Errorf("tray toggle exit node %q: %v", id, err)
|
|
t.notifyError(t.loc.T("notify.error.exitNode", "name", id))
|
|
return
|
|
}
|
|
t.refreshExitNodes()
|
|
}()
|
|
}
|
|
|
|
// exitNodesFromNetworks filters the daemon's routed-network list down to
|
|
// exit-node candidates (a default-route range) and maps them to selectable
|
|
// rows. Sorted case-insensitively by ID so the submenu reads alphabetically.
|
|
func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry {
|
|
out := []exitNodeEntry{}
|
|
for _, n := range networks {
|
|
if !rangeIsDefaultRoute(n.Range) {
|
|
continue
|
|
}
|
|
out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID)
|
|
})
|
|
return out
|
|
}
|
|
|
|
// rangeIsDefaultRoute reports whether a Network.Range string contains an IPv4
|
|
// or IPv6 default route. The daemon may merge a v4+v6 exit pair into a single
|
|
// comma-joined range ("0.0.0.0/0, ::/0"), so we split and check each part,
|
|
// matching by Bits()==0 && unspecified rather than a literal string compare.
|
|
func rangeIsDefaultRoute(r string) bool {
|
|
for _, part := range strings.Split(r, ",") {
|
|
pref, err := netip.ParsePrefix(strings.TrimSpace(part))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if pref.Bits() == 0 && pref.Addr().IsUnspecified() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func equalExitNodes(a, b []exitNodeEntry) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|