Files
netbird/client/ui/tray.go
Zoltan Papp e3efaa5e59 [client] Fix tray flicker and stuck Connecting during management retry
The status snapshot tore down on every management retry because
state.Status() blanks the status when an error is wrapped, and the
SubscribeStatus stream propagated that as FailedPrecondition. The UI
treated any stream error as "daemon not running" and flickered the tray
to Not running between retries.

Disconnect was also unresponsive: Down set Idle before the retry
goroutine exited, which then overwrote it with Set(Connecting) on the
next attempt; the backoff sleep (up to 15s) wasn't context-aware, so the
goroutine kept running long after actCancel.

- buildStatusResponse falls back to the underlying status (via new
  state.CurrentStatus) instead of breaking the stream on wrapped errors.
- UI only flips to DaemonUnavailable on codes.Unavailable / non-status
  errors, so a live daemon returning FailedPrecondition is not reported
  as down.
- connect retry uses backoff.WithContext so actCancel interrupts the
  inter-attempt sleep, and skips Wrap(err) when the dial fails due to
  ctx cancellation.
- Down sets Idle after waiting for giveUpChan, so the retry goroutine
  can no longer race the disconnect.
- Tray hides Connect during Connecting and keeps Disconnect enabled so
  the user can abort an in-flight connection attempt.
2026-05-12 20:38:30 +02:00

835 lines
28 KiB
Go

