mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-21 07:51:29 +02: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.
184 lines
5.9 KiB
Go
184 lines
5.9 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"github.com/netbirdio/netbird/client/ui/services"
|
|
)
|
|
|
|
// loadConfig seeds the in-process notifications gate from the daemon's
|
|
// stored config and caches the active-profile identity for any future
|
|
// SetConfig calls. Called once at startup from a goroutine so a slow or
|
|
// unreachable daemon does not block menu construction.
|
|
//
|
|
// The Settings page in the main window is the source of truth for every
|
|
// other knob (SSH, auto-connect, Rosenpass, lazy connections, block-inbound,
|
|
// notifications); we only mirror the notifications flag because the tray
|
|
// itself uses it to gate OS toasts in onSystemEvent.
|
|
func (t *Tray) loadConfig() {
|
|
ctx := context.Background()
|
|
|
|
active, err := t.svc.Profiles.GetActive(ctx)
|
|
if err != nil {
|
|
log.Debugf("get active profile: %v", err)
|
|
return
|
|
}
|
|
cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams(active))
|
|
if err != nil {
|
|
log.Debugf("get config: %v", err)
|
|
return
|
|
}
|
|
|
|
t.profileMu.Lock()
|
|
t.activeProfile = active.ProfileName
|
|
t.activeUsername = active.Username
|
|
t.notificationsEnabled = !cfg.DisableNotifications
|
|
t.profileMu.Unlock()
|
|
}
|
|
|
|
// loadProfiles fetches the profile list from the daemon, caches it under
|
|
// profilesMu, and drives a full tray relayout (relayoutMenu) so the Profiles
|
|
// submenu repaints. Called on ApplicationStarted, after a successful
|
|
// switchProfile, and from applyStatus whenever the daemon's status text
|
|
// changes — the last case catches profile flips driven by another channel
|
|
// (CLI "netbird profile select", autoconnect picking the persisted profile
|
|
// after the UI's first ListProfiles, etc.) since the daemon does not emit a
|
|
// dedicated active-profile event. The relayout (rather than a Clear()+Add()
|
|
// into the live submenu) is what makes KDE/Plasma actually repaint and keep
|
|
// the click→id mapping live — see relayoutMenu's doc comment.
|
|
func (t *Tray) loadProfiles() {
|
|
if t.profileSubmenu == nil {
|
|
return
|
|
}
|
|
t.profileLoadMu.Lock()
|
|
defer t.profileLoadMu.Unlock()
|
|
ctx := context.Background()
|
|
|
|
username, err := t.svc.Profiles.Username()
|
|
if err != nil {
|
|
log.Debugf("get current user: %v", err)
|
|
return
|
|
}
|
|
profiles, err := t.svc.Profiles.List(ctx, username)
|
|
if err != nil {
|
|
log.Debugf("list profiles: %v", err)
|
|
return
|
|
}
|
|
|
|
t.profilesMu.Lock()
|
|
t.profiles = profiles
|
|
t.profilesUser = username
|
|
t.profilesMu.Unlock()
|
|
|
|
t.relayoutMenu()
|
|
}
|
|
|
|
// fillProfileSubmenu paints the cached profile rows into the (freshly built)
|
|
// Profiles submenu and updates the parent label + email row. Pure UI: it
|
|
// never fetches and never calls SetMenu — relayoutMenu owns the single
|
|
// SetMenu that pushes the whole tree. Reads the rows captured by loadProfiles
|
|
// under profilesMu.
|
|
func (t *Tray) fillProfileSubmenu() {
|
|
if t.profileSubmenu == nil {
|
|
return
|
|
}
|
|
t.profilesMu.Lock()
|
|
profiles := append([]services.Profile(nil), t.profiles...)
|
|
username := t.profilesUser
|
|
t.profilesMu.Unlock()
|
|
|
|
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
|
|
|
t.profileSubmenu.Clear()
|
|
var activeName, activeEmail string
|
|
for _, p := range profiles {
|
|
name := p.Name
|
|
active := p.IsActive
|
|
// Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's
|
|
// checked state on click (before the OnClick handler fires), so with
|
|
// AddCheckbox both the old and the new profile would briefly show as
|
|
// checked while the switchProfile goroutine is running. A plain item
|
|
// with a "✓ " prefix avoids the race entirely.
|
|
label := name
|
|
if active {
|
|
label = "✓ " + name
|
|
}
|
|
item := t.profileSubmenu.Add(label)
|
|
item.OnClick(func(*application.Context) {
|
|
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
|
|
if active {
|
|
return
|
|
}
|
|
t.switchProfile(name)
|
|
})
|
|
if active {
|
|
activeName = name
|
|
activeEmail = p.Email
|
|
}
|
|
}
|
|
t.profileSubmenu.AddSeparator()
|
|
t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")).OnClick(func(*application.Context) {
|
|
t.svc.WindowManager.OpenSettings("profiles")
|
|
})
|
|
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
|
|
if t.profileSubmenuItem != nil && activeName != "" {
|
|
t.profileSubmenuItem.SetLabel(activeName)
|
|
}
|
|
if t.profileEmailItem != nil {
|
|
if activeEmail != "" {
|
|
t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail))
|
|
t.profileEmailItem.SetHidden(false)
|
|
} else {
|
|
t.profileEmailItem.SetHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// switchProfile cancels any in-flight profile switch, then starts a new one.
|
|
// Cancelling the previous context aborts its in-flight gRPC calls (Down/Up)
|
|
// so rapid clicks always converge to the last selected profile.
|
|
//
|
|
// The optimistic Connecting paint (and suppression of the transient
|
|
// Idle/stale Connected daemon events that follow Down) lives in
|
|
// services/daemon_feed.go — ProfileSwitcher calls DaemonFeed.BeginProfileSwitch
|
|
// when the previous status was Connected/Connecting, which emits a
|
|
// synthetic Connecting status to the event bus and starts filtering
|
|
// the daemon stream. That way both this tray and the React Status
|
|
// page see the same optimistic state without duplicating policy.
|
|
func (t *Tray) switchProfile(name string) {
|
|
t.profileMu.Lock()
|
|
if t.switchCancel != nil {
|
|
t.switchCancel()
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.switchCancel = cancel
|
|
t.profileMu.Unlock()
|
|
|
|
go func() {
|
|
username, err := t.svc.Profiles.Username()
|
|
if err != nil {
|
|
log.Errorf("tray switchProfile: get current user: %v", err)
|
|
return
|
|
}
|
|
if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{
|
|
ProfileName: name,
|
|
Username: username,
|
|
}); err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
log.Errorf("tray switchProfile: %v", err)
|
|
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", name))
|
|
return
|
|
}
|
|
t.loadProfiles()
|
|
}()
|
|
}
|