ui: split tray.go into feature files, rename Peers service to DaemonFeed

The 1542-line tray.go grew into a 14-feature kitchen sink. Split it
into feature-coherent same-package siblings, give the daemon-stream
service a name that matches what it actually does, and trim the
cargo-cult context.WithCancel pattern from click handlers.

File layout (tray.go: 1542 → ~470 lines):
  - tray_status.go    onStatusEvent / applyStatus / status indicator
  - tray_icon.go      applyIcon / iconForState (tray icon painting)
  - tray_events.go    onSystemEvent + eventTitle / titleCase, plus a
                      shouldSkipSystemEvent helper that names the
                      three "daemon notification we don't surface"
                      filters
  - tray_session.go   session-expiry row + warning notification flow +
                      handleSessionExpired (moved from tray.go)
  - tray_profiles.go  loadConfig / loadProfiles / switchProfile
  - tray_exitnodes.go exit-node submenu (rebuild / refresh / toggle)

Mutex split: the kitchen-sink t.mu becomes four domain-scoped mutexes
so a long-running gRPC call in one domain can't block status-push
readers in another:
  - statusMu        connected / lastStatus / lastDaemonVersion /
                    lastNetworksRevision / pendingConnectLogin
  - sessionMu       sessionExpiresAt (read by the 30s ticker,
                    written by applySessionExpiry on every status push)
  - profileMu       activeProfile / activeUsername /
                    notificationsEnabled / switchCancel
  - exitNodesMu     row cache (read in reapplyMenuState's Repaint copy)
  - exitNodesRebuildMu  serialises ListNetworks + submenu rebuild +
                        SetMenu (already separate, kept)

Service rename: the "Peers" service handled the daemon's full
SubscribeStatus snapshot (peers, daemon version, management/signal
link state, networks revision, SSO deadline) plus the SubscribeEvents
notification stream and the profile-switch suppression filter. Peers
was a misleading name for a daemon-stream fan-out service. Rename to
DaemonFeed in services/, profileswitcher's stored reference, the
TrayServices struct, main.go wiring, and every doc comment that
referenced it. peers.go → daemon_feed.go. The Status.Peers field
itself (the peer list in the snapshot) is unchanged.

Event constant renames (wire strings unchanged so the frontend keeps
working without regenerating bindings beyond the rename):
  - EventStatus → EventStatusSnapshot
    Payload is a full Status struct (daemon-wide snapshot), not just
    a state-change ping — name the value-shape.
  - EventSystem → EventDaemonNotification
    Payload is a daemon SystemEvent meant to drive an OS toast or a
    Recent Events row. "System" was too generic; "Notification"
    matches what consumers do with it.

Concurrency fixes:
  - WaitExtendAuthSession now preempts a previous in-flight wait
    via the existing SetWaitCancel/CancelWait infrastructure on
    PendingFlow, the same pattern WaitSSOLogin uses. The previous
    waiter exits with codes.Canceled; the authsession service
    translates that to ExtendResult{Preempted: true} so the tray
    and the about-to-expire dialog stay silent on the losing flow
    instead of showing a false-failure toast. Without this, both
    a tray "Extend now" click and a dialog "Stay connected" click
    on the same deadline started two parallel IdP polls, and
    whichever lost the device-code check painted a bogus error.
  - mgmClient.ExtendAuthSession drops the dead backoff retry loop.
    The loop only retried on codes.Canceled, but the inner mgmCtx
    was derived from context.Background() and never cancelled, so
    every real error went straight to backoff.Permanent on the
    first attempt. Replace with a single
    context.WithTimeout(c.ctx, ConnectTimeout) call; daemon
    shutdown now interrupts the RPC and behaviour on real errors
    is unchanged.

Click-handler hygiene: six call sites used the cargo-cult
context.WithCancel(context.Background()) + defer cancel() pattern
without ever calling cancel() externally. Replace with
context.Background() directly (loadConfig, loadProfiles,
runExtendSession, dismissSessionWarning, handleConnect's Up,
handleDisconnect's Down). The one site that genuinely needs the
cancel — switchProfile, which stores it in t.switchCancel so
handleDisconnect can preempt the switch — keeps WithCancel.

Helper extraction: shouldSkipSystemEvent groups the three
"daemon notification we drop on the floor" checks
(new_version_available metadata, progress_window metadata, the
::/0 partner of an exit-node default-route event) behind a single
named predicate. Each had a comment explaining why; collecting
them moves the rationale into the helper docstring and shrinks
onSystemEvent to a router.
This commit is contained in:
Zoltán Papp
2026-05-28 21:26:57 +02:00
parent 3279b705fe
commit 2cdc6ef1c6
12 changed files with 1287 additions and 1171 deletions

View File

@@ -58,8 +58,8 @@ func (s *stringList) Set(v string) error {
}
func init() {
application.RegisterEvent[services.Status](services.EventStatus)
application.RegisterEvent[services.SystemEvent](services.EventSystem)
application.RegisterEvent[services.Status](services.EventStatusSnapshot)
application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification)
application.RegisterEvent[services.ProfileRef](services.EventProfileChanged)
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
application.RegisterEvent[updater.State](updater.EventStateChanged)
@@ -125,12 +125,12 @@ func main() {
settings := services.NewSettings(conn)
profiles := services.NewProfiles(conn)
// updater.Holder owns the typed update State. Peers feeds the daemon
// SubscribeEvents stream into it; the Update service is a thin
// updater.Holder owns the typed update State. DaemonFeed pipes the
// daemon SubscribeEvents stream into it; the Update service is a thin
// Wails-bound facade over the holder plus the install RPCs.
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
peers := services.NewPeers(conn, app.Event, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder)
notifier := notifications.New()
// localesFS reroots the embedded tree at the locales directory itself
@@ -157,7 +157,7 @@ func main() {
// Connection lives after bundle + prefStore so it can localise daemon
// errors (services.NewConnection takes both as dependencies).
connection := services.NewConnection(conn, bundle, prefStore)
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
app.RegisterService(application.NewService(connection))
// authsession.Session owns the full extend + dismiss surface; the tray
@@ -173,7 +173,7 @@ func main() {
app.RegisterService(application.NewService(profiles))
app.RegisterService(application.NewService(services.NewDebug(conn)))
app.RegisterService(application.NewService(update))
app.RegisterService(application.NewService(peers))
app.RegisterService(application.NewService(daemonFeed))
app.RegisterService(application.NewService(notifier))
app.RegisterService(application.NewService(profileSwitcher))
app.RegisterService(application.NewService(services.NewI18n(bundle)))
@@ -235,7 +235,7 @@ func main() {
Settings: settings,
Profiles: profiles,
Networks: networks,
Peers: peers,
DaemonFeed: daemonFeed,
Notifier: notifier,
Update: update,
ProfileSwitcher: profileSwitcher,
@@ -245,7 +245,7 @@ func main() {
})
listenForShowSignal(context.Background(), tray)
peers.Watch(context.Background())
daemonFeed.Watch(context.Background())
if err := app.Run(); err != nil {
log.Fatal(err)

View File

@@ -20,14 +20,15 @@ import (
)
const (
// EventStatus is emitted to the frontend whenever a fresh Status snapshot
// is captured (from a poll or a stream-driven refresh).
EventStatus = "netbird:status"
// EventSystem is emitted for each SubscribeEvents message (DNS, network,
// auth, connectivity categories). Auto-update SystemEvents are also
// forwarded here to updater.Holder.OnSystemEvent so the typed update
// state can be maintained without a second daemon subscription.
EventSystem = "netbird:event"
// EventStatusSnapshot is emitted to the frontend whenever a fresh
// Status snapshot is captured (from a poll or a stream-driven refresh).
EventStatusSnapshot = "netbird:status"
// EventDaemonNotification is emitted for each SubscribeEvents message
// (DNS, network, auth, connectivity categories). Auto-update
// SystemEvents are also forwarded here to updater.Holder.OnSystemEvent
// so the typed update state can be maintained without a second daemon
// subscription.
EventDaemonNotification = "netbird:event"
// EventProfileChanged fires after ProfileSwitcher.SwitchActive completes
// a daemon-side switch. The payload is the new ProfileRef. Both tray
// and React subscribers refresh their profile views off this so a flip
@@ -37,8 +38,9 @@ const (
EventProfileChanged = "netbird:profile:changed"
// EventSessionWarning is emitted on every session-warning watcher
// fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed
// sibling of EventSystem so React / tray subscribers don't have to
// filter the firehose of EventSystem. Consumers branch on the
// sibling of EventDaemonNotification so React / tray subscribers
// don't have to filter the firehose of EventDaemonNotification.
// Consumers branch on the
// SessionWarning.Final flag to tell the interactive T-10 event apart
// from the fallback T-2 event; the dialog auto-open lives in the
// tray (Go side) so the frontend stays passive on this flow.
@@ -60,7 +62,7 @@ const (
StatusSessionExpired = "SessionExpired"
)
// Emitter is what peers.Watch needs from the host application: a simple
// Emitter is what DaemonFeed.Watch needs from the host application: a simple
// "send this name and payload to the frontend" hook. The Wails app.Event
// satisfies this with its Emit method.
type Emitter interface {
@@ -138,8 +140,11 @@ type Status struct {
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
}
// Peers serves the dashboard data: one polled Status RPC and a long-running
// SubscribeEvents stream that re-emits every event over the Wails event bus.
// DaemonFeed fans the daemon's two long-running gRPC streams out to the
// frontend and the tray: SubscribeStatus snapshots (per state change) and
// SubscribeEvents system notifications (per DNS / network / auth / etc.
// event). Also exposes a one-shot Status RPC for callers that want the
// current snapshot without subscribing.
//
// Profile-switch suppression: ProfileSwitcher calls BeginProfileSwitch
// before tearing down the old profile when it would otherwise be followed
@@ -160,7 +165,7 @@ type Status struct {
// │ DaemonUnavailable │ "Up won't run" terminal state) │
// │ (timeout elapsed) │ Clear flag, emit normally │
// └────────────────────────────────────────────┴──────────────────────────────────┘
type Peers struct {
type DaemonFeed struct {
conn DaemonConn
emitter Emitter
updater *updater.Holder
@@ -174,8 +179,8 @@ type Peers struct {
switchInProgressUntil time.Time
}
func NewPeers(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *Peers {
return &Peers{conn: conn, emitter: emitter, updater: updaterHolder}
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *DaemonFeed {
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder}
}
// BeginProfileSwitch is called by ProfileSwitcher at the start of a switch
@@ -186,25 +191,89 @@ func NewPeers(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *
// Connecting snapshot is emitted right away so both consumers (tray and
// React) paint the optimistic state immediately. A 30s safety timeout
// clears the flag if the daemon never emits a follow-up status.
func (s *Peers) BeginProfileSwitch() {
func (s *DaemonFeed) BeginProfileSwitch() {
s.switchMu.Lock()
s.switchInProgress = true
s.switchInProgressUntil = time.Now().Add(30 * time.Second)
s.switchMu.Unlock()
s.emitter.Emit(EventStatus, Status{Status: StatusConnecting})
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting})
}
// CancelProfileSwitch is called by callers that abort the switch midway
// (the tray's Disconnect click while Connecting). Clears the suppression
// flag so the next daemon Idle paints through immediately instead of
// being swallowed.
func (s *Peers) CancelProfileSwitch() {
func (s *DaemonFeed) CancelProfileSwitch() {
s.switchMu.Lock()
s.switchInProgress = false
s.switchMu.Unlock()
}
func (s *Peers) shouldSuppress(st Status) bool {
// Watch starts the background loops that feed the frontend:
// - statusStreamLoop: push-driven snapshots on connection-state change
// (Connected/Disconnected/Connecting, peer list, address). Drives the
// tray icon, Status page, and Peers page.
// - toastStreamLoop: DNS / network / auth / connectivity / update
// SystemEvent stream. Drives OS notifications, the Recent Events
// list, and the update-overlay flag. The daemon-side RPC is named
// SubscribeEvents — only the loop's local alias differs to keep the
// two streams distinguishable in this file.
//
// Safe to call once at boot; both loops self-restart on stream errors
// via exponential backoff.
func (s *DaemonFeed) Watch(ctx context.Context) {
s.mu.Lock()
if s.cancel != nil {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.mu.Unlock()
s.streamWg.Add(2)
go s.statusStreamLoop(ctx)
go s.toastStreamLoop(ctx)
}
// ServiceShutdown is the Wails service hook fired on app exit.
func (s *DaemonFeed) ServiceShutdown() error {
s.mu.Lock()
cancel := s.cancel
s.cancel = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
s.streamWg.Wait()
return nil
}
// Get returns the current daemon status snapshot. When the daemon socket
// is unreachable (process down, socket missing, permission denied) it
// returns Status{Status: StatusDaemonUnavailable} instead of an error so
// the frontend's initial useStatus().refresh() picks up the same string
// the live event stream emits — the React overlay and per-screen gating
// then key off a single status enum without a parallel "error" path.
func (s *DaemonFeed) Get(ctx context.Context) (Status, error) {
cli, err := s.conn.Client()
if err != nil {
if isDaemonUnreachable(err) {
return Status{Status: StatusDaemonUnavailable}, nil
}
return Status{}, err
}
resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
if isDaemonUnreachable(err) {
return Status{Status: StatusDaemonUnavailable}, nil
}
return Status{}, err
}
return statusFromProto(resp), nil
}
func (s *DaemonFeed) shouldSuppress(st Status) bool {
s.switchMu.Lock()
defer s.switchMu.Unlock()
if !s.switchInProgress {
@@ -232,77 +301,13 @@ func (s *Peers) shouldSuppress(st Status) bool {
}
}
// Watch starts the background loops that feed the frontend:
// - statusStreamLoop: push-driven snapshots on connection-state change
// (Connected/Disconnected/Connecting, peer list, address). Drives the
// tray icon, Status page, and Peers page.
// - toastStreamLoop: DNS / network / auth / connectivity / update
// SystemEvent stream. Drives OS notifications, the Recent Events
// list, and the update-overlay flag. The daemon-side RPC is named
// SubscribeEvents — only the loop's local alias differs to keep the
// two streams distinguishable in this file.
//
// Safe to call once at boot; both loops self-restart on stream errors
// via exponential backoff.
func (s *Peers) Watch(ctx context.Context) {
s.mu.Lock()
if s.cancel != nil {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.mu.Unlock()
s.streamWg.Add(2)
go s.statusStreamLoop(ctx)
go s.toastStreamLoop(ctx)
}
// ServiceShutdown is the Wails service hook fired on app exit.
func (s *Peers) ServiceShutdown() error {
s.mu.Lock()
cancel := s.cancel
s.cancel = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
s.streamWg.Wait()
return nil
}
// Get returns the current daemon status snapshot. When the daemon socket
// is unreachable (process down, socket missing, permission denied) it
// returns Status{Status: StatusDaemonUnavailable} instead of an error so
// the frontend's initial useStatus().refresh() picks up the same string
// the live event stream emits — the React overlay and per-screen gating
// then key off a single status enum without a parallel "error" path.
func (s *Peers) Get(ctx context.Context) (Status, error) {
cli, err := s.conn.Client()
if err != nil {
if isDaemonUnreachable(err) {
return Status{Status: StatusDaemonUnavailable}, nil
}
return Status{}, err
}
resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
if isDaemonUnreachable(err) {
return Status{Status: StatusDaemonUnavailable}, nil
}
return Status{}, err
}
return statusFromProto(resp), nil
}
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
// re-emits each FullStatus snapshot on the Wails event bus. The first
// message is the current snapshot; subsequent messages fire on
// connection-state changes only — no fixed-interval polling, no idle
// chatter. Reconnects with exponential backoff if the stream drops
// (daemon restart, socket break).
func (s *Peers) statusStreamLoop(ctx context.Context) {
func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
defer s.streamWg.Done()
bo := backoff.WithContext(&backoff.ExponentialBackOff{
@@ -325,7 +330,7 @@ func (s *Peers) statusStreamLoop(ctx context.Context) {
return
}
unavailable = true
s.emitter.Emit(EventStatus, Status{Status: StatusDaemonUnavailable})
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
}
op := func() error {
@@ -359,7 +364,7 @@ func (s *Peers) statusStreamLoop(ctx context.Context) {
log.Debugf("suppressing status=%q during profile switch", st.Status)
continue
}
s.emitter.Emit(EventStatus, st)
s.emitter.Emit(EventStatusSnapshot, st)
}
}
@@ -375,7 +380,7 @@ func (s *Peers) statusStreamLoop(ctx context.Context) {
// "new_version_available" metadata to flip the tray's update overlay.
// Local name differs from the RPC ("SubscribeEvents") so the file's
// two streams aren't both called streamLoop.
func (s *Peers) toastStreamLoop(ctx context.Context) {
func (s *DaemonFeed) toastStreamLoop(ctx context.Context) {
defer s.streamWg.Done()
bo := backoff.WithContext(&backoff.ExponentialBackOff{
@@ -407,7 +412,7 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
}
se := systemEventFromProto(ev)
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
s.emitter.Emit(EventSystem, se)
s.emitter.Emit(EventDaemonNotification, se)
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
s.emitter.Emit(EventSessionWarning, warn)
}

View File

@@ -16,7 +16,7 @@ import (
// so both the tray and the React frontend use identical logic.
//
// Reconnect policy + optimistic-feedback table (driven by prevStatus
// captured from Peers.Get at SwitchActive entry):
// captured from DaemonFeed.Get at SwitchActive entry):
//
// ┌─────────────────┬──────────────────────┬──────────────────────────┬────────────────────┐
// │ Previous status │ Action │ Optimistic UI label │ Suppressed events │
@@ -31,10 +31,10 @@ import (
// └─────────────────┴──────────────────────┴──────────────────────────┴────────────────────┘
//
// Only Connected/Connecting trigger the optimistic Connecting paint
// (via Peers.BeginProfileSwitch): they're the only prevStatuses where
// (via DaemonFeed.BeginProfileSwitch): they're the only prevStatuses where
// the daemon emits stale Connected updates (peer count drops as the
// engine tears down) and then Idle, before the new profile's Up
// resumes the stream. Both are swallowed by Peers.shouldSuppress
// resumes the stream. Both are swallowed by DaemonFeed.shouldSuppress
// until a status that signals the new flow has begun (Connecting, or
// any of the "Up won't run" terminal states: NeedsLogin / LoginFailed /
// SessionExpired / DaemonUnavailable). The other prevStatuses either
@@ -53,15 +53,15 @@ import (
type ProfileSwitcher struct {
profiles *Profiles
connection *Connection
peers *Peers
feed *DaemonFeed
}
// NewProfileSwitcher creates a ProfileSwitcher backed by the given services.
// EventProfileChanged is emitted via peers.emitter (same package), so React
// EventProfileChanged is emitted via feed.emitter (same package), so React
// refreshes after a tray-driven switch and vice versa — the daemon does
// not emit a dedicated profile event.
func NewProfileSwitcher(profiles *Profiles, connection *Connection, peers *Peers) *ProfileSwitcher {
return &ProfileSwitcher{profiles: profiles, connection: connection, peers: peers}
func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher {
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
}
// SwitchActive switches to the named profile applying the reconnect policy.
@@ -70,7 +70,7 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, peers *Peers
// SubscribeStatus stream.
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
prevStatus := ""
if st, err := s.peers.Get(ctx); err == nil {
if st, err := s.feed.Get(ctx); err == nil {
prevStatus = st.Status
} else {
log.Warnf("profileswitcher: get status: %v", err)
@@ -89,11 +89,11 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
// Optimistic Connecting feedback for tray + React Status page: only
// when wasActive — those are the prevStatuses where the daemon will
// emit stale Connected + transient Idle pushes during Down before
// the new profile's Up resumes the stream (see Peers godoc for the
// the new profile's Up resumes the stream (see DaemonFeed godoc for the
// suppression table). Other prevStatuses already terminate cleanly
// on Idle, no suppression needed.
if wasActive {
s.peers.BeginProfileSwitch()
s.feed.BeginProfileSwitch()
}
if err := s.profiles.Switch(ctx, p); err != nil {
@@ -132,8 +132,8 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
// old profile after a tray-initiated switch (and the tray's profile
// submenu would lag a React-initiated one, except the tray rebuilds on
// every status transition).
if s.peers != nil && s.peers.emitter != nil {
s.peers.emitter.Emit(EventProfileChanged, p)
if s.feed != nil && s.feed.emitter != nil {
s.feed.emitter.Emit(EventProfileChanged, p)
}
return nil

File diff suppressed because it is too large Load Diff

115
client/ui/tray_events.go Normal file
View File

@@ -0,0 +1,115 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"fmt"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
// 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 shouldSkipSystemEvent(se) {
return
}
critical := se.Severity == "critical"
t.profileMu.Lock()
enabled := t.notificationsEnabled
t.profileMu.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)
}
// 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:])
}
// shouldSkipSystemEvent reports whether a daemon SystemEvent must not
// surface as a tray notification. Three sources are filtered out:
// - update-available announcements (trayUpdater emits its own richer
// notification when EventUpdateState fires)
// - install-progress signals (consumed by the install-progress window)
// - the ::/0 partner of an exit-node default-route event (the 0.0.0.0/0
// partner already drove the user-facing toast, so the v6 row is
// suppressed to avoid a duplicate notification)
func shouldSkipSystemEvent(se services.SystemEvent) bool {
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return true
}
if _, isProgress := se.Metadata["progress_window"]; isProgress {
return true
}
if se.Category == "network" && se.Metadata["network"] == "::/0" {
return true
}
return false
}

178
client/ui/tray_exitnodes.go Normal file
View File

@@ -0,0 +1,178 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"net/netip"
"sort"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
// 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
}
// 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 exitNodesRebuildMu 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 exitNodesRebuildMu 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.exitNodesRebuildMu.Lock()
defer t.exitNodesRebuildMu.Unlock()
t.statusMu.Lock()
connected := t.connected
t.statusMu.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.exitNodesMu.Lock()
changed := !equalExitNodes(nodes, t.exitNodes)
t.exitNodes = nodes
t.exitNodesMu.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()
}()
}
// 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
}

90
client/ui/tray_icon.go Normal file
View File

@@ -0,0 +1,90 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ui/services"
)
func (t *Tray) applyIcon() {
t.statusMu.Lock()
connected := t.connected
statusLabel := t.lastStatus
t.statusMu.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.statusMu.Lock()
connected := t.connected
statusLabel := t.lastStatus
t.statusMu.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
}
}

169
client/ui/tray_profiles.go Normal file
View File

@@ -0,0 +1,169 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"fmt"
"sort"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
// 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 := context.Background()
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.profileMu.Lock()
t.activeProfile = active.ProfileName
t.activeUsername = active.Username
t.notificationsEnabled = !cfg.DisableNotifications
t.profileMu.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 := context.Background()
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/daemon_feed.go — ProfileSwitcher calls DaemonFeed.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.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
}
ctx, cancel := context.WithCancel(context.Background())
t.switchCancel = cancel
t.profileMu.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()
}()
}

334
client/ui/tray_session.go Normal file
View File

@@ -0,0 +1,334 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"strconv"
"time"
log "github.com/sirupsen/logrus"
"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/services"
)
const (
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"
// 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
)
// 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()
}
}
// applySessionExpiry refreshes the "Session: 47m" tray row from the latest
// SSO deadline carried on the Status snapshot. Hidden when no deadline is
// tracked or the tunnel is down; otherwise renders the remaining time via
// formatSessionRemaining.
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.sessionMu.Lock()
t.sessionExpiresAt = d
t.sessionMu.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.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.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))
}
}
// 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 := context.Background()
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
}
if err := t.svc.Session.DismissWarning(context.Background()); 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.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
return
}
t.svc.WindowManager.OpenSessionAboutToExpire(seconds)
}

193
client/ui/tray_status.go Normal file
View File

@@ -0,0 +1,193 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// 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/daemon_feed.go
// (DaemonFeed.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.statusMu.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.statusMu.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)
}
// 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
}
}

View File

@@ -51,7 +51,7 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from the cached state so we don't miss an event that fired
// before NewTray finished wiring (Peers.Watch starts after tray
// before NewTray finished wiring (DaemonFeed.Watch starts after tray
// construction today, but treat that as an implementation detail).
u.state = update.GetState()
return u

View File

@@ -51,7 +51,7 @@ type Emitter interface {
}
// Holder caches the latest update State and broadcasts changes. Fed by
// services.Peers, which forwards every daemon SystemEvent here via
// services.DaemonFeed, which forwards every daemon SystemEvent here via
// OnSystemEvent. The state is read by the Wails-bound services.Update
// facade (Get) and pushed to subscribers via the Emitter.
type Holder struct {