tray: rework menu layout, exit-node submenu, session countdown wording

- Reorder the menu: status, Connect/Disconnect, profile block, Open
  NetBird, Exit Node, then Settings… / Help & Support / Quit NetBird.
- Rename About → Help & Support, Quit → Quit NetBird, Settings → Settings…
  (ellipsis flags the window-opening action per the macOS HIG); drop the
  brand icon from Open NetBird; enable Documentation (opens docs.netbird.io)
  and add a Troubleshoot entry that deep-links the Settings window.
- Exit Node is now a submenu listing only peers that advertise a default
  route (0.0.0.0/0 or ::/0), sorted case-insensitively; the row stays
  visible but greyed when the tunnel is down or no candidate exists.
- Session row reads "Session expires in <n minutes/hours/days>" and
  recomputes on menu open so the countdown tracks wall time between the
  daemon's status pushes.
This commit is contained in:
Zoltan Papp
2026-05-26 22:52:49 +02:00
parent 53bbc2d551
commit 80d6df6260
4 changed files with 185 additions and 49 deletions

View File

@@ -3,7 +3,14 @@
"tray.status.disconnected": "Getrennt",
"tray.status.daemonUnavailable": "Nicht aktiv",
"tray.status.error": "Fehler",
"tray.session.expiresIn": "Läuft ab in {remaining}",
"tray.session.expiresIn": "Sitzung läuft ab in {remaining}",
"tray.session.unit.lessThanMinute": "weniger als einer Minute",
"tray.session.unit.minute": "1 Minute",
"tray.session.unit.minutes": "{count} Minuten",
"tray.session.unit.hour": "1 Stunde",
"tray.session.unit.hours": "{count} Stunden",
"tray.session.unit.day": "1 Tag",
"tray.session.unit.days": "{count} Tagen",
"tray.menu.open": "NetBird öffnen",
"tray.menu.connect": "Verbinden",
@@ -11,17 +18,18 @@
"tray.menu.exitNode": "Exit-Node",
"tray.menu.networks": "Ressourcen",
"tray.menu.profiles": "Profile",
"tray.menu.settings": "Einstellungen",
"tray.menu.settings": "Einstellungen...",
"tray.menu.debugBundle": "Debug-Paket erstellen",
"tray.menu.about": "Über",
"tray.menu.about": "Hilfe & Support",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Dokumentation",
"tray.menu.troubleshoot": "Fehlerbehebung",
"tray.menu.downloadLatest": "Neueste Version herunterladen",
"tray.menu.installVersion": "Version {version} installieren",
"tray.menu.guiVersion": "Oberfläche: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Beenden",
"tray.menu.quit": "NetBird beenden",
"notify.update.title": "NetBird-Update verfügbar",
"notify.update.body": "NetBird {version} ist verfügbar.",

View File

@@ -3,7 +3,14 @@
"tray.status.disconnected": "Disconnected",
"tray.status.daemonUnavailable": "Not running",
"tray.status.error": "Error",
"tray.session.expiresIn": "Expires in {remaining}",
"tray.session.expiresIn": "Session expires in {remaining}",
"tray.session.unit.lessThanMinute": "less than a minute",
"tray.session.unit.minute": "1 minute",
"tray.session.unit.minutes": "{count} minutes",
"tray.session.unit.hour": "1 hour",
"tray.session.unit.hours": "{count} hours",
"tray.session.unit.day": "1 day",
"tray.session.unit.days": "{count} days",
"tray.menu.open": "Open NetBird",
"tray.menu.connect": "Connect",
@@ -11,17 +18,18 @@
"tray.menu.exitNode": "Exit Node",
"tray.menu.networks": "Resources",
"tray.menu.profiles": "Profiles",
"tray.menu.settings": "Settings",
"tray.menu.settings": "Settings...",
"tray.menu.debugBundle": "Create Debug Bundle",
"tray.menu.about": "About",
"tray.menu.about": "Help & Support",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Documentation",
"tray.menu.troubleshoot": "Troubleshoot",
"tray.menu.downloadLatest": "Download latest version",
"tray.menu.installVersion": "Install version {version}",
"tray.menu.guiVersion": "GUI: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Quit",
"tray.menu.quit": "Quit NetBird",
"notify.update.title": "NetBird update available",
"notify.update.body": "NetBird {version} is available.",

View File

