//go:build !android && !ios && !freebsd && !js package main import ( "context" "fmt" "net/netip" "runtime" "sort" "strconv" "strings" "sync" "time" log "github.com/sirupsen/logrus" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/services/notifications" 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" "github.com/netbirdio/netbird/version" ) // Translation keys for every user-facing string the tray paints. The text // itself lives in i18n/locales//common.json — both the tray and the // React UI read from there so a single bundle drives the whole product. // Keys are referenced by the Tray.tr helper. // Non-translated identifiers. Notification IDs coalesce duplicate toasts // (the OS uses them as dedup keys); statusError is a tray-only sentinel // distinguishing the error-icon state from real daemon status strings; // URLs are baked-in product links. const ( notifyIDUpdatePrefix = "netbird-update-" notifyIDEvent = "netbird-event-" notifyIDTrayError = "netbird-tray-error" notifyIDSessionExpired = "netbird-session-expired" 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" 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 // systray 1:1 and routes clicks back to the gRPC services. Dynamic state // (status icon, exit-node submenu) is driven by the netbird:status event. // TrayServices bundles the daemon-RPC and notification services the tray // menu needs. Grouped into a single struct so NewTray stays under the // linter's parameter-count threshold and so adding another service later // is a one-line struct change instead of a NewTray signature break. type TrayServices struct { Connection *services.Connection Settings *services.Settings Profiles *services.Profiles Networks *services.Networks Peers *services.Peers Notifier *notifications.NotificationService Update *services.Update ProfileSwitcher *services.ProfileSwitcher WindowManager *services.WindowManager // Session drives the SSO session-extend flow invoked from the // "Extend now" action on the T-10min OS notification, plus the // Dismiss hand-off that suppresses the T-2 fallback dialog. Bound to // the authsession package directly because the Wails wrapper in // services only re-exposes the React-facing subset. Session *authsession.Session // Localizer is the tray's bridge to translations. Constructed in main // from i18n.Bundle + preferences.Store; the Wails-bound facades // (services.I18n, services.Preferences) are registered separately for // React and are not needed here. Localizer *Localizer } type Tray struct { app *application.App tray *application.SystemTray window *application.WebviewWindow svc TrayServices // loc owns the active language plus the preference subscription. The // tray talks to it for every translated label (t.loc.T(...)) and // registers a callback in NewTray that re-renders the menu on a // language switch. loc *Localizer menu *application.Menu statusItem *application.MenuItem // sessionExpiresItem displays the SSO session deadline as a humanised // remaining-time label ("Session: 47m"). Hidden when no deadline is // tracked (non-SSO peer or login-expiration disabled on the account). // Refreshed by applyStatus on every Status push and by a 1-minute // ticker between pushes so the countdown moves naturally. sessionExpiresItem *application.MenuItem upItem *application.MenuItem downItem *application.MenuItem exitNodeItem *application.MenuItem exitNodeSubmenu *application.Menu profileSubmenu *application.Menu profileSubmenuItem *application.MenuItem profileEmailItem *application.MenuItem settingsItem *application.MenuItem daemonVersionItem *application.MenuItem updater *trayUpdater 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 lastNetworksRevision uint64 lastStatus string lastDaemonVersion string notificationsEnabled bool activeProfile string activeUsername string 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 // or the post-switchProfile reload — both manipulate profileSubmenu and // 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 } func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { t := &Tray{ app: app, window: window, svc: svc, notificationsEnabled: true, // Localizer is constructed by main from the i18n.Bundle and // preferences.Store so the first menu render below is already in // the right locale — no English flash followed by a re-paint. loc: svc.Localizer, } t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }) t.tray = app.SystemTray.New() t.applyIcon() t.tray.SetTooltip(t.loc.T("tray.tooltip")) t.menu = t.buildMenu() t.tray.SetMenu(t.menu) // Left-click on the tray icon opens the menu on every platform. The // window is reached through the explicit "Open NetBird" entry. This // matches macOS NSStatusItem convention (click → menu), the Linux // StatusNotifierItem spec, and the legacy Fyne client. On Linux, // AttachWindow plus Wails3's applySmartDefaults would also pop the // window alongside the menu on environments like GNOME Shell with the // AppIndicator extension, so we intentionally skip both AttachWindow // and OnClick here. Right-click still opens the menu through Wails' // default rightClickHandler fallback. app.Event.On(services.EventStatus, t.onStatusEvent) app.Event.On(services.EventSystem, t.onSystemEvent) // Refresh the Profiles submenu when ProfileSwitcher fires the change. // applyStatus already reloads on status-text transitions, but a // switch on an idle daemon doesn't drive one — without this hook, // a React-initiated switch leaves the tray's submenu and active- // profile label stale. app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { go t.loadProfiles() }) // Defer the first profile load until the macOS/GTK/Win32 menu impl is // live — Menu.Update() short-circuits while app.running is false, and // AppKit's main queue isn't ready earlier either (see d23ef34 InvokeSync // nil-deref). app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { go t.loadProfiles() go t.runSessionExpiryTicker() // Notification-category registration must run after the Wails // notifications service Startup has populated wn.appName / // registry path on Windows; before app.Run() the category lookup // in SendNotificationWithActions silently falls back to a // gomb-nélküli notification (the Windows impl logs "Category not // found"). The macOS/Linux impls don't strictly require this // ordering, but running here is harmless for them. t.registerSessionWarningCategory() }) // Localizer fires this callback after it has already swapped its own // cached language, so every t.loc.T(...) lookup inside applyLanguage // runs against the new locale. t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) go t.loadConfig() return t } // ShowWindow brings the main window forward — used by SIGUSR1 / Windows event. // Show() alone is not enough on macOS: makeKeyAndOrderFront skips app // activation, so a tray-style app's window pops up behind the currently // active app. Focus() additionally calls activateIgnoringOtherApps:YES on // macOS and SetForegroundWindow on Windows. func (t *Tray) ShowWindow() { // While an auto-update install is running the install-progress window // is the focal surface (all other windows hidden by WindowManager). // Tray "Open" / SIGUSR1 / dock-reopen should bring it forward, not // resurrect the main one mid-install. Checked before BrowserLogin // because an install supersedes every other flow. if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { w.Show() w.Focus() return } // While an SSO flow is in progress the BrowserLogin popup is the focal // window — the main window was hidden by WindowManager so the user // stays on the sign-in surface. Tray "Open" / SIGUSR1 / dock-reopen // should bring that window forward, not resurrect the main one mid-flow. if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { w.Show() w.Focus() return } if t.window == nil { return } t.window.Show() t.window.Focus() } // applyLanguage re-renders every translated surface using the Localizer's // current language. Wails dispatches menu/tray APIs onto the platform's // UI thread internally, so calling them from the Localizer's background // goroutine is safe; profileLoadMu prevents loadProfiles from racing the // rebuild. func (t *Tray) applyLanguage() { t.tray.SetTooltip(t.loc.T("tray.tooltip")) t.menu = t.buildMenu() t.tray.SetMenu(t.menu) t.reapplyMenuState() } // reapplyMenuState walks cached state and re-applies the visibility, // enablement and label mutations that applyStatus would have performed // since the last menu rebuild. Required after buildMenu because that // constructor returns items in their default (disconnected) shape. The // update menu item is re-applied by trayUpdater.applyLanguage. func (t *Tray) reapplyMenuState() { t.mu.Lock() connected := t.connected lastStatus := t.lastStatus daemonVersion := t.lastDaemonVersion exitNodes := append([]exitNodeEntry(nil), t.exitNodes...) sessionDeadline := t.sessionExpiresAt t.mu.Unlock() daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) connecting := strings.EqualFold(lastStatus, services.StatusConnecting) if t.statusItem != nil && lastStatus != "" { t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus)) t.statusItem.SetEnabled(statusRowEnabled()) t.applyStatusIndicator(lastStatus) } if t.sessionExpiresItem != nil { if sessionDeadline.IsZero() { t.sessionExpiresItem.SetHidden(true) } else { remaining := t.formatSessionRemaining(time.Until(sessionDeadline)) t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) t.sessionExpiresItem.SetHidden(false) } } if t.upItem != nil { t.upItem.SetHidden(connected || connecting || daemonUnavailable) t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) } if t.downItem != nil { t.downItem.SetHidden(!connected && !connecting) t.downItem.SetEnabled(connected || connecting) } if t.exitNodeItem != nil { t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) } if t.settingsItem != nil { t.settingsItem.SetEnabled(!daemonUnavailable) } if t.profileSubmenuItem != nil { t.profileSubmenuItem.SetEnabled(!daemonUnavailable) } if daemonVersion != "" && t.daemonVersionItem != nil { t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) } if t.updater != nil { t.updater.applyLanguage() } // 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() go t.loadProfiles() } func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() // statusItem shows the daemon's current status. Informational row // with no OnClick handler — clicks are no-ops. Whether the row is // kept enabled is platform-dependent (see statusRowEnabled): on // Windows the disabled-state mask would desaturate the coloured // status dot painted into the check-mark slot, so the row stays // enabled there; macOS/Linux disable it so the greyed-out label // signals that it is not clickable. The Connect entry below drives // every actionable transition, including the SSO re-auth flow for // NeedsLogin/SessionExpired (the daemon's Up RPC returns // NeedsSSOLogin when applicable). t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). SetEnabled(statusRowEnabled()). SetBitmap(iconMenuDotIdle) menu.AddSeparator() // Only the action that applies to the current state is visible: Connect // when disconnected, Disconnect when connected. applyStatus swaps them on // each daemon status change. t.upItem = menu.Add(t.loc.T("tray.menu.connect")).OnClick(func(*application.Context) { t.handleConnect() }) t.downItem = menu.Add(t.loc.T("tray.menu.disconnect")).OnClick(func(*application.Context) { t.handleDisconnect() }) t.downItem.SetHidden(true) menu.AddSeparator() // Profiles submenu is populated asynchronously once the application // has started — Menu.Update() is a no-op before app.running is true, // so the initial fill is gated on the ApplicationStarted hook. profilesLabel := t.loc.T("tray.menu.profiles") t.profileSubmenu = menu.AddSubmenu(profilesLabel) // profileSubmenuItem is the parent MenuItem whose label is the active // profile name. AddSubmenu returns the child *Menu, so we retrieve the // parent *MenuItem via FindByLabel immediately after insertion. t.profileSubmenuItem = menu.FindByLabel(profilesLabel) // profileEmailItem shows the account email of the active profile directly // in the main menu, below the Profiles submenu — matching the behaviour of // the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a // non-empty email for the active profile. t.profileEmailItem = menu.Add("").SetEnabled(false) t.profileEmailItem.SetHidden(true) // sessionExpiresItem sits below the profile email so the active profile, // its account email, and the SSO session deadline read as a single block. // Hidden until applyStatus sees a non-zero SessionExpiresAt on the daemon // Status snapshot — peers without SSO tracking or with login expiry // disabled never reveal this row. Click opens the SessionAboutToExpire // window so the user can extend the session ahead of the daemon's // T-FinalWarningLead auto-prompt. t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) t.sessionExpiresItem.SetHidden(true) menu.AddSeparator() // The tray icon's left-click handler is intentionally unbound (see // NewTray for the rationale), so expose the window through an explicit // menu entry on every platform. // // Accelerators are wired on the Settings and Quit entries below. // Cross-platform behaviour in Wails v3 alpha.95: // - macOS: SetAccelerator calls NSMenuItem.setKeyEquivalent — the // glyph row paints to the right of the label and the combo fires // when the menu is open OR while the app is the frontmost app. // - Linux (GTK): SetAccelerator binds the GTK accel — the combo // fires while the menu is open and the label paints the row. On // XEmbed/AppIndicator hosts the visual hint may not render but // activation through the keyboard still resolves. // - Windows: SetAccelerator is a no-op in alpha.95 (the impl is // commented out in menuitem_windows.go), so the row is plain // text. We still call it for forward compatibility — a future // Wails release picks the labels up without churn here. menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) menu.AddSeparator() // exitNodeSubmenu hosts one row per peer advertising a default // route (0.0.0.0/0 or ::/0). Populated asynchronously by // rebuildExitNodes on every Status push that changes the set; // the parent row stays disabled until at least one candidate is // known. We grab the parent MenuItem via FindByLabel (same // pattern as the Profiles submenu) so applyStatus can flip its // enabled state independently of the children. exitNodeLabel := t.loc.T("tray.menu.exitNode") t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) t.exitNodeItem = menu.FindByLabel(exitNodeLabel) t.exitNodeItem.SetEnabled(false) menu.AddSeparator() // Settings, runtime toggles (SSH, Quantum-Resistance, lazy connection, // block-inbound, auto-connect, notifications) and profile switching // all live in the in-window Settings page now. The tray menu only // surfaces the day-to-day actions. The trailing ellipsis on the label // (i18n string) follows the macOS HIG convention for menu items that // open a dialog/window rather than performing an inline action. t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")). SetAccelerator("CmdOrCtrl+,"). OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) aboutLabel := t.loc.T("tray.menu.about") about := menu.AddSubmenu(aboutLabel) about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlGitHubRepo) }) about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlDocs) }) // Troubleshoot deep-links into the Settings window at the // Troubleshooting tab, which hosts the debug-bundle flow that used // to live as a top-level tray entry. about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("troubleshooting") }) about.AddSeparator() // Disabled informational entries: the GUI version is baked in at // build time via -ldflags, the daemon version comes from the first // Status snapshot and is updated in applyStatus. about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) // Update menu item is hidden until the daemon reports a new version // (EventUpdateState with Available=true). trayUpdater rewrites the // label between tray.menu.downloadLatest (opt-in) and // tray.menu.installVersion (enforced) and drives the click. updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). OnClick(func(*application.Context) { t.updater.handleClick() }) updateItem.SetHidden(true) t.updater.attach(updateItem) menu.AddSeparator() menu.Add(t.loc.T("tray.menu.quit")). SetAccelerator("CmdOrCtrl+Q"). OnClick(func(*application.Context) { t.app.Quit() }) return menu } func (t *Tray) handleConnect() { // NeedsLogin/SessionExpired/LoginFailed mean the daemon won't honor a // plain Up RPC ("up already in progress: current status NeedsLogin") — // it needs the Login → WaitSSOLogin → Up sequence instead. Emit // EventTriggerLogin so the React-side startLogin() (which owns the // BrowserLogin popup) drives the flow. The main window's webview is // alive even while hidden, so we don't surface it — only the popup // appears. t.mu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || strings.EqualFold(t.lastStatus, services.StatusLoginFailed) t.mu.Unlock() if needsLogin { t.app.Event.Emit(services.EventTriggerLogin) return } t.upItem.SetEnabled(false) // Arm the SSO auto-handoff: Up() is async and the daemon may flip to // NeedsLogin once it detects an SSO peer with no cached token. The // flag is consumed by applyStatus on that transition, which then // triggers the browser-login flow without the user having to click // Connect a second time. Cleared on any terminal state (Connected / // Idle / LoginFailed / DaemonUnavailable / SessionExpired) so a stale // flag can't hijack a future status push. t.mu.Lock() t.pendingConnectLogin = true t.mu.Unlock() go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil { log.Errorf("connect: %v", err) t.notifyError(t.loc.T("notify.error.connect")) t.mu.Lock() t.pendingConnectLogin = false t.mu.Unlock() t.upItem.SetEnabled(true) } }() } // handleDisconnect aborts any in-flight profile switch before sending // Down — otherwise the switcher's queued Up would re-establish the // connection right after the Disconnect, making the click look like a // no-op. Also clears Peers' optimistic-Connecting guard so the daemon's // Idle push (and any subsequent updates) paint through immediately // instead of being swallowed by the profile-switch suppression filter. func (t *Tray) handleDisconnect() { t.downItem.SetEnabled(false) t.mu.Lock() if t.switchCancel != nil { t.switchCancel() t.switchCancel = nil } t.mu.Unlock() t.svc.Peers.CancelProfileSwitch() go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := t.svc.Connection.Down(ctx); err != nil { log.Errorf("disconnect: %v", err) t.notifyError(t.loc.T("notify.error.disconnect")) t.downItem.SetEnabled(true) } }() } func (t *Tray) onStatusEvent(ev *application.CustomEvent) { st, ok := ev.Data.(services.Status) if !ok { return } t.applyStatus(st) } // onSystemEvent fires an OS notification for daemon SystemEvents that carry // a user-facing message, mirroring the legacy event.Manager behaviour: gated // by the user's "Notifications" toggle, with CRITICAL events bypassing the // gate. Update-related events are skipped here because trayUpdater produces // its own richer notification when EventUpdateState fires. func (t *Tray) onSystemEvent(ev *application.CustomEvent) { se, ok := ev.Data.(services.SystemEvent) if !ok { 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 } } 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) { if t.svc.Notifier == nil { return } if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{ ID: id, Title: title, Body: body, }); err != nil { log.Debugf("notify %q: %v", title, err) } } // 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) } } if _, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ DeviceCode: start.DeviceCode, UserCode: start.UserCode, }); err != nil { log.Warnf("session-warning: WaitExtend failed: %v", err) t.notifyError(t.loc.T("notify.sessionWarning.failed")) 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. 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:]) }