mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
Integrates main's MDM configuration-profile feature and adapts it to the Wails UI (this branch had already replaced the Fyne UI). Conflict resolution: - go.mod/go.sum: take main's deps; howett.net/plist pinned to v1.0.2-... (tidy) - client/proto/daemon.pb.go: regenerated from the merged daemon.proto - client/internal/peer/status.go: union of ipToKey (main) + sessionExpiresAt (HEAD) - client/server/server.go: main's intent/liveness model (connectionGoroutineRunning, clientRunning no longer cleared by the goroutine) + empty-PSK guard - client/ui/client_ui.go, client/ui/profile.go: removed (dead Fyne UI) MDM port (backend + tray): - services/settings.go: expose MDMManagedFields plus a managedFields map keyed by Config field names so the settings form can gate a control without translating mdm.Key* names - tray: gate Profiles / Exit Node menus on DisableProfiles / DisableNetworks via GetFeatures, refreshed on the config_changed system event (replaces the legacy 2s poll); localized MDM policy-applied toast in all shipped locales - client/proto/metadata.go: shared constants for the config_changed / policy_applied event markers PreSharedKey: GetConfig now returns preSharedKeySet (bool) instead of the masked value; the settings form provides its own placeholder and sends a new key only when the user types one.
670 lines
30 KiB
Go
670 lines
30 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"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"
|
|
|
|
"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"
|
|
notifyIDMDMPolicy = "netbird-mdm-policy"
|
|
|
|
statusError = "Error"
|
|
|
|
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
|
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
|
urlDocs = "https://docs.netbird.io"
|
|
)
|
|
|
|
// 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
|
|
DaemonFeed *services.DaemonFeed
|
|
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
|
|
// panelDark reports whether the desktop panel uses a dark colour
|
|
// scheme, so iconForState can pick the black vs white monochrome tray
|
|
// icon on Linux. Set by startTrayTheme (Linux only); nil on macOS and
|
|
// Windows, where the OS/Wails handles light-vs-dark icon selection and
|
|
// panelIsDark falls back to its default.
|
|
panelDark func() bool
|
|
// 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
|
|
|
|
// statusMu guards the daemon-status core mirrored on the tray —
|
|
// connected, the last status string, the daemon version, the
|
|
// routed-networks revision, and the post-connect login-trigger flag.
|
|
// These are all written by applyStatus and read by the menu painters
|
|
// (applyIcon, relayoutMenu, refreshExitNodes' connected sample,
|
|
// etc.). One mutex covers them because they change together on every
|
|
// Status push.
|
|
statusMu sync.Mutex
|
|
connected bool
|
|
lastStatus string
|
|
lastDaemonVersion string
|
|
// 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.
|
|
lastNetworksRevision uint64
|
|
// 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.
|
|
//
|
|
// Profile-switch reconnects (which also fire an Up) are handled
|
|
// centrally by DaemonFeed.statusStreamLoop — see DaemonFeed's
|
|
// switchInProgress transitions and its EventTriggerLogin emit, so
|
|
// that the React UI's profile dropdown gets the same auto-handoff
|
|
// without going through this tray flag.
|
|
pendingConnectLogin bool
|
|
|
|
// sessionMu guards the cached SSO deadline used by the "Session: 47m"
|
|
// tray row. Independent of statusMu because the ticker reads it on a
|
|
// 30s cadence and applySessionExpiry writes it whenever the daemon's
|
|
// Status push carries a new value — neither should block the other's
|
|
// readers.
|
|
sessionMu sync.Mutex
|
|
// 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.
|
|
sessionExpiresAt time.Time
|
|
|
|
// profileMu guards the profile-domain state: the active profile
|
|
// identity cached by loadConfig, the notifications gate also cached
|
|
// there, and the in-flight switchProfile cancel. Independent of
|
|
// statusMu because a long-running switch (Down + Up) holds the
|
|
// switchCancel write under this lock, and we don't want it to block
|
|
// a concurrent Status-push reader of t.connected.
|
|
profileMu sync.Mutex
|
|
activeProfile string
|
|
activeUsername string
|
|
notificationsEnabled bool
|
|
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
|
|
|
|
// profilesMu guards the cached profile rows that relayoutMenu repaints
|
|
// into a freshly built Profiles submenu. loadProfiles fetches and stores
|
|
// them here; fillProfileSubmenu reads them. Kept separate from the live
|
|
// submenu so a relayout (which throws the old submenu away) always has a
|
|
// source of truth to repaint from without re-hitting the daemon.
|
|
profilesMu sync.Mutex
|
|
profiles []services.Profile
|
|
profilesUser string
|
|
|
|
// menuMu serialises relayoutMenu — the full buildMenu + SetMenu cycle.
|
|
// loadProfiles (under profileLoadMu) and refreshExitNodes (under
|
|
// exitNodesRebuildMu) both drive a relayout from independent mutexes, and
|
|
// applyLanguage drives one from the Localizer goroutine; without this guard
|
|
// two relayouts could interleave their t.menu swap and SetMenu push.
|
|
menuMu sync.Mutex
|
|
|
|
// exitNodesMu guards the t.exitNodes row cache so reading the cached
|
|
// rows in relayoutMenu (and tearing a copy off the slice for
|
|
// Repaint) doesn't contend with status-push readers of statusMu.
|
|
exitNodesMu sync.Mutex
|
|
// exitNodes are the rows currently painted into the Exit Node
|
|
// submenu, sourced from Networks.List() (NetID + selected state) so
|
|
// each row can be toggled.
|
|
exitNodes []exitNodeEntry
|
|
// exitNodesRebuildMu serialises the submenu.Clear + Add + SetMenu
|
|
// cycle. 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.
|
|
exitNodesRebuildMu sync.Mutex
|
|
|
|
// featureMu guards the daemon feature kill switches mirrored on the
|
|
// tray. Fetched once at startup and refreshed on every config_changed
|
|
// system event (the daemon re-applies MDM policy on each engine spawn
|
|
// and signals it via that event). Folded into the Profiles and Exit
|
|
// Node menu enablement by featuresDisabled so an operator- or
|
|
// MDM-disabled surface greys out without a periodic GetFeatures poll.
|
|
featureMu sync.Mutex
|
|
disableProfiles bool
|
|
disableNetworks bool
|
|
}
|
|
|
|
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() }, func() { t.relayoutMenu() })
|
|
t.tray = app.SystemTray.New()
|
|
// Seed panel-theme detection (Linux only) before the first paint so the
|
|
// initial icon already matches the panel's light/dark scheme; repaints
|
|
// on live theme switches.
|
|
t.startTrayTheme()
|
|
t.applyIcon()
|
|
t.tray.SetTooltip(t.loc.T("tray.tooltip"))
|
|
// On Linux the SNI hover tooltip is sourced from the systray *Label*
|
|
// (the StatusNotifierItem Title/ToolTip props), not SetTooltip —
|
|
// SetTooltip is a no-op on Linux. With no label set, Wails falls back
|
|
// to the literal "Wails", so set it explicitly here. macOS is skipped
|
|
// because its setLabel paints visible text next to the icon; Windows
|
|
// is skipped because its tooltip comes from SetTooltip above.
|
|
if runtime.GOOS == "linux" {
|
|
t.tray.SetLabel(t.loc.T("tray.tooltip"))
|
|
}
|
|
t.menu = t.buildMenu()
|
|
t.tray.SetMenu(t.menu)
|
|
// Left-click on the tray icon opens the menu, and 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. macOS and Linux give us click→menu
|
|
// natively, so bindTrayClick is a no-op there (binding OnClick→OpenMenu
|
|
// on macOS would freeze the tray — see tray_click_other.go). Windows has
|
|
// no native left-click handler, so bindTrayClick wires one explicitly
|
|
// (see tray_click_windows.go). On Linux we deliberately skip AttachWindow:
|
|
// it plus Wails3's applySmartDefaults would pop the window alongside the
|
|
// menu on environments like GNOME Shell with the AppIndicator extension.
|
|
// Right-click opens the menu through Wails' default rightClickHandler on
|
|
// every platform.
|
|
bindTrayClick(t)
|
|
|
|
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
|
|
app.Event.On(services.EventDaemonNotification, 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()
|
|
// Seed the feature kill switches so a DisableProfiles / DisableNetworks
|
|
// policy already greys out the matching menus on the first paint
|
|
// (config_changed events refresh them afterwards).
|
|
go t.refreshFeatures()
|
|
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
|
|
}
|
|
// Route through WindowManager so the main window is centered on its
|
|
// first show (see WindowManager.ShowMain) — minimal WMs (fluxbox, the
|
|
// XEmbed tray path) otherwise drop it in the top-left corner.
|
|
if t.svc.WindowManager != nil {
|
|
t.svc.WindowManager.ShowMain()
|
|
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"))
|
|
// Mirror the Linux label fix from NewTray — the SNI hover tooltip
|
|
// rides on the label, so refresh it on language change too.
|
|
if runtime.GOOS == "linux" {
|
|
t.tray.SetLabel(t.loc.T("tray.tooltip"))
|
|
}
|
|
t.relayoutMenu()
|
|
}
|
|
|
|
// relayoutMenu rebuilds the ENTIRE tray menu from scratch (buildMenu), repaints
|
|
// the cached status/session/profile/exit-node state into the fresh items, and
|
|
// pushes the whole tree with a single SetMenu. It is the only Linux path that
|
|
// reliably propagates submenu changes.
|
|
//
|
|
// Why a full rebuild rather than mutating the existing submenu in place: on
|
|
// KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first
|
|
// time it is opened (GetLayout for that submenu id) and never re-fetches it on
|
|
// a LayoutUpdated(parent=0) signal — so Clear()+Add() into the same submenu
|
|
// container left the visible menu (and, worse, the click→id mapping) frozen on
|
|
// the first snapshot: clicks sent the stale ids, which the freshly-rebuilt
|
|
// itemMap no longer knew, so they silently no-op'd. buildMenu allocates a brand
|
|
// new submenu container id every time, which Plasma treats as an unseen menu
|
|
// and re-queries on next open — both the labels and the click ids stay live.
|
|
// (Confirmed via dbus-monitor: a re-opened submenu issued no GetLayout until
|
|
// its container id changed.) The darwin detached-NSMenu workaround that the old
|
|
// per-submenu SetMenu addressed is also covered, since this rebuilds the whole
|
|
// tree against the cached top-level pointer.
|
|
//
|
|
// Pulls profile/exit-node rows from their caches (profilesMu / exitNodes) so it
|
|
// never re-hits the daemon and never recurses back into loadProfiles.
|
|
func (t *Tray) relayoutMenu() {
|
|
t.menuMu.Lock()
|
|
defer t.menuMu.Unlock()
|
|
|
|
t.menu = t.buildMenu()
|
|
|
|
t.statusMu.Lock()
|
|
connected := t.connected
|
|
lastStatus := t.lastStatus
|
|
daemonVersion := t.lastDaemonVersion
|
|
t.statusMu.Unlock()
|
|
|
|
t.sessionMu.Lock()
|
|
sessionDeadline := t.sessionExpiresAt
|
|
t.sessionMu.Unlock()
|
|
|
|
t.exitNodesMu.Lock()
|
|
exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...)
|
|
t.exitNodesMu.Unlock()
|
|
|
|
disableProfiles, disableNetworks := t.featuresDisabled()
|
|
|
|
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(exitNodeEntries) > 0 && !disableNetworks)
|
|
}
|
|
if t.settingsItem != nil {
|
|
t.settingsItem.SetEnabled(!daemonUnavailable)
|
|
}
|
|
if t.profileSubmenuItem != nil {
|
|
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
|
|
}
|
|
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 empty Profiles + Exit Node submenus, so repaint
|
|
// both from their caches before the single SetMenu below. fillExitNodeSubmenu
|
|
// uses the entries snapshotted above; fillProfileSubmenu reads profilesMu.
|
|
// Neither re-fetches, so relayoutMenu never recurses back into
|
|
// loadProfiles/refreshExitNodes. (We must NOT re-take exitNodesRebuildMu
|
|
// here — refreshExitNodes already holds it when it calls relayoutMenu.)
|
|
t.fillExitNodeSubmenu(exitNodeEntries)
|
|
t.fillProfileSubmenu()
|
|
|
|
// Single push of the whole tree. On Linux this emits one LayoutUpdated with
|
|
// fresh submenu container ids; on darwin it rebuilds the NSMenu against the
|
|
// cached top-level pointer.
|
|
t.tray.SetMenu(t.menu)
|
|
}
|
|
|
|
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 SessionExpiration
|
|
// 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
|
|
// refreshExitNodes (via relayoutMenu) 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 := menuLabel(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.statusMu.Lock()
|
|
needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) ||
|
|
strings.EqualFold(t.lastStatus, services.StatusSessionExpired) ||
|
|
strings.EqualFold(t.lastStatus, services.StatusLoginFailed)
|
|
t.statusMu.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.statusMu.Lock()
|
|
t.pendingConnectLogin = true
|
|
t.statusMu.Unlock()
|
|
go func() {
|
|
if err := t.svc.Connection.Up(context.Background(), services.UpParams{}); err != nil {
|
|
log.Errorf("connect: %v", err)
|
|
t.notifyError(t.loc.T("notify.error.connect"))
|
|
t.statusMu.Lock()
|
|
t.pendingConnectLogin = false
|
|
t.statusMu.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.profileMu.Lock()
|
|
if t.switchCancel != nil {
|
|
t.switchCancel()
|
|
t.switchCancel = nil
|
|
}
|
|
t.profileMu.Unlock()
|
|
t.svc.DaemonFeed.CancelProfileSwitch()
|
|
go func() {
|
|
if err := t.svc.Connection.Down(context.Background()); err != nil {
|
|
log.Errorf("disconnect: %v", err)
|
|
t.notifyError(t.loc.T("notify.error.disconnect"))
|
|
t.downItem.SetEnabled(true)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
_ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{
|
|
ID: id,
|
|
Title: title,
|
|
Body: body,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|