NSMenuItem.setImage stretches the row to the leading image's pixel
size regardless of the surrounding rows, so any non-empty bitmap on
the About entry made it visibly taller than the rest of the tray
menu — leaving 16, 18 or 22 px versions all looking wrong next to
the unadorned rows above and below.
Drop the macOS brand mark and gate the SetBitmap call on a non-empty
byte slice; iconMenuNetbird is now nil on macOS, so the About row
falls back to text only. Windows and Linux still ship the brand mark
through their per-platform embed files.
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.
The main-window toggle stayed visually stuck on "Connecting" when the
user clicked Connect in the UI and then clicked Disconnect in the
tray (or the daemon was otherwise cancelled mid-Connecting).
Repro: open the main window, click the toggle to Connect, then while
the daemon is still in Connecting click Disconnect in the tray menu.
The tray and daemon agree the session is Idle, but the React toggle
keeps painting "Connecting" until the next manual interaction.
Root cause is in ConnectionStatusSwitch.tsx. The component holds an
`action` latch ("connect" | "logging-in" | "disconnect" | null) so the
toggle can show an optimistic transitional state while the daemon
catches up. The connState memo treats `action === "connect"` plus any
non-Connected daemon state as Connecting:
if ((action === "connect" || action === "logging-in") &&
daemonState !== "Connected") {
return ConnectionState.Connecting;
}
The effect that releases the latch only cleared it on `Connected` or
`DaemonUnavailable`. There was no branch for "the connect flow was
cancelled externally and the daemon is back at Idle", so the latch
remained set forever and the optimistic Connecting state never
collapsed.
Fix: add a `sawConnectingRef` that flips to true the first time the
daemon reports Connecting during an active "connect" action, and
resets when `action` returns to null. When `action === "connect"` and
the daemon flips from a state we'd observed as Connecting back to
Idle, clear the latch so connState falls through to Disconnected.
Other paths are untouched:
- Successful connect still clears on Connected.
- NeedsLogin still hands off to driveLogin.
- DaemonUnavailable still clears via the `unreachable` branch.
- The `"logging-in"` action is intentionally not handled here; Login's
internal Down flaps the daemon through Idle and driveLogin's
.finally remains the sole clearer for that latch.
- The `"disconnect"` action's Idle/Disconnected/unreachable clear is
unchanged.
The daemon returns gRPC errors whose message is a wrapped mgm + JWT
stack (e.g. "invalid jwt token, err: token could not be parsed: ...").
Showing that in a native dialog is unreadable. Connection now maps the
substrings it recognises to a ClientError{code, short, long} so the UI
can render a localised summary plus a Details: block carrying the raw
daemon text. formatErrorMessage on the TS side reads the structured
payload from Wails' Error.cause (or the JSON-stringified Error.message)
and falls back to plain Error.message for callers not yet migrated.
Also bumps Wails to v3.0.0-alpha.95.
Adds an end-to-end SSO session-extension feature: the management server
publishes per-peer session deadlines on every Login/Sync, a new
ExtendAuthSession RPC refreshes the deadline using a fresh JWT without
tearing down the tunnel, and the daemon tracks the deadline locally so
the UI can fire a T-10min warning toast with an interactive "Extend now"
action.
- Peers.Get returns Status{Status: DaemonUnavailable} on Unavailable
instead of an error so the React useStatus initial refresh picks up
the same string the live event stream emits — the overlay no longer
depends on receiving the synthetic event during boot.
- ProfileContext.refresh swallows Unavailable so the redundant
"Load Profiles Failed" popup does not overlap the overlay.
- Tray Profiles submenu is disabled while the daemon is unavailable,
matching the existing settings/debug/connect gating.
- gRPC client uses a 5s ConnectParams MaxDelay; the default 120s cap
was keeping the SubChannel in backoff for tens of seconds after the
daemon came back, masking the recovery.
Brings two main-side PRs' UI behavior across the Fyne→Wails rewrite:
- #5631 (IPv6 overlay support): add "Enable IPv6" row to the polished
SettingsNetwork tab; the legacy screens/Settings.tsx already had it,
but modules/settings/SettingsNetwork.tsx (the user-visible Settings
window) was missing the toggle.
- #6150 (mirror v4 exit selection onto v6 pair): replace the literal
"0.0.0.0/0" || "::/0" filter in screens/Networks.tsx with an
isDefaultRoute() helper that handles the daemon's merged-range
display string (e.g. "0.0.0.0/0, ::/0"), so paired v4/v6 exit
nodes are classified correctly.
The auto-update feature was driven by two narrow Wails events
(netbird:update:available and :progress) plus a SystemEvent-metadata
iteration on the React side. Both surfaces had to know the daemon
metadata schema (new_version_available, enforced, progress_window),
and the frontend had no pull endpoint to seed its state on mount.
Extract the state machine into a new client/ui/updater package, mirroring
how i18n and preferences are split between domain logic and a thin
services facade. The package owns the State type, the metadata-key
parsing, the mutex-guarded Holder, and the single netbird:update:state
event. services.Update keeps the daemon RPCs (Trigger, GetInstallerResult,
Quit) and gains GetState as a Wails pull endpoint.
Tray-side update behaviour moves out of tray.go into a dedicated
trayUpdater (tray_update.go): owns its menu item, OS notification,
click handler, and the /update window opener triggered by the
daemon's progress_window:show. tray.go drops three callbacks and four
fields, and reads hasUpdate through the updater.
Frontend ClientVersionContext now seeds from Update.GetState() and
subscribes to netbird:update:state; the status.events iteration and
metadata-key string literals are gone. UpdateAvailableBanner renders
only for the enforced && !installing branch and labels its action
"Install now"; UpdateVersionCard splits the install vs. download
branches by Enforced so the disabled flow routes to GitHub.
Adds a tray + React translation pipeline driven by a single JSON locale
tree (frontend/src/i18n/locales) embedded into the Go binary. The tray
re-renders on language switch via a Localizer that subscribes to the
preferences store.
Layout:
- client/ui/i18n: Bundle, LanguageCode, Language, errors, embedded-FS
loader. Pure domain, no Wails/daemon deps.
- client/ui/preferences: Store + UIPreferences for user-scope UI state,
persisted under os.UserConfigDir()/netbird/ui-preferences.json with
atomic writes and a subscribe/broadcast channel.
- client/ui/services: thin Wails-binding facades (services.I18n,
services.Preferences) so React sees ctx-first signatures.
- client/ui/localizer.go: tray bridge that owns the active language,
exposes T()/StatusLabel() and re-paints the menu on prefs change.
- tray.go: every user-facing const replaced by translation keys via
t.loc.T(...); menu rebuild + state replay on language switch.
- main.go: //go:embed all:frontend/src/i18n/locales, wires Bundle ->
Store -> Localizer -> Wails facades in order.
Frontend API exposed via Wails bindings: I18n.Languages, I18n.Bundle,
Preferences.Get, Preferences.SetLanguage, plus the
netbird:preferences:changed event.
Includes regenerated Wails TS bindings (peers/profileswitcher/etc.
re-emitted as part of the build) and en/hu seed bundles.
The optimistic Connecting paint and the Idle/stale-Connected
suppression lived in the tray's applyStatus, so only the tray got the
smoothed-out transition during a profile switch — the React Status
page (useStatus hook in frontend) subscribes to the same
netbird:status event and was seeing the raw daemon stream, complete
with the Disconnected blink.
Move the policy one layer up into the Peers service, between
SubscribeStatus and the Wails event bus, so every consumer downstream
sees the same filtered stream:
* Peers gains BeginProfileSwitch / CancelProfileSwitch / shouldSuppress.
BeginProfileSwitch sets the in-progress flag and emits a synthetic
Connecting status so both the tray and React paint Connecting
immediately. shouldSuppress swallows the daemon's stale Connected
(peer-count teardown) and transient Idle (Down between flows)
until Connecting / NeedsLogin / LoginFailed / SessionExpired /
DaemonUnavailable indicates the new profile's flow has started,
or a 30s safety timeout fires.
* ProfileSwitcher.SwitchActive calls peers.BeginProfileSwitch when
wasActive (prevStatus was Connected or Connecting) — the only
cases where the daemon emits the blink-inducing sequence. Other
prevStatuses already terminate cleanly on Idle.
* Tray loses its switchInProgress fields, applyOptimisticConnecting
helper, applyStatus suppression switch, and switchProfile's
optimistic-paint call. handleDisconnect now calls
Peers.CancelProfileSwitch alongside cancelling switchCancel, so
the abort path bypasses the suppression filter and the daemon's
Idle paints through immediately.
The full prevStatus -> action / optimistic label / suppressed events
matrix now lives in the ProfileSwitcher struct godoc, with the
suppression-rule-per-incoming-status table on the Peers struct
godoc — together they describe the click-time policy and the
stream-filter behaviour without duplication.
Wails bindings need regenerating to pick up Peers.BeginProfileSwitch
and Peers.CancelProfileSwitch.
Three UX fixes for the tray's profile-switch flow:
* Optimistic Connecting paint when switching from Connected/Connecting.
Previously the daemon's Down step emitted Idle before the new
profile's Up emitted Connecting, leaving the tray flashing
"Disconnected" for the duration of the Down. switchProfile now sets a
flag and synthesizes a Connecting paint at click time; applyStatus
suppresses the transient Idle and the stale Connected updates that
arrive during the old profile's teardown, clearing the flag only when
the new profile's flow surfaces (Connecting, NeedsLogin, LoginFailed,
SessionExpired, DaemonUnavailable, or a 30s safety timeout).
* Disconnect during an in-flight switch now actually disconnects. The
switchCancel context cancels the ProfileSwitcher.SwitchActive
goroutine so its queued Up RPC never fires, and the
switchInProgress flag is cleared so the daemon's Idle push paints
through immediately. Without this, the user's Disconnect click was
followed seconds later by the switcher's Up bringing the new
profile back online.
* The first menu row is informational only. SetEnabled(false) is
re-applied to t.statusItem (initial build, applyStatus, and the
optimistic paint) and the openRoute("/login") OnClick handler is
dropped — every actionable transition flows through the
Connect/Disconnect entries below.
The switchProfile and applyStatus godocs carry the full
prevStatus → suppressed-events / final-state transition tables so
future readers don't have to rebuild the policy from the code.