mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 07:21:27 +02:00
tray: reorganise menu, refresh expiry countdown on open
Layout changes: - Drop "Debug Bundle" row; reach the flow via the in-window Settings UI. - Move the brand-mark icon from the About row to "Open NetBird". - Collapse Settings / Exit Node / About into a single block, with the Settings → Exit Node order swap to put the configuration entry first. - Relocate Connect / Disconnect to the bottom block, sharing its separator with Quit. Drops the connectSeparator field + lastMenuItem helper that only existed to suppress the daemon-unavailable double separator in the old position. Countdown freshness: the daemon's Status snapshots arrive too coarse to keep a minute-grained "Expires in …" row honest while the menu is closed. Wails v3 alpha 95 does not expose a public NSMenu needsUpdate hook, so the tray binds OnClick / OnRightClick and recomputes the label from cached sessionExpiresAt just before the menu paints. macOS and Windows right-click additionally call OpenMenu() to restore the native auto-show that binding the handler suppresses; Linux's dbusmenu host paints the menu itself.
This commit is contained in:
@@ -110,17 +110,11 @@ type Tray struct {
|
||||
sessionExpiresItem *application.MenuItem
|
||||
upItem *application.MenuItem
|
||||
downItem *application.MenuItem
|
||||
// connectSeparator sits between the profile block and the
|
||||
// Connect/Disconnect rows. Hidden together with both action rows when
|
||||
// the daemon is unavailable so the menu doesn't show two adjacent
|
||||
// separators with nothing between them.
|
||||
connectSeparator *application.MenuItem
|
||||
exitNodeItem *application.MenuItem
|
||||
profileSubmenu *application.Menu
|
||||
profileSubmenuItem *application.MenuItem
|
||||
profileEmailItem *application.MenuItem
|
||||
settingsItem *application.MenuItem
|
||||
debugItem *application.MenuItem
|
||||
daemonVersionItem *application.MenuItem
|
||||
|
||||
updater *trayUpdater
|
||||
@@ -170,15 +164,37 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
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.
|
||||
// Refresh the "Expires in …" countdown row each time the menu opens
|
||||
// — the daemon's Status pushes are too coarse for a minute-grained
|
||||
// countdown. Wails v3 alpha 95 does not expose a public NSMenu-
|
||||
// needsUpdate hook, so we piggy-back on the click handlers, which
|
||||
// fire just before (macOS / Windows right-click) or alongside (Linux
|
||||
// dbusmenu `opened`) the menu becoming visible.
|
||||
//
|
||||
// Where binding the click handler suppresses the platform's native
|
||||
// auto-show (macOS pre-click returns 0 instead of 1; Windows
|
||||
// rightClickHandler defaults to ShowMenu and is replaced) we have to
|
||||
// call OpenMenu() ourselves to restore the popup. Linux's dbusmenu
|
||||
// host paints the menu independently, so the handler only refreshes
|
||||
// the label. AttachWindow is still skipped — under GNOME Shell with
|
||||
// the AppIndicator extension it pops the window alongside the menu,
|
||||
// which is not the UX we want.
|
||||
refresh := func() { t.refreshSessionExpiresLabel() }
|
||||
refreshAndOpen := func() {
|
||||
t.refreshSessionExpiresLabel()
|
||||
t.tray.OpenMenu()
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
t.tray.OnClick(refreshAndOpen)
|
||||
t.tray.OnRightClick(refreshAndOpen)
|
||||
case "windows":
|
||||
t.tray.OnClick(refresh)
|
||||
t.tray.OnRightClick(refreshAndOpen)
|
||||
default:
|
||||
t.tray.OnClick(refresh)
|
||||
t.tray.OnRightClick(refresh)
|
||||
}
|
||||
|
||||
app.Event.On(services.EventStatus, t.onStatusEvent)
|
||||
app.Event.On(services.EventSystem, t.onSystemEvent)
|
||||
@@ -266,21 +282,12 @@ func (t *Tray) reapplyMenuState() {
|
||||
t.downItem.SetHidden(!connected && !connecting)
|
||||
t.downItem.SetEnabled(connected || connecting)
|
||||
}
|
||||
if t.connectSeparator != nil {
|
||||
// Hide the separator above Connect/Disconnect when both rows are
|
||||
// hidden (happens when the daemon is unavailable) — otherwise the
|
||||
// menu shows two adjacent separators with nothing between them.
|
||||
t.connectSeparator.SetHidden(daemonUnavailable)
|
||||
}
|
||||
if t.exitNodeItem != nil {
|
||||
t.exitNodeItem.SetEnabled(connected)
|
||||
}
|
||||
if t.settingsItem != nil {
|
||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
if t.debugItem != nil {
|
||||
t.debugItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
if t.profileSubmenuItem != nil {
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
@@ -348,8 +355,13 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
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 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)
|
||||
}
|
||||
menu.AddSeparator()
|
||||
// Profiles submenu is populated asynchronously once the application
|
||||
// has started — Menu.Update() is a no-op before app.running is true,
|
||||
@@ -375,20 +387,6 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
// T-FinalWarningLead auto-prompt.
|
||||
t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() })
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
// AddSeparator returns no reference, so capture the separator by walking
|
||||
// ItemAt until it returns nil and grabbing the last index.
|
||||
menu.AddSeparator()
|
||||
t.connectSeparator = lastMenuItem(menu)
|
||||
// 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()
|
||||
|
||||
t.exitNodeItem = menu.Add(t.loc.T("tray.menu.exitNode")).SetEnabled(false)
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
@@ -397,19 +395,11 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
// all live in the in-window Settings page now. The tray menu only
|
||||
// surfaces the day-to-day actions.
|
||||
t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") })
|
||||
t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") })
|
||||
|
||||
menu.AddSeparator()
|
||||
t.exitNodeItem = menu.Add(t.loc.T("tray.menu.exitNode")).SetEnabled(false)
|
||||
|
||||
aboutLabel := t.loc.T("tray.menu.about")
|
||||
about := menu.AddSubmenu(aboutLabel)
|
||||
// iconMenuNetbird is the brand mark painted in the leading-image slot
|
||||
// of the About row. The icons_menu_<os>.go files own the per-platform
|
||||
// asset choice — guard with len() so a platform that opts out (sets
|
||||
// it to an empty []byte) still renders the row text-only.
|
||||
if aboutItem := menu.FindByLabel(aboutLabel); aboutItem != nil && len(iconMenuNetbird) > 0 {
|
||||
aboutItem.SetBitmap(iconMenuNetbird)
|
||||
}
|
||||
about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) {
|
||||
_ = t.app.Browser.OpenURL(urlGitHubRepo)
|
||||
})
|
||||
@@ -429,6 +419,12 @@ 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
|
||||
@@ -680,24 +676,15 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
t.downItem.SetHidden(!connected && !connecting)
|
||||
t.downItem.SetEnabled(connected || connecting)
|
||||
}
|
||||
if t.connectSeparator != nil {
|
||||
// Hide the separator above Connect/Disconnect when both rows
|
||||
// are hidden (daemon unavailable) — otherwise the menu shows
|
||||
// two adjacent separators with nothing between them.
|
||||
t.connectSeparator.SetHidden(daemonUnavailable)
|
||||
}
|
||||
// Exit Node surfaces tunnel-routed state, so only expose it while
|
||||
// the tunnel is up. Settings/Debug-Bundle just need the daemon
|
||||
// socket reachable.
|
||||
// the tunnel is up. Settings just needs the daemon socket
|
||||
// reachable.
|
||||
if t.exitNodeItem != nil {
|
||||
t.exitNodeItem.SetEnabled(connected)
|
||||
}
|
||||
if t.settingsItem != nil {
|
||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
if t.debugItem != nil {
|
||||
t.debugItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
if t.profileSubmenuItem != nil {
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
@@ -1059,6 +1046,24 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
|
||||
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.
|
||||
func (t *Tray) refreshSessionExpiresLabel() {
|
||||
if t.sessionExpiresItem == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
deadline := t.sessionExpiresAt
|
||||
t.mu.Unlock()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1314,19 +1319,3 @@ func titleCase(s string) string {
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
|
||||
// lastMenuItem returns the menu's most recently appended *MenuItem. The Wails
|
||||
// AddSeparator helper doesn't return one, so to keep a handle on a separator
|
||||
// (e.g. to toggle SetHidden later) we call AddSeparator and then ask for the
|
||||
// item at the tail. Walks ItemAt(i) until it returns nil; cheap because menus
|
||||
// are short.
|
||||
func lastMenuItem(m *application.Menu) *application.MenuItem {
|
||||
var last *application.MenuItem
|
||||
for i := 0; ; i++ {
|
||||
item := m.ItemAt(i)
|
||||
if item == nil {
|
||||
return last
|
||||
}
|
||||
last = item
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user