From 3dbd96b17227386d4dc0a63ab110f58a4bf645f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 19:23:41 +0200 Subject: [PATCH 1/7] Add Version service exposing GUI version to frontend --- client/ui/services/version.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 client/ui/services/version.go diff --git a/client/ui/services/version.go b/client/ui/services/version.go new file mode 100644 index 000000000..ca25630c7 --- /dev/null +++ b/client/ui/services/version.go @@ -0,0 +1,26 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/version" +) + +// 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 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() +} From 5bebecc427e11d078eb8e8c44d1e665ef33a8cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 20:06:44 +0200 Subject: [PATCH 2/7] ui: disable WebKit sandbox when unprivileged userns are blocked WebKitGTK crashes at startup when its bubblewrap sandbox can't create an unprivileged user namespace (bwrap: setting up uid map: Permission denied -> Failed to fully launch dbus-proxy -> panic in webkit_web_view_load_uri). This happens in containers/VMs and on Ubuntu 24.04+ where AppArmor restricts unprivileged user namespaces. Detect that the kernel blocks userns via procfs and set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS so the UI stays usable; honor an explicit user override either way. --- client/ui/tray_linux.go | 52 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) 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 From 4cee07bef56ae48048a8486936d1e0c32ab4018b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 20:23:46 +0200 Subject: [PATCH 3/7] client/ui: use monochrome tray icons on Linux Linux now shows monochrome (black/white silhouette) tray icons instead of the colored orange PNGs, matching the macOS template look. Since Wails' Linux SNI backend ignores SetDarkModeIcon (its setDarkModeIcon just calls setIcon, last-write-wins) and the SNI spec carries no panel light/dark hint, the panel color scheme is detected in-process and the black-vs-white silhouette is chosen in iconForState, pushed via a single SetIcon. Detection order (tray_theme_linux.go): freedesktop Settings portal (org.freedesktop.appearance/color-scheme) -> GTK_THEME env (:dark suffix) -> default dark. A SettingChanged subscription repaints live on theme flips. macOS (template) and Windows (colored) paths are unchanged. Icons are 48x48 mono PNGs (3% margin) generated from the macOS silhouettes. --- client/ui/CLAUDE.md | 3 +- ...netbird-systemtray-connected-mono-dark.png | Bin 0 -> 1548 bytes .../netbird-systemtray-connected-mono.png | Bin 0 -> 1388 bytes ...etbird-systemtray-connecting-mono-dark.png | Bin 0 -> 1448 bytes .../netbird-systemtray-connecting-mono.png | Bin 0 -> 1274 bytes ...bird-systemtray-disconnected-mono-dark.png | Bin 0 -> 1235 bytes .../netbird-systemtray-disconnected-mono.png | Bin 0 -> 1088 bytes .../netbird-systemtray-error-mono-dark.png | Bin 0 -> 1571 bytes .../assets/netbird-systemtray-error-mono.png | Bin 0 -> 1449 bytes ...tbird-systemtray-needs-login-mono-dark.png | Bin 0 -> 1571 bytes .../netbird-systemtray-needs-login-mono.png | Bin 0 -> 1449 bytes ...-systemtray-update-connected-mono-dark.png | Bin 0 -> 1553 bytes ...tbird-systemtray-update-connected-mono.png | Bin 0 -> 1405 bytes ...stemtray-update-disconnected-mono-dark.png | Bin 0 -> 1444 bytes ...rd-systemtray-update-disconnected-mono.png | Bin 0 -> 1322 bytes client/ui/icons.go | 49 ++++ client/ui/tray.go | 10 + client/ui/tray_icon.go | 52 ++++ client/ui/tray_theme_linux.go | 240 ++++++++++++++++++ client/ui/tray_theme_other.go | 11 + 20 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 client/ui/assets/netbird-systemtray-connected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-connected-mono.png create mode 100644 client/ui/assets/netbird-systemtray-connecting-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-connecting-mono.png create mode 100644 client/ui/assets/netbird-systemtray-disconnected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-disconnected-mono.png create mode 100644 client/ui/assets/netbird-systemtray-error-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-error-mono.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login-mono.png create mode 100644 client/ui/assets/netbird-systemtray-update-connected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-update-connected-mono.png create mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-mono.png create mode 100644 client/ui/tray_theme_linux.go create mode 100644 client/ui/tray_theme_other.go 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 0000000000000000000000000000000000000000..1f7d40121c11e9886d395344ee8ec2ce96a6238d GIT binary patch literal 1548 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..035e71ba7f80e8a04caa63fc13b77bf2ed147381 GIT binary patch literal 1235 zcmV;^1T6cBP)yJ_qL}CyOhD9#t2|Ip2C{LXh-gq;1UGS!=pvv*R1ic^M8$<}ghV2U z?p*i+-S_~(MTigyVn8K9Fp94j-)5ZdzMqRzHMi!bANTgm*a%&4xOdLEr>g#{bL#x3 zCj<0aINc}FdSK_E#T`C#qxM;y(E*AOL|n&W1+v(!)`ZeOPY{$K+>TF z#mjgS`|`-O7)-m;2g=`u;{C0!zEt@WKPX;LX# zbCP~D1t%mOmvl_h*EZe_R7cX&=KP$bDb?S5x#@aB(huf$o}i_*^w1FN(Pa)e3LF4# z2iAo3J^?g=?e+~oyQcn*t^Ln|Dbt<#9*7(5Ih$}AXoc1etyOBBE(Q+=Ls9>^z)Jx{ zUJ-x;-fnuDQLjp!ZlG0BIp#=*lnH=66qV;{;L?(fhiiav=ilq*d<2l$U)hYV z`7!_pkpZiLgB3xzp*Mn#i-BKAORF8ed6LDMc)O&-#h2wNV1^>N0|6*}xEA=dhk>%8 z)4)cG>hMBoo}Zuq_XRsMz>lUQ76hMnKSed`ddl}Y`aNCif#ZEZc;7P=dua>ZD^wo( zXM?TXC0%{XW>o+Y#75w|pq%iY+t~ROnR~wi;G@C0Ch(c*ip%ZRk{`7J#MEsit;Yz$ z4ebu!Z|~#0i|?s>!@Ql==LD~3*{lm7g4hQ9MX?khmCYt8lGDH!6#Jr=c;-$3=TH=l zZ*6RzK4o(l01?DZr09C2V%dD46ybj28QK}IL8;`^F!urH_b!_x9ag%K zG&J~P;?gmSyhGBfDyVm+{+FXOoT)3$&6`TRd|%L0)p@CKayHCusD|^>@R#}+sUyP} zRsnXCJ##J2Y$X{MK&Es@m83nAUQxX2%T+Ifl7($%x0yLAJM~cSQlXk*yS@D<{#5+3?L85;=>GMybU%R|3EX!8zwVCn@+#Mmm&w(QyC! zmXh8T1>i<*>*AEZe*;_|=3EJUZ)0T!`|ff$UloA=t~m{SNq4sCjvWKmQ7nlqz^~z( z4g)UfD*?dykF(8QT;DhC5#WO0+%^*D@$r4j<$M(Yv1~fq?8o)p$&Y|l!O5Gg|4&lc ztjso-3cwwB(HyAqvtV53)1)&(z%GhQ`m#hH`m<4Iotq_nY^P*Zw%L-jO47@cc3GdF x(6!j+AX(0z*GD4b|B;Pa#sEewV*sO;e*xEJ!J}<>$UXo7002ovPDHLkV1lEZT7dun literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d68c4dc5bbc9b83346dafb3f5c886e07dbb461a9 GIT binary patch literal 1088 zcmV-G1i$-^^SeR8zY_(t05mPIUcukB>xVsjm4*n$WEA`{3!rI%7hU-#@E0)Q zY%*-uM}ZZ<6Tl?!7%*;qOMwwH&;b4c{s1lmKLK;VFZMi-R1-J^?6LWyK7d5D%+>+m zcZ<8_2fS5qMSN=jmw=Cf7lBnSK&9EVjZxYZv9A>S-jUBu+4pV2ZSH;Z7hH?0LNmwV z1@c$flLK|ifnJy*`Ps1FubHhH@HFsvxQ0qI9P~Tx2UjsIfU!hvAf6DpV z9ob4Qo2ew$0#}{!xISM69wOVha|whmx!~%+cV^3{+m0xXQqb0IB;=zfAxFF2_~|ae ztLdHE>*mc^p9EgdvRQ`2X5c1SDyVEq(2W5*Wbw`+^&Bc-2^b8Y)c0s+Z|l;+FnNMv^)n6&FK z5o09W=hjhsV;o#Y6{Ug8<(q*_;n~yl#eTP;_WwQZnvA$u#iTX9YNoe(FBfEWt zoNOc$T`hvVm~vxQ5yV+1O|)ogvn+x0E;wnP>ty<+MmUr@$#DJN9@*_HM?d0H4r(_4Cr{3?c1w%I+fj zNFUhhL&(>8Le1~>4+yne1QI96{uD0^R4!DgP@zJFuJ|9-pl)IY;7^MH0000>!^2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6bcdacd447ee52d82d3a511abd5435b9a81297bc GIT binary patch literal 1571 zcmV+;2Hg3HP)lCl;3IO|$Nluj%-P?$v%h=y-s{>D?7%R;`}@tD^ZlMVbLPz4 z0-*nMDGoM(6D>mo2D=OqXt0#r^9)HVC4D35kfbh2ow2P}J7W7~uN2_Fs>Rq|3493r zL*u0kbOKkI+?L3rkW^oMoXz`&q;fx&FUCI0Bt5BY=6h=BO-r|=7D;bN+S&_f0QZ3w zWn{ci1OJMo``!0aDxgL&LmiVeP11oVr*{UID|B(+Hz76%-cbVSmh zlJ-g38|!Zh7D`J=!OBlX@Ewv`)%e;gM_Erx+8=R;S&zQ-SQGwewPXa@>W zZcFt2JAfZ!|Lz9m_e9_S2^bmWnfzV?1>p5K;5g7#XD#O_+y=CgEn2H*0Pi$G!usAG z<&?bKG(wnyt_Bp830CTezn731WjZi6(XpZd64vLdJn$)jA}|d2qzMu>&ASP1Q;4$m z0KWn^Mm?>uU0$b-?kM|;{s zewt4QzDor42mVth<4oXS1l~okB7|Te!t4mxR7HomZ_-fm}k(dJf5rvr*eKAF^I#5#y3px)} zIewc+X3>WdSvKw$;1rUuSuX+4`f}h%4hggVAz+LH^icxH_bmf+35&TD$Fve+y!Ekh zcT>an>mlGt%p&X$WoK1Ww8Ztlp$2#l%5QW67RK+E;BAS0YJ#tafLC&hvyi#N9zs+p zMrGR*0v`N4Di^R3DFCAhi{94BOzN3XwFU`@T? z8zJD8+jMV=zl*>q;CCv0-N4^V)FCHsG_U$KDm(^9d91ARY&>wEAE z67oKlr*VCxlb;7Tmqeok@)S=3{*IRSQRKb^yn&ku+?l*fffn4@#ZG4*63q~>X>W<` z$7CCofnS(_HrucPqvgl0-NlI1cYq}S7^bS3-t zB9+HYE9Of3f$JH788C%+RRN!p_#j#>RqDaN6#IJc-Bhj#fMaBluBkO({@VuZrSC#Z zi>28s;`rh8wS3-fMBd`H_9$>oH4^n&e`yrfN#?U@wVBxoOeTw3K6iO82fU42O}4Pp zec}jV{aG}ui-2{!koxcPcLC=ndtx7({awI$=?v5hs8naIcJwoa^PNjP>%jYQMq+|< zr=5f}rH_G5#tc-emd88-yy?K^kv0;OfiDO*c>aBF9mv0!2Bi%BPdwOVh`?Z%e*sEd VpKP%8!^Qvr002ovPDHLkV1iNR^nw5Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..164d65a4f20c892e9c852a5e2f978dfa1e7e7881 GIT binary patch literal 1449 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 VpKP%8!^Qvr002ovPDHLkV1iNR^nw5Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..164d65a4f20c892e9c852a5e2f978dfa1e7e7881 GIT binary patch literal 1449 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 LNkvXXu0mjfn;@Q* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb0c4bcf5396f1b52f0532f61aa3a86d77c88588 GIT binary patch literal 1444 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 literal 0 HcmV?d00001 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/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_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() {} From 60c86c63aa3db194f3e00b35b700bb545559beea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 21:07:12 +0200 Subject: [PATCH 4/7] client/server: throttle and single-flight health probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status(GetFullPeerStatus=true) RPCs trigger a full health probe (network round-trips to management, signal and the relays). The desktop UI issues these frequently and concurrently, and a burst of parallel Get() calls each fired its own probe — the lastProbe guard was unprotected against concurrent access and only advanced when every component was healthy, so a sustained unhealthy state (e.g. relay down) disabled the throttle entirely and let every call re-probe. Extract the throttle/single-flight policy into probeThrottle: - single-flight: only one probe runs at a time; concurrent callers that piled up while it ran share its result instead of each launching another, even when that probe failed. - throttle: lastOK only advances on a fully successful probe, so while anything is unhealthy callers keep probing frequently and notice recovery quickly (preserved from the original design). RunHealthProbes now takes a context so a caller that gives up (e.g. a Status RPC whose client disconnected) cancels the in-flight STUN/TURN probe instead of letting it run to its per-component timeout. The engine's own lifetime ctx still applies independently. --- client/embed/embed.go | 2 +- client/internal/engine.go | 21 +++++- client/server/debug.go | 5 +- client/server/probe_throttle.go | 88 +++++++++++++++++++++ client/server/probe_throttle_test.go | 109 +++++++++++++++++++++++++++ client/server/server.go | 24 +++--- client/server/status_stream.go | 2 +- 7 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 client/server/probe_throttle.go create mode 100644 client/server/probe_throttle_test.go 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/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 From 61da51ed2e66450f0a481afb9bce782c68ad63de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 21:11:32 +0200 Subject: [PATCH 5/7] client/peer: don't fan out unchanged management/signal state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkManagement{Connected,Disconnected} and MarkSignal{Connected, Disconnected} fired notifyStateChange unconditionally. The connect goroutine re-marks the same state on every health-check cycle, so a steady "connected -> connected" re-mark pushed a full SubscribeStatus snapshot to every consumer each time — flooding the desktop UI (and its tray) with identical Connected snapshots. Guard each with an early return when neither the state nor the error actually changed, so only real transitions wake SubscribeStatus subscribers. The notifier already deduplicates, so collapsing both calls under one guard is safe. --- client/internal/peer/status.go | 19 +++++++++++++++ client/internal/peer/status_test.go | 36 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) 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") +} From 8d05fe07bff9f22a1a8746b7ae1fd63025a8555a Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:48:39 +0200 Subject: [PATCH 6/7] Fix watcher registration on wayland (#6320) * Fix hover label on linux * Fix watcher registration on wayland --- client/ui/tray_watcher_linux.go | 22 ++++++++++++++++++++-- client/ui/xembed_host_linux.go | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go index 7a9b72096..63213985e 100644 --- a/client/ui/tray_watcher_linux.go +++ b/client/ui/tray_watcher_linux.go @@ -98,9 +98,27 @@ 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. func startStatusNotifierWatcher() { + if !xembedTrayAvailable() { + log.Debugf("StatusNotifierWatcher: no XEmbed tray present, leaving the watcher to the desktop") + return + } + 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) From 072d78946324ad020e4a2558f1a36b551e9a1b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 1 Jun 2026 21:54:37 +0200 Subject: [PATCH 7/7] client/ui: retry XEmbed-tray probe before claiming SNI watcher The XEmbed tray (panel) can come up after the autostarted UI on minimal WMs, so the single startup probe added in #6320 could miss a tray that appears a second or two later, leaving the icon silently absent. Re-probe for a ~10s grace period in a goroutine, claiming the watcher as soon as a tray shows up; back off cleanly if none ever appears (headless/Wayland). --- client/ui/tray_watcher_linux.go | 41 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go index 63213985e..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 { @@ -113,12 +124,34 @@ func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus. // 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() { - if !xembedTrayAvailable() { - log.Debugf("StatusNotifierWatcher: no XEmbed tray present, leaving the watcher to the desktop") - return - } + 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)