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) {