diff --git a/client/ui/main.go b/client/ui/main.go index 6eb39e83d..96ed7b646 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -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) diff --git a/client/ui/services/peers.go b/client/ui/services/daemon_feed.go similarity index 90% rename from client/ui/services/peers.go rename to client/ui/services/daemon_feed.go index 3f812af3a..5d7d7103b 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/daemon_feed.go @@ -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) } diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index 55ca0efad..84b02837b 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -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 diff --git a/client/ui/tray.go b/client/ui/tray.go index c677052ba..a6dc094ff 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -4,11 +4,6 @@ package main import ( "context" - "fmt" - "net/netip" - "runtime" - "sort" - "strconv" "strings" "sync" "time" @@ -18,7 +13,6 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" "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/i18n" "github.com/netbirdio/netbird/client/ui/services" @@ -35,31 +29,15 @@ import ( // distinguishing the error-icon state from real daemon status strings; // URLs are baked-in product links. const ( - notifyIDUpdatePrefix = "netbird-update-" - notifyIDEvent = "netbird-event-" - notifyIDTrayError = "netbird-tray-error" - notifyIDSessionExpired = "netbird-session-expired" - 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" + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" statusError = "Error" urlGitHubRepo = "https://github.com/netbirdio/netbird" urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" urlDocs = "https://docs.netbird.io" - - // 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 ) // Tray builds and updates the systray menu. It mirrors the layout of the Fyne @@ -74,7 +52,7 @@ type TrayServices struct { Settings *services.Settings Profiles *services.Profiles Networks *services.Networks - Peers *services.Peers + DaemonFeed *services.DaemonFeed Notifier *notifications.NotificationService Update *services.Update ProfileSwitcher *services.ProfileSwitcher @@ -123,34 +101,54 @@ type Tray struct { updater *trayUpdater - mu sync.Mutex - connected bool - // exitNodes are the rows currently painted into the Exit Node submenu, - // sourced from Networks.List() (NetID + selected state) so each row can be - // toggled. 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. - exitNodes []exitNodeEntry + // 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, reapplyMenuState, 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 - lastStatus string - lastDaemonVersion string - notificationsEnabled bool + // 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. + 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 - // 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. Guarded by mu. - sessionExpiresAt time.Time - // 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. Guarded by mu. - pendingConnectLogin bool // profileLoadMu serializes loadProfiles so the daemon-status-driven // refresh in applyStatus cannot race with the ApplicationStarted seed @@ -158,11 +156,20 @@ type Tray struct { // SetMenu, which the Wails menu API is not safe against concurrent // callers. profileLoadMu sync.Mutex - // exitNodesLoadMu serializes refreshExitNodes for the same reason: 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. - exitNodesLoadMu sync.Mutex + + // exitNodesMu guards the t.exitNodes row cache so reading the cached + // rows in reapplyMenuState (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 } func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { @@ -192,8 +199,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe // and OnClick here. Right-click still opens the menu through Wails' // default rightClickHandler fallback. - app.Event.On(services.EventStatus, t.onStatusEvent) - app.Event.On(services.EventSystem, t.onSystemEvent) + 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, @@ -278,13 +285,19 @@ func (t *Tray) applyLanguage() { // constructor returns items in their default (disconnected) shape. The // update menu item is re-applied by trayUpdater.applyLanguage. func (t *Tray) reapplyMenuState() { - t.mu.Lock() + t.statusMu.Lock() connected := t.connected lastStatus := t.lastStatus daemonVersion := t.lastDaemonVersion - exitNodes := append([]exitNodeEntry(nil), t.exitNodes...) + t.statusMu.Unlock() + + t.sessionMu.Lock() sessionDeadline := t.sessionExpiresAt - t.mu.Unlock() + t.sessionMu.Unlock() + + t.exitNodesMu.Lock() + exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...) + t.exitNodesMu.Unlock() daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) connecting := strings.EqualFold(lastStatus, services.StatusConnecting) @@ -312,7 +325,7 @@ func (t *Tray) reapplyMenuState() { t.downItem.SetEnabled(connected || connecting) } if t.exitNodeItem != nil { - t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) + t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0) } if t.settingsItem != nil { t.settingsItem.SetEnabled(!daemonUnavailable) @@ -328,11 +341,12 @@ func (t *Tray) reapplyMenuState() { } // buildMenu just recreated an empty Exit Node submenu, so repaint the // cached rows unconditionally (a refreshExitNodes would skip the rebuild - // when the list is unchanged). Hold exitNodesLoadMu so this rebuild can't - // race a status-push-driven refreshExitNodes mutating the same submenu. - t.exitNodesLoadMu.Lock() - t.rebuildExitNodes(exitNodes) - t.exitNodesLoadMu.Unlock() + // when the list is unchanged). Hold exitNodesRebuildMu so this rebuild + // can't race a status-push-driven refreshExitNodes mutating the same + // submenu. + t.exitNodesRebuildMu.Lock() + t.rebuildExitNodes(exitNodeEntries) + t.exitNodesRebuildMu.Unlock() go t.loadProfiles() } @@ -480,11 +494,11 @@ func (t *Tray) handleConnect() { // 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.mu.Lock() + t.statusMu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || strings.EqualFold(t.lastStatus, services.StatusLoginFailed) - t.mu.Unlock() + t.statusMu.Unlock() if needsLogin { t.app.Event.Emit(services.EventTriggerLogin) return @@ -497,18 +511,16 @@ func (t *Tray) handleConnect() { // 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.mu.Lock() + t.statusMu.Lock() t.pendingConnectLogin = true - t.mu.Unlock() + t.statusMu.Unlock() go func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil { + 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.mu.Lock() + t.statusMu.Lock() t.pendingConnectLogin = false - t.mu.Unlock() + t.statusMu.Unlock() t.upItem.SetEnabled(true) } }() @@ -522,17 +534,15 @@ func (t *Tray) handleConnect() { // instead of being swallowed by the profile-switch suppression filter. func (t *Tray) handleDisconnect() { t.downItem.SetEnabled(false) - t.mu.Lock() + t.profileMu.Lock() if t.switchCancel != nil { t.switchCancel() t.switchCancel = nil } - t.mu.Unlock() - t.svc.Peers.CancelProfileSwitch() + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() go func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := t.svc.Connection.Down(ctx); err != nil { + 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) @@ -540,718 +550,6 @@ func (t *Tray) handleDisconnect() { }() } -func (t *Tray) onStatusEvent(ev *application.CustomEvent) { - st, ok := ev.Data.(services.Status) - if !ok { - return - } - t.applyStatus(st) -} - -// onSystemEvent fires an OS notification for daemon SystemEvents that carry -// a user-facing message, mirroring the legacy event.Manager behaviour: gated -// by the user's "Notifications" toggle, with CRITICAL events bypassing the -// gate. Update-related events are skipped here because trayUpdater produces -// its own richer notification when EventUpdateState fires. -func (t *Tray) onSystemEvent(ev *application.CustomEvent) { - se, ok := ev.Data.(services.SystemEvent) - if !ok { - 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 _, isUpdate := se.Metadata["new_version_available"]; isUpdate { - return - } - if _, isProgress := se.Metadata["progress_window"]; isProgress { - return - } - // Management pairs ::/0 with 0.0.0.0/0 for exit-node default routes; - // the v4 partner already drives the user-facing toast, so the v6 one - // is suppressed to avoid a duplicate notification. - if se.Category == "network" && se.Metadata["network"] == "::/0" { - return - } - - critical := se.Severity == "critical" - t.mu.Lock() - enabled := t.notificationsEnabled - t.mu.Unlock() - if !enabled && !critical { - return - } - - // 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) -} - -// applyStatus updates the tray icon, status label, exit-node submenu, and -// connect/disconnect enablement based on the latest daemon snapshot. -// Skips the icon refresh when none of the icon-relevant inputs -// (connected, hasUpdate, status label) changed — the daemon emits -// rapid SubscribeStatus bursts during health probes that would -// otherwise spam Shell_NotifyIcon and the log. -// -// Profile-switch suppression lives one layer up in services/peers.go -// (Peers.BeginProfileSwitch / shouldSuppress) so the optimistic -// Connecting paint and the suppressed Idle/Connected events are shared -// with the React Status page rather than being a tray-only behaviour. -func (t *Tray) applyStatus(st services.Status) { - t.mu.Lock() - connected := strings.EqualFold(st.Status, services.StatusConnected) - iconChanged := connected != t.connected || st.Status != t.lastStatus - // Detect the transition into SessionExpired: the daemon emits the - // state on every Status snapshot for as long as the session stays - // expired, so without this guard we would re-fire the notification - // on every push. Mirrors the legacy Fyne client's sendNotification - // flag in onSessionExpire. - sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && - !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) - - // 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.mu.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) -} - -// 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() - } -} - -// 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 exitNodesLoadMu 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 exitNodesLoadMu 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.exitNodesLoadMu.Lock() - defer t.exitNodesLoadMu.Unlock() - - t.mu.Lock() - connected := t.connected - t.mu.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.mu.Lock() - changed := !equalExitNodes(nodes, t.exitNodes) - t.exitNodes = nodes - t.mu.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() - }() -} - -// 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 - } -} - -func (t *Tray) applyIcon() { - t.mu.Lock() - connected := t.connected - statusLabel := t.lastStatus - t.mu.Unlock() - hasUpdate := false - if t.updater != nil { - hasUpdate = t.updater.hasUpdate() - } - - log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s", - connected, hasUpdate, statusLabel, runtime.GOOS) - - icon, dark := t.iconForState() - if runtime.GOOS == "darwin" { - t.tray.SetTemplateIcon(icon) - return - } - t.tray.SetIcon(icon) - if dark != nil { - t.tray.SetDarkModeIcon(dark) - } -} - -func (t *Tray) iconForState() (icon, dark []byte) { - t.mu.Lock() - connected := t.connected - statusLabel := t.lastStatus - t.mu.Unlock() - hasUpdate := false - if t.updater != nil { - hasUpdate = t.updater.hasUpdate() - } - - connecting := strings.EqualFold(statusLabel, services.StatusConnecting) - errored := strings.EqualFold(statusLabel, statusError) || - strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) - needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || - strings.EqualFold(statusLabel, services.StatusSessionExpired) || - strings.EqualFold(statusLabel, services.StatusLoginFailed) - - if runtime.GOOS == "darwin" { - switch { - case connecting: - return iconConnectingMacOS, nil - case errored: - return iconErrorMacOS, nil - case needsLogin: - return iconNeedsLoginMacOS, nil - case connected && hasUpdate: - return iconUpdateConnectedMacOS, nil - case connected: - return iconConnectedMacOS, nil - case hasUpdate: - return iconUpdateDisconnectedMacOS, nil - default: - return iconDisconnectedMacOS, nil - } - } - - switch { - case connecting: - return iconConnecting, nil - case errored: - return iconError, nil - case needsLogin: - return iconNeedsLogin, nil - case connected && hasUpdate: - return iconUpdateConnected, nil - case connected: - return iconConnected, iconConnectedDark - case hasUpdate: - return iconUpdateDisconnected, nil - default: - return iconDisconnected, nil - } -} - -// loadConfig seeds the in-process notifications gate from the daemon's -// stored config and caches the active-profile identity for any future -// SetConfig calls. Called once at startup from a goroutine so a slow or -// unreachable daemon does not block menu construction. -// -// The Settings page in the main window is the source of truth for every -// other knob (SSH, auto-connect, Rosenpass, lazy connections, block-inbound, -// notifications); we only mirror the notifications flag because the tray -// itself uses it to gate OS toasts in onSystemEvent. -func (t *Tray) loadConfig() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - active, err := t.svc.Profiles.GetActive(ctx) - if err != nil { - log.Debugf("get active profile: %v", err) - return - } - cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams(active)) - if err != nil { - log.Debugf("get config: %v", err) - return - } - - t.mu.Lock() - t.activeProfile = active.ProfileName - t.activeUsername = active.Username - t.notificationsEnabled = !cfg.DisableNotifications - t.mu.Unlock() -} - -// loadProfiles refreshes the Profiles submenu from the daemon. Each -// entry is a checkbox showing the active profile and switches on click. -// Called on ApplicationStarted, after a successful switchProfile, and -// from applyStatus whenever the daemon's status text changes — the -// last case catches profile flips driven by another channel (CLI -// "netbird profile select", autoconnect picking the persisted profile -// after the UI's first ListProfiles, etc.) since the daemon does not -// emit a dedicated active-profile event. -func (t *Tray) loadProfiles() { - if t.profileSubmenu == nil { - return - } - t.profileLoadMu.Lock() - defer t.profileLoadMu.Unlock() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - username, err := t.svc.Profiles.Username() - if err != nil { - log.Debugf("get current user: %v", err) - return - } - profiles, err := t.svc.Profiles.List(ctx, username) - if err != nil { - log.Debugf("list profiles: %v", err) - return - } - sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) - - 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/peers.go — ProfileSwitcher calls Peers.BeginProfileSwitch -// when the previous status was Connected/Connecting, which emits a -// synthetic Connecting status to the event bus and starts filtering -// the daemon stream. That way both this tray and the React Status -// page see the same optimistic state without duplicating policy. -func (t *Tray) switchProfile(name string) { - t.mu.Lock() - if t.switchCancel != nil { - t.switchCancel() - } - ctx, cancel := context.WithCancel(context.Background()) - t.switchCancel = cancel - t.mu.Unlock() - - go func() { - username, err := t.svc.Profiles.Username() - if err != nil { - log.Errorf("tray switchProfile: get current user: %v", err) - return - } - if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{ - ProfileName: name, - Username: username, - }); err != nil { - if ctx.Err() != nil { - return - } - log.Errorf("tray switchProfile: %v", err) - t.notifyError(t.loc.T("notify.error.switchProfile", "profile", name)) - return - } - t.loadProfiles() - }() -} - -// applySessionExpiry refreshes the "Session: 47m" tray row from the latest -// Status snapshot's SessionExpiresAt. Only shown when the tunnel is up: -// in any other state (Idle after a Down, Connecting, NeedsLogin, -// SessionExpired, LoginFailed, DaemonUnavailable, or mid profile-switch) -// the deadline is meaningless and the row is hidden. The internal -// sessionExpiresAt cache is cleared in the same path so reapplyMenuState -// after a language switch doesn't resurrect a stale label. -// -// No per-minute ticker: between Status pushes the label may drift by a -// few minutes, which is fine for a tray-menu status row that the user -// opens on demand. The T-10min OS notification (driven by the daemon's -// sessionwatch) does the time-critical signalling. -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= connected=%v → row hidden", connected) - case deadline.IsZero(): - log.Infof("tray applySessionExpiry: deadline= 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.mu.Lock() - t.sessionExpiresAt = d - t.mu.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.mu.Lock() - deadline := t.sessionExpiresAt - t.mu.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)) - } -} - // 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) { @@ -1267,197 +565,6 @@ func (t *Tray) notify(title, body, id string) { } } -// 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, cancel := context.WithCancel(context.Background()) - defer cancel() - - 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 - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := t.svc.Session.DismissWarning(ctx); 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.mu.Lock() - deadline := t.sessionExpiresAt - t.mu.Unlock() - if deadline.IsZero() { - return - } - seconds := int(time.Until(deadline).Seconds()) - if seconds <= 0 { - return - } - t.svc.WindowManager.OpenSessionAboutToExpire(seconds) -} - // 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. @@ -1465,78 +572,3 @@ func (t *Tray) notifyError(message string) { t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) } -// 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 -} - -// 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 -} - -// 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:]) -} diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go new file mode 100644 index 000000000..1a628f0bc --- /dev/null +++ b/client/ui/tray_events.go @@ -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 +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go new file mode 100644 index 000000000..2b9454118 --- /dev/null +++ b/client/ui/tray_exitnodes.go @@ -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 +} diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go new file mode 100644 index 000000000..0c4c864ff --- /dev/null +++ b/client/ui/tray_icon.go @@ -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 + } +} diff --git a/client/ui/tray_profiles.go b/client/ui/tray_profiles.go new file mode 100644 index 000000000..eb7b748f7 --- /dev/null +++ b/client/ui/tray_profiles.go @@ -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() + }() +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go new file mode 100644 index 000000000..194cf834f --- /dev/null +++ b/client/ui/tray_session.go @@ -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= connected=%v → row hidden", connected) + case deadline.IsZero(): + log.Infof("tray applySessionExpiry: deadline= 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) +} diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go new file mode 100644 index 000000000..6a509bf1a --- /dev/null +++ b/client/ui/tray_status.go @@ -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 + } +} diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index a4860683e..0028f0565 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -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 diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go index 66a70cc08..040309e51 100644 --- a/client/ui/updater/state.go +++ b/client/ui/updater/state.go @@ -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 {