From 04b43303933088cbc162c93b3a64dbef38288501 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 12 May 2026 20:01:35 +0200 Subject: [PATCH 1/9] [client/ui] Add coloured status dot to tray menu Show a small dot next to the first menu entry that reflects the daemon state: green for Connected, yellow for Connecting, blue for NeedsLogin/SessionExpired, red for LoginFailed/Error, grey for Idle/Disconnected and dark grey for DaemonUnavailable. PNGs are 24x24 with a pHYs chunk declaring 144 DPI so NSImage renders them at 12 pt while keeping retina-sharp pixel data; circles are supersampled 8x for smooth edges. Idle now surfaces as "Disconnected" in the menu label, daemon-status literals moved to status* constants, and Exit Node / Resources are gated on the Connected state instead of just daemon availability. --- .../ui/assets/netbird-menu-dot-connected.png | Bin 0 -> 452 bytes .../ui/assets/netbird-menu-dot-connecting.png | Bin 0 -> 452 bytes client/ui/assets/netbird-menu-dot-error.png | Bin 0 -> 433 bytes client/ui/assets/netbird-menu-dot-idle.png | Bin 0 -> 483 bytes client/ui/assets/netbird-menu-dot-login.png | Bin 0 -> 475 bytes client/ui/assets/netbird-menu-dot-offline.png | Bin 0 -> 456 bytes client/ui/icons.go | 22 +++++ client/ui/tray.go | 93 +++++++++++++----- 8 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 client/ui/assets/netbird-menu-dot-connected.png create mode 100644 client/ui/assets/netbird-menu-dot-connecting.png create mode 100644 client/ui/assets/netbird-menu-dot-error.png create mode 100644 client/ui/assets/netbird-menu-dot-idle.png create mode 100644 client/ui/assets/netbird-menu-dot-login.png create mode 100644 client/ui/assets/netbird-menu-dot-offline.png diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8ce4d857b68466807de884b622b0d22ec2c6b2 GIT binary patch literal 452 zcmV;#0XzPQP)m1pa;b#dq-Q`>4`S0@rt4 zyL%0%4?r$&e0F;~!{2{I#SI2vX8KbvrRs_76L=~Dg*hl#$Tl1V{{CYDnE^5j!vau* z5*z0D0LToGSuj34h>Y)6Jf?;P4A4kFtK1>W0P_JTom0zj2B3+ literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connecting.png b/client/ui/assets/netbird-menu-dot-connecting.png new file mode 100644 index 0000000000000000000000000000000000000000..3f8bc29d816138baefd75c584999ff4d01660e89 GIT binary patch literal 452 zcmV;#0XzPQP);FD$dAa-wUHBb-Y?4s9INRpm zzi&5R5Yo$tOF}f%p68DJ{Q^19Pw9`I|Chn0MoyCP$)oLjGgS^MPI$fg;1{AT=zeJ7 z((>oVd|U=&v#5qeBe7Ke#2$hc+zZtW5ifeQ{Lfzo7GeyC0TyP4KQH^_B9&HOUW3yD zkjt~4UEfZs;mDRulhX7=_6a-{fx;XVEaVyv0wBF0GeBlxSOAJpV#6FC0GRh*@0jS6Y*-WY7ATvN_ zVORhv&p|~lsK6rEaFAY*8OU`5&5LV{d;n4aDso8)E|6M~9(2R8`Tz!KT4kaGP{9Q% uTVUBAl!-v;{P*jP7l_eMQve760RR7Xh`~Y9fk(0c0000S@Z{5Ey z+66*-8F5LxTD6LY`O%{Sg#!oDAN}|tgH4Sb2gj2K4h}Q_%E?U-3k&-~v<1!o|GRYg z`p(B?FgAWn|n#(1IK1&xc5tl`a4C|36W|2LmjOjDMbXbwz3z8m_@<0m$VA zZf@I2H5}QJ3A=WABKrhx0VvFs7cII@Y_Q-0py0Y27pDn}6L>NOMJT0)g8;}3kXbN3 z+yYQ)q113}W`QjLrE_Yy9tJ>Wfy{(k0LnzvG8_hAW`Zq%n1JdYyog4?yKP$Q7gn7f3Bg4|-j|Fkn?CI-sP|r#S!w b00960)gi!0=-XYW00000NkvXXu0mjfUTLV+ literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..79e7bbbf8cf94aaea503fc2ce2162a6b774ddbcf GIT binary patch literal 483 zcmV<90UZ8`P)QaLM8!3|&OXkUd_z^KlPc|HRcIM#h z=nI7OGUAf>KXodP^X@AJx#=ogKDhlsG~~#(xy%3l{YzBv z!ClJC{Kv*CIZ{>N^%|T$0J)s!PyBjP4F`e$|Nk>Rxf>AlN67LxvQOYX0EPJmC4=j@ z!kbtCikE02Va23ed5 z-R0yM4gw%OAiW?nu=)TL<`H|RT_-kJZ~;(ot#HoLgvANm2cQV0)Nl|0nE^5j#)n$~ zN-dNcj?FBv1)y|J4cEf}$SjbVa0_CmhnY~za2SA@3AW(g_A;uu9tL1$(!4AJTcG6G z^@Q36!^{L*usCzo8ERVqGZSpVG4F>LKpBKuhJ(xknaO}02cS}sS{5Li1-AfHI zATvN_!T2bpDX5@|n;o;AoWhO)TB0sWUE+ycY@;MokOZj61t}!QaFAZ~I)UaD4%UnY zQV1$??YdZN@x=>B4x|R87MtNXeSi)?1sAAnfn|SCCIY2%SW+BN00;m8|Np)Uz<-L6 R{<#1E002ovPDHLkV1k+L#jgMW literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png new file mode 100644 index 0000000000000000000000000000000000000000..7aec5d01da5b9697291400ae9b341a6f3292102e GIT binary patch literal 456 zcmV;(0XP1MP)$j9x}u60ho4 z@qEgOE0{C2BmMKI4>H))@bU>fNy)C5$v0)u1hK}jFGO1~Y3)OoXP4K{$7L`!i==b| z6Z3-Q_YkzeExIj4UGmTJfB*im5MwwDFflRxx%r$Wa@W$TH7MffE+@xu5CG``=>?gA z)d!$3?_+#+o!DT(1wg@7^j}I77AJ5YfFhJq!$AOK2FNTJpBcvB;_g}c>*o(XN-Y3^ ztCxJ#jvUz0&j9lQC{0tt^)LW33zkp77F<6v*MwSz!vM@oumwjB?4X+KVE|?(eaa#m z2ahMzHW+3m*aA(P Date: Tue, 12 May 2026 20:11:08 +0200 Subject: [PATCH 2/9] [client/ui] Add Profiles submenu to the tray Mirror the main branch's profile list: a Profiles submenu populated from the daemon's ListProfiles RPC, with the active profile shown as a checked entry and a click on any other entry switching to it via SwitchProfile. The initial fill is deferred to the Common.ApplicationStarted hook because Menu.Update() short-circuits while app.running is false and the Wails3 macOS impl would nil-deref on early-startup InvokeSync. --- client/ui/tray.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/client/ui/tray.go b/client/ui/tray.go index 099f33f23..3b0c8c0db 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -13,6 +13,7 @@ import ( 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" "github.com/netbirdio/netbird/client/ui/services" @@ -33,6 +34,7 @@ const ( menuDisconnect = "Disconnect" menuExitNode = "Exit Node" menuNetworks = "Resources" + menuProfiles = "Profiles" menuQuit = "Quit" // Settings + diagnostics. The settings page replaces the Fyne tray's @@ -125,6 +127,7 @@ type Tray struct { downItem *application.MenuItem exitNodeItem *application.MenuItem networksItem *application.MenuItem + profileSubmenu *application.Menu settingsItem *application.MenuItem debugItem *application.MenuItem updateItem *application.MenuItem @@ -168,6 +171,13 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe app.Event.On(services.EventSystem, t.onSystemEvent) app.Event.On(services.EventUpdateAvailable, t.onUpdateAvailable) app.Event.On(services.EventUpdateProgress, t.onUpdateProgress) + // 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.loadConfig() return t @@ -204,6 +214,11 @@ func (t *Tray) buildMenu() *application.Menu { // menu entry on every platform. menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() }) 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. + t.profileSubmenu = menu.AddSubmenu(menuProfiles) + 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. @@ -675,6 +690,68 @@ func (t *Tray) loadConfig() { 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 once on ApplicationStarted and again after a successful switch +// so the checkmark moves to the new active profile. +func (t *Tray) loadProfiles() { + if t.profileSubmenu == nil { + return + } + 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() + for _, p := range profiles { + name := p.Name + active := p.IsActive + item := t.profileSubmenu.AddCheckbox(name, active) + item.OnClick(func(*application.Context) { + if active { + return + } + t.switchProfile(name) + }) + } + t.profileSubmenu.Update() +} + +// switchProfile runs the daemon RPC in a goroutine so the menu click +// returns immediately, then reloads the submenu to move the checkmark. +func (t *Tray) switchProfile(name string) { + go func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + username, err := t.svc.Profiles.Username() + if err != nil { + log.Errorf("get current user: %v", err) + return + } + if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{ + ProfileName: name, + Username: username, + }); err != nil { + log.Errorf("switch profile to %s: %v", name, err) + t.notifyError(fmt.Sprintf("Failed to switch to %s", name)) + return + } + t.loadProfiles() + }() +} + // 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) { From e3efaa5e596a89df93a40f5cfaa5184e59bb910f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 12 May 2026 20:38:30 +0200 Subject: [PATCH 3/9] [client] Fix tray flicker and stuck Connecting during management retry The status snapshot tore down on every management retry because state.Status() blanks the status when an error is wrapped, and the SubscribeStatus stream propagated that as FailedPrecondition. The UI treated any stream error as "daemon not running" and flickered the tray to Not running between retries. Disconnect was also unresponsive: Down set Idle before the retry goroutine exited, which then overwrote it with Set(Connecting) on the next attempt; the backoff sleep (up to 15s) wasn't context-aware, so the goroutine kept running long after actCancel. - buildStatusResponse falls back to the underlying status (via new state.CurrentStatus) instead of breaking the stream on wrapped errors. - UI only flips to DaemonUnavailable on codes.Unavailable / non-status errors, so a live daemon returning FailedPrecondition is not reported as down. - connect retry uses backoff.WithContext so actCancel interrupts the inter-attempt sleep, and skips Wrap(err) when the dial fails due to ctx cancellation. - Down sets Idle after waiting for giveUpChan, so the retry goroutine can no longer race the disconnect. - Tray hides Connect during Connecting and keeps Disconnect enabled so the user can abort an in-flight connection attempt. --- client/internal/connect.go | 15 ++++++++++++++- client/internal/state.go | 11 +++++++++++ client/server/server.go | 20 +++++++++++++++----- client/ui/services/peers.go | 27 +++++++++++++++++++++++++-- client/ui/tray.go | 16 ++++++++++++---- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/client/internal/connect.go b/client/internal/connect.go index 8c0e9b1ba..a5d12d896 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -258,6 +258,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled) if err != nil { + // On daemon shutdown / Down() the parent context is cancelled + // and the dial fails with "context canceled". Wrapping that + // into state would leave the snapshot stuck at Connecting+err + // until the backoff loop wakes up — instead let the operation + // return cleanly so the deferred state.Set(StatusIdle) takes + // effect on the next iteration. + if c.ctx.Err() != nil { + return nil + } return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err)) } mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder) @@ -426,7 +435,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } c.statusRecorder.ClientStart() - err = backoff.Retry(operation, backOff) + // Wrap the backoff with c.ctx so Down()/actCancel propagates into the + // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry + // loop alive long after the caller asked to give up, leaving the + // status stream stuck at Connecting. + err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { log.Debugf("exiting client retry loop due to unrecoverable error: %s", err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { diff --git a/client/internal/state.go b/client/internal/state.go index 041cb73f8..20db33ecc 100644 --- a/client/internal/state.go +++ b/client/internal/state.go @@ -57,6 +57,17 @@ func (c *contextState) Status() (StatusType, error) { return c.status, nil } +// CurrentStatus returns the last status set via Set, ignoring any wrapped +// error. Use when the status is needed for reporting purposes (e.g. the +// status snapshot stream) and a transient wrapped error from a retry loop +// shouldn't blank out the underlying status. +func (c *contextState) CurrentStatus() StatusType { + c.mutex.Lock() + defer c.mutex.Unlock() + + return c.status +} + func (c *contextState) Wrap(err error) error { c.mutex.Lock() defer c.mutex.Unlock() diff --git a/client/server/server.go b/client/server/server.go index 532fbfaca..1daec9973 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -846,9 +846,6 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes return nil, err } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusIdle) - s.mutex.Unlock() // Wait for the connectWithRetryRuns goroutine to finish with a short timeout. @@ -863,6 +860,12 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes } } + // Set Idle only after the retry goroutine has exited (or timed out). + // Setting it earlier races with the goroutine's own Set(StatusConnecting) + // at the top of each retry attempt, which would leave the snapshot + // stuck at Connecting long after the user asked to disconnect. + internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle) + return &proto.DownResponse{}, nil } @@ -1123,9 +1126,16 @@ func (s *Server) Status( // state. Shared between the unary Status RPC and the SubscribeStatus // stream so both paths return identical snapshots. func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusResponse, error) { - status, err := internal.CtxGetState(s.rootCtx).Status() + state := internal.CtxGetState(s.rootCtx) + status, err := state.Status() if err != nil { - return nil, err + // state.Status() blanks the status when err is set (e.g. management + // retry loop wrapped a connection error). The underlying status is + // still meaningful and the failure is already surfaced via + // FullStatus.ManagementState.Error, so don't propagate err — that + // would tear down the SubscribeStatus stream and cause the UI to + // mark the daemon as unreachable on every retry. + status = state.CurrentStatus() } if status == internal.StatusNeedsLogin && s.isSessionActive.Load() { diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index 2fa2a1b12..b658cf9aa 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -11,6 +11,8 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/proto" ) @@ -185,6 +187,23 @@ func (s *Peers) Get(ctx context.Context) (Status, error) { return statusFromProto(resp), nil } +// isDaemonUnreachable reports whether a gRPC stream error indicates the +// daemon socket itself is not answering (process down, socket missing, +// permission denied) versus the daemon responding with an application-level +// error code. Only the former should flip the tray to "Not running" — a +// daemon that returns FailedPrecondition (e.g. while it's retrying the +// management connection) is alive and shouldn't be reported as down. +func isDaemonUnreachable(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return true + } + return st.Code() == codes.Unavailable +} + // 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 @@ -225,7 +244,9 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { } stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { - emitUnavailable() + if isDaemonUnreachable(err) { + emitUnavailable() + } return fmt.Errorf("subscribe status: %w", err) } for { @@ -234,7 +255,9 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { if ctx.Err() != nil { return ctx.Err() } - emitUnavailable() + if isDaemonUnreachable(err) { + emitUnavailable() + } return fmt.Errorf("status stream recv: %w", err) } unavailable = false diff --git a/client/ui/tray.go b/client/ui/tray.go index 3b0c8c0db..916a92278 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -479,6 +479,7 @@ func (t *Tray) applyStatus(st services.Status) { strings.EqualFold(st.Status, statusSessionExpired) || strings.EqualFold(st.Status, statusLoginFailed) daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) + connecting := strings.EqualFold(st.Status, statusConnecting) if t.statusItem != nil { // When the daemon needs re-authentication the status row turns // into the actionable Login entry — Connect would only fail. @@ -496,12 +497,19 @@ func (t *Tray) applyStatus(st services.Status) { t.applyStatusIndicator(st.Status) } if t.upItem != nil { - t.upItem.SetHidden(connected || needsLogin || daemonUnavailable) - t.upItem.SetEnabled(!connected && !needsLogin && !daemonUnavailable) + // Hide Connect whenever an Up action would be a no-op or would + // only fail: tunnel already up, daemon mid-connect (Disconnect + // takes over the slot so the user can abort), login required, + // or daemon socket unreachable. + t.upItem.SetHidden(connected || connecting || needsLogin || daemonUnavailable) + t.upItem.SetEnabled(!connected && !connecting && !needsLogin && !daemonUnavailable) } if t.downItem != nil { - t.downItem.SetHidden(!connected) - t.downItem.SetEnabled(connected) + // 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 and Resources surface tunnel-routed state, so only // expose them while the tunnel is up. Settings/Debug-Bundle just From 4988f2aa68ed28a4f36ed69e7e615746fb697693 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 12 May 2026 21:24:52 +0200 Subject: [PATCH 4/9] [client/ui] Refresh Profiles submenu by rebuilding the tray menu Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on darwin instead of mutating the one attached to the parent menu item at initial setup, so the visible Profiles entries stayed frozen on the empty snapshot captured when the tray was registered: clicks reached the new Go MenuItem objects (and the daemon SwitchProfile RPC ran), but the checkmark never moved and reopening the menu still showed the old selection. Cache the top-level menu and call tray.SetMenu(t.menu) after each loadProfiles refresh; macosSystemTray.setMenu clears and rebuilds the entire NSMenu tree against the cached pointer, which propagates submenu content changes to the visible menu. Also adds INFO logs around profile click / SwitchProfile RPC / list refresh so the active-profile flow is observable end-to-end. --- client/ui/tray.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/client/ui/tray.go b/client/ui/tray.go index 916a92278..d5d3a515c 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -122,6 +122,7 @@ type Tray struct { window *application.WebviewWindow svc TrayServices + menu *application.Menu statusItem *application.MenuItem upItem *application.MenuItem downItem *application.MenuItem @@ -156,7 +157,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe t.tray = app.SystemTray.New() t.applyIcon() t.tray.SetTooltip(trayTooltip) - t.tray.SetMenu(t.buildMenu()) + 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 @@ -721,19 +723,32 @@ func (t *Tray) loadProfiles() { } sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) + log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username) t.profileSubmenu.Clear() for _, p := range profiles { name := p.Name active := p.IsActive + log.Infof("tray loadProfiles: profile=%q active=%v", name, active) item := t.profileSubmenu.AddCheckbox(name, active) item.OnClick(func(*application.Context) { + log.Infof("tray profile click: profile=%q wasActive=%v", name, active) if active { return } t.switchProfile(name) }) } - t.profileSubmenu.Update() + // 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 runs the daemon RPC in a goroutine so the menu click @@ -748,14 +763,16 @@ func (t *Tray) switchProfile(name string) { log.Errorf("get current user: %v", err) return } + log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q", name, username) if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{ ProfileName: name, Username: username, }); err != nil { - log.Errorf("switch profile to %s: %v", name, err) + log.Errorf("tray switchProfile: SwitchProfile RPC failed profile=%q err=%v", name, err) t.notifyError(fmt.Sprintf("Failed to switch to %s", name)) return } + log.Infof("tray switchProfile: SwitchProfile RPC succeeded profile=%q", name) t.loadProfiles() }() } From af40ee52f81547bb0adf249c5b7feb457ec8f5e5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 12 May 2026 21:40:29 +0200 Subject: [PATCH 5/9] [client/ui] Auto-reconnect tray profile switch when daemon was active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a profile from the tray submenu only ran SwitchProfile on the daemon, so the in-flight retry loop kept dialing the previous profile's management server. The fix is to follow up Switch with Down+Up, but only when the daemon was actively trying to be online — Connected or Connecting. Idle / NeedsLogin / LoginFailed / SessionExpired stay as deliberate waiting points so a profile pick doesn't surprise the user with an SSO redirect or flip an intentionally offline daemon online. The decision table lives in the switchProfile godoc. --- client/ui/tray.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/client/ui/tray.go b/client/ui/tray.go index d5d3a515c..539a945b7 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -753,7 +753,33 @@ func (t *Tray) loadProfiles() { // switchProfile runs the daemon RPC in a goroutine so the menu click // returns immediately, then reloads the submenu to move the checkmark. +// +// Reconnect policy by previous daemon status: +// +// ┌─────────────────┬──────────────────────┬───────────────────────────────────┐ +// │ Previous status │ Tray action │ Rationale │ +// ├─────────────────┼──────────────────────┼───────────────────────────────────┤ +// │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │ +// │ Connecting │ Switch + Down + Up │ Stop the retry loop still dialing │ +// │ │ │ the old management server, then │ +// │ │ │ restart with new config. │ +// │ Idle │ Switch only │ User chose to be offline; don't │ +// │ │ │ silently flip the daemon online. │ +// │ NeedsLogin │ Switch only │ Login needs interactive SSO; let │ +// │ LoginFailed │ Switch only │ the user trigger the next step. │ +// │ SessionExpired │ Switch only │ │ +// └─────────────────┴──────────────────────┴───────────────────────────────────┘ +// +// Rule of thumb: auto-reconnect only when the daemon was actively trying +// to be online (Connected or Connecting). Any other state is a deliberate +// waiting point — keep the user in control of the next action. func (t *Tray) switchProfile(name string) { + t.mu.Lock() + prevStatus := t.lastStatus + t.mu.Unlock() + wasActive := strings.EqualFold(prevStatus, statusConnected) || + strings.EqualFold(prevStatus, statusConnecting) + go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -763,7 +789,8 @@ func (t *Tray) switchProfile(name string) { log.Errorf("get current user: %v", err) return } - log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q", name, username) + log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q prevStatus=%q wasActive=%v", + name, username, prevStatus, wasActive) if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{ ProfileName: name, Username: username, @@ -773,6 +800,24 @@ func (t *Tray) switchProfile(name string) { return } log.Infof("tray switchProfile: SwitchProfile RPC succeeded profile=%q", name) + + if wasActive { + // Stop the in-flight (or established) connection that's still + // pointing at the previous profile's management server, then + // bring it back up against the new profile. + log.Infof("tray switchProfile: was active (%s), reconnecting with new profile %q", prevStatus, name) + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("tray switchProfile: Down failed: %v", err) + } + if err := t.svc.Connection.Up(ctx, services.UpParams{ + ProfileName: name, + Username: username, + }); err != nil { + log.Errorf("tray switchProfile: Up failed: %v", err) + t.notifyError(fmt.Sprintf("Failed to reconnect with %s", name)) + } + } + t.loadProfiles() }() } From e1bf362675b05a77405264680fc00ce7e4ddc48c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 12 May 2026 21:46:05 +0200 Subject: [PATCH 6/9] [client/ui] Refresh tray menu after status-indicator bitmap change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails v3 alpha's setMenuItemBitmap on darwin calls NSMenuItem.setImage from whichever thread invokes SetBitmap — unlike the sibling setters for label/disabled/hidden/checked, which dispatch_sync onto the main queue. The off-thread AppKit call doesn't redraw, so the coloured status dot stayed stale until the user closed and reopened the menu. Force a tray.SetMenu rebuild after updating the bitmap; the rebuild runs processMenu inside InvokeSync, which applies the bitmap to a fresh NSMenuItem on the main thread and macOS picks it up immediately. --- client/ui/tray.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/client/ui/tray.go b/client/ui/tray.go index 539a945b7..d68d00d03 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -571,11 +571,22 @@ func (t *Tray) rebuildExitNodes(nodes []string) { // 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 { From 1f4ed5c8ef3e45aa7081f06ccc980f8851db8d85 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 13 May 2026 01:39:12 +0200 Subject: [PATCH 7/9] [ci] Install Wails GTK deps on Linux lint/test runners Add libwebkit2gtk-4.1-dev and libsoup-3.0-dev to apt installs so the Wails v3 client/ui package compiles on Linux CI runners. --- .github/workflows/golang-test-linux.yml | 4 ++-- .github/workflows/golangci-lint.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 1183768fa..0ba7a07e2 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -51,7 +51,7 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: steps.cache.outputs.cache-hit != 'true' @@ -141,7 +141,7 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: matrix.arch == '386' diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index d62871168..4fcaf8658 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -50,7 +50,7 @@ jobs: cache: false - name: Install dependencies if: matrix.os == 'ubuntu-latest' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev - name: Stub Wails frontend bundle # client/ui/main.go has //go:embed all:frontend/dist. The # directory is produced by `pnpm run build` and is gitignored, so From 77fdf23a507c9465ead569631a601454932f9f5c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 13 May 2026 01:40:16 +0200 Subject: [PATCH 8/9] [ci] Drop Mesa3D opengl32.dll bundling from Windows installer Wails3 renders via WebView2 on Windows, so the software-OpenGL fallback needed by the previous Fyne UI is no longer required. --- .github/workflows/release.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62ce0ce81..96739fd96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -546,24 +546,6 @@ jobs: - name: Move wintun.dll into dist run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download Mesa3D (amd64 only) - uses: carlosperate/download-file-action@v2 - id: download-mesa3d - if: matrix.arch == 'amd64' - with: - file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z - file-name: mesa3d.7z - location: ${{ env.downloadPath }} - sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9' - - - name: Extract Mesa3D driver (amd64 only) - if: matrix.arch == 'amd64' - run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z" - - - name: Move opengl32.dll into dist (amd64 only) - if: matrix.arch == 'amd64' - run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download EnVar plugin for NSIS uses: carlosperate/download-file-action@v2 with: From 74ea03da9bdd7485ef1a913c1e6a306b4ef871b4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 13 May 2026 02:28:43 +0200 Subject: [PATCH 9/9] [ci] Fix Windows installer icon/banner paths missed in ui-wails rename The ui-wails -> ui rename deleted the fyne installer assets but left the NSIS and WiX scripts pointing at client/ui/assets/netbird.ico, which broke the Windows Installer CI job. Point both scripts at the Wails-side icon (client/ui/build/windows/icon.ico) and restore banner.bmp into the new build directory so the NSIS welcome/finish sidebar keeps rendering. --- client/installer.nsis | 2 +- client/netbird.wxs | 2 +- client/ui/build/banner.bmp | Bin 0 -> 26494 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 client/ui/build/banner.bmp diff --git a/client/installer.nsis b/client/installer.nsis index de3144960..b64378d08 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -6,7 +6,7 @@ !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" -!define ICON "ui\\assets\\netbird.ico" +!define ICON "ui\\build\\windows\\icon.ico" !define BANNER "ui\\build\\banner.bmp" !define LICENSE_DATA "..\\LICENSE" diff --git a/client/netbird.wxs b/client/netbird.wxs index 4ca96cab8..79eba575a 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -123,7 +123,7 @@ - + diff --git a/client/ui/build/banner.bmp b/client/ui/build/banner.bmp new file mode 100644 index 0000000000000000000000000000000000000000..5524eef945119e421d076d8fa9f8ded1205c1021 GIT binary patch literal 26494 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 Dp19ST literal 0 HcmV?d00001