Merge branch 'ui-refactor' into ui-refactor-ui
4
.github/workflows/golang-test-linux.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
@@ -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
|
||||
|
||||
18
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<!-- Icons -->
|
||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\build\windows\icon.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||
|
||||
</Package>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
BIN
client/ui/assets/netbird-menu-dot-connected.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-connecting.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-error.png
Normal file
|
After Width: | Height: | Size: 433 B |
BIN
client/ui/assets/netbird-menu-dot-idle.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
client/ui/assets/netbird-menu-dot-login.png
Normal file
|
After Width: | Height: | Size: 475 B |
BIN
client/ui/assets/netbird-menu-dot-offline.png
Normal file
|
After Width: | Height: | Size: 456 B |
BIN
client/ui/build/banner.bmp
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||