//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"fmt"
"runtime"
"sort"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/version"
)
// User-facing strings exposed in the tray, OS notifications and the
// browser-opened URLs. Centralised here so future copy edits and (one
// day) localisation have a single source of truth.
const (
trayTooltip = "NetBird"
// Top-level menu entries.
menuStatusDisconnected = "Disconnected"
menuStatusDaemonUnavailable = "Not running"
menuOpenNetBird = "Open NetBird"
menuConnect = "Connect"
menuDisconnect = "Disconnect"
menuExitNode = "Exit Node"
menuNetworks = "Resources"
menuProfiles = "Profiles"
menuQuit = "Quit"
// Settings + diagnostics. The settings page replaces the Fyne tray's
// Settings submenu (per-toggle checkboxes for SSH, auto-connect,
// Rosenpass, lazy connections, block-inbound, notifications); those
// live in the in-window Settings page now.
menuSettings = "Settings"
menuCreateDebugBundle = "Create Debug Bundle"
// About submenu and update flow.
menuAbout = "About"
menuGitHub = "GitHub"
menuDocumentation = "Documentation"
menuDownloadLatestVersion = "Download latest version"
// menuInstallVersionPrefix is rewritten with the target version when
// the management server enforces the update.
menuInstallVersionPrefix = "Install version "
// menuGUIVersionFmt and menuDaemonVersionFmt drive the disabled
// version-info entries under About. The daemon line is "—" until the
// first Status snapshot reports the daemon's version.
menuGUIVersionFmt = "GUI: %s"
menuDaemonVersionFmt = "Daemon: %s"
menuVersionUnknown = "—"
// OS notifications.
notifyUpdateTitle = "NetBird update available"
notifyUpdateBodyFmt = "NetBird %s is available."
notifyUpdateEnforcedSuffix = " Your administrator requires this update."
notifyErrorTitle = "Error"
notifyErrorConnect = "Failed to connect"
notifyErrorDisconnect = "Failed to disconnect"
notifySessionExpiredTitle = "NetBird session expired"
notifySessionExpiredBody = "Your NetBird session has expired. Please log in again."
// Notification IDs (used to coalesce duplicate toasts).
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDSessionExpired = "netbird-session-expired"
// Daemon status strings mirroring internal.Status* — kept in sync
// with client/internal/state.go.
statusConnected = "Connected"
statusConnecting = "Connecting"
statusIdle = "Idle"
statusError = "Error"
// Daemon status string for an SSO session that has expired and needs
// re-authentication. Mirrors internal.StatusSessionExpired.
statusSessionExpired = "SessionExpired"
// statusNeedsLogin is what the daemon publishes before the user has
// completed an SSO authentication on this profile. Mirrors
// internal.StatusNeedsLogin.
statusNeedsLogin = "NeedsLogin"
// statusLoginFailed is what the daemon publishes when a login attempt
// failed with a non-auth error (management unreachable, init error,
// etc.). The CLI groups it with NeedsLogin/SessionExpired and prompts
// the user to run "netbird up", so we mirror that here. Mirrors
// internal.StatusLoginFailed.
statusLoginFailed = "LoginFailed"
// External URLs.
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
)
// Tray builds and updates the systray menu. It mirrors the layout of the Fyne
// systray 1:1 and routes clicks back to the gRPC services. Dynamic state
// (status icon, exit-node submenu) is driven by the netbird:status event.
// TrayServices bundles the daemon-RPC and notification services the tray
// menu needs. Grouped into a single struct so NewTray stays under the
// linter's parameter-count threshold and so adding another service later
// is a one-line struct change instead of a NewTray signature break.
type TrayServices struct {
Connection *services.Connection
Settings *services.Settings
Profiles *services.Profiles
Peers *services.Peers
Notifier *notifications.NotificationService
Update *services.Update
}
type Tray struct {
app *application.App
tray *application.SystemTray
window *application.WebviewWindow
svc TrayServices
statusItem *application.MenuItem
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
networksItem *application.MenuItem
profileSubmenu *application.Menu
settingsItem *application.MenuItem
debugItem *application.MenuItem
updateItem *application.MenuItem
daemonVersionItem *application.MenuItem
mu sync.Mutex
connected bool
hasUpdate bool
updateVersion string
updateEnforced bool
exitNodes []string
lastStatus string
lastDaemonVersion string
notificationsEnabled bool
activeProfile string
activeUsername string
}
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
t := &Tray{
app: app,
window: window,
svc: svc,
notificationsEnabled: true,
}
t.tray = app.SystemTray.New()
t.applyIcon()
t.tray.SetTooltip(trayTooltip)
t.tray.SetMenu(t.buildMenu())
// Left-click on the tray icon opens the menu on every platform. The
// window is reached through the explicit "Open NetBird" entry. This
// matches macOS NSStatusItem convention (click → menu), the Linux
// StatusNotifierItem spec, and the legacy Fyne client. On Linux,
// AttachWindow plus Wails3's applySmartDefaults would also pop the
// window alongside the menu on environments like GNOME Shell with the
// AppIndicator extension, so we intentionally skip both AttachWindow
// and OnClick here. Right-click still opens the menu through Wails'
// default rightClickHandler fallback.
app.Event.On(services.EventStatus, t.onStatusEvent)
app.Event.On(services.EventSystem, t.onSystemEvent)
app.Event.On(services.EventUpdateAvailable, t.onUpdateAvailable)
app.Event.On(services.EventUpdateProgress, t.onUpdateProgress)
// Defer the first profile load until the macOS/GTK/Win32 menu impl is
// live — Menu.Update() short-circuits while app.running is false, and
// AppKit's main queue isn't ready earlier either (see d23ef34 InvokeSync
// nil-deref).
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
go t.loadProfiles()
})
go t.loadConfig()
return t
}
// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event.
// Show() alone is not enough on macOS: makeKeyAndOrderFront skips app
// activation, so a tray-style app's window pops up behind the currently
// active app. Focus() additionally calls activateIgnoringOtherApps:YES on
// macOS and SetForegroundWindow on Windows.
func (t *Tray) ShowWindow() {
if t.window == nil {
return
}
t.window.Show()
t.window.Focus()
}
func (t *Tray) buildMenu() *application.Menu {
menu := application.NewMenu()
// statusItem doubles as the "Login" entry once the daemon reports
// NeedsLogin/SessionExpired — applyStatus toggles its enabled state and
// label. The click handler is harmless while disabled, so we wire it
// up unconditionally rather than swapping items at runtime.
t.statusItem = menu.Add(menuStatusDisconnected).
OnClick(func(*application.Context) { t.openRoute("/login") }).
SetEnabled(false).
SetBitmap(iconMenuDotIdle)
menu.AddSeparator()
// The tray icon's left-click handler is intentionally unbound (see
// NewTray for the rationale), so expose the window through an explicit
// menu entry on every platform.
menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() })
menu.AddSeparator()
// Profiles submenu is populated asynchronously once the application
// has started — Menu.Update() is a no-op before app.running is true,
// so the initial fill is gated on the ApplicationStarted hook.
t.profileSubmenu = menu.AddSubmenu(menuProfiles)
menu.AddSeparator()
// Only the action that applies to the current state is visible: Connect
// when disconnected, Disconnect when connected. applyStatus swaps them on
// each daemon status change.
t.upItem = menu.Add(menuConnect).OnClick(func(*application.Context) { t.handleConnect() })
t.downItem = menu.Add(menuDisconnect).OnClick(func(*application.Context) { t.handleDisconnect() })
t.downItem.SetHidden(true)
menu.AddSeparator()
t.exitNodeItem = menu.Add(menuExitNode).SetEnabled(false)
t.networksItem = menu.Add(menuNetworks).OnClick(func(*application.Context) { t.openRoute("/networks") })
menu.AddSeparator()
// Settings, runtime toggles (SSH, Quantum-Resistance, lazy connection,
// block-inbound, auto-connect, notifications) and profile switching
// all live in the in-window Settings page now. The tray menu only
// surfaces the day-to-day actions.
t.settingsItem = menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") })
t.debugItem = menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") })
menu.AddSeparator()
about := menu.AddSubmenu(menuAbout)
about.Add(menuGitHub).OnClick(func(*application.Context) {
_ = t.app.Browser.OpenURL(urlGitHubRepo)
})
about.Add(menuDocumentation).SetEnabled(false)
// Disabled informational entries: the GUI version is baked in at
// build time via -ldflags, the daemon version comes from the first
// Status snapshot and is updated in applyStatus.
about.Add(fmt.Sprintf(menuGUIVersionFmt, version.NetbirdVersion())).SetEnabled(false)
t.daemonVersionItem = about.Add(fmt.Sprintf(menuDaemonVersionFmt, menuVersionUnknown)).SetEnabled(false)
// Hidden until the daemon emits EventUpdateAvailable. The label is
// rewritten in onUpdateAvailable to match the legacy Fyne UI:
// menuDownloadLatestVersion for opt-in, menuInstallVersionPrefix+version
// when the management server enforces the update.
t.updateItem = about.Add(menuDownloadLatestVersion).OnClick(func(*application.Context) { t.handleUpdate() })
t.updateItem.SetHidden(true)
menu.AddSeparator()
menu.Add(menuQuit).OnClick(func(*application.Context) { t.app.Quit() })
return menu
}
func (t *Tray) openRoute(route string) {
if t.window == nil {
return
}
t.window.Show()
t.window.Focus()
t.window.SetURL("/#" + route)
}
func (t *Tray) handleConnect() {
t.upItem.SetEnabled(false)
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil {
log.Errorf("connect: %v", err)
t.notifyError(notifyErrorConnect)
t.upItem.SetEnabled(true)
}
}()
}
func (t *Tray) handleDisconnect() {
t.downItem.SetEnabled(false)
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect: %v", err)
t.notifyError(notifyErrorDisconnect)
t.downItem.SetEnabled(true)
}
}()
}
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// onSystemEvent fires an OS notification for daemon SystemEvents that carry
// a user-facing message, mirroring the legacy event.Manager behaviour: gated
// by the user's "Notifications" toggle, with CRITICAL events bypassing the
// gate. The narrowly-scoped EventUpdate* events are skipped here because
// onUpdateAvailable already produces a richer notification for them.
func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
se, ok := ev.Data.(services.SystemEvent)
if !ok || se.UserMessage == "" {
return
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return
}
// Management pairs ::/0 with 0.0.0.0/0 for exit-node default routes;
// the v4 partner already drives the user-facing toast, so the v6 one
// is suppressed to avoid a duplicate notification.
if se.Category == "network" && se.Metadata["network"] == "::/0" {
return
}
critical := se.Severity == "critical"
t.mu.Lock()
enabled := t.notificationsEnabled
t.mu.Unlock()
if !enabled && !critical {
return
}
body := se.UserMessage
if id := se.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
}
// onUpdateAvailable runs when the daemon reports a new netbird version. It
// flips the tray's hasUpdate flag (icon swap), reveals the update menu
// item with the right label, and posts an OS notification.
// The notification is what the legacy Fyne UI used to alert the user.
func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) {
upd, ok := ev.Data.(services.UpdateAvailable)
if !ok {
log.Warnf("update event payload not UpdateAvailable: %T", ev.Data)
return
}
log.Infof("tray onUpdateAvailable: version=%s enforced=%v", upd.Version, upd.Enforced)
t.mu.Lock()
t.hasUpdate = true
t.updateVersion = upd.Version
t.updateEnforced = upd.Enforced
t.mu.Unlock()
t.applyIcon()
if t.updateItem != nil {
// Match the Fyne wording: enforced updates name the version
// because the install starts on click; opt-in updates just
// route the user to the latest release.
if upd.Enforced {
t.updateItem.SetLabel(menuInstallVersionPrefix + upd.Version)
} else {
t.updateItem.SetLabel(menuDownloadLatestVersion)
}
t.updateItem.SetHidden(false)
}
body := fmt.Sprintf(notifyUpdateBodyFmt, upd.Version)
if upd.Enforced {
body += notifyUpdateEnforcedSuffix
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + upd.Version,
Title: notifyUpdateTitle,
Body: body,
}); err != nil {
log.Debugf("send update notification: %v", err)
}
}
// handleUpdate runs when the user clicks the "Download latest version" /
// "Install version X" menu item. Enforced updates trigger the daemon's
// installer flow and surface the in-window /update progress page;
// opt-in updates just open the GitHub releases page in the browser.
func (t *Tray) handleUpdate() {
t.mu.Lock()
enforced := t.updateEnforced
updateVersion := t.updateVersion
t.mu.Unlock()
if !enforced {
_ = t.app.Browser.OpenURL(urlGitHubReleases)
return
}
// Surface the progress page first so the user sees the install
// kick off; the daemon then drives the rest via the InstallerResult
// RPC the /update page is polling.
if t.window != nil {
url := "/#/update"
if updateVersion != "" {
url += "?version=" + updateVersion
}
t.window.SetURL(url)
t.window.Show()
t.window.Focus()
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := t.svc.Update.Trigger(ctx); err != nil {
log.Errorf("trigger update: %v", err)
}
}()
}
// onUpdateProgress runs when the daemon enters the install phase of an
// enforced update. The Fyne UI used to spawn a separate process with the
// update window; here the window is already in-process, so we just route to
// the /update page and bring it forward.
func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
prog, ok := ev.Data.(services.UpdateProgress)
if !ok || prog.Action != "show" {
return
}
if t.window == nil {
return
}
url := "/#/update"
if prog.Version != "" {
url += "?version=" + prog.Version
}
t.window.SetURL(url)
t.window.Show()
t.window.Focus()
}
// applyStatus updates the tray icon, status label, exit-node submenu, and
// connect/disconnect enablement based on the latest daemon snapshot.
// Skips the icon refresh when none of the icon-relevant inputs
// (connected, hasUpdate, status label) changed — the daemon emits
// rapid SubscribeStatus bursts during health probes that would
// otherwise spam Shell_NotifyIcon and the log.
func (t *Tray) applyStatus(st services.Status) {
t.mu.Lock()
connected := strings.EqualFold(st.Status, statusConnected)
iconChanged := connected != t.connected || st.Status != t.lastStatus
// Detect the transition into SessionExpired: the daemon emits the
// state on every Status snapshot for as long as the session stays
// expired, so without this guard we would re-fire the notification
// on every push. Mirrors the legacy Fyne client's sendNotification
// flag in onSessionExpire.
sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) &&
!strings.EqualFold(t.lastStatus, statusSessionExpired)
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
t.connected = connected
t.lastStatus = st.Status
if daemonVersionChanged {
t.lastDaemonVersion = st.DaemonVersion
}
exitNodes := exitNodesFromStatus(st)
exitNodesChanged := !equalStrings(exitNodes, t.exitNodes)
t.exitNodes = exitNodes
t.mu.Unlock()
if iconChanged {
t.applyIcon()
needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) ||
strings.EqualFold(st.Status, statusSessionExpired) ||
strings.EqualFold(st.Status, statusLoginFailed)
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
connecting := strings.EqualFold(st.Status, statusConnecting)
if t.statusItem != nil {
// When the daemon needs re-authentication the status row turns
// into the actionable Login entry — Connect would only fail.
// When the daemon socket is unreachable, swap the label to make
// the cause obvious; Connect/Disconnect would just fail.
label := st.Status
switch {
case daemonUnavailable:
label = menuStatusDaemonUnavailable
case strings.EqualFold(st.Status, statusIdle):
label = menuStatusDisconnected
}
t.statusItem.SetLabel(label)
t.statusItem.SetEnabled(needsLogin)
t.applyStatusIndicator(st.Status)
}
if t.upItem != nil {
// Hide Connect whenever an Up action would be a no-op or would
// only fail: tunnel already up, daemon mid-connect (Disconnect
// takes over the slot so the user can abort), login required,
// or daemon socket unreachable.
t.upItem.SetHidden(connected || connecting || needsLogin || daemonUnavailable)
t.upItem.SetEnabled(!connected && !connecting && !needsLogin && !daemonUnavailable)
}
if t.downItem != nil {
// Disconnect is the abort path while the daemon is still
// retrying the management dial — without it the user has no
// way to stop the loop short of killing the daemon.
t.downItem.SetHidden(!connected && !connecting)
t.downItem.SetEnabled(connected || connecting)
}
// Exit Node and Resources surface tunnel-routed state, so only
// expose them while the tunnel is up. Settings/Debug-Bundle just
// need the daemon socket reachable.
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected)
}
if t.networksItem != nil {
t.networksItem.SetEnabled(connected)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
}
if t.debugItem != nil {
t.debugItem.SetEnabled(!daemonUnavailable)
}
}
if exitNodesChanged {
t.rebuildExitNodes(exitNodes)
}
if daemonVersionChanged && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(fmt.Sprintf(menuDaemonVersionFmt, st.DaemonVersion))
}
if sessionExpiredEnter {
t.handleSessionExpired()
}
}
// handleSessionExpired surfaces the SSO re-authentication path when the
// daemon reports StatusSessionExpired. Posts a single OS notification
// (the applyStatus guard ensures it fires only on the transition, not
// on every status snapshot) and brings the main window forward so the
// frontend's /login route can drive the renewed SSO flow. Mirrors the
// Fyne client's onSessionExpire, which used a runSelfCommand to spawn
// the login-url helper; here the window is already in-process.
func (t *Tray) handleSessionExpired() {
t.notify(notifySessionExpiredTitle, notifySessionExpiredBody, notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
}
func (t *Tray) rebuildExitNodes(nodes []string) {
if t.exitNodeItem == nil || len(nodes) == 0 {
return
}
sub := application.NewMenu()
for _, fqdn := range nodes {
sub.AddCheckbox(fqdn, false)
}
}
// applyStatusIndicator sets the small coloured dot shown on the status
// menu entry. The dot mirrors the tray icon's state through a fixed
// palette: green for Connected, yellow for Connecting, blue for the
// login states, red for hard errors, grey for the idle/disconnected
// pair and a darker grey when the daemon socket is unreachable.
func (t *Tray) applyStatusIndicator(status string) {
if t.statusItem == nil {
return
}
t.statusItem.SetBitmap(statusIndicatorBitmap(status))
}
func statusIndicatorBitmap(status string) []byte {
switch {
case strings.EqualFold(status, statusConnected):
return iconMenuDotConnected
case strings.EqualFold(status, statusConnecting):
return iconMenuDotConnecting
case strings.EqualFold(status, statusNeedsLogin),
strings.EqualFold(status, statusSessionExpired):
return iconMenuDotLogin
case strings.EqualFold(status, statusLoginFailed),
strings.EqualFold(status, statusError):
return iconMenuDotError
case strings.EqualFold(status, services.StatusDaemonUnavailable):
return iconMenuDotOffline
default:
return iconMenuDotIdle
}
}
func (t *Tray) applyIcon() {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s",
connected, hasUpdate, statusLabel, runtime.GOOS)
icon, dark := t.iconForState()
if runtime.GOOS == "darwin" {
t.tray.SetTemplateIcon(icon)
return
}
t.tray.SetIcon(icon)
if dark != nil {
t.tray.SetDarkModeIcon(dark)
}
}
func (t *Tray) iconForState() (icon, dark []byte) {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
connecting := strings.EqualFold(statusLabel, statusConnecting)
errored := strings.EqualFold(statusLabel, statusError) ||
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) ||
strings.EqualFold(statusLabel, statusSessionExpired) ||
strings.EqualFold(statusLabel, statusLoginFailed)
if runtime.GOOS == "darwin" {
switch {
case connecting:
return iconConnectingMacOS, nil
case errored:
return iconErrorMacOS, nil
case needsLogin:
return iconNeedsLoginMacOS, nil
case connected && hasUpdate:
return iconUpdateConnectedMacOS, nil
case connected:
return iconConnectedMacOS, nil
case hasUpdate:
return iconUpdateDisconnectedMacOS, nil
default:
return iconDisconnectedMacOS, nil
}
}
switch {
case connecting:
return iconConnecting, nil
case errored:
return iconError, nil
case needsLogin:
return iconNeedsLogin, nil
case connected && hasUpdate:
return iconUpdateConnected, nil
case connected:
return iconConnected, iconConnectedDark
case hasUpdate:
return iconUpdateDisconnected, nil
default:
return iconDisconnected, nil
}
}
// 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, cancel := context.WithCancel(context.Background())
defer cancel()
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.mu.Lock()
t.activeProfile = active.ProfileName
t.activeUsername = active.Username
t.notificationsEnabled = !cfg.DisableNotifications
t.mu.Unlock()
}
// loadProfiles refreshes the Profiles submenu from the daemon. Each
// entry is a checkbox showing the active profile and switches on click.
// Called once on ApplicationStarted and again after a successful switch
// so the checkmark moves to the new active profile.
func (t *Tray) loadProfiles() {
if t.profileSubmenu == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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
}
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
t.profileSubmenu.Clear()
for _, p := range profiles {
name := p.Name
active := p.IsActive
item := t.profileSubmenu.AddCheckbox(name, active)
item.OnClick(func(*application.Context) {
if active {
return
}
t.switchProfile(name)
})
}
t.profileSubmenu.Update()
}
// switchProfile runs the daemon RPC in a goroutine so the menu click
// returns immediately, then reloads the submenu to move the checkmark.
func (t *Tray) switchProfile(name string) {
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
username, err := t.svc.Profiles.Username()
if err != nil {
log.Errorf("get current user: %v", err)
return
}
if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{
ProfileName: name,
Username: username,
}); err != nil {
log.Errorf("switch profile to %s: %v", name, err)
t.notifyError(fmt.Sprintf("Failed to switch to %s", name))
return
}
t.loadProfiles()
}()
}
// notify wraps the Wails notification service with the tray's standard
// id-prefix scheme and swallows errors (notifications are best-effort).
func (t *Tray) notify(title, body, id string) {
if t.svc.Notifier == nil {
return
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: id,
Title: title,
Body: body,
}); err != nil {
log.Debugf("notify %q: %v", title, err)
}
}
// notifyError fires a generic "Error" notification for tray-driven action
// failures. Each tray click site already logs the underlying error; this
// adds the user-visible toast.
func (t *Tray) notifyError(message string) {
t.notify(notifyErrorTitle, message, notifyIDTrayError)
}
func exitNodesFromStatus(st services.Status) []string {
seen := map[string]struct{}{}
out := []string{}
for _, p := range st.Peers {
if p.Fqdn == "" {
continue
}
if _, ok := seen[p.Fqdn]; ok {
continue
}
seen[p.Fqdn] = struct{}{}
out = append(out, p.Fqdn)
}
sort.Strings(out)
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// eventTitle composes a notification title from a SystemEvent's severity and
// category — "Critical: DNS", "Warning: Authentication", etc. — matching the
// format the legacy Fyne event.Manager produced.
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)
if prefix == "" {
prefix = "Info"
}
category := titleCase(e.Category)
if category == "" {
category = "System"
}
return prefix + ": " + category
}
func titleCase(s string) string {
if s == "" {
return ""
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}