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
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:
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/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/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/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/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png
new file mode 100644
index 000000000..fc8ce4d85
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected.png differ
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 000000000..3f8bc29d8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error.png b/client/ui/assets/netbird-menu-dot-error.png
new file mode 100644
index 000000000..ce5d0e8ef
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error.png differ
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 000000000..79e7bbbf8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle.png differ
diff --git a/client/ui/assets/netbird-menu-dot-login.png b/client/ui/assets/netbird-menu-dot-login.png
new file mode 100644
index 000000000..9ddc8f0ae
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-login.png differ
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 000000000..7aec5d01d
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline.png differ
diff --git a/client/ui/build/banner.bmp b/client/ui/build/banner.bmp
new file mode 100644
index 000000000..5524eef94
Binary files /dev/null and b/client/ui/build/banner.bmp differ
diff --git a/client/ui/icons.go b/client/ui/icons.go
index 3fe3ea9ef..3e8215b73 100644
--- a/client/ui/icons.go
+++ b/client/ui/icons.go
@@ -58,3 +58,25 @@ var iconUpdateDisconnectedMacOS []byte
//go:embed assets/netbird.png
var iconWindow []byte
+
+// Small colored dots shown next to the status menu entry. Rendered as
+// regular NSImage/HBITMAP/GTK menu-item icons (not template), so the
+// colours stay intact on every platform.
+
+//go:embed assets/netbird-menu-dot-connected.png
+var iconMenuDotConnected []byte
+
+//go:embed assets/netbird-menu-dot-connecting.png
+var iconMenuDotConnecting []byte
+
+//go:embed assets/netbird-menu-dot-login.png
+var iconMenuDotLogin []byte
+
+//go:embed assets/netbird-menu-dot-error.png
+var iconMenuDotError []byte
+
+//go:embed assets/netbird-menu-dot-idle.png
+var iconMenuDotIdle []byte
+
+//go:embed assets/netbird-menu-dot-offline.png
+var iconMenuDotOffline []byte
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 7fb8b65c1..d68d00d03 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"
@@ -26,14 +27,15 @@ const (
trayTooltip = "NetBird"
// Top-level menu entries.
- menuStatusDisconnected = "Disconnected"
+ menuStatusDisconnected = "Disconnected"
menuStatusDaemonUnavailable = "Not running"
- menuOpenNetBird = "Open NetBird"
- menuConnect = "Connect"
- menuDisconnect = "Disconnect"
- menuExitNode = "Exit Node"
- menuNetworks = "Networks"
- menuQuit = "Quit"
+ menuOpenNetBird = "Open NetBird"
+ menuConnect = "Connect"
+ menuDisconnect = "Disconnect"
+ menuExitNode = "Exit Node"
+ menuNetworks = "Resources"
+ menuProfiles = "Profiles"
+ menuQuit = "Quit"
// Settings + diagnostics. The settings page replaces the Fyne tray's
// Settings submenu (per-toggle checkboxes for SSH, auto-connect,
@@ -68,11 +70,17 @@ const (
notifySessionExpiredBody = "Your NetBird session has expired. Please log in again."
// Notification IDs (used to coalesce duplicate toasts).
- notifyIDUpdatePrefix = "netbird-update-"
- notifyIDEvent = "netbird-event-"
- notifyIDTrayError = "netbird-tray-error"
- notifyIDSessionExpired = "netbird-session-expired"
+ notifyIDUpdatePrefix = "netbird-update-"
+ notifyIDEvent = "netbird-event-"
+ notifyIDTrayError = "netbird-tray-error"
+ notifyIDSessionExpired = "netbird-session-expired"
+ // Daemon status strings mirroring internal.Status* — kept in sync
+ // with client/internal/state.go.
+ statusConnected = "Connected"
+ statusConnecting = "Connecting"
+ statusIdle = "Idle"
+ statusError = "Error"
// Daemon status string for an SSO session that has expired and needs
// re-authentication. Mirrors internal.StatusSessionExpired.
statusSessionExpired = "SessionExpired"
@@ -114,11 +122,13 @@ type Tray struct {
window *application.WebviewWindow
svc TrayServices
+ menu *application.Menu
statusItem *application.MenuItem
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
networksItem *application.MenuItem
+ profileSubmenu *application.Menu
settingsItem *application.MenuItem
debugItem *application.MenuItem
updateItem *application.MenuItem
@@ -147,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
@@ -162,6 +173,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
@@ -189,7 +207,8 @@ func (t *Tray) buildMenu() *application.Menu {
// up unconditionally rather than swapping items at runtime.
t.statusItem = menu.Add(menuStatusDisconnected).
OnClick(func(*application.Context) { t.openRoute("/login") }).
- SetEnabled(false)
+ SetEnabled(false).
+ SetBitmap(iconMenuDotIdle)
menu.AddSeparator()
// The tray icon's left-click handler is intentionally unbound (see
@@ -197,6 +216,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.
@@ -430,7 +454,7 @@ func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
// otherwise spam Shell_NotifyIcon and the log.
func (t *Tray) applyStatus(st services.Status) {
t.mu.Lock()
- connected := strings.EqualFold(st.Status, "Connected")
+ connected := strings.EqualFold(st.Status, 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
@@ -457,32 +481,46 @@ 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.
// When the daemon socket is unreachable, swap the label to make
// the cause obvious; Connect/Disconnect would just fail.
label := st.Status
- if daemonUnavailable {
+ switch {
+ case daemonUnavailable:
label = menuStatusDaemonUnavailable
+ case strings.EqualFold(st.Status, statusIdle):
+ label = menuStatusDisconnected
}
t.statusItem.SetLabel(label)
t.statusItem.SetEnabled(needsLogin)
+ 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
+ // need the daemon socket reachable.
+ if t.exitNodeItem != nil {
+ t.exitNodeItem.SetEnabled(connected)
}
- // Settings, Networks and Debug Bundle all drive daemon RPCs from
- // their respective frontend routes — disable them while the daemon
- // socket is unreachable so the user doesn't land on a page that
- // would only fail to load.
if t.networksItem != nil {
- t.networksItem.SetEnabled(!daemonUnavailable)
+ t.networksItem.SetEnabled(connected)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
@@ -519,18 +557,55 @@ func (t *Tray) handleSessionExpired() {
}
func (t *Tray) rebuildExitNodes(nodes []string) {
- if t.exitNodeItem == nil {
- return
- }
- if len(nodes) == 0 {
- t.exitNodeItem.SetEnabled(false)
+ if t.exitNodeItem == nil || len(nodes) == 0 {
return
}
sub := application.NewMenu()
for _, fqdn := range nodes {
sub.AddCheckbox(fqdn, false)
}
- t.exitNodeItem.SetEnabled(true)
+}
+
+// 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, statusConnected):
+ return iconMenuDotConnected
+ case strings.EqualFold(status, statusConnecting):
+ return iconMenuDotConnecting
+ case strings.EqualFold(status, statusNeedsLogin),
+ strings.EqualFold(status, statusSessionExpired):
+ return iconMenuDotLogin
+ case strings.EqualFold(status, statusLoginFailed),
+ strings.EqualFold(status, statusError):
+ return iconMenuDotError
+ case strings.EqualFold(status, services.StatusDaemonUnavailable):
+ return iconMenuDotOffline
+ default:
+ return iconMenuDotIdle
+ }
}
func (t *Tray) applyIcon() {
@@ -561,8 +636,8 @@ func (t *Tray) iconForState() (icon, dark []byte) {
statusLabel := t.lastStatus
t.mu.Unlock()
- connecting := strings.EqualFold(statusLabel, "Connecting")
- errored := strings.EqualFold(statusLabel, "Error") ||
+ connecting := strings.EqualFold(statusLabel, statusConnecting)
+ errored := strings.EqualFold(statusLabel, statusError) ||
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) ||
strings.EqualFold(statusLabel, statusSessionExpired) ||
@@ -636,6 +711,128 @@ 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 })
+
+ 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)
+ })
+ }
+ // 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
+// 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()
+
+ username, err := t.svc.Profiles.Username()
+ if err != nil {
+ log.Errorf("get current user: %v", err)
+ return
+ }
+ 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,
+ }); err != nil {
+ 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)
+
+ 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()
+ }()
+}
+
// 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) {