Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts: # client/ui/services/version.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
client/server/probe_throttle.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
109
client/server/probe_throttle_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
client/ui/assets/netbird-systemtray-connected-mono-dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/ui/assets/netbird-systemtray-connected-mono.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/ui/assets/netbird-systemtray-connecting-mono-dark.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/ui/assets/netbird-systemtray-connecting-mono.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
client/ui/assets/netbird-systemtray-disconnected-mono-dark.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
client/ui/assets/netbird-systemtray-disconnected-mono.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
client/ui/assets/netbird-systemtray-error-mono-dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/ui/assets/netbird-systemtray-error-mono.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/ui/assets/netbird-systemtray-needs-login-mono-dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/ui/assets/netbird-systemtray-needs-login-mono.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/ui/assets/netbird-systemtray-update-connected-mono.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/ui/assets/netbird-systemtray-update-disconnected-mono.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
240
client/ui/tray_theme_linux.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
11
client/ui/tray_theme_other.go
Normal file
@@ -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() {}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||