//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 }) // 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() }() }