Files
netbird/client/ui/tray.go
Zoltán Papp e94a4cbce5 session-extend: preempt previous WaitExtendAuthSession on new wait
When the tray "Extend now" notification action and the about-to-expire
dialog both start a flow for the same deadline, the daemon was running
two independent IdP polls and the older one surfaced an InvalidArgument
toast as soon as the second RequestExtend overwrote the pending flow.

Follow the WaitSSOLogin pattern: at the top of WaitExtendAuthSession
cancel the previous wait (the SetWaitCancel/CancelWait pair on
PendingFlow already existed but was unused), then register the new
wait's cancel. Preempted callers exit with codes.Canceled; the
authsession service translates that into ExtendResult{Preempted: true}
so the tray and the React dialog can stay silent on the losing flow
instead of showing a false-failure toast / error dialog.
2026-05-28 19:17:46 +02:00

1543 lines
59 KiB
Go

//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/<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"
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
Networks *services.Networks
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 are the rows currently painted into the Exit Node submenu,
// sourced from Networks.List() (NetID + selected state) so each row can be
// toggled. lastNetworksRevision is the daemon's routed-networks revision
// from the last Status snapshot; a bump in it — or a connect/disconnect
// transition — is what triggers a refreshExitNodes re-fetch, so we hit
// ListNetworks only when routes or their selection actually change rather
// than on every push. The peer-status route list can't be used here: it
// only carries actively-routed (chosen) routes, not candidate exit nodes.
exitNodes []exitNodeEntry
lastNetworksRevision uint64
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
// exitNodesLoadMu serializes refreshExitNodes for the same reason: the
// Status stream can fire several pushes in quick succession and each may
// kick a refresh, but the ListNetworks fetch + submenu rebuild + SetMenu
// must not run concurrently with itself.
exitNodesLoadMu 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([]exitNodeEntry(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()
}
// buildMenu just recreated an empty Exit Node submenu, so repaint the
// cached rows unconditionally (a refreshExitNodes would skip the rebuild
// when the list is unchanged). Hold exitNodesLoadMu so this rebuild can't
// race a status-push-driven refreshExitNodes mutating the same submenu.
t.exitNodesLoadMu.Lock()
t.rebuildExitNodes(exitNodes)
t.exitNodesLoadMu.Unlock()
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.
//
// Accelerators are wired on the Settings and Quit entries below.
// Cross-platform behaviour in Wails v3 alpha.95:
// - macOS: SetAccelerator calls NSMenuItem.setKeyEquivalent — the
// glyph row paints to the right of the label and the combo fires
// when the menu is open OR while the app is the frontmost app.
// - Linux (GTK): SetAccelerator binds the GTK accel — the combo
// fires while the menu is open and the label paints the row. On
// XEmbed/AppIndicator hosts the visual hint may not render but
// activation through the keyboard still resolves.
// - Windows: SetAccelerator is a no-op in alpha.95 (the impl is
// commented out in menuitem_windows.go), so the row is plain
// text. We still call it for forward compatibility — a future
// Wails release picks the labels up without churn here.
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")).
SetAccelerator("CmdOrCtrl+,").
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")
})
about.AddSeparator()
// 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")).
SetAccelerator("CmdOrCtrl+Q").
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
}
revisionChanged := st.NetworksRevision != t.lastNetworksRevision
t.lastNetworksRevision = st.NetworksRevision
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 parent-item enablement (greyed unless the tunnel is up
// AND at least one candidate exists) is owned by refreshExitNodes,
// triggered below on this same transition. Settings just needs the
// daemon socket reachable.
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()
}
// Re-fetch the selectable exit-node list whenever the daemon's routed-
// networks revision bumps (a route candidate added/removed, or a selection
// applied from any surface) or the tunnel flips state (iconChanged). The
// revision is the only reliable signal: candidate routes never appear in
// the peer-status snapshot, so a removed exit node would otherwise go
// unnoticed. The refresh owns the parent item's enablement and the rebuild.
if iconChanged || revisionChanged {
go t.refreshExitNodes()
}
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 clickable row per exit-node candidate into the
// Exit Node submenu. Each row carries the network's NetID and its selected
// state from ListNetworks; clicking toggles it via toggleExitNode. The active
// node is marked with a "✓ " prefix using a plain Add rather than AddCheckbox
// for the same reason as loadProfiles — Wails auto-toggles a checkbox's state
// on click before the OnClick handler runs, so the deselect/select round-trip
// would briefly show two checked rows. Rebuilds via Clear + Add so the row set
// stays in sync; 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).
// Callers must hold exitNodesLoadMu so concurrent rebuilds can't race the
// submenu's item slice.
func (t *Tray) rebuildExitNodes(nodes []exitNodeEntry) {
if t.exitNodeSubmenu == nil {
return
}
t.exitNodeSubmenu.Clear()
for _, n := range nodes {
id := n.ID
selected := n.Selected
label := id
if selected {
label = "✓ " + id
}
t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) {
t.toggleExitNode(id, selected)
})
}
if t.menu != nil {
t.tray.SetMenu(t.menu)
}
}
// refreshExitNodes re-fetches the routed-network list from the daemon and
// repaints the Exit Node submenu. Sourcing the rows from Networks.List() (not
// the Status stream) is what makes them selectable: the stream only ships peer
// FQDNs, whereas ListNetworks returns the NetID + selected state the
// Select/Deselect RPCs need. Serialized by exitNodesLoadMu so overlapping
// Status pushes can't race the submenu rebuild. Owns the parent item's
// enablement: greyed unless the tunnel is up and at least one candidate exists.
func (t *Tray) refreshExitNodes() {
t.exitNodesLoadMu.Lock()
defer t.exitNodesLoadMu.Unlock()
t.mu.Lock()
connected := t.connected
t.mu.Unlock()
var nodes []exitNodeEntry
if connected {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
list, err := t.svc.Networks.List(ctx)
cancel()
if err != nil {
log.Debugf("tray list networks: %v", err)
return
}
nodes = exitNodesFromNetworks(list)
}
log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes))
for _, n := range nodes {
log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected)
}
t.mu.Lock()
changed := !equalExitNodes(nodes, t.exitNodes)
t.exitNodes = nodes
t.mu.Unlock()
// Set enablement before rebuildExitNodes' SetMenu so the rebuild reads the
// updated state at NSMenuItem construction time (Wails v3 alpha reads
// item.disabled at build time, not lazily).
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected && len(nodes) > 0)
}
if changed {
t.rebuildExitNodes(nodes)
}
}
// toggleExitNode activates or deactivates one exit node by NetID. Exit nodes
// are mutually exclusive, so Select uses append=false to clear any other
// active node before turning this one on; deselecting an active node turns
// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs
// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the
// new selection.
func (t *Tray) toggleExitNode(id string, selected bool) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false}
var err error
if selected {
err = t.svc.Networks.Deselect(ctx, params)
} else {
err = t.svc.Networks.Select(ctx, params)
}
if err != nil {
log.Errorf("tray toggle exit node %q: %v", id, err)
t.notifyError(t.loc.T("notify.error.exitNode", "name", id))
return
}
t.refreshExitNodes()
}()
}
// 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 iconMenuDotConnecting
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 })
t.profileSubmenu.Clear()
var activeName, activeEmail string
for _, p := range profiles {
name := p.Name
active := p.IsActive
// Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's
// checked state on click (before the OnClick handler fires), so with
// AddCheckbox both the old and the new profile would briefly show as
// checked while the switchProfile goroutine is running. A plain item
// with a "✓ " prefix avoids the race entirely.
label := name
if active {
label = "✓ " + name
}
item := t.profileSubmenu.Add(label)
item.OnClick(func(*application.Context) {
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
if active {
return
}
t.switchProfile(name)
})
if active {
activeName = name
activeEmail = p.Email
}
}
t.profileSubmenu.AddSeparator()
t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")).OnClick(func(*application.Context) {
t.svc.WindowManager.OpenSettings("profiles")
})
log.Infof("tray loadProfiles: received %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
if t.profileSubmenuItem != nil && activeName != "" {
t.profileSubmenuItem.SetLabel(activeName)
}
if t.profileEmailItem != nil {
if activeEmail != "" {
t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail))
t.profileEmailItem.SetHidden(false)
} else {
t.profileEmailItem.SetHidden(true)
}
}
// 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=<nil> connected=%v → row hidden", connected)
case deadline.IsZero():
log.Infof("tray applySessionExpiry: deadline=<zero> 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)
}
}
result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{
DeviceCode: start.DeviceCode,
UserCode: start.UserCode,
})
if err != nil {
log.Warnf("session-warning: WaitExtend failed: %v", err)
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
return
}
if result.Preempted {
// Another UI surface (e.g. the about-to-expire dialog) started a
// flow for the same deadline and took over. Stay silent so the
// user only sees the outcome of the surviving flow.
log.Debugf("session-warning: WaitExtend preempted by a newer flow")
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)
}
// exitNodeEntry is one selectable row in the Exit Node submenu. ID is the
// network's NetID — both the row label and the argument the Select/Deselect
// RPCs take; Selected drives the ✓ prefix.
type exitNodeEntry struct {
ID string
Selected bool
}
// exitNodesFromNetworks filters the daemon's routed-network list down to
// exit-node candidates (a default-route range) and maps them to selectable
// rows. Sorted case-insensitively by ID so the submenu reads alphabetically.
func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry {
out := []exitNodeEntry{}
for _, n := range networks {
if !rangeIsDefaultRoute(n.Range) {
continue
}
out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected})
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID)
})
return out
}
// rangeIsDefaultRoute reports whether a Network.Range string contains an IPv4
// or IPv6 default route. The daemon may merge a v4+v6 exit pair into a single
// comma-joined range ("0.0.0.0/0, ::/0"), so we split and check each part,
// matching by Bits()==0 && unspecified rather than a literal string compare.
func rangeIsDefaultRoute(r string) bool {
for _, part := range strings.Split(r, ",") {
pref, err := netip.ParsePrefix(strings.TrimSpace(part))
if err != nil {
continue
}
if pref.Bits() == 0 && pref.Addr().IsUnspecified() {
return true
}
}
return false
}
func equalExitNodes(a, b []exitNodeEntry) 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:])
}