mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 13:49:58 +00:00
The auto-update feature was driven by two narrow Wails events (netbird:update:available and :progress) plus a SystemEvent-metadata iteration on the React side. Both surfaces had to know the daemon metadata schema (new_version_available, enforced, progress_window), and the frontend had no pull endpoint to seed its state on mount. Extract the state machine into a new client/ui/updater package, mirroring how i18n and preferences are split between domain logic and a thin services facade. The package owns the State type, the metadata-key parsing, the mutex-guarded Holder, and the single netbird:update:state event. services.Update keeps the daemon RPCs (Trigger, GetInstallerResult, Quit) and gains GetState as a Wails pull endpoint. Tray-side update behaviour moves out of tray.go into a dedicated trayUpdater (tray_update.go): owns its menu item, OS notification, click handler, and the /update window opener triggered by the daemon's progress_window:show. tray.go drops three callbacks and four fields, and reads hasUpdate through the updater. Frontend ClientVersionContext now seeds from Update.GetState() and subscribes to netbird:update:state; the status.events iteration and metadata-key string literals are gone. UpdateAvailableBanner renders only for the enforced && !installing branch and labels its action "Install now"; UpdateVersionCard splits the install vs. download branches by Enforced so the disabled flow routes to GitHub.
897 lines
32 KiB
Go
897 lines
32 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
"github.com/wailsapp/wails/v3/pkg/events"
|
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
|
|
|
"github.com/netbirdio/netbird/client/ui/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"
|
|
|
|
statusError = "Error"
|
|
|
|
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
|
|
ProfileSwitcher *services.ProfileSwitcher
|
|
// 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
|
|
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
|
|
|
|
// 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)
|
|
// 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()
|
|
})
|
|
|
|
// 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...)
|
|
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.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 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() {
|
|
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)
|
|
|
|
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.openRoute("/settings") })
|
|
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. The
|
|
// frontend's /login route already drives that flow, so route the
|
|
// click there rather than re-implementing the SSO dance in the tray.
|
|
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.openRoute("/login")
|
|
return
|
|
}
|
|
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(t.loc.T("notify.error.connect"))
|
|
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 || 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
|
|
}
|
|
|
|
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)
|
|
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()
|
|
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)
|
|
}
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}()
|
|
}
|
|
|
|
// 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(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:])
|
|
}
|