Files
netbird/client/ui-wails/tray.go
Zoltán Papp 8b8f38de1b [client/ui-wails] Show GUI and daemon versions in the About submenu
Restore the legacy Fyne UI's two disabled "GUI: x.y.z" / "Daemon: a.b.c"
entries under About so users (and support) can read the running
versions from the tray. The GUI line is baked in at build time via
version.NetbirdVersion() — the same -ldflags chain the rest of the
repo uses. The daemon line starts as "—" and is rewritten in
applyStatus on every Status snapshot whose DaemonVersion differs from
the last one we recorded, so a daemon restart with a new build
(e.g. after an enforced update) updates the menu automatically.

Drive-by: rename the local variable that shadowed the version package
in handleUpdate so the import resolves cleanly.
2026-05-06 16:55:52 +02:00

668 lines
21 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/services/notifications"
"github.com/netbirdio/netbird/client/ui-wails/services"
"github.com/netbirdio/netbird/version"
)
// User-facing strings exposed in the tray, OS notifications and the
// browser-opened URLs. Centralised here so future copy edits and (one
// day) localisation have a single source of truth.
const (
trayTooltip = "NetBird"
// Top-level menu entries.
menuStatusDisconnected = "Disconnected"
menuOpenNetBird = "Open NetBird"
menuConnect = "Connect"
menuDisconnect = "Disconnect"
menuExitNode = "Exit Node"
menuNetworks = "Networks"
menuQuit = "Quit"
// Settings + diagnostics. The settings page replaces the Fyne tray's
// Settings submenu (per-toggle checkboxes for SSH, auto-connect,
// Rosenpass, lazy connections, block-inbound, notifications); those
// live in the in-window Settings page now.
menuSettings = "Settings"
menuCreateDebugBundle = "Create Debug Bundle"
// About submenu and update flow.
menuAbout = "About"
menuGitHub = "GitHub"
menuDocumentation = "Documentation"
menuDownloadLatestVersion = "Download latest version"
// menuInstallVersionPrefix is rewritten with the target version when
// the management server enforces the update.
menuInstallVersionPrefix = "Install version "
// menuGUIVersionFmt and menuDaemonVersionFmt drive the disabled
// version-info entries under About. The daemon line is "—" until the
// first Status snapshot reports the daemon's version.
menuGUIVersionFmt = "GUI: %s"
menuDaemonVersionFmt = "Daemon: %s"
menuVersionUnknown = "—"
// OS notifications.
notifyUpdateTitle = "NetBird update available"
notifyUpdateBodyFmt = "NetBird %s is available."
notifyUpdateEnforcedSuffix = " Your administrator requires this update."
notifyErrorTitle = "Error"
notifyErrorConnect = "Failed to connect"
notifyErrorDisconnect = "Failed to disconnect"
notifySessionExpiredTitle = "NetBird session expired"
notifySessionExpiredBody = "Your NetBird session has expired. Please log in again."
// Notification IDs (used to coalesce duplicate toasts).
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDSessionExpired = "netbird-session-expired"
// Daemon status string for an SSO session that has expired and needs
// re-authentication. Mirrors internal.StatusSessionExpired.
statusSessionExpired = "SessionExpired"
// External URLs.
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
)
// Tray builds and updates the systray menu. It mirrors the layout of the Fyne
// systray 1:1 and routes clicks back to the gRPC services. Dynamic state
// (status icon, exit-node submenu) is driven by the netbird:status event.
// TrayServices bundles the daemon-RPC and notification services the tray
// menu needs. Grouped into a single struct so NewTray stays under the
// linter's parameter-count threshold and so adding another service later
// is a one-line struct change instead of a NewTray signature break.
type TrayServices struct {
Connection *services.Connection
Settings *services.Settings
Profiles *services.Profiles
Peers *services.Peers
Notifier *notifications.NotificationService
Update *services.Update
}
type Tray struct {
app *application.App
tray *application.SystemTray
window *application.WebviewWindow
svc TrayServices
statusItem *application.MenuItem
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
networksItem *application.MenuItem
updateItem *application.MenuItem
daemonVersionItem *application.MenuItem
mu sync.Mutex
connected bool
hasUpdate bool
updateVersion string
updateEnforced bool
exitNodes []string
lastStatus string
lastDaemonVersion string
notificationsEnabled bool
activeProfile string
activeUsername string
}
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
t := &Tray{
app: app,
window: window,
svc: svc,
notificationsEnabled: true,
}
t.tray = app.SystemTray.New()
t.applyIcon()
t.tray.SetTooltip(trayTooltip)
t.tray.SetMenu(t.buildMenu())
// Tray click handling is platform-specific by design:
//
// On Windows and macOS the OS-level tray protocol cleanly separates left
// and right click. AttachWindow plus an explicit OnClick gives the
// expected "click the icon to toggle the window, right-click to open the
// menu" UX, and the platform never delivers both events at once.
//
// On Linux the tray rides on the org.kde.StatusNotifierItem D-Bus protocol
// (libayatana-appindicator). The SNI Activate signal *is* left-click, but
// several environments — GNOME Shell with the AppIndicator extension is
// the loudest offender — also pop the attached menu on left-click,
// regardless of the ItemIsMenu property the spec defines for that purpose.
// Worse, AttachWindow on its own is enough to trigger this: Wails3's
// SystemTray.applySmartDefaults installs ToggleWindow as the default
// click handler whenever a window is attached, so even without an
// explicit OnClick the window pops up alongside the menu. The result
// looks like a bug to users.
//
// Mirror the legacy Fyne client's behaviour on Linux: skip both
// AttachWindow and OnClick so left-click only opens the menu, and expose
// the window through an explicit "Open NetBird" item. Right-click still
// opens the menu through Wails' default rightClickHandler fallback.
if runtime.GOOS != "linux" {
t.tray.AttachWindow(window)
t.tray.OnClick(func() { t.toggleWindow() })
}
app.Event.On(services.EventStatus, t.onStatusEvent)
app.Event.On(services.EventSystem, t.onSystemEvent)
app.Event.On(services.EventUpdateAvailable, t.onUpdateAvailable)
app.Event.On(services.EventUpdateProgress, t.onUpdateProgress)
go t.loadConfig()
return t
}
// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event.
func (t *Tray) ShowWindow() {
if t.window == nil {
return
}
t.window.Show()
}
func (t *Tray) buildMenu() *application.Menu {
menu := application.NewMenu()
t.statusItem = menu.Add(menuStatusDisconnected).SetEnabled(false)
menu.AddSeparator()
// On Linux the tray icon's left-click handler is intentionally unbound
// (see NewTray for the rationale), so expose the window through an
// explicit menu entry. Windows and macOS get the window via left-click.
if runtime.GOOS == "linux" {
menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() })
menu.AddSeparator()
}
t.upItem = menu.Add(menuConnect).OnClick(func(*application.Context) { t.handleConnect() })
t.downItem = menu.Add(menuDisconnect).OnClick(func(*application.Context) { t.handleDisconnect() })
t.downItem.SetEnabled(false)
menu.AddSeparator()
t.exitNodeItem = menu.Add(menuExitNode).SetEnabled(false)
t.networksItem = menu.Add(menuNetworks).OnClick(func(*application.Context) { t.openRoute("/networks") })
menu.AddSeparator()
// Settings, runtime toggles (SSH, Quantum-Resistance, lazy connection,
// block-inbound, auto-connect, notifications) and profile switching
// all live in the in-window Settings page now. The tray menu only
// surfaces the day-to-day actions.
menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") })
menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") })
menu.AddSeparator()
about := menu.AddSubmenu(menuAbout)
about.Add(menuGitHub).OnClick(func(*application.Context) {
_ = t.app.Browser.OpenURL(urlGitHubRepo)
})
about.Add(menuDocumentation).SetEnabled(false)
// Disabled informational entries: the GUI version is baked in at
// build time via -ldflags, the daemon version comes from the first
// Status snapshot and is updated in applyStatus.
about.Add(fmt.Sprintf(menuGUIVersionFmt, version.NetbirdVersion())).SetEnabled(false)
t.daemonVersionItem = about.Add(fmt.Sprintf(menuDaemonVersionFmt, menuVersionUnknown)).SetEnabled(false)
// Hidden until the daemon emits EventUpdateAvailable. The label is
// rewritten in onUpdateAvailable to match the legacy Fyne UI:
// menuDownloadLatestVersion for opt-in, menuInstallVersionPrefix+version
// when the management server enforces the update.
t.updateItem = about.Add(menuDownloadLatestVersion).OnClick(func(*application.Context) { t.handleUpdate() })
t.updateItem.SetHidden(true)
menu.AddSeparator()
menu.Add(menuQuit).OnClick(func(*application.Context) { t.app.Quit() })
return menu
}
func (t *Tray) toggleWindow() {
if t.window == nil {
return
}
if t.window.IsVisible() {
t.window.Hide()
return
}
t.window.Show()
}
func (t *Tray) openRoute(route string) {
if t.window == nil {
return
}
t.window.Show()
t.window.SetURL("/#" + route)
}
func (t *Tray) handleConnect() {
t.upItem.SetEnabled(false)
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil {
log.Errorf("connect: %v", err)
t.notifyError(notifyErrorConnect)
t.upItem.SetEnabled(true)
}
}()
}
func (t *Tray) handleDisconnect() {
t.downItem.SetEnabled(false)
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect: %v", err)
t.notifyError(notifyErrorDisconnect)
t.downItem.SetEnabled(true)
}
}()
}
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// onSystemEvent fires an OS notification for daemon SystemEvents that carry
// a user-facing message, mirroring the legacy event.Manager behaviour: gated
// by the user's "Notifications" toggle, with CRITICAL events bypassing the
// gate. The narrowly-scoped EventUpdate* events are skipped here because
// onUpdateAvailable already produces a richer notification for them.
func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
se, ok := ev.Data.(services.SystemEvent)
if !ok || se.UserMessage == "" {
return
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return
}
critical := se.Severity == "critical"
t.mu.Lock()
enabled := t.notificationsEnabled
t.mu.Unlock()
if !enabled && !critical {
return
}
body := se.UserMessage
if id := se.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
}
// onUpdateAvailable runs when the daemon reports a new netbird version. It
// flips the tray's hasUpdate flag (icon swap), reveals the update menu
// item with the right label, and posts an OS notification.
// The notification is what the legacy Fyne UI used to alert the user.
func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) {
upd, ok := ev.Data.(services.UpdateAvailable)
if !ok {
log.Warnf("update event payload not UpdateAvailable: %T", ev.Data)
return
}
log.Infof("tray onUpdateAvailable: version=%s enforced=%v", upd.Version, upd.Enforced)
t.mu.Lock()
t.hasUpdate = true
t.updateVersion = upd.Version
t.updateEnforced = upd.Enforced
t.mu.Unlock()
t.applyIcon()
if t.updateItem != nil {
// Match the Fyne wording: enforced updates name the version
// because the install starts on click; opt-in updates just
// route the user to the latest release.
if upd.Enforced {
t.updateItem.SetLabel(menuInstallVersionPrefix + upd.Version)
} else {
t.updateItem.SetLabel(menuDownloadLatestVersion)
}
t.updateItem.SetHidden(false)
}
body := fmt.Sprintf(notifyUpdateBodyFmt, upd.Version)
if upd.Enforced {
body += notifyUpdateEnforcedSuffix
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + upd.Version,
Title: notifyUpdateTitle,
Body: body,
}); err != nil {
log.Debugf("send update notification: %v", err)
}
}
// handleUpdate runs when the user clicks the "Download latest version" /
// "Install version X" menu item. Enforced updates trigger the daemon's
// installer flow and surface the in-window /update progress page;
// opt-in updates just open the GitHub releases page in the browser.
func (t *Tray) handleUpdate() {
t.mu.Lock()
enforced := t.updateEnforced
updateVersion := t.updateVersion
t.mu.Unlock()
if !enforced {
_ = t.app.Browser.OpenURL(urlGitHubReleases)
return
}
// Surface the progress page first so the user sees the install
// kick off; the daemon then drives the rest via the InstallerResult
// RPC the /update page is polling.
if t.window != nil {
url := "/#/update"
if updateVersion != "" {
url += "?version=" + updateVersion
}
t.window.SetURL(url)
t.window.Show()
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := t.svc.Update.Trigger(ctx); err != nil {
log.Errorf("trigger update: %v", err)
}
}()
}
// onUpdateProgress runs when the daemon enters the install phase of an
// enforced update. The Fyne UI used to spawn a separate process with the
// update window; here the window is already in-process, so we just route to
// the /update page and bring it forward.
func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
prog, ok := ev.Data.(services.UpdateProgress)
if !ok || prog.Action != "show" {
return
}
if t.window == nil {
return
}
url := "/#/update"
if prog.Version != "" {
url += "?version=" + prog.Version
}
t.window.SetURL(url)
t.window.Show()
}
// applyStatus updates the tray icon, status label, exit-node submenu, and
// connect/disconnect enablement based on the latest daemon snapshot.
// Skips the icon refresh when none of the icon-relevant inputs
// (connected, hasUpdate, status label) changed — the daemon emits
// rapid SubscribeStatus bursts during health probes that would
// otherwise spam Shell_NotifyIcon and the log.
func (t *Tray) applyStatus(st services.Status) {
t.mu.Lock()
connected := strings.EqualFold(st.Status, "Connected")
iconChanged := connected != t.connected || st.Status != t.lastStatus
// Detect the transition into SessionExpired: the daemon emits the
// state on every Status snapshot for as long as the session stays
// expired, so without this guard we would re-fire the notification
// on every push. Mirrors the legacy Fyne client's sendNotification
// flag in onSessionExpire.
sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) &&
!strings.EqualFold(t.lastStatus, statusSessionExpired)
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
t.connected = connected
t.lastStatus = st.Status
if daemonVersionChanged {
t.lastDaemonVersion = st.DaemonVersion
}
exitNodes := exitNodesFromStatus(st)
exitNodesChanged := !equalStrings(exitNodes, t.exitNodes)
t.exitNodes = exitNodes
t.mu.Unlock()
if iconChanged {
t.applyIcon()
if t.statusItem != nil {
t.statusItem.SetLabel(st.Status)
}
if t.upItem != nil {
t.upItem.SetEnabled(!connected)
}
if t.downItem != nil {
t.downItem.SetEnabled(connected)
}
}
if exitNodesChanged {
t.rebuildExitNodes(exitNodes)
}
if daemonVersionChanged && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(fmt.Sprintf(menuDaemonVersionFmt, st.DaemonVersion))
}
if sessionExpiredEnter {
t.handleSessionExpired()
}
}
// handleSessionExpired surfaces the SSO re-authentication path when the
// daemon reports StatusSessionExpired. Posts a single OS notification
// (the applyStatus guard ensures it fires only on the transition, not
// on every status snapshot) and brings the main window forward so the
// frontend's /login route can drive the renewed SSO flow. Mirrors the
// Fyne client's onSessionExpire, which used a runSelfCommand to spawn
// the login-url helper; here the window is already in-process.
func (t *Tray) handleSessionExpired() {
t.notify(notifySessionExpiredTitle, notifySessionExpiredBody, notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
}
}
func (t *Tray) rebuildExitNodes(nodes []string) {
if t.exitNodeItem == nil {
return
}
if len(nodes) == 0 {
t.exitNodeItem.SetEnabled(false)
return
}
sub := application.NewMenu()
for _, fqdn := range nodes {
sub.AddCheckbox(fqdn, false)
}
t.exitNodeItem.SetEnabled(true)
}
func (t *Tray) applyIcon() {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s",
connected, hasUpdate, statusLabel, runtime.GOOS)
icon, dark := t.iconForState()
if runtime.GOOS == "darwin" {
t.tray.SetTemplateIcon(icon)
return
}
t.tray.SetIcon(icon)
if dark != nil {
t.tray.SetDarkModeIcon(dark)
}
}
func (t *Tray) iconForState() (icon, dark []byte) {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
connecting := strings.EqualFold(statusLabel, "Connecting")
errored := strings.EqualFold(statusLabel, "Error")
if runtime.GOOS == "darwin" {
switch {
case connecting:
return iconConnectingMacOS, nil
case errored:
return iconErrorMacOS, 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 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()
}
// notify wraps the Wails notification service with the tray's standard
// id-prefix scheme and swallows errors (notifications are best-effort).
func (t *Tray) notify(title, body, id string) {
if t.svc.Notifier == nil {
return
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: id,
Title: title,
Body: body,
}); err != nil {
log.Debugf("notify %q: %v", title, err)
}
}
// notifyError fires a generic "Error" notification for tray-driven action
// failures. Each tray click site already logs the underlying error; this
// adds the user-visible toast.
func (t *Tray) notifyError(message string) {
t.notify(notifyErrorTitle, message, notifyIDTrayError)
}
func exitNodesFromStatus(st services.Status) []string {
seen := map[string]struct{}{}
out := []string{}
for _, p := range st.Peers {
if p.Fqdn == "" {
continue
}
if _, ok := seen[p.Fqdn]; ok {
continue
}
seen[p.Fqdn] = struct{}{}
out = append(out, p.Fqdn)
}
sort.Strings(out)
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// eventTitle composes a notification title from a SystemEvent's severity and
// category — "Critical: DNS", "Warning: Authentication", etc. — matching the
// format the legacy Fyne event.Manager produced.
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)
if prefix == "" {
prefix = "Info"
}
category := titleCase(e.Category)
if category == "" {
category = "System"
}
return prefix + ": " + category
}
func titleCase(s string) string {
if s == "" {
return ""
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}