diff --git a/client/embed/embed.go b/client/embed/embed.go index 04bc60fb8..59d278fa1 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -464,7 +464,7 @@ func (c *Client) Status() (peer.FullStatus, error) { if connect != nil { engine := connect.Engine() if engine != nil { - _ = engine.RunHealthProbes(false) + _ = engine.RunHealthProbes(context.Background(), false) } } diff --git a/client/internal/engine.go b/client/internal/engine.go index 99ae66673..be260418d 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1173,7 +1173,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR TempDir: e.config.TempDir, ClientMetrics: e.clientMetrics, RefreshStatus: func() { - e.RunHealthProbes(true) + e.RunHealthProbes(e.ctx, true) }, } @@ -2058,7 +2058,20 @@ func (e *Engine) getRosenpassAddr() string { // RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services // and updates the status recorder with the latest states. -func (e *Engine) RunHealthProbes(waitForResult bool) bool { +// +// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up — +// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe +// returns instead of running to its per-component timeout. The engine's own +// lifetime ctx still applies independently, so an engine shutdown aborts the +// probe even if the caller's ctx is context.Background(). +func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool { + // Tie the caller's ctx to the engine lifetime: either cancelling aborts + // the probe below. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(e.ctx, cancel) + defer stop() + e.syncMsgMux.Lock() signalHealthy := e.signal.IsHealthy() @@ -2081,9 +2094,9 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool { if runtime.GOOS != "js" { var results []relay.ProbeResult if waitForResult { - results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns) } else { - results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAll(ctx, stuns, turns) } e.statusRecorder.UpdateRelayStates(results) diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 64722d593..c09124695 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -882,6 +882,13 @@ func (d *Status) CleanLocalPeerState() { // MarkManagementDisconnected sets ManagementState to disconnected func (d *Status) MarkManagementDisconnected(err error) { d.mux.Lock() + // Health checks re-mark the same state on every probe; skip the fan-out + // when nothing actually changed so we don't flood SubscribeStatus + // consumers with identical snapshots. + if !d.managementState && errors.Is(d.managementError, err) { + d.mux.Unlock() + return + } d.managementState = false d.managementError = err mgm := d.managementState @@ -895,6 +902,10 @@ func (d *Status) MarkManagementDisconnected(err error) { // MarkManagementConnected sets ManagementState to connected func (d *Status) MarkManagementConnected() { d.mux.Lock() + if d.managementState && d.managementError == nil { + d.mux.Unlock() + return + } d.managementState = true d.managementError = nil mgm := d.managementState @@ -936,6 +947,10 @@ func (d *Status) UpdateLazyConnection(enabled bool) { // MarkSignalDisconnected sets SignalState to disconnected func (d *Status) MarkSignalDisconnected(err error) { d.mux.Lock() + if !d.signalState && errors.Is(d.signalError, err) { + d.mux.Unlock() + return + } d.signalState = false d.signalError = err mgm := d.managementState @@ -949,6 +964,10 @@ func (d *Status) MarkSignalDisconnected(err error) { // MarkSignalConnected sets SignalState to connected func (d *Status) MarkSignalConnected() { d.mux.Lock() + if d.signalState && d.signalError == nil { + d.mux.Unlock() + return + } d.signalState = true d.signalError = nil mgm := d.managementState diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 8d889b0ae..9dbc6af08 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -275,3 +275,39 @@ func TestGetFullStatus(t *testing.T) { assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal") assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match") } + +// notified reports whether a state-change tick is pending on ch, draining it. +func notified(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { + status := NewRecorder("https://mgm") + _, ch := status.SubscribeToStateChanges() + + // First transition is a real change and must notify. + status.MarkManagementConnected() + require.True(t, notified(ch), "first connect should notify") + + // Re-marking the same state must not notify again. + status.MarkManagementConnected() + assert.False(t, notified(ch), "redundant connect should not notify") + + // Same for signal. + status.MarkSignalConnected() + require.True(t, notified(ch), "first signal connect should notify") + status.MarkSignalConnected() + assert.False(t, notified(ch), "redundant signal connect should not notify") + + // A genuine change (disconnect with an error) notifies again. + err := errors.New("boom") + status.MarkManagementDisconnected(err) + require.True(t, notified(ch), "disconnect should notify") + status.MarkManagementDisconnected(err) + assert.False(t, notified(ch), "redundant disconnect should not notify") +} diff --git a/client/server/debug.go b/client/server/debug.go index 33247db5f..4120b4cd7 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -52,7 +52,10 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( if engine != nil { refreshStatus = func() { log.Debug("refreshing system health status for debug bundle") - engine.RunHealthProbes(true) + // Background ctx: the bundle wants a full, fresh probe regardless + // of the DebugBundle RPC client's lifetime. The engine's own ctx + // still aborts it on shutdown. + engine.RunHealthProbes(context.Background(), true) } } } diff --git a/client/server/probe_throttle.go b/client/server/probe_throttle.go new file mode 100644 index 000000000..ec6137e15 --- /dev/null +++ b/client/server/probe_throttle.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// healthProbeRunner runs the full, expensive probe (network round-trips to +// management, signal and the relays) and reports whether every component was +// healthy. ctx cancels the probe when the caller gives up. Satisfied by +// *internal.Engine. +type healthProbeRunner interface { + RunHealthProbes(ctx context.Context, waitForResult bool) bool +} + +// statsRefresher does the cheap WireGuard-stats refresh callers fall back to +// when a fresh probe isn't warranted. Satisfied by *peer.Status. +type statsRefresher interface { + RefreshWireGuardStats() error +} + +// probeThrottle rate-limits and single-flights the daemon's health probes. +// +// Health probes are expensive (network round-trips to management, signal and +// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently +// and concurrently — the desktop UI alone issues one per connect/disconnect. +// probeThrottle keeps that load bounded with two rules: +// +// - Single-flight: only one probe runs at a time. Callers that pile up while +// a probe is in flight share its result instead of each launching another, +// even when that probe failed. A failed probe therefore does not make every +// waiter re-probe in turn; the next, non-overlapping caller can try again. +// - Throttle: after a fully successful probe the result is cached for +// interval. While any component is unhealthy the cache is not advanced, so +// later callers keep probing frequently and notice recovery quickly — the +// intentional "probe often while unhealthy" behaviour from the original +// design. +type probeThrottle struct { + interval time.Duration + + mu sync.Mutex + lastOK time.Time // last fully-successful probe; drives the throttle window + completedAt time.Time // when the most recent probe finished; drives single-flight sharing +} + +func newProbeThrottle(interval time.Duration) *probeThrottle { + return &probeThrottle{interval: interval} +} + +// Run decides whether to run a fresh health probe or serve the most recent +// result. It serialises concurrent callers: at most one runner.RunHealthProbes +// executes at a time and the rest call refresher.RefreshWireGuardStats and read +// the snapshot it produced. +// +// Both calls run while the throttle's lock is held, so a slow probe blocks +// other callers until it completes — that blocking is the single-flight +// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up +// cancels the in-flight probe (and any caller still queued on the lock falls +// through quickly once it acquires it, since the probe ctx is already done). +func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) { + entered := time.Now() + + t.mu.Lock() + defer t.mu.Unlock() + + // A probe that finished after we entered ran while we were waiting on the + // lock — i.e. a peer in the same burst already probed for us, so share its + // result rather than launch another. This holds even when that probe + // failed, so a failed probe doesn't make every waiter re-probe in turn. + sharedRecentProbe := t.completedAt.After(entered) + throttled := time.Since(t.lastOK) <= t.interval + + if sharedRecentProbe || throttled { + if err := refresher.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + return + } + + healthy := runner.RunHealthProbes(ctx, waitForResult) + t.completedAt = time.Now() + if healthy { + t.lastOK = t.completedAt + } +} diff --git a/client/server/probe_throttle_test.go b/client/server/probe_throttle_test.go new file mode 100644 index 000000000..cae776fa4 --- /dev/null +++ b/client/server/probe_throttle_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeProber implements both healthProbeRunner and statsRefresher with +// caller-supplied behaviour. +type fakeProber struct { + onProbe func() bool + onRefresh func() +} + +func (f fakeProber) RunHealthProbes(context.Context, bool) bool { + return f.onProbe() +} + +func (f fakeProber) RefreshWireGuardStats() error { + if f.onRefresh != nil { + f.onRefresh() + } + return nil +} + +func TestProbeThrottle_CachesAfterSuccess(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes, refreshes int + prober := fakeProber{ + onProbe: func() bool { probes++; return true }, + onRefresh: func() { refreshes++ }, + } + + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 1 { + t.Fatalf("expected 1 probe within the throttle window, got %d", probes) + } + if refreshes != 1 { + t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes) + } +} + +func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int + prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy + + // Sequential, non-overlapping callers must each re-probe while unhealthy: + // a failed probe does not advance the throttle window. + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 3 { + t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes) + } +} + +func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int32 + release := make(chan struct{}) + started := make(chan struct{}) + + // First caller blocks inside the probe until released, holding the lock so + // the others pile up behind it. + prober := fakeProber{onProbe: func() bool { + if atomic.AddInt32(&probes, 1) == 1 { + close(started) + <-release + } + return false // unhealthy — the share must happen regardless of result + }} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + + <-started // ensure the first probe is in flight before the burst arrives + + const waiters = 9 + wg.Add(waiters) + for i := 0; i < waiters; i++ { + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + } + + // Give the waiters time to block on the lock, then let the first finish. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&probes); got != 1 { + t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got) + } +} diff --git a/client/server/server.go b/client/server/server.go index 22165fbcf..6b070b1e7 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -87,7 +87,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher - lastProbe time.Time + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool @@ -131,6 +131,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable networksDisabled: networksDisabled, jwtCache: newJWTCache(), extendAuthSessionFlow: auth.NewPendingFlow(), + probeThrottle: newProbeThrottle(probeThreshold), } agent := &serverAgent{s} s.sleepHandler = sleephandler.New(agent) @@ -1242,13 +1243,14 @@ func (s *Server) Status( } } - return s.buildStatusResponse(msg) + return s.buildStatusResponse(ctx, msg) } // buildStatusResponse composes a StatusResponse from the current daemon // 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) { +// stream so both paths return identical snapshots. ctx scopes the health +// probe runProbes may trigger — a caller that disconnects cancels it. +func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) { state := internal.CtxGetState(s.rootCtx) status, err := state.Status() if err != nil { @@ -1277,7 +1279,7 @@ func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusRes s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) if msg.GetFullPeerStatus { - s.runProbes(msg.ShouldRunProbes) + s.runProbes(ctx, msg.ShouldRunProbes) fullStatus := s.statusRecorder.GetFullStatus() pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() @@ -1707,7 +1709,7 @@ func isUnixRunningDesktop() bool { return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" } -func (s *Server) runProbes(waitForProbeResult bool) { +func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return } @@ -1717,15 +1719,7 @@ func (s *Server) runProbes(waitForProbeResult bool) { return } - if time.Since(s.lastProbe) > probeThreshold { - if engine.RunHealthProbes(waitForProbeResult) { - s.lastProbe = time.Now() - } - } else { - if err := s.statusRecorder.RefreshWireGuardStats(); err != nil { - log.Debugf("failed to refresh WireGuard stats: %v", err) - } - } + s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult) } // GetConfig of the daemon. diff --git a/client/server/status_stream.go b/client/server/status_stream.go index aa1dc508c..c6ba547eb 100644 --- a/client/server/status_stream.go +++ b/client/server/status_stream.go @@ -44,7 +44,7 @@ func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonSe } func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { - resp, err := s.buildStatusResponse(req) + resp, err := s.buildStatusResponse(stream.Context(), req) if err != nil { log.Warnf("build status snapshot for stream: %v", err) return err diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index f3e01b36d..fe927c1d7 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -13,7 +13,8 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th - `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`. - `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working. - `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows. -- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). +- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux uses a monochrome black/white pair (`*-mono.png` black for light panels, `*-mono-dark.png` white for dark panels); Windows reuses the colored light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). The `*-mono*` set is generated from the macOS template silhouettes (states differ by shape, not color); `tray_icon.go iconForState` branches on `runtime.GOOS` (`linux` → mono, else colored). +- **Linux mono icon theme selection** — Wails v3's Linux SNI backend ignores `SetDarkModeIcon` (its `setDarkModeIcon` just calls `setIcon`, last-write-wins — see `pkg/application/systemtray_linux.go`), and the SNI spec carries no panel light/dark hint. So `tray_theme_linux.go` detects the desktop colour scheme itself and `iconForState` picks black-vs-white, with `applyIcon` pushing a single `SetIcon` on Linux (no `SetDarkModeIcon`). Detection order: freedesktop **Settings portal** (`org.freedesktop.portal.Settings.Read` of `org.freedesktop.appearance`/`color-scheme`: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to the **`GTK_THEME`** env var (`:dark` suffix ⇒ dark) → else default dark (suits the common dark panel). A private session-bus `SettingChanged` subscription repaints live on theme flips. `Tray.panelDark func() bool` is seeded by `startTrayTheme()` (Linux only; `tray_theme_other.go` is a no-op stub) before the first `applyIcon`; `panelIsDark()` returns true when `panelDark` is nil. ### Wails services (`services/*.go`) Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. Frontend-facing details (TS signatures, push events, models) are in `frontend/WAILS-API.md`. After editing any `services/*.go` or the proto, regenerate with `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). `frontend/bindings/**` is gitignored. diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png new file mode 100644 index 000000000..1f7d40121 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png new file mode 100644 index 000000000..8a8710746 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png new file mode 100644 index 000000000..f208cb6bf Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png new file mode 100644 index 000000000..e254321fd Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png new file mode 100644 index 000000000..035e71ba7 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png new file mode 100644 index 000000000..d68c4dc5b Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png new file mode 100644 index 000000000..284efa880 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png new file mode 100644 index 000000000..ed9ceb8a2 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png new file mode 100644 index 000000000..eb0c4bcf5 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png new file mode 100644 index 000000000..519ea014a Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ diff --git a/client/ui/icons.go b/client/ui/icons.go index d5759cee7..1680d2d4a 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -56,6 +56,55 @@ var iconUpdateConnectedMacOS []byte //go:embed assets/netbird-systemtray-update-disconnected-macos.png var iconUpdateDisconnectedMacOS []byte +// Linux monochrome tray icons. Linux's SNI tray has no template-recoloring +// (unlike macOS's SetTemplateIcon), so we ship an explicit black/white pair: +// the black silhouette (*-mono.png) goes to SetIcon for light panels, the +// white one (*-mono-dark.png) goes to SetDarkModeIcon for dark panels, and +// the SNI host picks per panel theme. Generated from the macOS template +// silhouettes — states differ by shape, not color. + +//go:embed assets/netbird-systemtray-connected-mono.png +var iconConnectedMono []byte + +//go:embed assets/netbird-systemtray-connected-mono-dark.png +var iconConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-connecting-mono.png +var iconConnectingMono []byte + +//go:embed assets/netbird-systemtray-connecting-mono-dark.png +var iconConnectingMonoDark []byte + +//go:embed assets/netbird-systemtray-disconnected-mono.png +var iconDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-disconnected-mono-dark.png +var iconDisconnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-error-mono.png +var iconErrorMono []byte + +//go:embed assets/netbird-systemtray-error-mono-dark.png +var iconErrorMonoDark []byte + +//go:embed assets/netbird-systemtray-needs-login-mono.png +var iconNeedsLoginMono []byte + +//go:embed assets/netbird-systemtray-needs-login-mono-dark.png +var iconNeedsLoginMonoDark []byte + +//go:embed assets/netbird-systemtray-update-connected-mono.png +var iconUpdateConnectedMono []byte + +//go:embed assets/netbird-systemtray-update-connected-mono-dark.png +var iconUpdateConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono.png +var iconUpdateDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono-dark.png +var iconUpdateDisconnectedMonoDark []byte + //go:embed assets/netbird.png var iconWindow []byte diff --git a/client/ui/services/version.go b/client/ui/services/version.go index 82034ec37..ca25630c7 100644 --- a/client/ui/services/version.go +++ b/client/ui/services/version.go @@ -8,18 +8,19 @@ import ( "github.com/netbirdio/netbird/version" ) -// Version is the Wails-bound facade exposing the GUI version baked in at -// build time via -ldflags. The tray reads version.NetbirdVersion() directly; -// this service exists so the React layer can show the same string instead -// of the static placeholder in frontend/package.json. +// Version is the Wails-bound facade exposing build/version metadata to the +// frontend. Today it only reports the GUI's own version (the daemon version is +// surfaced separately through the status feed's DaemonVersion field). type Version struct{} +// NewVersion constructs the Version service. func NewVersion() *Version { return &Version{} } -// GUI returns the GUI version string (e.g. "0.65.0" in release builds, -// "development" in dev builds). -func (s *Version) GUI(_ context.Context) (string, error) { - return version.NetbirdVersion(), nil +// GUI returns the version of the running UI binary, baked in at build time via +// the version package's ldflags. Falls back to "development" for un-stamped +// builds (see version.NetbirdVersion). +func (v *Version) GUI(_ context.Context) string { + return version.NetbirdVersion() } diff --git a/client/ui/tray.go b/client/ui/tray.go index 4570a8303..c54376454 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -76,6 +76,12 @@ type Tray struct { tray *application.SystemTray window *application.WebviewWindow svc TrayServices + // panelDark reports whether the desktop panel uses a dark colour + // scheme, so iconForState can pick the black vs white monochrome tray + // icon on Linux. Set by startTrayTheme (Linux only); nil on macOS and + // Windows, where the OS/Wails handles light-vs-dark icon selection and + // panelIsDark falls back to its default. + panelDark func() bool // loc owns the active language plus the preference subscription. The // tray talks to it for every translated label (t.loc.T(...)) and // registers a callback in NewTray that re-renders the menu on a @@ -192,6 +198,10 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe } t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }) t.tray = app.SystemTray.New() + // Seed panel-theme detection (Linux only) before the first paint so the + // initial icon already matches the panel's light/dark scheme; repaints + // on live theme switches. + t.startTrayTheme() t.applyIcon() t.tray.SetTooltip(t.loc.T("tray.tooltip")) // On Linux the SNI hover tooltip is sourced from the systray *Label* diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go index 0c4c864ff..cc893e2d1 100644 --- a/client/ui/tray_icon.go +++ b/client/ui/tray_icon.go @@ -29,12 +29,32 @@ func (t *Tray) applyIcon() { t.tray.SetTemplateIcon(icon) return } + if runtime.GOOS == "linux" { + // Wails' Linux SNI backend ignores SetDarkModeIcon (its + // setDarkModeIcon just calls setIcon, last-write-wins), so we pick + // the black-vs-white silhouette ourselves in iconForState based on + // the panel theme and push a single SetIcon. Calling + // SetDarkModeIcon here would only clobber that choice. + t.tray.SetIcon(icon) + return + } t.tray.SetIcon(icon) if dark != nil { t.tray.SetDarkModeIcon(dark) } } +// panelIsDark reports whether the desktop panel uses a dark colour scheme, so +// the Linux branch of iconForState can choose the white silhouette. Defaults +// to true when no detector is wired (panelDark nil — non-Linux, or the +// freedesktop portal was unavailable), matching the common dark Linux panel. +func (t *Tray) panelIsDark() bool { + if t.panelDark == nil { + return true + } + return t.panelDark() +} + func (t *Tray) iconForState() (icon, dark []byte) { t.statusMu.Lock() connected := t.connected @@ -71,6 +91,38 @@ func (t *Tray) iconForState() (icon, dark []byte) { } } + if runtime.GOOS == "linux" { + // Linux: monochrome silhouette chosen by panel theme. Wails' SNI + // backend can't switch icons per theme itself (see applyIcon), so we + // resolve black (light panel) vs white (dark panel) here and the + // caller pushes a single SetIcon. The second return is unused on + // Linux. + dark := t.panelIsDark() + pick := func(black, white []byte) ([]byte, []byte) { + if dark { + return white, nil + } + return black, nil + } + switch { + case connecting: + return pick(iconConnectingMono, iconConnectingMonoDark) + case errored: + return pick(iconErrorMono, iconErrorMonoDark) + case needsLogin: + return pick(iconNeedsLoginMono, iconNeedsLoginMonoDark) + case connected && hasUpdate: + return pick(iconUpdateConnectedMono, iconUpdateConnectedMonoDark) + case connected: + return pick(iconConnectedMono, iconConnectedMonoDark) + case hasUpdate: + return pick(iconUpdateDisconnectedMono, iconUpdateDisconnectedMonoDark) + default: + return pick(iconDisconnectedMono, iconDisconnectedMonoDark) + } + } + + // Windows: colored PNGs. switch { case connecting: return iconConnecting, nil diff --git a/client/ui/tray_linux.go b/client/ui/tray_linux.go index a213ce2f9..f732cbc9c 100644 --- a/client/ui/tray_linux.go +++ b/client/ui/tray_linux.go @@ -2,10 +2,18 @@ package main -import "os" +import ( + "os" + "strings" +) -// init runs before Wails' own init(), so the env var is set in time. +// init runs before Wails' own init(), so the env vars are set in time. func init() { + disableDMABUFRenderer() + disableWebKitSandboxIfNeeded() +} + +func disableDMABUFRenderer() { if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" { return } @@ -18,6 +26,46 @@ func init() { _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") } +// disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when +// its bubblewrap (bwrap) sandbox can't create an unprivileged user namespace — +// "bwrap: setting up uid map: Permission denied" followed by "Failed to fully +// launch dbus-proxy" and a panic in webkit_web_view_load_uri. This happens in +// containers/VMs and on Ubuntu 24.04+ where AppArmor restricts unprivileged +// user namespaces (kernel.apparmor_restrict_unprivileged_userns=1). Software +// can't grant the namespace from here, so when we detect that userns are +// blocked we disable the WebKit sandbox to keep the UI usable. The user can +// override either way by setting WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS. +func disableWebKitSandboxIfNeeded() { + if _, set := os.LookupEnv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"); set { + return + } + if unprivilegedUsernsAllowed() { + return + } + _ = os.Setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") +} + +// unprivilegedUsernsAllowed reports whether the kernel currently permits +// unprivileged user namespaces, which WebKit's bwrap sandbox needs. It reads +// the relevant procfs knobs; on a kernel that doesn't expose them (older or +// hardened), it conservatively assumes namespaces are available so we don't +// needlessly weaken the sandbox. +func unprivilegedUsernsAllowed() bool { + // Debian/Ubuntu legacy switch: 0 disables unprivileged user namespaces. + if v, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { + if strings.TrimSpace(string(v)) == "0" { + return false + } + } + // Ubuntu 24.04+ AppArmor restriction: non-zero restricts/blocks them. + if v, err := os.ReadFile("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); err == nil { + if strings.TrimSpace(string(v)) != "0" { + return false + } + } + return true +} + // On Linux, the system tray provider may require the menu to be recreated // rather than updated in place. The rebuildExitNodeMenu method in tray.go // already handles this by removing and re-adding items; no additional diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go new file mode 100644 index 000000000..861d8ac98 --- /dev/null +++ b/client/ui/tray_theme_linux.go @@ -0,0 +1,240 @@ +//go:build linux && !(linux && 386) + +package main + +// Linux panel-theme detection for the monochrome tray icons. +// +// Wails v3's Linux SNI backend does not honour SetDarkModeIcon — its +// setDarkModeIcon just calls setIcon, so the last write wins regardless of +// panel theme (see pkg/application/systemtray_linux.go). The SNI spec itself +// also carries no reliable "panel is dark/light" hint for clients. So we +// detect the desktop's colour-scheme preference ourselves via the +// freedesktop Settings portal (org.freedesktop.portal.Settings, the +// org.freedesktop.appearance/color-scheme key) and pick the black or white +// silhouette in iconForState. We also subscribe to the portal's +// SettingChanged signal so a live theme switch repaints the icon. +// +// color-scheme values (per the freedesktop appearance spec): +// 0 = no preference, 1 = prefer dark, 2 = prefer light. + +import ( + "os" + "strings" + "sync" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObjectPath = "/org/freedesktop/portal/desktop" + portalSettings = "org.freedesktop.portal.Settings" + + appearanceNamespace = "org.freedesktop.appearance" + colorSchemeKey = "color-scheme" + + colorSchemeNoPreference = 0 + colorSchemePreferDark = 1 + colorSchemePreferLight = 2 +) + +// startTrayTheme wires the Linux panel-theme watcher into the tray: it seeds +// t.panelDark from the freedesktop Settings portal and repaints the icon on +// every live colour-scheme flip. Called from NewTray before the first +// applyIcon so the initial paint already uses the right silhouette. +func (t *Tray) startTrayTheme() { + w := startThemeWatcher(func() { t.applyIcon() }) + t.panelDark = w.IsDark +} + +// themeWatcher reads the desktop colour-scheme preference over the session +// bus and invokes onChange whenever it flips. It owns a private session-bus +// connection so its signal subscription is isolated from the SNI watcher's. +type themeWatcher struct { + conn *dbus.Conn + onChange func() + + mu sync.Mutex + darkMode bool +} + +// startThemeWatcher opens a private session-bus connection, seeds the current +// colour scheme, and subscribes to the portal's SettingChanged signal. It +// returns nil (and logs) if the portal is unavailable — callers treat a nil +// watcher as "no preference", which keeps the default-dark icon choice. +func startThemeWatcher(onChange func()) *themeWatcher { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err) + return nil + } + if err := conn.Auth(nil); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus auth failed: %v", err) + return nil + } + if err := conn.Hello(); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus hello failed: %v", err) + return nil + } + + w := &themeWatcher{conn: conn, onChange: onChange} + w.darkMode = w.readDarkMode() + + if err := w.subscribe(); err != nil { + log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err) + // Keep the connection: the seeded darkMode value is still useful. + } + + log.Infof("tray theme: panel dark mode = %v", w.IsDark()) + return w +} + +// IsDark reports the last observed colour-scheme preference. A nil watcher +// (portal unavailable) reports true so the icon defaults to the white +// silhouette, which suits the common dark Linux panel. +func (w *themeWatcher) IsDark() bool { + if w == nil { + return true + } + w.mu.Lock() + defer w.mu.Unlock() + return w.darkMode +} + +// readDarkMode resolves the current dark/light preference. The freedesktop +// color-scheme portal is the primary source; when it is unavailable or +// reports "no preference" (0), we fall back to the GTK_THEME env var (the +// GTK convention appends ":dark" for the dark variant, e.g. "Adwaita:dark"). +// If neither yields a signal we default to dark, matching the common dark +// Linux panel. +func (w *themeWatcher) readDarkMode() bool { + switch w.readColorScheme() { + case colorSchemePreferDark: + return true + case colorSchemePreferLight: + return false + default: // colorSchemeNoPreference or portal unavailable + return gtkThemeIsDark() + } +} + +// readColorScheme returns the raw freedesktop color-scheme value (0 = no +// preference, 1 = prefer dark, 2 = prefer light), or colorSchemeNoPreference +// when the portal can't be reached. +func (w *themeWatcher) readColorScheme() uint32 { + obj := w.conn.Object(portalBusName, portalObjectPath) + call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey) + if call.Err != nil { + log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err) + return colorSchemeNoPreference + } + + var v dbus.Variant + if err := call.Store(&v); err != nil { + log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err) + return colorSchemeNoPreference + } + + return variantToColorScheme(v) +} + +// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is +// treated as dark to match the default-dark fallback used elsewhere. +func gtkThemeIsDark() bool { + theme := os.Getenv("GTK_THEME") + if theme == "" { + return true + } + // GTK_THEME is "Name[:variant]"; the dark variant is ":dark". + return strings.Contains(strings.ToLower(theme), ":dark") +} + +// subscribe registers a match rule for the portal's SettingChanged signal and +// spawns a goroutine that re-reads the scheme and fires onChange on each +// relevant change. +func (w *themeWatcher) subscribe() error { + if err := w.conn.AddMatchSignal( + dbus.WithMatchObjectPath(portalObjectPath), + dbus.WithMatchInterface(portalSettings), + dbus.WithMatchMember("SettingChanged"), + ); err != nil { + return err + } + + sigs := make(chan *dbus.Signal, 8) + w.conn.Signal(sigs) + go w.loop(sigs) + return nil +} + +// loop consumes SettingChanged signals, filters to the colour-scheme key, and +// repaints the icon when the dark/light preference actually flips. +func (w *themeWatcher) loop(sigs chan *dbus.Signal) { + for sig := range sigs { + if sig.Name != portalSettings+".SettingChanged" { + continue + } + // Signal body: (namespace string, key string, value variant). + if len(sig.Body) < 3 { + continue + } + namespace, _ := sig.Body[0].(string) + key, _ := sig.Body[1].(string) + if namespace != appearanceNamespace || key != colorSchemeKey { + continue + } + variant, ok := sig.Body[2].(dbus.Variant) + if !ok { + continue + } + + dark := colorSchemeToDark(variantToColorScheme(variant)) + w.mu.Lock() + changed := dark != w.darkMode + w.darkMode = dark + w.mu.Unlock() + + if changed && w.onChange != nil { + log.Infof("tray theme: panel dark mode changed to %v", dark) + w.onChange() + } + } +} + +// colorSchemeToDark maps a freedesktop color-scheme value to a dark/light +// bool, deferring "no preference" (0) to the GTK_THEME fallback. +func colorSchemeToDark(scheme uint32) bool { + switch scheme { + case colorSchemePreferDark: + return true + case colorSchemePreferLight: + return false + default: + return gtkThemeIsDark() + } +} + +// variantToColorScheme unwraps the color-scheme variant (the portal nests it +// one level: a variant holding a uint32) into the raw scheme value, returning +// colorSchemeNoPreference for an unexpected payload. +func variantToColorScheme(v dbus.Variant) uint32 { + inner := v.Value() + if nested, ok := inner.(dbus.Variant); ok { + inner = nested.Value() + } + + switch n := inner.(type) { + case uint32: + return n + case int32: + return uint32(n) + case uint8: + return uint32(n) + default: + log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner) + return colorSchemeNoPreference + } +} diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go new file mode 100644 index 000000000..a0d20f7ab --- /dev/null +++ b/client/ui/tray_theme_other.go @@ -0,0 +1,11 @@ +//go:build !linux || (linux && 386) + +package main + +// startTrayTheme is a no-op off Linux: macOS uses template icons (the OS +// recolours them per menubar appearance) and Windows ships colored PNGs, so +// neither needs the freedesktop colour-scheme probe that the Linux build +// uses to choose between the black and white monochrome silhouettes. Left +// callable so NewTray can invoke it unconditionally; panelDark stays nil and +// panelIsDark returns its default. +func (t *Tray) startTrayTheme() {} diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go index 7a9b72096..4b2c06b29 100644 --- a/client/ui/tray_watcher_linux.go +++ b/client/ui/tray_watcher_linux.go @@ -18,6 +18,7 @@ package main import ( "sync" + "time" "github.com/godbus/dbus/v5" log "github.com/sirupsen/logrus" @@ -27,6 +28,16 @@ const ( watcherName = "org.kde.StatusNotifierWatcher" watcherPath = "/StatusNotifierWatcher" watcherIface = "org.kde.StatusNotifierWatcher" + + // watcherProbeInterval / watcherProbeTimeout bound how long we keep + // re-probing for an XEmbed tray before giving up. The UI is commonly + // autostarted *before* the panel/tray on minimal WMs, so a single probe + // at startup would miss a tray that comes up a second or two later and + // the icon would silently never appear. ~10s of polling covers a slow + // panel launch while staying short enough that a headless / pure-Wayland + // session (no XEmbed tray ever) winds down quickly. + watcherProbeInterval = 500 * time.Millisecond + watcherProbeTimeout = 10 * time.Second ) type statusNotifierWatcher struct { @@ -98,9 +109,49 @@ func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus. } // startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the -// session bus if it is not already provided by another process. -// Safe to call unconditionally — it does nothing when a real watcher is present. +// session bus, but ONLY as a bridge to an XEmbed system tray on minimal WMs. +// +// The in-process watcher is a stub: its RegisterStatusNotifierItem only +// tracks items so it can mirror them into an XEmbed tray icon — it does +// NOT relay them to any other StatusNotifierHost. So if we claim the name +// on a desktop that has a real watcher/host (e.g. Hyprland + Waybar), every +// other tray app (Slack, etc.) registers into our dead-end watcher and its +// icon never reaches the real host. We won that name purely by starting +// first; a GetNameOwner check doesn't help against a login-order race. +// +// The correct discriminator is whether an XEmbed tray actually exists. If +// one does, we are the bridge of last resort and should claim the watcher. +// If not (pure Wayland, or any environment already running a real watcher), +// we have nothing to bridge and must stay off the bus entirely so the real +// watcher owns the name. Safe to call unconditionally. +// +// The XEmbed tray may come up *after* the UI (the panel and the autostarted +// app race at login), so we re-probe for a short grace period instead of +// deciding once at startup. The probing runs in a goroutine so it never +// blocks the caller's startup path. func startStatusNotifierWatcher() { + go func() { + deadline := time.Now().Add(watcherProbeTimeout) + for { + if xembedTrayAvailable() { + claimStatusNotifierWatcher() + return + } + if time.Now().After(deadline) { + log.Debugf("StatusNotifierWatcher: no XEmbed tray appeared within %s, leaving the watcher to the desktop", watcherProbeTimeout) + return + } + time.Sleep(watcherProbeInterval) + } + }() +} + +// claimStatusNotifierWatcher opens a private session-bus connection and takes +// ownership of org.kde.StatusNotifierWatcher, exporting the in-process stub +// watcher. The caller has already confirmed an XEmbed tray is present, so we +// genuinely have an item to bridge. The GetNameOwner / DoNotQueue guards still +// back off if a real watcher already holds the name. +func claimStatusNotifierWatcher() { conn, err := dbus.SessionBusPrivate() if err != nil { log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err) diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go index f90e7de75..2b66ddb32 100644 --- a/client/ui/xembed_host_linux.go +++ b/client/ui/xembed_host_linux.go @@ -88,6 +88,25 @@ func goMenuItemClicked(id C.int) { } // newXembedHost creates an XEmbed tray icon for the given SNI item. +// xembedTrayAvailable reports whether an XEmbed system tray manager +// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen. +// It is a cheap, side-effect-free probe — it only queries the selection +// owner, creating no windows. Used to decide whether the in-process +// StatusNotifierWatcher is needed at all: the watcher exists solely to +// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed +// tray is present (e.g. Wayland compositors with a real SNI host like +// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the +// real one. Returns false when there is no X display (pure Wayland). +func xembedTrayAvailable() bool { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return false + } + defer C.XCloseDisplay(dpy) + screen := C.xembed_default_screen(dpy) + return C.xembed_find_tray(dpy, screen) != 0 +} + // Returns an error if no XEmbed tray manager is available (graceful fallback). func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { dpy := C.XOpenDisplay(nil)