//go:build !android && !ios && !freebsd && !js package main import ( "context" "fmt" "net/netip" "runtime" "sort" "strconv" "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" nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/client/ui/authsession" "github.com/netbirdio/netbird/client/ui/i18n" "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/version" ) // Translation keys for every user-facing string the tray paints. The text // itself lives in i18n/locales//common.json — both the tray and the // React UI read from there so a single bundle drives the whole product. // Keys are referenced by the Tray.tr helper. // Non-translated identifiers. Notification IDs coalesce duplicate toasts // (the OS uses them as dedup keys); statusError is a tray-only sentinel // distinguishing the error-icon state from real daemon status strings; // URLs are baked-in product links. const ( notifyIDUpdatePrefix = "netbird-update-" notifyIDEvent = "netbird-event-" notifyIDTrayError = "netbird-tray-error" notifyIDSessionExpired = "netbird-session-expired" notifyIDSessionWarning = "netbird-session-warning" // notifyCategorySessionWarning groups the "Extend now" / "Dismiss" // actions on the T-10min OS notification. Registered once at tray // construction with the Wails notifications service; subsequent // SendNotificationWithActions calls reference it by ID. notifyCategorySessionWarning = "netbird-session-warning" notifyActionExtendNow = "extend-now" notifyActionDismiss = "dismiss" statusError = "Error" urlGitHubRepo = "https://github.com/netbirdio/netbird" urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" urlDocs = "https://docs.netbird.io" // finalWarningCountdownSeconds is the countdown shown in the auto-opened // SessionAboutToExpire dialog. Mirrors sessionwatch.FinalWarningLead // (2 minutes); the values stay in sync by hand because the lead is fixed // for the initial rollout. finalWarningCountdownSeconds = 120 ) // 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 ProfileSwitcher *services.ProfileSwitcher WindowManager *services.WindowManager // Session drives the SSO session-extend flow invoked from the // "Extend now" action on the T-10min OS notification, plus the // Dismiss hand-off that suppresses the T-2 fallback dialog. Bound to // the authsession package directly because the Wails wrapper in // services only re-exposes the React-facing subset. Session *authsession.Session // Localizer is the tray's bridge to translations. Constructed in main // from i18n.Bundle + preferences.Store; the Wails-bound facades // (services.I18n, services.Preferences) are registered separately for // React and are not needed here. Localizer *Localizer } type Tray struct { app *application.App tray *application.SystemTray window *application.WebviewWindow svc TrayServices // loc owns the active language plus the preference subscription. The // tray talks to it for every translated label (t.loc.T(...)) and // registers a callback in NewTray that re-renders the menu on a // language switch. loc *Localizer menu *application.Menu statusItem *application.MenuItem // sessionExpiresItem displays the SSO session deadline as a humanised // remaining-time label ("Session: 47m"). Hidden when no deadline is // tracked (non-SSO peer or login-expiration disabled on the account). // Refreshed by applyStatus on every Status push and by a 1-minute // ticker between pushes so the countdown moves naturally. sessionExpiresItem *application.MenuItem upItem *application.MenuItem downItem *application.MenuItem exitNodeItem *application.MenuItem exitNodeSubmenu *application.Menu profileSubmenu *application.Menu profileSubmenuItem *application.MenuItem profileEmailItem *application.MenuItem settingsItem *application.MenuItem daemonVersionItem *application.MenuItem updater *trayUpdater mu sync.Mutex connected bool exitNodes []string lastStatus string lastDaemonVersion string notificationsEnabled bool activeProfile string activeUsername string switchCancel context.CancelFunc // sessionExpiresAt is the most recent deadline observed on a Status // snapshot. Used to skip a no-op label rewrite when the daemon repeats // the same value across rapid pushes. Guarded by mu. sessionExpiresAt time.Time // pendingConnectLogin is set when handleConnect kicks off an Up on an // idle daemon. The daemon will flip to NeedsLogin if the peer is // SSO-tracked and has no cached token; applyStatus consumes this flag // on that transition to automatically open the browser-login flow, // saving the user a second Connect click. Guarded by mu. pendingConnectLogin bool // profileLoadMu serializes loadProfiles so the daemon-status-driven // refresh in applyStatus cannot race with the ApplicationStarted seed // or the post-switchProfile reload — both manipulate profileSubmenu and // SetMenu, which the Wails menu API is not safe against concurrent // callers. profileLoadMu sync.Mutex } func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { t := &Tray{ app: app, window: window, svc: svc, notificationsEnabled: true, // Localizer is constructed by main from the i18n.Bundle and // preferences.Store so the first menu render below is already in // the right locale — no English flash followed by a re-paint. loc: svc.Localizer, } t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }) t.tray = app.SystemTray.New() t.applyIcon() t.tray.SetTooltip(t.loc.T("tray.tooltip")) t.menu = t.buildMenu() t.tray.SetMenu(t.menu) // 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) // Refresh the Profiles submenu when ProfileSwitcher fires the change. // applyStatus already reloads on status-text transitions, but a // switch on an idle daemon doesn't drive one — without this hook, // a React-initiated switch leaves the tray's submenu and active- // profile label stale. app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { go t.loadProfiles() }) // 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.runSessionExpiryTicker() // Notification-category registration must run after the Wails // notifications service Startup has populated wn.appName / // registry path on Windows; before app.Run() the category lookup // in SendNotificationWithActions silently falls back to a // gomb-nélküli notification (the Windows impl logs "Category not // found"). The macOS/Linux impls don't strictly require this // ordering, but running here is harmless for them. t.registerSessionWarningCategory() }) // Localizer fires this callback after it has already swapped its own // cached language, so every t.loc.T(...) lookup inside applyLanguage // runs against the new locale. t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) 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() { // While an auto-update install is running the install-progress window // is the focal surface (all other windows hidden by WindowManager). // Tray "Open" / SIGUSR1 / dock-reopen should bring it forward, not // resurrect the main one mid-install. Checked before BrowserLogin // because an install supersedes every other flow. if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { w.Show() w.Focus() return } // While an SSO flow is in progress the BrowserLogin popup is the focal // window — the main window was hidden by WindowManager so the user // stays on the sign-in surface. Tray "Open" / SIGUSR1 / dock-reopen // should bring that window forward, not resurrect the main one mid-flow. if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { w.Show() w.Focus() return } if t.window == nil { return } t.window.Show() t.window.Focus() } // applyLanguage re-renders every translated surface using the Localizer's // current language. Wails dispatches menu/tray APIs onto the platform's // UI thread internally, so calling them from the Localizer's background // goroutine is safe; profileLoadMu prevents loadProfiles from racing the // rebuild. func (t *Tray) applyLanguage() { t.tray.SetTooltip(t.loc.T("tray.tooltip")) t.menu = t.buildMenu() t.tray.SetMenu(t.menu) t.reapplyMenuState() } // reapplyMenuState walks cached state and re-applies the visibility, // enablement and label mutations that applyStatus would have performed // since the last menu rebuild. Required after buildMenu because that // constructor returns items in their default (disconnected) shape. The // update menu item is re-applied by trayUpdater.applyLanguage. func (t *Tray) reapplyMenuState() { t.mu.Lock() connected := t.connected lastStatus := t.lastStatus daemonVersion := t.lastDaemonVersion exitNodes := append([]string(nil), t.exitNodes...) sessionDeadline := t.sessionExpiresAt t.mu.Unlock() daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) connecting := strings.EqualFold(lastStatus, services.StatusConnecting) if t.statusItem != nil && lastStatus != "" { t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus)) t.statusItem.SetEnabled(statusRowEnabled()) t.applyStatusIndicator(lastStatus) } if t.sessionExpiresItem != nil { if sessionDeadline.IsZero() { t.sessionExpiresItem.SetHidden(true) } else { remaining := t.formatSessionRemaining(time.Until(sessionDeadline)) t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) t.sessionExpiresItem.SetHidden(false) } } if t.upItem != nil { t.upItem.SetHidden(connected || connecting || daemonUnavailable) t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) } if t.downItem != nil { t.downItem.SetHidden(!connected && !connecting) t.downItem.SetEnabled(connected || connecting) } if t.exitNodeItem != nil { t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) } if t.settingsItem != nil { t.settingsItem.SetEnabled(!daemonUnavailable) } if t.profileSubmenuItem != nil { t.profileSubmenuItem.SetEnabled(!daemonUnavailable) } if daemonVersion != "" && t.daemonVersionItem != nil { t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) } if t.updater != nil { t.updater.applyLanguage() } t.rebuildExitNodes(exitNodes) go t.loadProfiles() } func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() // statusItem shows the daemon's current status. Informational row // with no OnClick handler — clicks are no-ops. Whether the row is // kept enabled is platform-dependent (see statusRowEnabled): on // Windows the disabled-state mask would desaturate the coloured // status dot painted into the check-mark slot, so the row stays // enabled there; macOS/Linux disable it so the greyed-out label // signals that it is not clickable. The Connect entry below drives // every actionable transition, including the SSO re-auth flow for // NeedsLogin/SessionExpired (the daemon's Up RPC returns // NeedsSSOLogin when applicable). t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). SetEnabled(statusRowEnabled()). SetBitmap(iconMenuDotIdle) 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(t.loc.T("tray.menu.connect")).OnClick(func(*application.Context) { t.handleConnect() }) t.downItem = menu.Add(t.loc.T("tray.menu.disconnect")).OnClick(func(*application.Context) { t.handleDisconnect() }) t.downItem.SetHidden(true) 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. profilesLabel := t.loc.T("tray.menu.profiles") t.profileSubmenu = menu.AddSubmenu(profilesLabel) // profileSubmenuItem is the parent MenuItem whose label is the active // profile name. AddSubmenu returns the child *Menu, so we retrieve the // parent *MenuItem via FindByLabel immediately after insertion. t.profileSubmenuItem = menu.FindByLabel(profilesLabel) // profileEmailItem shows the account email of the active profile directly // in the main menu, below the Profiles submenu — matching the behaviour of // the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a // non-empty email for the active profile. t.profileEmailItem = menu.Add("").SetEnabled(false) t.profileEmailItem.SetHidden(true) // sessionExpiresItem sits below the profile email so the active profile, // its account email, and the SSO session deadline read as a single block. // Hidden until applyStatus sees a non-zero SessionExpiresAt on the daemon // Status snapshot — peers without SSO tracking or with login expiry // disabled never reveal this row. Click opens the SessionAboutToExpire // window so the user can extend the session ahead of the daemon's // T-FinalWarningLead auto-prompt. t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) t.sessionExpiresItem.SetHidden(true) 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(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) menu.AddSeparator() // exitNodeSubmenu hosts one row per peer advertising a default // route (0.0.0.0/0 or ::/0). Populated asynchronously by // rebuildExitNodes on every Status push that changes the set; // the parent row stays disabled until at least one candidate is // known. We grab the parent MenuItem via FindByLabel (same // pattern as the Profiles submenu) so applyStatus can flip its // enabled state independently of the children. exitNodeLabel := t.loc.T("tray.menu.exitNode") t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) t.exitNodeItem = menu.FindByLabel(exitNodeLabel) t.exitNodeItem.SetEnabled(false) 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. The trailing ellipsis on the label // (i18n string) follows the macOS HIG convention for menu items that // open a dialog/window rather than performing an inline action. t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) aboutLabel := t.loc.T("tray.menu.about") about := menu.AddSubmenu(aboutLabel) about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlGitHubRepo) }) about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlDocs) }) // Troubleshoot deep-links into the Settings window at the // Troubleshooting tab, which hosts the debug-bundle flow that used // to live as a top-level tray entry. about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("troubleshooting") }) // 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(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) // Update menu item is hidden until the daemon reports a new version // (EventUpdateState with Available=true). trayUpdater rewrites the // label between tray.menu.downloadLatest (opt-in) and // tray.menu.installVersion (enforced) and drives the click. updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). OnClick(func(*application.Context) { t.updater.handleClick() }) updateItem.SetHidden(true) t.updater.attach(updateItem) menu.AddSeparator() menu.Add(t.loc.T("tray.menu.quit")).OnClick(func(*application.Context) { t.app.Quit() }) return menu } func (t *Tray) handleConnect() { // NeedsLogin/SessionExpired/LoginFailed mean the daemon won't honor a // plain Up RPC ("up already in progress: current status NeedsLogin") — // it needs the Login → WaitSSOLogin → Up sequence instead. Emit // EventTriggerLogin so the React-side startLogin() (which owns the // BrowserLogin popup) drives the flow. The main window's webview is // alive even while hidden, so we don't surface it — only the popup // appears. t.mu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || strings.EqualFold(t.lastStatus, services.StatusLoginFailed) t.mu.Unlock() if needsLogin { t.app.Event.Emit(services.EventTriggerLogin) return } t.upItem.SetEnabled(false) // Arm the SSO auto-handoff: Up() is async and the daemon may flip to // NeedsLogin once it detects an SSO peer with no cached token. The // flag is consumed by applyStatus on that transition, which then // triggers the browser-login flow without the user having to click // Connect a second time. Cleared on any terminal state (Connected / // Idle / LoginFailed / DaemonUnavailable / SessionExpired) so a stale // flag can't hijack a future status push. t.mu.Lock() t.pendingConnectLogin = true t.mu.Unlock() 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(t.loc.T("notify.error.connect")) t.mu.Lock() t.pendingConnectLogin = false t.mu.Unlock() t.upItem.SetEnabled(true) } }() } // handleDisconnect aborts any in-flight profile switch before sending // Down — otherwise the switcher's queued Up would re-establish the // connection right after the Disconnect, making the click look like a // no-op. Also clears Peers' optimistic-Connecting guard so the daemon's // Idle push (and any subsequent updates) paint through immediately // instead of being swallowed by the profile-switch suppression filter. func (t *Tray) handleDisconnect() { t.downItem.SetEnabled(false) t.mu.Lock() if t.switchCancel != nil { t.switchCancel() t.switchCancel = nil } t.mu.Unlock() t.svc.Peers.CancelProfileSwitch() 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(t.loc.T("notify.error.disconnect")) 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. Update-related events are skipped here because trayUpdater produces // its own richer notification when EventUpdateState fires. func (t *Tray) onSystemEvent(ev *application.CustomEvent) { se, ok := ev.Data.(services.SystemEvent) if !ok { return } // Session-warning events carry no UserMessage — the tray builds the // localised notification body locally from metadata. Every other // event needs a non-empty UserMessage to show anything meaningful. isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" if !isSessionWarning && se.UserMessage == "" { return } if _, isUpdate := se.Metadata["new_version_available"]; isUpdate { return } if _, isProgress := se.Metadata["progress_window"]; isProgress { 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 } // Session-warning events come in two flavours; detect via the stable // metadata flags rather than category/severity so a future reword on // the daemon side still routes here. // - T-WarningLead (MetaSessionWarning + no MetaSessionFinal) → // interactive "Extend now / Dismiss" OS notification. Title and // body are built locally from i18n + metadata so the text follows // the active UI language regardless of what the daemon (which has // no locale context) writes into UserMessage. // - T-FinalWarningLead (MetaSessionFinal=true) → auto-open the // SessionAboutToExpire dialog. No OS notification here; the // dialog is the last-chance reminder, doubling up would be noise. if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { t.openSessionAboutToExpire() return } t.notifySessionWarning( t.loc.T("notify.sessionWarning.title"), t.buildSessionWarningBody(se.Metadata), ) return } body := se.UserMessage if id := se.Metadata["id"]; id != "" { body += fmt.Sprintf(" ID: %s", id) } t.notify(eventTitle(se), body, notifyIDEvent+se.ID) } // 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. // // Profile-switch suppression lives one layer up in services/peers.go // (Peers.BeginProfileSwitch / shouldSuppress) so the optimistic // Connecting paint and the suppressed Idle/Connected events are shared // with the React Status page rather than being a tray-only behaviour. func (t *Tray) applyStatus(st services.Status) { t.mu.Lock() connected := strings.EqualFold(st.Status, services.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, services.StatusSessionExpired) && !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) // Consume the SSO auto-handoff flag armed by handleConnect. Trigger // the browser-login flow on a Connect → NeedsLogin transition so the // user doesn't need to click Connect a second time. Clear it on any // other terminal state — including Connecting bursts that resolve to // Connected / Idle / LoginFailed / DaemonUnavailable — so a stale // flag can't fire weeks later when the daemon happens to flip. triggerLogin := false if t.pendingConnectLogin { switch { case strings.EqualFold(st.Status, services.StatusNeedsLogin): triggerLogin = true t.pendingConnectLogin = false case strings.EqualFold(st.Status, services.StatusConnected), strings.EqualFold(st.Status, services.StatusIdle), strings.EqualFold(st.Status, services.StatusLoginFailed), strings.EqualFold(st.Status, services.StatusSessionExpired), strings.EqualFold(st.Status, services.StatusDaemonUnavailable): t.pendingConnectLogin = false } } 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 triggerLogin { t.app.Event.Emit(services.EventTriggerLogin) } if iconChanged { t.applyIcon() daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) connecting := strings.EqualFold(st.Status, services.StatusConnecting) if t.statusItem != nil { // Label-only: row is informational (no OnClick). Enablement // is platform-dependent via statusRowEnabled — Windows // keeps it enabled so the Win32 disabled-state mask does // not desaturate the coloured dot; macOS/Linux disable it. // Swap the displayed text so the user sees a familiar // phrase instead of the raw daemon enum. t.statusItem.SetLabel(t.loc.StatusLabel(st.Status)) t.statusItem.SetEnabled(statusRowEnabled()) t.applyStatusIndicator(st.Status) } if t.upItem != nil { // Connect stays visible/clickable in NeedsLogin/SessionExpired/ // LoginFailed too — the daemon's Up RPC kicks off the SSO flow // when re-auth is required, mirroring the legacy Fyne client // where the same button drove the initial and the re-login // paths. Hidden only when the action would be a no-op (tunnel // up, daemon mid-connect — Disconnect takes the slot) or // would fail with no useful side effect (daemon unreachable). t.upItem.SetHidden(connected || connecting || daemonUnavailable) t.upItem.SetEnabled(!connected && !connecting && !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 surfaces tunnel-routed state, so only expose it while // the tunnel is up AND the account actually has at least one // exit-node candidate (a peer advertising 0.0.0.0/0 or ::/0). // The row stays visible but greyed when no candidate is around, // so the user can tell the feature exists. Settings just needs // the daemon socket reachable. if t.exitNodeItem != nil { t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) } if t.settingsItem != nil { t.settingsItem.SetEnabled(!daemonUnavailable) } if t.profileSubmenuItem != nil { t.profileSubmenuItem.SetEnabled(!daemonUnavailable) } // Refresh the Profiles submenu on every status-text transition: the // daemon does not emit an active-profile event, so the startup race // (UI loads profiles before autoconnect picks the persisted profile) // and a CLI "profile select && up" both surface here. Fired AFTER // all SetHidden/SetEnabled writes on the static menu items above so // loadProfiles' SetMenu rebuild (which clearMenu+processMenu the // entire NSMenu and re-assigns item.impl) cannot race those // writes — the Wails 3 alpha menu API is not goroutine-safe and // reads item.disabled/item.hidden at NSMenuItem construction time. go t.loadProfiles() } if exitNodesChanged { // Re-evaluate the parent item's enablement here too, not only in // the iconChanged block above: the daemon ships peer routes in a // later snapshot than the Connected status text (networks=[] first, // then populated), so the candidate list can flip empty→non-empty // while the status string is unchanged. Without this the parent // "Exit Node" item stays greyed from when it was last set on the // status transition and the freshly painted rows are unreachable. // Set before rebuildExitNodes' SetMenu so the rebuild reads the // updated enabled state at NSMenuItem construction time. if t.exitNodeItem != nil { t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) } t.rebuildExitNodes(exitNodes) } if daemonVersionChanged && t.daemonVersionItem != nil { t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion)) } if sessionExpiredEnter { t.handleSessionExpired() } t.applySessionExpiry(st.SessionExpiresAt, connected) } // 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(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) if t.window != nil { t.window.SetURL("/#/login") t.window.Show() t.window.Focus() } } // rebuildExitNodes paints one row per exit-node candidate into the // Exit Node submenu. The list is read-only for now — selection wiring // would need ListNetworks + a peer-FQDN → network-ID lookup, which the // PeerStatus stream doesn't ship. Rebuilds via Clear + Add so the row // set stays in sync with the daemon snapshot; SetMenu on the root menu // is required because Wails v3 alpha menu Update() builds a detached // NSMenu on darwin that never replaces the empty submenu attached at // initial setup (same workaround as loadProfiles). func (t *Tray) rebuildExitNodes(nodes []string) { if t.exitNodeSubmenu == nil { return } t.exitNodeSubmenu.Clear() for _, fqdn := range nodes { t.exitNodeSubmenu.Add(fqdn).SetEnabled(false) } if t.menu != nil { t.tray.SetMenu(t.menu) } } // 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. // // Wails v3 alpha's setMenuItemBitmap calls NSMenuItem.setImage from // whichever thread invoked SetBitmap — unlike setMenuItemLabel/Disabled/ // Hidden/Checked which dispatch_sync onto the main queue. The off-thread // AppKit call leaves the visible dot stale until the next time the menu // is reopened (close+reopen workaround). Rebuilding via tray.SetMenu // reruns processMenu inside InvokeSync, so the bitmap is applied to a // fresh NSMenuItem on the main thread and macOS picks it up. func (t *Tray) applyStatusIndicator(status string) { if t.statusItem == nil { return } t.statusItem.SetBitmap(statusIndicatorBitmap(status)) if t.menu != nil { t.tray.SetMenu(t.menu) } } func statusIndicatorBitmap(status string) []byte { switch { case strings.EqualFold(status, services.StatusConnected): return iconMenuDotConnected case strings.EqualFold(status, services.StatusConnecting): return iconMenuDotConnecting case strings.EqualFold(status, services.StatusNeedsLogin), strings.EqualFold(status, services.StatusSessionExpired): return iconMenuDotLogin case strings.EqualFold(status, services.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 statusLabel := t.lastStatus t.mu.Unlock() hasUpdate := false if t.updater != nil { hasUpdate = t.updater.hasUpdate() } 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 statusLabel := t.lastStatus t.mu.Unlock() hasUpdate := false if t.updater != nil { hasUpdate = t.updater.hasUpdate() } connecting := strings.EqualFold(statusLabel, services.StatusConnecting) errored := strings.EqualFold(statusLabel, statusError) || strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || strings.EqualFold(statusLabel, services.StatusSessionExpired) || strings.EqualFold(statusLabel, services.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 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. func (t *Tray) loadProfiles() { if t.profileSubmenu == nil { return } t.profileLoadMu.Lock() defer t.profileLoadMu.Unlock() 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 }) log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username) t.profileSubmenu.Clear() var activeName, activeEmail string for _, p := range profiles { name := p.Name active := p.IsActive log.Infof("tray loadProfiles: profile=%q active=%v", name, active) // 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 } } 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) } } // Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on // darwin that never replaces the empty NSMenu attached to the parent // menu item at initial setup — so the visible Profiles menu stays // frozen on the snapshot taken when the tray was registered. Re-running // SetMenu on the top-level rebuilds the entire NSMenu tree against the // cached pointer and is the only path that propagates submenu changes. if t.menu != nil { t.tray.SetMenu(t.menu) } else { t.profileSubmenu.Update() } } // 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/peers.go — ProfileSwitcher calls Peers.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.mu.Lock() if t.switchCancel != nil { t.switchCancel() } ctx, cancel := context.WithCancel(context.Background()) t.switchCancel = cancel t.mu.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() }() } // applySessionExpiry refreshes the "Session: 47m" tray row from the latest // Status snapshot's SessionExpiresAt. Only shown when the tunnel is up: // in any other state (Idle after a Down, Connecting, NeedsLogin, // SessionExpired, LoginFailed, DaemonUnavailable, or mid profile-switch) // the deadline is meaningless and the row is hidden. The internal // sessionExpiresAt cache is cleared in the same path so reapplyMenuState // after a language switch doesn't resurrect a stale label. // // No per-minute ticker: between Status pushes the label may drift by a // few minutes, which is fine for a tray-menu status row that the user // opens on demand. The T-10min OS notification (driven by the daemon's // sessionwatch) does the time-critical signalling. func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) { var d time.Time if connected && deadline != nil { d = *deadline } switch { case deadline == nil: log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) case deadline.IsZero(): log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) default: log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v", deadline.Format(time.RFC3339), time.Until(*deadline), connected) } t.mu.Lock() t.sessionExpiresAt = d t.mu.Unlock() if t.sessionExpiresItem == nil { return } if d.IsZero() { t.sessionExpiresItem.SetHidden(true) return } remaining := t.formatSessionRemaining(time.Until(d)) t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) t.sessionExpiresItem.SetHidden(false) } // runSessionExpiryTicker keeps the "Expires in …" countdown row fresh by // recomputing its label every 30 seconds for the app's lifetime. Started // once on ApplicationStarted; the goroutine lives until the process exits. func (t *Tray) runSessionExpiryTicker() { tk := time.NewTicker(30 * time.Second) for range tk.C { t.refreshSessionExpiresLabel() } } // refreshSessionExpiresLabel recomputes the "Session expires in …" tray // row label from the cached SSO deadline. func (t *Tray) refreshSessionExpiresLabel() { if t.sessionExpiresItem == nil { return } t.mu.Lock() deadline := t.sessionExpiresAt t.mu.Unlock() if deadline.IsZero() { return } remaining := t.formatSessionRemaining(time.Until(deadline)) t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) } // formatSessionRemaining renders the time-to-deadline as a localised // long-form string ("47 minutes", "2 hours", "1 day"). Picks the // largest unit that fits non-zero and keeps singular/plural distinct // — the unit name keys (`tray.session.unit.minute(s)|hour(s)|day(s)`) // are split per language so translators can spell each form properly. // Sub-minute deltas read as "less than a minute" so a countdown that // has rolled past zero between Status pushes still produces something // sensible. func (t *Tray) formatSessionRemaining(d time.Duration) string { switch { case d < time.Minute: return t.loc.T("tray.session.unit.lessThanMinute") case d < time.Hour: m := int(d / time.Minute) if m == 1 { return t.loc.T("tray.session.unit.minute") } return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) case d < 24*time.Hour: h := int(d / time.Hour) if h == 1 { return t.loc.T("tray.session.unit.hour") } return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) default: days := int(d / (24 * time.Hour)) if days == 1 { return t.loc.T("tray.session.unit.day") } return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days)) } } // 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) } } // registerSessionWarningCategory wires the OS notification category for the // T-10min SSO expiry warning. The category carries two actions ("Extend now" // and "Dismiss") and the global response handler so a click resolves back // into runExtendSession. Idempotent — called once from NewTray; errors are // logged and swallowed because the worst case is a plain text notification // without buttons. func (t *Tray) registerSessionWarningCategory() { if t.svc.Notifier == nil { return } if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{ ID: notifyCategorySessionWarning, Actions: []notifications.NotificationAction{ {ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")}, {ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")}, }, }); err != nil { log.Debugf("register session-warning notification category: %v", err) } t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { if result.Error != nil { log.Debugf("notification response error: %v", result.Error) return } if result.Response.CategoryID != notifyCategorySessionWarning { return } switch result.Response.ActionIdentifier { case notifyActionExtendNow, notifications.DefaultActionIdentifier: // DefaultActionIdentifier covers the body-click on platforms // that don't expose buttons separately (e.g. some minimal // Linux notification daemons fall back to a single click // area). Treat it as Extend so the user always has a path. go t.runExtendSession() case notifyActionDismiss: // Explicit user opt-out. Tell the daemon so the // T-FinalWarningLead fallback dialog stays closed for this // deadline; the regular watcher remains armed for the next // deadline value (e.g. after a successful extend elsewhere). go t.dismissSessionWarning() } }) } // buildSessionWarningBody composes the localised body for the T-10min // notification from the daemon's metadata. The daemon does not have a // locale, so it ships a stable RFC3339 deadline ("session_expires_at") // and integer lead time ("lead_minutes") in metadata; the tray turns // them into a user-language sentence via the active i18n bundle. // // Falls back to a constant string when the metadata is missing or the // timestamp fails to parse — the user still sees the warning, just // without the remaining-time count. func (t *Tray) buildSessionWarningBody(meta map[string]string) string { if meta == nil { return t.loc.T("notify.sessionWarning.bodyGeneric") } raw := meta[authsession.MetaExpiresAt] if raw == "" { return t.loc.T("notify.sessionWarning.bodyGeneric") } deadline, err := authsession.ParseExpiresAt(raw) if err != nil { return t.loc.T("notify.sessionWarning.bodyGeneric") } remaining := nbstatus.FormatRemainingDuration(time.Until(deadline)) return t.loc.T("notify.sessionWarning.body", "remaining", remaining) } // notifySessionWarning sends the interactive T-10min OS notification. Falls // back to the plain `notify` helper if the Wails service doesn't expose the // with-actions variant (older platform impls, or a bare Notifier in tests). func (t *Tray) notifySessionWarning(title, body string) { if t.svc.Notifier == nil { return } err := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{ ID: notifyIDSessionWarning, Title: title, Body: body, CategoryID: notifyCategorySessionWarning, }) if err != nil { log.Debugf("notify session-warning with actions: %v", err) // Fall back to a plain notification so the user at least gets // the warning text, even without buttons. t.notify(title, body, notifyIDSessionWarning) } } // runExtendSession drives the daemon's RequestExtendAuthSession + // WaitExtendAuthSession pair when the user clicks "Extend now" on the // session-warning notification. Mirrors `doExtendSession` in // client/cmd/login.go but talks to the in-process Wails Session service // instead of opening a daemon gRPC channel from a CLI process. The // browser is opened via Connection.OpenURL (which honours $BROWSER on // Unix). Errors surface as plain notifyError calls — there is no foreground // UI flow here because the warning may fire while the main window is // closed. func (t *Tray) runExtendSession() { if t.svc.Session == nil || t.svc.Connection == nil { log.Debugf("session-warning: extend requested but services not wired") return } ctx, cancel := context.WithCancel(context.Background()) defer cancel() start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{}) if err != nil { log.Warnf("session-warning: RequestExtend failed: %v", err) t.notifyError(t.loc.T("notify.sessionWarning.failed")) return } uri := start.VerificationURIComplete if uri == "" { uri = start.VerificationURI } if uri != "" { if err := t.svc.Connection.OpenURL(uri); err != nil { log.Debugf("session-warning: opening verification URL: %v", err) } } if _, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ DeviceCode: start.DeviceCode, UserCode: start.UserCode, }); err != nil { log.Warnf("session-warning: WaitExtend failed: %v", err) t.notifyError(t.loc.T("notify.sessionWarning.failed")) return } t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) } // dismissSessionWarning tells the daemon to silence the T-FinalWarningLead // fallback dialog for the current deadline. Best-effort: a failure only // means the dialog will still appear, so we log and move on. func (t *Tray) dismissSessionWarning() { if t.svc.Session == nil { return } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := t.svc.Session.DismissWarning(ctx); err != nil { log.Debugf("session-warning: DismissWarning failed: %v", err) } } // openSessionAboutToExpire fires the auto-opened fallback dialog at // T-FinalWarningLead when the user did not dismiss the earlier T-10 // notification. Idempotent on the WindowManager side (a second call // while the window is already open is a no-op). func (t *Tray) openSessionAboutToExpire() { if t.svc.WindowManager == nil { return } t.svc.WindowManager.OpenSessionAboutToExpire(finalWarningCountdownSeconds) } // openSessionExtendFlow opens the SessionAboutToExpire window seeded with // the actual remaining time on the cached SSO deadline. Triggered by a // click on the "Expires in …" tray row so the user can extend the session // proactively, instead of waiting for the daemon's T-FinalWarningLead // auto-prompt. Silently no-ops when the deadline is unknown or already // elapsed — the menu row is hidden in those states anyway. func (t *Tray) openSessionExtendFlow() { if t.svc.WindowManager == nil { return } t.mu.Lock() deadline := t.sessionExpiresAt t.mu.Unlock() if deadline.IsZero() { return } seconds := int(time.Until(deadline).Seconds()) if seconds <= 0 { return } t.svc.WindowManager.OpenSessionAboutToExpire(seconds) } // 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(t.loc.T("notify.error.title"), message, notifyIDTrayError) } // exitNodesFromStatus returns the FQDNs of peers advertising an IPv4 // or IPv6 default route (`0.0.0.0/0` or `::/0`) — the only candidates // the user can pick as an exit node. The daemon ships each peer's // route table as `maps.Keys(...)` of a CIDR-keyed map (see // client/internal/peer/status.go: pbPeerState.Networks = maps.Keys( // peerState.GetRoutes())), so we parse each entry with netip and // match by `Bits()==0 && Addr().IsUnspecified()` rather than // string-comparing "0.0.0.0/0" — that catches the v4/v6 partner // management pairs together for a dual-stack exit, and tolerates any // future canonicalisation of the prefix string. func exitNodesFromStatus(st services.Status) []string { seen := map[string]struct{}{} out := []string{} for _, p := range st.Peers { if p.Fqdn == "" { continue } if !advertisesDefaultRoute(p.Networks) { continue } if _, ok := seen[p.Fqdn]; ok { continue } seen[p.Fqdn] = struct{}{} out = append(out, p.Fqdn) } // Case-insensitive sort so the submenu reads alphabetically the // way a human would — sort.Strings alone would put every // uppercase letter ahead of any lowercase one. sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) }) return out } func advertisesDefaultRoute(networks []string) bool { for _, n := range networks { pref, err := netip.ParsePrefix(n) if err != nil { continue } if pref.Bits() == 0 && pref.Addr().IsUnspecified() { return true } } return false } 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:]) }