Files
netbird/client/ui/tray.go
Zoltán Papp ef6b4f7538 add SSO session extend flow
Adds an end-to-end SSO session-extension feature: the management server
publishes per-peer session deadlines on every Login/Sync, a new
ExtendAuthSession RPC refreshes the deadline using a fresh JWT without
tearing down the tunnel, and the daemon tracks the deadline locally so
the UI can fire a T-10min warning toast with an interactive "Extend now"
action.
2026-05-20 16:43:14 +02:00

1257 lines
47 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"
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 frontend/src/i18n/locales/<lang>/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"
// 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
networksItem *application.MenuItem
profileSubmenu *application.Menu
profileSubmenuItem *application.MenuItem
profileEmailItem *application.MenuItem
settingsItem *application.MenuItem
debugItem *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()
// 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
}
// 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(false)
t.applyStatusIndicator(lastStatus)
}
if t.sessionExpiresItem != nil {
if sessionDeadline.IsZero() {
t.sessionExpiresItem.SetHidden(true)
} else {
remaining := nbstatus.FormatRemainingDuration(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)
}
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 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()
}
if len(exitNodes) > 0 {
t.rebuildExitNodes(exitNodes)
}
go t.loadProfiles()
}
// 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 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()
}
func (t *Tray) buildMenu() *application.Menu {
menu := application.NewMenu()
// statusItem shows the daemon's current status. Disabled (and no
// OnClick handler) so clicks are no-ops — the row is informational
// only. 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(false).
SetBitmap(iconMenuDotIdle)
// sessionExpiresItem sits directly below the status row so the
// remaining-time label reads as a sub-line of "Connected" etc. 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.
t.sessionExpiresItem = menu.Add("").SetEnabled(false)
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()
// 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)
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()
t.exitNodeItem = menu.Add(t.loc.T("tray.menu.exitNode")).SetEnabled(false)
t.networksItem = menu.Add(t.loc.T("tray.menu.networks")).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(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") })
t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") })
menu.AddSeparator()
about := menu.AddSubmenu(t.loc.T("tray.menu.about"))
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")).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(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) openRoute(route string) {
if t.window == nil {
return
}
t.window.Show()
t.window.Focus()
t.window.SetURL("/#" + route)
}
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. Hand off
// to the React-side startLogin() (which owns the browser-login window
// and SSO orchestration) by showing the main window and emitting
// EventTriggerLogin. The frontend subscribes in
// layouts/ConnectionStatusSwitch.tsx.
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.ShowWindow()
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.ShowWindow()
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: kept disabled (informational row). 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(false)
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 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 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 {
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()
}
}
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.
//
// 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
}
t.mu.Lock()
t.sessionExpiresAt = d
t.mu.Unlock()
if t.sessionExpiresItem == nil {
return
}
if d.IsZero() {
t.sessionExpiresItem.SetHidden(true)
return
}
remaining := nbstatus.FormatRemainingDuration(time.Until(d))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetHidden(false)
}
// 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)
}
// 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)
}
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:])
}