@@ -3,7 +3,14 @@
"tray.status.disconnected": "Lekapcsolva",
"tray.status.daemonUnavailable": "Nem fut",
"tray.status.error": "Hiba",
"tray.session.expiresIn": "Lejár {remaining} múlva",
"tray.session.expiresIn": "Munkamenet lejár {remaining} múlva",
"tray.session.unit.lessThanMinute": "egy percnél kevesebb",
"tray.session.unit.minute": "1 perc",
"tray.session.unit.minutes": "{count} perc",
"tray.session.unit.hour": "1 óra",
"tray.session.unit.hours": "{count} óra",
"tray.session.unit.day": "1 nap",
"tray.session.unit.days": "{count} nap",
"tray.menu.open": "NetBird megnyitása",
"tray.menu.connect": "Csatlakozás",
@@ -11,17 +18,18 @@
"tray.menu.exitNode": "Kilépő csomópont",
"tray.menu.networks": "Erőforrások",
"tray.menu.profiles": "Profilok",
"tray.menu.settings": "Beállítások",
"tray.menu.settings": "Beállítások...",
"tray.menu.debugBundle": "Hibakeresési csomag készítése",
"tray.menu.about": "Névjegy",
"tray.menu.about": "Súgó és támogatás",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Dokumentáció",
"tray.menu.troubleshoot": "Hibakeresés",
"tray.menu.downloadLatest": "Legfrissebb verzió letöltése",
"tray.menu.installVersion": "{version} verzió telepítése",
"tray.menu.guiVersion": "Felület: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Kilépés",
"tray.menu.quit": "NetBird bezárása",
"notify.update.title": "NetBird frissítés elérhető",
"notify.update.body": "Elérhető a NetBird {version}.",

View File

