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
@@ -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)
}
+12 -12
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