per-platform tray menu icons and Windows-specific status row

The Windows menu renderer paints leading bitmaps into the Win32
check-mark slot (SetMenuItemBitmaps), which differs from how Cocoa
and GTK handle NSMenuItem.image / menu-row icons:

  - SM_CXMENUCHECK sizing: Windows expects ~16x16 at 100% DPI in the
    check-mark slot and visually overflows the row for anything bigger.
  - Disabled-state mask: Windows desaturates both the row text and the
    bitmap when MFS_DISABLED is set, so a disabled informational row
    renders the coloured status dot in greyscale.

Per the platform icon guidelines:

  Platform | Size           | Notes
  ---------|----------------|-----------------------------------------
  Windows  | 16x16          | check-mark slot, status row stays enabled
  macOS    | 22x22 (18-22)  | NSMenuItem leading image, HIG
  Linux    | 24x24 (22-48)  | GTK4 menu-row icon channel

Changes:

  * Split the menu-row icon embeds into icons_menu_{windows,darwin,linux}.go
    so each platform pulls its own size; the brand mark is rendered from
    assets/svg/netbird-menu.svg (new vector source) at 16/22/24 px with
    Inkscape, and the Windows status dots ship as 8x8 content centred on
    a 16x16 transparent canvas (the renderer upscales the bitmap, so the
    padding keeps the dot visually proportional to the row text).

  * Introduce statusRowEnabled() in tray_status_enabled_{windows,other}.go:
    true on Windows so the disabled-state mask does not strip the dot's
    colour; false on macOS/Linux where disabled menu rows fade the label
    without desaturating the leading bitmap, signalling that the row is
    informational.

  * Add an icon to the About submenu using the same brand mark.
This commit is contained in:
Zoltán Papp
2026-05-21 16:41:52 +02:00
parent c3aeb5be15
commit 85029898a5
23 changed files with 183 additions and 33 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

+7
View File
@@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0.5 4.5)">
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 556 B