@@ -5,8 +5,10 @@ package main
import (
"context"
"fmt"
"net/netip"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -51,6 +53,7 @@ const (
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
@@ -111,6 +114,7 @@ type Tray struct {
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
exitNodeSubmenu *application.Menu
profileSubmenu *application.Menu
profileSubmenuItem *application.MenuItem
profileEmailItem *application.MenuItem
@@ -269,7 +273,7 @@ func (t *Tray) reapplyMenuState() {
if sessionDeadline.IsZero() {
t.sessionExpiresItem.SetHidden(true)
} else {
remaining := nbstatus.FormatRemainingDuration(time.Until(sessionDeadline))
remaining := t.formatSessionRemaining(time.Until(sessionDeadline))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetHidden(false)
}
@@ -283,7 +287,7 @@ func (t *Tray) reapplyMenuState() {
t.downItem.SetEnabled(connected || connecting)
}
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected)
t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
@@ -297,9 +301,7 @@ func (t *Tray) reapplyMenuState() {
if t.updater != nil {
t.updater.applyLanguage()
}
if len(exitNodes) > 0 {
t.rebuildExitNodes(exitNodes)
}
t.rebuildExitNodes(exitNodes)
go t.loadProfiles()
}
@@ -353,16 +355,16 @@ func (t *Tray) buildMenu() *application.Menu {
SetBitmap(iconMenuDotIdle)
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. iconMenuNetbird (the brand mark) is
// applied to this row — per-platform asset choice lives in the
// icons_menu_<os>.go files; an empty []byte opts the platform out.
openItem := menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() })
if len(iconMenuNetbird) > 0 {
openItem.SetBitmap(iconMenuNetbird)
}
// 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.
@@ -388,22 +390,50 @@ func (t *Tray) buildMenu() *application.Menu {
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.
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.
// 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")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") })
t.exitNodeItem = menu.Add(t.loc.T("tray.menu.exitNode")).SetEnabled(false)
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")).SetEnabled(false)
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")
})
// 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.
@@ -419,12 +449,6 @@ func (t *Tray) buildMenu() *application.Menu {
t.updater.attach(updateItem)
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.Add(t.loc.T("tray.menu.quit")).OnClick(func(*application.Context) { t.app.Quit() })
return menu
@@ -677,10 +701,13 @@ func (t *Tray) applyStatus(st services.Status) {
t.downItem.SetEnabled(connected || connecting)
}
// Exit Node surfaces tunnel-routed state, so only expose it while
// the tunnel is up. Settings just needs the daemon socket
// reachable.
// the tunnel is up AND the account actually has at least one
// exit-node candidate (a peer advertising 0.0.0.0/0 or ::/0).
// The row stays visible but greyed when no candidate is around,
// so the user can tell the feature exists. Settings just needs
// the daemon socket reachable.
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected)
t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
@@ -728,13 +755,24 @@ func (t *Tray) handleSessionExpired() {
}
}
// rebuildExitNodes paints one row per exit-node candidate into the
// Exit Node submenu. The list is read-only for now — selection wiring
// would need ListNetworks + a peer-FQDN → network-ID lookup, which the
// PeerStatus stream doesn't ship. Rebuilds via Clear + Add so the row
// set stays in sync with the daemon snapshot; 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).
func (t *Tray) rebuildExitNodes(nodes []string) {
if t.exitNodeItem == nil || len(nodes) == 0 {
if t.exitNodeSubmenu == nil {
return
}
sub := application.NewMenu()
t.exitNodeSubmenu.Clear()
for _, fqdn := range nodes {
sub.AddCheckbox(fqdn, false)
t.exitNodeSubmenu.Add(fqdn).SetEnabled(false)
}
if t.menu != nil {
t.tray.SetMenu(t.menu)
}
}
@@ -1030,6 +1068,16 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
d = *deadline
}
switch {
case deadline == nil:
log.Infof("tray applySessionExpiry: deadline=<nil> connected=%v → row hidden", connected)
case deadline.IsZero():
log.Infof("tray applySessionExpiry: deadline=<zero> connected=%v → row hidden", connected)
default:
log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v",
deadline.Format(time.RFC3339), time.Until(*deadline), connected)
}
t.mu.Lock()
t.sessionExpiresAt = d
t.mu.Unlock()
@@ -1041,15 +1089,15 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
t.sessionExpiresItem.SetHidden(true)
return
}
remaining := nbstatus.FormatRemainingDuration(time.Until(d))
remaining := t.formatSessionRemaining(time.Until(d))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetHidden(false)
}
// refreshSessionExpiresLabel recomputes the "Expires in …" tray row
// label from the cached SSO deadline. Triggered from the click handlers
// just before the menu paints, so the countdown reads against wall time
// instead of the value baked in by the last Status push.
// refreshSessionExpiresLabel recomputes the "Session expires in …" tray
// row label from the cached SSO deadline. Triggered from the click
// handlers just before the menu paints, so the countdown reads against
// wall time instead of the value baked in by the last Status push.
func (t *Tray) refreshSessionExpiresLabel() {
if t.sessionExpiresItem == nil {
return
@@ -1060,10 +1108,43 @@ func (t *Tray) refreshSessionExpiresLabel() {
if deadline.IsZero() {
return
}
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
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) {
@@ -1269,6 +1350,16 @@ func (t *Tray) notifyError(message string) {
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
}
// exitNodesFromStatus returns the FQDNs of peers advertising an IPv4
// or IPv6 default route (`0.0.0.0/0` or `::/0`) — the only candidates
// the user can pick as an exit node. The daemon ships each peer's
// route table as `maps.Keys(...)` of a CIDR-keyed map (see
// client/internal/peer/status.go: pbPeerState.Networks = maps.Keys(
// peerState.GetRoutes())), so we parse each entry with netip and
// match by `Bits()==0 && Addr().IsUnspecified()` rather than
// string-comparing "0.0.0.0/0" — that catches the v4/v6 partner
// management pairs together for a dual-stack exit, and tolerates any
// future canonicalisation of the prefix string.
func exitNodesFromStatus(st services.Status) []string {
seen := map[string]struct{}{}
out := []string{}
@@ -1276,16 +1367,37 @@ func exitNodesFromStatus(st services.Status) []string {
if p.Fqdn == "" {
continue
}
if !advertisesDefaultRoute(p.Networks) {
continue
}
if _, ok := seen[p.Fqdn]; ok {
continue
}
seen[p.Fqdn] = struct{}{}
out = append(out, p.Fqdn)
}
sort.Strings(out)
// Case-insensitive sort so the submenu reads alphabetically the
// way a human would — sort.Strings alone would put every
// uppercase letter ahead of any lowercase one.
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out
}
func advertisesDefaultRoute(networks []string) bool {
for _, n := range networks {
pref, err := netip.ParsePrefix(n)
if err != nil {
continue
}
if pref.Bits() == 0 && pref.Addr().IsUnspecified() {
return true
}
}
return false
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false