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] 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 zcmV;V1zY-wP)FEvBCmP=}88qP$@}RRv-EjNkx5%iVUnEDuN0MO6a9Xkwk&^AP6Lg zf})_J2MS?AMGz^ZqA7)yQrY8P@12=nANF4Ntl6HKd(Paci*OfgW@hiT*INIz*IN5A z8K7gC`X<_d@zNxKiKa;a6{+UFFP8MQq^*(;Ng9zf96#H&AxU*fpN&cZ{*RUceewHJ z;2q!y)u{;#0~ed@{)i(>+9wm3Z^giZIHCejn`D3%t41GEs%6D${AgOxsql{I#bd-NvBGh9s6cTni3}rO8P_6 zuafpl+AV39q#qQ#Sp=#fX>BxqNYXS_&%I`p^|+*8qTS5^EyU6TUBsi&5U?G11-Jp2 z?c%*3r~xa0ZzF%bqWngT{T;xxC_A$p@QL>me!Sc*XgK}=ahxzA>foKGtH?& z0CJ2c0pBD>reul+fwRf0!%N9L+d%>DC->;dz=1?&gWPHNkyo?AUB2DcZ_70o*x3Pu z^{pWf!wYHkrUz>=zs%`c1^fft3cTj{w*ym2%4S&r9>iI|PX!>XKK}^JB44>rO90*h zOd$`~^PJvCBHuNM9hCw2rCS6XbcTClZg>3GcQD?_LdlZaZ+hnGCCTru0eBFX0DsaN z&tFCl^%1JePV&4cB3m_pC&)c)N$O9nrCM1=194R}emEL(x#O`7YOJz21gxah`#wzC zR-Eww@F1>@hP>?*`m$NHF&U^mgq1X-Z37i>J}|QYP|pB(5Vrvf$z$~8#89KA>-!`E zH96kXHxn*6+kn#(T|EWhm)+V0!omXp#hu1l<5Gvl4j<;hXcLTs1(GPq< zzT+C%_hI0UIL9AVL^yW8@A#W;ULCkQnb%VQw!}5yxu|p``P>Iw>Uc9}{6)a`3BQqF zxebKX05fn2dDUqWj?&BHn4R+t#A$?+W@>zs za;xA0`n<4Bv?~CcopAHgZ-o1=Pc$pD>k|OpvzrJzxEW|;TL|UZ&$IEvxSL;9<8LhR zj43?;oSX1l4tyMWtV)d#cJl$?Z)czxyD^U0jm_@H5UNYmei(Eb`1J4>1p5lhgmC)AJ0im9Nrq`r*Xbl&t?d!ade!lUXs zNhiej$y$8dOj{P;P$g-pqz@#`Ok`OrdPglH;v1;RKk5a$KkabT7Zoj(v|Q3Ym3x%U z6#k4@AZd-Hdz>MjrwTRO4RiuvlXgiuC;qOYxT)~6of_X$>IA@Oa2)cFjAL#oDg!{O yzT}?%(|^)?`47#6(B$8&6HSuNklDc9v89poStV9b860$v050j-i(lR)zHP7bQT z0(VstSNwj=#&;8ezb67z2O0*f7x;O*KheWi-y(uB8G+wu02&6oY~&ljUf?iilLdC4 z0uBU@295v@2M)DoOMr!DWES`h_!-ypX(llH50Y4e; zrdPPFWJLTm2W$bJ11>Y-0;n`w4SY$KHf8K9#kM-~$&`K62F#LQ`E_^2yFw%3@N?2p z-rq=yM3KDrgjbuLDhbg;IjEJ0kn(fR{@-A>Duk`_sL011B}fSTJKey`nHJ#7RZ*-Qk&NoGNGwxYcy3z*1m$WV7Ldc8lq1yDu!7t|{P~E+q8qVbiYwr;v}P zC##%4)A?Ep{0&?Uyx{b=0E;MA>AR9R2H2G#q5b&>Lc!C_yhrq z<95H<)p(^!**2pTi3<#T&48TibX1^vZHsfj8XF_9K21vPIAug6E;b;qI){GSOcf@P z`i?M@0<8iHa1wBUd-t!1NL&M~Aj{EbNk_lwp1nsPDmy;XR}v05p8(4vUqhF8dQT=R zRzCWEHXbL7YL$G1*ORZT*--VgoQS2>6v`yNi^P$HPBRAIq}VLfqcO6fBH9;$8E0Hk+Dkb9LblMf&2ESY_{eS|RPZ?J9}>1_ zKThv|Cx7xQ1^#l{BO|A~ffFKqt$}yR?^5%GYCZw{MSFywws$(Q>1^C0>|&)v`8+}R zkd^8-MQA(Omghe^E+zCq(mC67mXW2HC$P!+f57>9#GWmrgeWEMq&@m65?>L{v5VXcgK)|d!O0E;UNqv1Om_|W=CSZLD&;9co~!3+BrbnW*i6=0>N86!DBTD^h;-LB zqF0YmF@x)YgUAZgSmLUXP3ndCgH#ygCD9CX^v(Gu)lABv4x~)cQ?qPqBX$S gk&I*{Bl(}pKYr1pMy#m+HUIzs07*qoM6N<$g6rga!2kdN 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() {}