+6 -21
View File
@@ -59,24 +59,9 @@ var iconUpdateDisconnectedMacOS []byte
//go:embed assets/netbird.png
var iconWindow []byte
// Small colored dots shown next to the status menu entry. Rendered as
// regular NSImage/HBITMAP/GTK menu-item icons (not template), so the
// colours stay intact on every platform.
//go:embed assets/netbird-menu-dot-connected.png
var iconMenuDotConnected []byte
//go:embed assets/netbird-menu-dot-connecting.png
var iconMenuDotConnecting []byte
//go:embed assets/netbird-menu-dot-login.png
var iconMenuDotLogin []byte
//go:embed assets/netbird-menu-dot-error.png
var iconMenuDotError []byte
//go:embed assets/netbird-menu-dot-idle.png
var iconMenuDotIdle []byte
//go:embed assets/netbird-menu-dot-offline.png
var iconMenuDotOffline []byte
// Per-platform menu-row icons (status dots + NetBird brand mark) live in
// icons_menu_windows.go and icons_menu_other.go. Windows installs them
// into the Win32 check-mark slot, which expects SM_CXMENUCHECK-sized
// bitmaps (~16x16 at 100% DPI) — anything bigger gets cropped, anything
// smaller leaves blank space — so Windows ships its own 16x16 set
// while macOS/Linux keep the larger 24x24 assets that fit their menus.
+39
View File
@@ -0,0 +1,39 @@
//go:build darwin
package main
import _ "embed"
// 22x22 menu-row icons used on macOS. Apple's HIG recommends an 1822 px
// glyph for NSMenuItem leading images; 22 sits at the top of that range
// and matches the visual weight of the surrounding row text. Windows
// ships a 16x16 variant (Win32 SM_CXMENUCHECK slot) and Linux a 24x24
// variant (GTK menu row supports the larger range) — see the sibling
// icons_menu_*.go files.
//
// Regenerate the brand mark from assets/svg/netbird-menu.svg (vector
// source — re-rendering keeps the strokes crisp at every target size):
// inkscape assets/svg/netbird-menu.svg -o netbird-menu-22.png -w 22 -h 22 \
// --export-background-opacity=0
// Status dots are downscaled from the 24x24 originals with ImageMagick.
//go:embed assets/netbird-menu-22.png
var iconMenuNetbird []byte
//go:embed assets/netbird-menu-dot-connected-22.png
var iconMenuDotConnected []byte
//go:embed assets/netbird-menu-dot-connecting-22.png
var iconMenuDotConnecting []byte
//go:embed assets/netbird-menu-dot-login-22.png
var iconMenuDotLogin []byte
//go:embed assets/netbird-menu-dot-error-22.png
var iconMenuDotError []byte
//go:embed assets/netbird-menu-dot-idle-22.png
var iconMenuDotIdle []byte
//go:embed assets/netbird-menu-dot-offline-22.png
var iconMenuDotOffline []byte
+40
View File
@@ -0,0 +1,40 @@
//go:build linux
package main
import _ "embed"
// 24x24 menu-row icons used on Linux. GTK4 menu rows accept icons in the
// 2248 px range with no automatic downscaling at this size; 24 reads
// cleanly next to the row text across the GNOME / KDE / minimal-WM
// flavours we ship to. Windows ships a 16x16 variant (Win32
// SM_CXMENUCHECK slot) and macOS a 22x22 variant — see the sibling
// icons_menu_*.go files.
//
// Regenerate the brand mark from assets/svg/netbird-menu.svg (vector
// source — re-rendering keeps the strokes crisp at every target size):
// inkscape assets/svg/netbird-menu.svg -o netbird-menu-24.png -w 24 -h 24 \
// --export-background-opacity=0
// Status dots are the canonical 24x24 originals used everywhere else
// in the legacy Fyne tray.
//go:embed assets/netbird-menu-24.png
var iconMenuNetbird []byte
//go:embed assets/netbird-menu-dot-connected.png
var iconMenuDotConnected []byte
//go:embed assets/netbird-menu-dot-connecting.png
var iconMenuDotConnecting []byte
//go:embed assets/netbird-menu-dot-login.png
var iconMenuDotLogin []byte
//go:embed assets/netbird-menu-dot-error.png
var iconMenuDotError []byte
//go:embed assets/netbird-menu-dot-idle.png
var iconMenuDotIdle []byte
//go:embed assets/netbird-menu-dot-offline.png
var iconMenuDotOffline []byte
+42
View File
@@ -0,0 +1,42 @@
//go:build windows
package main
import _ "embed"
// 16x16 menu-row icons used on Windows. The Win32 SetMenuItemBitmaps API
// paints the HBITMAP into the check-mark slot, sized to SM_CXMENUCHECK /
// SM_CYMENUCHECK (typically 16x16 at 100% DPI). Larger bitmaps overflow
// the row visually, so Windows ships its own scaled set instead of the
// 24x24 assets used on macOS/Linux. Regenerate the brand mark from
// assets/svg/netbird-menu.svg (vector source — re-rendering keeps the
// strokes crisp at every target size):
// inkscape assets/svg/netbird-menu.svg -o netbird-menu-16.png -w 16 -h 16 \
// --export-background-opacity=0
// The status dots are downscaled from the 24x24 originals with
// ImageMagick — simple solid-fill circles survive the bicubic resize
// without visible quality loss:
// magick netbird-menu-dot-<state>.png -resize 16x16 \
// -background none -gravity center -extent 16x16 \
// netbird-menu-dot-<state>-16.png
//go:embed assets/netbird-menu-16.png
var iconMenuNetbird []byte
//go:embed assets/netbird-menu-dot-connected-16.png
var iconMenuDotConnected []byte
//go:embed assets/netbird-menu-dot-connecting-16.png
var iconMenuDotConnecting []byte
//go:embed assets/netbird-menu-dot-login-16.png
var iconMenuDotLogin []byte
//go:embed assets/netbird-menu-dot-error-16.png
var iconMenuDotError []byte
//go:embed assets/netbird-menu-dot-idle-16.png
var iconMenuDotIdle []byte
//go:embed assets/netbird-menu-dot-offline-16.png
var iconMenuDotOffline []byte
+24 -12
View File
@@ -242,7 +242,7 @@ func (t *Tray) reapplyMenuState() {
if t.statusItem != nil && lastStatus != "" {
t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus))
t.statusItem.SetEnabled(false)
t.statusItem.SetEnabled(statusRowEnabled())
t.applyStatusIndicator(lastStatus)
}
if t.sessionExpiresItem != nil {
@@ -314,13 +314,18 @@ func (t *Tray) ShowWindow() {
func (t *Tray) buildMenu() *application.Menu {
menu := application.NewMenu()
// statusItem shows the daemon's current status. Disabled (and no
// OnClick handler) so clicks are no-ops the row is informational
// only. The Connect entry below drives every actionable transition,
// including the SSO re-auth flow for NeedsLogin/SessionExpired
// (the daemon's Up RPC returns NeedsSSOLogin when applicable).
// statusItem shows the daemon's current status. Informational row
// with no OnClick handler clicks are no-ops. Whether the row is
// kept enabled is platform-dependent (see statusRowEnabled): on
// Windows the disabled-state mask would desaturate the coloured
// status dot painted into the check-mark slot, so the row stays
// enabled there; macOS/Linux disable it so the greyed-out label
// signals that it is not clickable. The Connect entry below drives
// every actionable transition, including the SSO re-auth flow for
// NeedsLogin/SessionExpired (the daemon's Up RPC returns
// NeedsSSOLogin when applicable).
t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")).
SetEnabled(false).
SetEnabled(statusRowEnabled()).
SetBitmap(iconMenuDotIdle)
// sessionExpiresItem sits directly below the status row so the
@@ -376,7 +381,11 @@ func (t *Tray) buildMenu() *application.Menu {
menu.AddSeparator()
about := menu.AddSubmenu(t.loc.T("tray.menu.about"))
aboutLabel := t.loc.T("tray.menu.about")
about := menu.AddSubmenu(aboutLabel)
if aboutItem := menu.FindByLabel(aboutLabel); aboutItem != nil {
aboutItem.SetBitmap(iconMenuNetbird)
}
about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) {
_ = t.app.Browser.OpenURL(urlGitHubRepo)
})
@@ -621,11 +630,14 @@ func (t *Tray) applyStatus(st services.Status) {
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
connecting := strings.EqualFold(st.Status, services.StatusConnecting)
if t.statusItem != nil {
// Label-only: kept disabled (informational row). Swap the
// displayed text so the user sees a familiar phrase instead
// of the raw daemon enum.
// Label-only: row is informational (no OnClick). Enablement
// is platform-dependent via statusRowEnabled — Windows
// keeps it enabled so the Win32 disabled-state mask does
// not desaturate the coloured dot; macOS/Linux disable it.
// Swap the displayed text so the user sees a familiar
// phrase instead of the raw daemon enum.
t.statusItem.SetLabel(t.loc.StatusLabel(st.Status))
t.statusItem.SetEnabled(false)
t.statusItem.SetEnabled(statusRowEnabled())
t.applyStatusIndicator(st.Status)
}
if t.upItem != nil {
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows && !android && !ios && !freebsd && !js
package main
// statusRowEnabled reports whether the informational status row at the
// top of the tray menu should stay enabled. False on macOS and Linux:
// both platforms paint disabled menu rows at slightly reduced opacity
// without desaturating the leading bitmap, so the coloured status dot
// stays visible while the greyed-out label still signals to the user
// that the row is informational and not clickable. Windows opts in via
// the sibling tray_status_enabled_windows.go file.
func statusRowEnabled() bool { return false }
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
package main
// statusRowEnabled reports whether the informational status row at the
// top of the tray menu should stay enabled. Always true on Windows:
// the Win32 disabled-state mask desaturates both the row text and the
// HBITMAP painted into the check-mark slot, so a disabled row would
// render the coloured status dot in greyscale and defeat the indicator.
// macOS/Linux disable the row (see tray_status_enabled_other.go) because
// neither platform applies that desaturation and the visual cue that
// the row is informational reads better.
func statusRowEnabled() bool { return true }