Files
netbird/client/ui/tray_profiles.go
T
Zoltán Papp d3f62458d2 fix(ui): route tray status repaints through relayoutMenu to fix item races
buildMenu reassigns every menu item pointer under menuMu, but the status
path (refreshMenuItemsForStatus, applyStatusIndicator), the click handlers
and the session ticker read them unguarded. Wails dispatches event
listeners and OnClick callbacks on fresh goroutines, so these reads race
the rebuilds and applyStatus even runs concurrently with itself.

- applyStatus drives a single aggregated relayoutMenu (status, daemon
  version and session-deadline changes); refreshMenuItemsForStatus is
  removed — relayoutMenu repaints the same items from the caches
- applyStatusIndicator is SetBitmap-only: its unguarded SetMenu could
  reinstall a stale tree and pushed a half-built one on every relayout
- Connect/Disconnect handlers receive the clicked item from the buildMenu
  closure instead of reading the menuMu-guarded fields
- applySessionExpiry is cache-only; the row is painted by relayoutMenu,
  the 30s ticker snapshots its item under menuMu
- drop loadProfiles' dead unguarded profileSubmenu nil-check
2026-06-12 15:36:36 +02:00

191 lines
6.3 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() {
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 })
// When the daemon (or an MDM policy) disables profiles, the parent menu
// is greyed out by relayoutMenu/refreshMenuItemsForStatus, but Wails'
// systray does not reliably propagate a disabled parent to its children
// on every platform — so disable each row and "Manage Profiles" too,
// mirroring the legacy Fyne UI's profile.setEnabled lock.
disableProfiles, _ := t.featuresDisabled()
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)
})
item.SetEnabled(!disableProfiles)
if active {
activeName = name
activeEmail = p.Email
}
}
t.profileSubmenu.AddSeparator()
manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles"))
manageProfiles.OnClick(func(*application.Context) {
t.svc.WindowManager.OpenSettings("profiles")
})
manageProfiles.SetEnabled(!disableProfiles)
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()
}()
}