KDE Plasma routes a tray left-click to the SNI Activate method (right-click
opens the context menu), but NetBird wired no Activate action, so on KDE a
left-click appeared completely dead while only right-click surfaced the menu.
Bind the Linux tray OnClick handler to ShowWindow(). OpenMenu() is not an
option on Linux: Wails v3 leaves linuxSystemTray.openMenu unimplemented (it
only logs), so left-click→OpenMenu would still do nothing on KDE. ShowWindow()
is the same call Windows already runs from its double-click handler, and it
does not reproduce the macOS OpenMenu freeze (c77e5cef8) — that came from
NSStatusItem's blocking embedded menu loop, whereas Show/Focus return
immediately.
Split the Linux click handler into its own tray_click_linux.go and narrow the
macOS no-op bindTrayClick build tag accordingly. The context menu stays on
right-click on every host. On hosts that already open the menu on left-click
natively (GNOME Shell + AppIndicator) left-click now opens the window instead;
the menu remains on right-click.
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).
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.
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.
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.
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.
A native Windows MessageBox attached to a parent window disables that
window (WS_DISABLED) for its lifetime and re-enables it on dismissal.
When the parent is the main window — whose WindowClosing hook hides
instead of closes — the enable/hide sequence races and leaves the window
unable to process its close (X) button afterwards, so e.g. a rejected
login error dialog left the main window stuck open.
Route all native dialogs through src/lib/dialogs.ts, which forces
Detached: true on Windows (NULL owner, no window ever disabled) and is a
no-op on macOS/Linux (keeps the attached sheet-style presentation).
Win32 swallows a lone & in an MFT_STRING menu item as the mnemonic
prefix, so "Help & Support" rendered as "Help Support". Add a
build-tagged menuLabel() helper that doubles & to && on Windows and is
the identity on macOS/Linux (which render & literally), and apply it to
the About submenu label.
Wire the Windows systray's double-click to ShowWindow(), matching the
Windows-native convention for tray apps. The Wails v3 systray dispatches
WM_LBUTTONDBLCLK to the doubleClickHandler, so OnDoubleClick fires; left-
and right-click continue to open the menu. macOS/Linux are unchanged.
Two follow-ups to the "hold NeedsLogin during the SSO browser wait" change.
Both target the visible state churn the tray showed during the auto-login
handoff (Connect / profile-switch lands on NeedsLogin -> the UI's startLogin
kicks off the SSO flow) and the broken recovery after the user dismisses the
browser-login popup with the window's X.
Background
----------
When a connect attempt lands on NeedsLogin, the UI's startLogin() drives the
SSO flow: Connection.Login() -> (NeedsSSOLogin) open the browser-login popup
-> Connection.WaitSSOLogin() blocks until the browser leg completes. The tray
and the React status page both paint the raw daemon status, so any transient
state the daemon publishes during this handoff is visible as a flicker.
Previously the handoff churned the daemon status through
NeedsLogin -> Idle -> Connecting -> NeedsLogin
which read as a flicker on the tray icon and the status dot. Two distinct
sources produced the two intermediate states:
* Idle came from the UI's defensive cli.Down() at the top of
Connection.Login (services/connection.go): it tore the engine
down before every login to dislodge a possibly-parked
WaitSSOLogin, emitting a StatusIdle on the way.
* Connecting came from server.go Login() unconditionally setting
StatusConnecting before deciding whether the request is an
SSO flow (which immediately returns NeedsLogin) or a
setup-key flow (which actually dials Management).
Changes
-------
1. server.go Login(): only set StatusConnecting on the setup-key path, where
we are about to dial Management with the key and the Connecting paint is
meaningful. The SSO path returns NeedsLogin and parks on the browser leg,
so it no longer flashes Connecting first. Removes the Connecting blip.
2. services/connection.go Login(): drop the pre-Login cli.Down(). The daemon
already dislodges a pending WaitSSOLogin at Login entry (actCancel), and an
abandoned browser leg is now torn down by cancelling the WaitSSOLogin RPC
(see 3/4). Removing the Down removes the Idle blip on every login.
3. MainConnectionStatusSwitch.tsx startLogin(): on cancel (the browser-login
popup's Cancel button or its window X, both routed through
EventBrowserLoginCancel), cancel the in-flight WaitSSOLogin gRPC call via
waitPromise.cancel() instead of issuing a heavy Connection.Down(). The
daemon ties the wait to this call's context, so cancelling the call ends
the wait cleanly with no engine teardown and no Idle paint.
4. server.go WaitSSOLogin(): when the wait unblocks with context.Canceled and
the cancellation came from our caller (callerCtx.Err() != nil — the client
cancelled the RPC or went away), clear the cached oauthAuthFlow so a fresh
Login starts a new device code instead of reusing the abandoned one. The
entry NeedsLogin stays in place, so a reattaching client still shows the
login affordance. An internal abort (actCancel fired by a newer
Login/WaitSSOLogin while our callerCtx is still live) is left untouched so
the new owner's flow is not clobbered.
Effect
------
The auto-login handoff now goes Connected -> Connecting -> NeedsLogin and
holds, with no Idle/Connecting flicker in between. Dismissing the browser-login
popup with X now recovers the same way as the Cancel button: the WaitSSOLogin
RPC is cancelled, the stale OAuth flow is cleared, and the next connect opens a
fresh browser-login window instead of getting stuck.
WaitSSOLogin set StatusConnecting on entry and ran the browser wait on
rootCtx. If the client that drove the login went away mid-wait (UI restart,
CLI Ctrl+C), the wait orphaned on rootCtx until the OAuth device-code window
expired, and the daemon stayed stuck reporting Connecting — a reattaching
client saw a spinner that never resolved instead of a login prompt.
Hold StatusNeedsLogin for the whole browser wait (also in the Login
cached-flow path) so any client attaching mid-wait reads 'login required',
and bridge the wait to callerCtx so a departing client cancels it. On that
cancel the defer leaves NeedsLogin in place, so the next client shows the
login affordance instead of a stale Connecting.
StatusLabel only mapped Idle and DaemonUnavailable, so Connected,
Connecting, NeedsLogin, LoginFailed and SessionExpired leaked the raw
daemon enum into the tray menu — untranslated in de/hu. Map all five to
tray.status.* keys (added in en/de/hu); keep the raw-enum default as a
fallback for any future status.
Add a second, longer-lived switchLoginWatch flag alongside switchInProgress
in DaemonFeed. Suppression still clears on the first Connecting push from the
new Up, but the login watcher survives past it to catch the eventual
NeedsLogin / LoginFailed / SessionExpired terminal and emit EventTriggerLogin,
so the React orchestrator opens the browser-login flow without a second
Connect click. shouldSuppress becomes consumeForSwitch, returning both the
suppress and triggerLogin signals. CancelProfileSwitch disarms the watch so
an aborted switch does not pop a login window.
Add an Autostart Wails service wrapping app.Autostart and a toggle in
the General settings tab. The OS login-item registration is the single
source of truth (nothing mirrored to the preferences file). Affects the
graphical UI only, not the daemon. The toggle hides itself on platforms
where autostart is unsupported.
Without an explicit authorization request the macOS notification center
keeps the app at .notDetermined and silently drops every toast. Request
it from the ApplicationStarted hook (after the notifier's Startup has
initialised the delegate), off the main goroutine since the call blocks
until the user responds. Linux/Windows notifier stubs report authorized,
so this is a no-op there.
Wails v3 does not auto-show the tray menu on left-click on Windows — its
default left-click handler only logs and does nothing visible, so only
right-click opened the menu. macOS (NSStatusItem) and Linux
(StatusNotifierItem host) give us click→menu natively.
Add a build-tag-split bindTrayClick: the Windows variant wires
OnClick→OpenMenu (the same menu.ShowAt path right-click uses), while the
macOS/Linux variant stays a no-op — binding OnClick→OpenMenu on macOS
freezes the tray via NSStatusItem's blocking mouseDown on the main GCD
queue (the reason commit c77e5cef8 reverted the earlier wiring).
test.go is a non-_test.go file so its exported StartTestServer helper is
visible to the ssh/proxy and ssh/client external test packages. That drags
the testing/flag/regexp chain into every build that links ssh/server,
including the wasm client (via the engine). Gate the file with //go:build
!js: native test packages still see the helper, wasm drops the dependency.
The wasm client never runs the engine's session-warning flow, so linking
the full sessionwatch package (timers, event composition) only bloats the
binary. Put the watcher behind a sessionDeadlineWatcher interface and split
the constructor by build tag: !js wires the real sessionwatch.Watcher, js
gets a no-op stub that still mirrors the deadline into the status recorder
(so the Status snapshot stays correct) but drops the timers. Removes the
sessionwatch package from the wasm dependency graph.
de/hu translations contain real foreign words (Sie, oder, ist) that
codespell flags as misspellings. Only en/common.json is the spell-check
source of truth; add each new locale dir to the skip list as languages land.
Extract parseFlagsAndInitLog, newApplication, buildI18n, registerServices,
and newMainWindow so main() stays under the 100-line limit. Wiring order and
shared service instances are unchanged.
Move the event backoff op body into subscribeAndStreamEvents and the
per-event fan-out into dispatchSystemEvent, bringing toastStreamLoop under
the 20 cognitive-complexity limit. No behavior change.
Explain that macOS/Windows have a native tray and the SNI+XEmbed bridge is
Linux-WM-only, so the body is intentionally empty to let main.go call it
unconditionally across all build targets.
Move the status backoff op closure body into a method so the nested
closure no longer carries the stream loop and its conditionals, bringing
statusStreamLoop under the 20 cognitive-complexity limit. No behavior change.
Extract helpers to bring three methods under the 20 cognitive-complexity
limit without changing behavior:
- DaemonFeed.statusStreamLoop: split out handleStatusRecvErr and emitStatus
- Tray.applyStatus: split out consumePendingConnectLogin and
refreshMenuItemsForStatus
- xembedHost.flattenMenu: split out menuItemFromLayout plus propString /
propBool / propInt32 dbusmenu property accessors
Missed in the previous commit. The StatusContext is the only frontend
consumer of the renamed service (the modules/main/.../peers/Peers.tsx
React component is a different identifier — unchanged).
The build/config.yml that wails3 init scaffolded shipped with 'My Company',
'My Product', 'com.mycompany.myproduct' and '(c) 2025, My Company' template
defaults. The per-platform assets generated from it (Info.plist,
Info.dev.plist, info.json, nsis/wails_tools.nsh) carried the same strings,
which were visible in macOS Finder Get Info, Windows .exe Properties and
the NSIS installer.
Updated to the NetBird identity used by the legacy Fyne UI on main:
- companyName / copyright -> 'NetBird GmbH' (matches main release.yml's
COPYRIGHT env passed to goversioninfo)
- productName -> 'NetBird'
- productIdentifier -> 'io.netbird.client' (matches CFBundleIdentifier)
- description -> 'NetBird desktop client'
- darwin NSHumanReadableCopyright -> 'NetBird GmbH'
- windows LegalCopyright -> 'NetBird GmbH'
- nsis INFO_COPYRIGHT -> 'NetBird GmbH'
Version fields (0.0.1) are left in place: release builds get the real
version via goversioninfo (Windows) and sign-pipelines (macOS .app),
so the placeholder is only visible in local task package / task run
output and doesn't reach release artifacts.
The 1542-line tray.go grew into a 14-feature kitchen sink. Split it
into feature-coherent same-package siblings, give the daemon-stream
service a name that matches what it actually does, and trim the
cargo-cult context.WithCancel pattern from click handlers.
File layout (tray.go: 1542 → ~470 lines):
- tray_status.go onStatusEvent / applyStatus / status indicator
- tray_icon.go applyIcon / iconForState (tray icon painting)
- tray_events.go onSystemEvent + eventTitle / titleCase, plus a
shouldSkipSystemEvent helper that names the
three "daemon notification we don't surface"
filters
- tray_session.go session-expiry row + warning notification flow +
handleSessionExpired (moved from tray.go)
- tray_profiles.go loadConfig / loadProfiles / switchProfile
- tray_exitnodes.go exit-node submenu (rebuild / refresh / toggle)
Mutex split: the kitchen-sink t.mu becomes four domain-scoped mutexes
so a long-running gRPC call in one domain can't block status-push
readers in another:
- statusMu connected / lastStatus / lastDaemonVersion /
lastNetworksRevision / pendingConnectLogin
- sessionMu sessionExpiresAt (read by the 30s ticker,
written by applySessionExpiry on every status push)
- profileMu activeProfile / activeUsername /
notificationsEnabled / switchCancel
- exitNodesMu row cache (read in reapplyMenuState's Repaint copy)
- exitNodesRebuildMu serialises ListNetworks + submenu rebuild +
SetMenu (already separate, kept)
Service rename: the "Peers" service handled the daemon's full
SubscribeStatus snapshot (peers, daemon version, management/signal
link state, networks revision, SSO deadline) plus the SubscribeEvents
notification stream and the profile-switch suppression filter. Peers
was a misleading name for a daemon-stream fan-out service. Rename to
DaemonFeed in services/, profileswitcher's stored reference, the
TrayServices struct, main.go wiring, and every doc comment that
referenced it. peers.go → daemon_feed.go. The Status.Peers field
itself (the peer list in the snapshot) is unchanged.
Event constant renames (wire strings unchanged so the frontend keeps
working without regenerating bindings beyond the rename):
- EventStatus → EventStatusSnapshot
Payload is a full Status struct (daemon-wide snapshot), not just
a state-change ping — name the value-shape.
- EventSystem → EventDaemonNotification
Payload is a daemon SystemEvent meant to drive an OS toast or a
Recent Events row. "System" was too generic; "Notification"
matches what consumers do with it.
Concurrency fixes:
- WaitExtendAuthSession now preempts a previous in-flight wait
via the existing SetWaitCancel/CancelWait infrastructure on
PendingFlow, the same pattern WaitSSOLogin uses. The previous
waiter exits with codes.Canceled; the authsession service
translates that to ExtendResult{Preempted: true} so the tray
and the about-to-expire dialog stay silent on the losing flow
instead of showing a false-failure toast. Without this, both
a tray "Extend now" click and a dialog "Stay connected" click
on the same deadline started two parallel IdP polls, and
whichever lost the device-code check painted a bogus error.
- mgmClient.ExtendAuthSession drops the dead backoff retry loop.
The loop only retried on codes.Canceled, but the inner mgmCtx
was derived from context.Background() and never cancelled, so
every real error went straight to backoff.Permanent on the
first attempt. Replace with a single
context.WithTimeout(c.ctx, ConnectTimeout) call; daemon
shutdown now interrupts the RPC and behaviour on real errors
is unchanged.
Click-handler hygiene: six call sites used the cargo-cult
context.WithCancel(context.Background()) + defer cancel() pattern
without ever calling cancel() externally. Replace with
context.Background() directly (loadConfig, loadProfiles,
runExtendSession, dismissSessionWarning, handleConnect's Up,
handleDisconnect's Down). The one site that genuinely needs the
cancel — switchProfile, which stores it in t.switchCancel so
handleDisconnect can preempt the switch — keeps WithCancel.
Helper extraction: shouldSkipSystemEvent groups the three
"daemon notification we drop on the floor" checks
(new_version_available metadata, progress_window metadata, the
::/0 partner of an exit-node default-route event) behind a single
named predicate. Each had a comment explaining why; collecting
them moves the rationale into the helper docstring and shrinks
onSystemEvent to a router.
The backoff loop only retried on codes.Canceled, but mgmCtx was derived
from context.Background() and never cancelled by anything — so every
real error path (Unavailable, DeadlineExceeded, etc.) went through
backoff.Permanent on the first attempt. The loop was a no-op wrapper
that just held the call open for the daemon's lifetime regardless of
shutdown.
Replace with a single context.WithTimeout(c.ctx, ConnectTimeout) call.
Daemon shutdown now interrupts the RPC; behaviour on real errors is
unchanged.
When the tray "Extend now" notification action and the about-to-expire
dialog both start a flow for the same deadline, the daemon was running
two independent IdP polls and the older one surfaced an InvalidArgument
toast as soon as the second RequestExtend overwrote the pending flow.
Follow the WaitSSOLogin pattern: at the top of WaitExtendAuthSession
cancel the previous wait (the SetWaitCancel/CancelWait pair on
PendingFlow already existed but was unused), then register the new
wait's cancel. Preempted callers exit with codes.Canceled; the
authsession service translates that into ExtendResult{Preempted: true}
so the tray and the React dialog can stay silent on the losing flow
instead of showing a false-failure toast / error dialog.
* add SSO session extend flow (management)
Adds the management-server half of the SSO session-extension feature:
- New ExtendAuthSession gRPC RPC that refreshes a peer's session expiry
using a fresh JWT, validated through the same pipeline as Login but
without tearing down the tunnel or redoing the NetworkMap sync.
- Per-peer SessionExpiresAt timestamp on every LoginResponse and
SyncResponse so connected clients learn the deadline on the existing
long-lived stream, and admin-side changes (toggling expiration,
changing the expiration window) reach every peer within seconds.
- SessionExpiresAt(...) helper on Peer that derives the absolute UTC
deadline from LastLogin + the account-level PeerLoginExpiration
setting, returning zero when the peer is not SSO-tracked or expiration
is disabled.
The matching client-side consumer of these fields lands separately.
* encode SessionExpiresAt as 3-state on the wire
Previously the `sessionExpiresAt` field on LoginResponse, SyncResponse
and ExtendAuthSessionResponse was 2-state: a valid timestamp meant
"new deadline", and nil meant "clear". That conflated two distinct
meanings — "no info in this snapshot" vs "expiry is explicitly off /
peer is not SSO-tracked" — so a Sync push that legitimately couldn't
compute the deadline (settings lookup failed) would silently clear the
client's anchor and lose the warning window.
Three states now, encoded on the same field number (no .proto schema
churn — only comments and the server-side encoder change):
- nil pointer (field absent) → "no info"; client preserves anchor
- &Timestamp{} (seconds=0, nanos=0) → explicit "disabled / not SSO"
sentinel; client clears
- valid timestamp → new absolute UTC deadline
A new encodeSessionExpiresAt helper centralises the zero/non-zero
encoding and is shared by the Sync, Login and ExtendAuthSession
builders. The Sync builder still emits nil when settings are missing.
Login and ExtendAuthSession always carry an authoritative value.
The matching client-side decoder lands on feature/session-extend.
* add UserExtendedPeerSession activity event
ExtendAuthSession previously reused UserLoggedInPeer for its audit
record, which conflated two distinct user actions: a full interactive
SSO login (tunnel re-established, network map resync) versus an
in-place deadline refresh (tunnel untouched). Auditors reading the log
couldn't tell which one happened, and downstream dashboards/alerts on
"login" volume were polluted by routine extends.
Adds a dedicated UserExtendedPeerSession Activity (code 125,
"user.peer.session.extend") and switches ExtendPeerSession over to it.
The peer-extend audit trail is now distinguishable from interactive
logins.
* make ExtendAuthSession JWT-retry backoff cancellable
Skip the retry log and 200ms wait on the final attempt, and replace the
uncancellable time.Sleep with a select on time.After/ctx.Done so an
upstream cancellation aborts the wait instead of running it to
completion.
All Wails3 Taskfiles passed -buildvcs=false to go build, which disables
the automatic VCS info embedding Go 1.18+ does by default. As a result
runtime/debug.ReadBuildInfo() returned an empty vcs.revision in our
netbird-ui binary, so the upcoming version.NetbirdCommit() helper from
PR #6263 could not display the git sha for dev builds.
Removed from build:native in all three platform Taskfiles plus the
Windows build:console and the Dockerfile.cross cross-compile script.
go version -m bin/netbird-ui now reports vcs.revision and vcs.modified.
pnpm 11 requires Node.js >= 22.13 (uses node:sqlite, added in 22.5),
but the release workflow still pinned Node 20. After bumping pnpm to
v11 in the previous commit, the frontend build hook now fails with
ERR_UNKNOWN_BUILTIN_MODULE 'node:sqlite' until Node also moves to 22.
The frontend uses pnpm 11 (packageManager field, v11 lockfile, and the
allowBuilds key in pnpm-workspace.yaml is a pnpm 10+ feature), but the
release_ui job's pnpm/action-setup was pinned to v9. v9 rejects the
workspace file with 'packages field missing or empty' before the
frontend build hook can run.
pnpm 11 blocks dependency build scripts by default and exits non-zero
when any are skipped, which made task build fail at install:frontend:deps.
esbuild's postinstall is required to fetch the platform-specific binary.
Exit nodes are mutually exclusive, but the RouteSelector stores routes with
default-on semantics, so every available exit node reported as selected at once.
Reconcile exit-node selection on each network map (and on runtime selection):
keep at most one selected — the user's persisted pick, else whatever management
marks for auto-apply (SkipAutoApply=false), else none. Never auto-activate an
exit node the map doesn't request; it stays off until the user picks it.
The server deselects sibling exit nodes when the user activates one (leaving
non-exit routes untouched), and the tray/React exit-node toggle now appends so
activating an exit node no longer wipes network-route selections.
Make the tray Exit Node submenu selectable (mutually exclusive, sourced from
ListNetworks by NetID) instead of read-only.
Add networksRevision to the status snapshot, bumped by the route manager on
network-map and selection changes, so the tray and the React NetworksContext
re-fetch ListNetworks via the push stream instead of polling. The peer-status
route list only carries chosen routes, so a candidate exit node appearing or
disappearing would otherwise never reach the UI.
Binding OnClick/OnRightClick to call OpenMenu() on macOS routes the menu
open through showMenu(), which runs the blocking [button mouseDown:] inside
a dispatched block on the serial main GCD queue. While the menu is open that
block never returns, starving every other main-queue task — both tray item
updates and the webview event delivery that drives React freeze until the
menu closes.
Revert to the pre-d9f0189 state: no click handlers bound, native NSStatusItem
auto-show for left-click, Wails default rightClickHandler for right-click.
refreshSessionExpiresLabel() is kept for the follow-up fix.
The menu reorganisation removed the About brand-mark bitmap and rerouted
every openRoute caller to WindowManager auxiliary windows, leaving both
the iconMenuNetbird embed (all three platforms) and the openRoute helper
unreferenced. Remove them so the unused linter passes.
The parent Exit Node item's enablement was only refreshed on icon/status
transitions. The daemon ships peer routes in a later snapshot than the
Connected status text, so after a profile switch the candidate list flips
empty to non-empty while the status string is unchanged — leaving the item
greyed and the freshly painted rows unreachable. Re-evaluate enablement in
the exitNodesChanged branch too.
- Reorder the menu: status, Connect/Disconnect, profile block, Open
NetBird, Exit Node, then Settings… / Help & Support / Quit NetBird.
- Rename About → Help & Support, Quit → Quit NetBird, Settings → Settings…
(ellipsis flags the window-opening action per the macOS HIG); drop the
brand icon from Open NetBird; enable Documentation (opens docs.netbird.io)
and add a Troubleshoot entry that deep-links the Settings window.
- Exit Node is now a submenu listing only peers that advertise a default
route (0.0.0.0/0 or ::/0), sorted case-insensitively; the row stays
visible but greyed when the tunnel is down or no candidate exists.
- Session row reads "Session expires in <n minutes/hours/days>" and
recomputes on menu open so the countdown tracks wall time between the
daemon's status pushes.
The session deadline lived in two sinks kept in sync by hand:
ApplySessionDeadline wrote both the (engine-scoped) sessionwatch.Watcher
and the (server-scoped) peer.Status recorder. The clear paths only
touched the watcher, so the recorder — which is what the Status RPC /
SubscribeStatus snapshot the UI reads from — kept reporting a deadline
that had gone stale, surfacing as a frozen "expires in …" countdown.
Two cases were leaking:
- Profile switch / Down: the watcher is recreated per engine but the
recorder outlives it, so a switch to a profile whose server sends no
deadline left the previous profile's value in place.
- In-place expiry: the watcher arms warning timers at T-WarningLead and
T-FinalWarningLead but nothing at the deadline itself, so once the
moment passed the recorder kept the now-past value indefinitely.
Make the watcher the single writer of the recorder deadline (Update /
clearLocked / Close all route through SetSessionExpiresAt) so teardown
clears it, and guard GetSessionExpiresAt to report a past deadline as
none so in-place expiry stops painting a stale countdown.
Layout changes:
- Drop "Debug Bundle" row; reach the flow via the in-window Settings UI.
- Move the brand-mark icon from the About row to "Open NetBird".
- Collapse Settings / Exit Node / About into a single block, with the
Settings → Exit Node order swap to put the configuration entry first.
- Relocate Connect / Disconnect to the bottom block, sharing its
separator with Quit. Drops the connectSeparator field + lastMenuItem
helper that only existed to suppress the daemon-unavailable double
separator in the old position.
Countdown freshness: the daemon's Status snapshots arrive too coarse to
keep a minute-grained "Expires in …" row honest while the menu is
closed. Wails v3 alpha 95 does not expose a public NSMenu needsUpdate
hook, so the tray binds OnClick / OnRightClick and recomputes the label
from cached sessionExpiresAt just before the menu paints. macOS and
Windows right-click additionally call OpenMenu() to restore the native
auto-show that binding the handler suppresses; Linux's dbusmenu host
paints the menu itself.
NSMenuItem rejected the dedicated netbird-menu-24.png brand mark
(rendered muddy) and the full 256x256 brand PNG (stretched the row).
Ship an 18x18 sips-downscale of assets/netbird.png — same source the
legacy Fyne client used for its About row — to sit visually alongside
the cap-height of the surrounding text.
Networks row removed from the tray; Exit Node remains the only routed-
state entry. Clicking the "Expires in …" row now opens the
SessionAboutToExpire window seeded with the actual remaining seconds, so
users can extend the SSO session proactively instead of waiting for the
daemon's T-FinalWarningLead auto-prompt.
- Relocate the session-expiry row from below the status item to below the
profile email so active profile, email, and session deadline form one block.
- Rename the label to "Expires in {remaining}" (en/hu/de).
- Capture the Connect/Disconnect separator via lastMenuItem and hide it when
both action rows are hidden (daemon unavailable), avoiding two adjacent
separators with nothing between them.
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.
Wails v3 alpha.94 switched its default Linux backend from GTK3 +
WebKit2GTK 4.1 to GTK4 + WebKitGTK 6.0 (the GTK3 path is now gated
behind a `gtk3` build tag). cgo files that the binary, the tests, and
the lint job all parse now request `pkg-config --cflags gtk4
webkitgtk-6.0 ...`, so the existing libgtk-3-dev + libwebkit2gtk-4.1
apt deps no longer satisfy them — lint, unit tests, and the linux
release build all fail with `Package 'gtk4' ... not found`.
Replace the apt deps across the four workflows that build/lint the
client tree (golangci-lint, golang-test-linux, release, and the wasm
lint job that also walks client/) with libgtk-4-dev + libwebkitgtk-6.0-dev
+ libsoup-3.0-dev. Both packages are available from jammy (22.04 LTS)
onwards, so existing ubuntu-22.04 runners stay valid.
client/installer.nsis:317 calls `File "MicrosoftEdgeWebview2Setup.exe"`
and client/netbird.wxs references the same payload. In the release
pipeline that file is generated by `wails3 generate webview2bootstrapper`
inside netbirdio/sign-pipelines; the netbird repo's test_windows_installer
job never ran that step, so makensis aborted with:
Error in macro nb.webview2runtime on macroline 21
Error in script "...\client\installer.nsis" on line 325
Mirror the sign-pipelines recipe: set up Go, install wails3 (version
derived from go.mod so the bootstrapper always matches the linked
runtime), then stage the bootstrapper into client/ before the makensis
step runs.
Picks up alpha.92..94 fixes; the binding generator and the
@wailsio/runtime npm package (pinned to "latest") stay compatible.
Brings tranzitive upgrades along (go-git, golang.org/x/exp,
golang.org/x/mod, golang.org/x/text, golang.org/x/tools, pjbgf/sha1cd).
The release_ui_darwin job builds the macOS UI bundle from
.goreleaser_ui_darwin.yaml, but cccb0e92 only added the wails3 CLI
install + bindings-regen hook to the Linux side (release.yml release_ui
job and .goreleaser_ui.yaml). The darwin counterpart still ran pnpm
build against the gitignored, empty bindings/ directory and failed with
~40 TS2307 "Cannot find module '@bindings/...'" errors.
Mirror the Linux setup on darwin: install wails3 from the version
pinned in go.mod, and run `wails3 generate bindings -clean=true -ts`
as the first goreleaser before-hook so vite can resolve @bindings/* by
the time pnpm build starts.
- 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 bindings under client/ui/frontend/bindings are gitignored (1ebb507),
so the release UI job has to regenerate them before pnpm build — the
@wailsio/runtime Vite plugin refuses to build without them. Add a
wails3 CLI install step (version derived from go.mod via go list -m,
so it stays in sync with the runtime the binary links against), plus a
goreleaser before-hook that runs `wails3 generate bindings -clean=true
-ts` ahead of the existing pnpm install + pnpm build pair.
Bump github.com/wailsapp/wails/v3 to v3.0.0-alpha.91 in the process.
The @wailsio/runtime npm package stays at "latest" since the registry
only goes up to alpha.79 — the binding generator and the runtime stay
compatible across that gap until the binding shape changes.
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.
The trailing close(giveUpChan) at the bottom of the function only ran on
the backoff.Retry path. The DisableAutoConnect path returned early via
the if-block, skipping the close entirely. That branch is hit whenever
the active profile has auto-connect disabled — so every Down for those
profiles waited the full 5s timeout in the Down RPC select (and twice
when two Downs queued up, since both snapped the same never-closing
chan).
Move close(giveUpChan) into the existing defer so it fires on every
exit path: DisableAutoConnect return, backoff.Retry return, or panic.
The close happens after clientRunning=false is committed under the
mutex, so a Down/Up that wakes on the chan-close doesn't observe a
half-state where the chan is closed but clientRunning is still true.
Updates the Down RPC comment to point at the deferred close as the
signal source, and reframes the 5s timeout warning as "the goroutine
is wedged in a slow teardown step" rather than the expected case.
The Fyne UI used to write the active profile to both fronts on every
switch (profile.go:264-273): the daemon SwitchProfile RPC for
/var/lib/netbird/active_profile.json, then profileManager.SwitchProfile
for the user-side ~/Library/Application Support/netbird/active_profile.
The Wails ProfileSwitcher only kept the first.
Without the user-side mirror, a UI tray switch updates the daemon's
state but the CLI ProfileManager.GetActiveProfile() still returns the
stale "default". The next "netbird up" then sends ProfileName="default"
in the Login/Up request, and the daemon silently switches back to
default, reverting whatever the user just picked in the tray.
Mirror the daemon switch with profilemanager.NewProfileManager().
SwitchProfile after the daemon RPC succeeds. The daemon stays the
authority — a user-side write failure is logged as a warning, not a
hard error.
Three tray fixes after the SubscribeStatus stream refactor:
* loadProfiles now serializes via a dedicated profileLoadMu and runs
AFTER the SetHidden/SetEnabled writes inside applyStatus's iconChanged
block. Previously the status-driven refresh fired before the menu-item
writes finished, so processMenu's clearMenu/re-add NSMenu rebuild
raced against SetHidden on darwin — the Disconnect entry could end
up visible-but-disabled even when applyStatus had requested it hidden.
* The status row is no longer a hidden "Login" entry. It now renders
as a plain enabled label (so the text isn't greyed out) but has no
OnClick handler — clicks are no-ops, matching the legacy Fyne UI.
All actionable transitions flow through Connect/Disconnect.
* handleConnect routes NeedsLogin/SessionExpired/LoginFailed to the
frontend's /login route (which already runs Login → WaitSSOLogin →
Up) instead of calling Up directly. The plain Up RPC errors with
"up already in progress: current status NeedsLogin" in those
states; the legacy Fyne UI drove the SSO dance from the Connect
button as well.
Two related daemon-side status-stream fixes that together keep the UI's
status in sync with the daemon's contextState:
* state.Set previously only mutated the in-memory enum — transitions
that weren't accompanied by a Mark{Management,Signal,...} call (e.g.
StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed
after OAuth init failure, StatusIdle in the Login defer) left the
UI stuck on the previous snapshot until an unrelated peer event
happened to fire notifyStateChange. Add a callback on contextState
fired from Set (outside the mutex, to avoid lock-order issues with
the recorder's stateChangeMux), and wire it in Server.Start to the
recorder's new public NotifyStateChange. Every state.Set callsite
now pushes automatically; new ones don't need to opt in.
* WaitSSOLogin's WaitToken error branch lumped every failure into
StatusLoginFailed, including context.Canceled aborts from a parallel
profile switch (actCancel/waitCancel). That spurious LoginFailed
then wedged the new profile's Up RPC with "up already in progress:
current status LoginFailed". Split the branch by error type:
context.Canceled lets the top-level defer pick StatusIdle,
context.DeadlineExceeded sets StatusNeedsLogin (retryable; OAuth
device-code window just expired), other errors keep LoginFailed
(real auth/IO failures). Document the full state-transition table
in the function godoc.
The UI never needs to block on Up — status updates flow via the
SubscribeStatus stream. Hardcode Async:true in Connection.Up and remove
the Async field from UpParams so frontend callers are unaffected.
Store a switchCancel in Tray. Each switchProfile call cancels the
previous in-flight goroutine before starting a new one. Because gRPC
respects context cancellation, the previous Down/Up RPCs are aborted
and rapid clicks always converge to the last selected profile.
Down and Up(async=true) are both fast RPCs; no background goroutine
is needed. SwitchActive is now fully synchronous — the tray wraps the
call in its own goroutine, and Wails handles React calls similarly.
Switch RPC errors are now returned synchronously to the caller so the
tray can show a toast immediately on invalid-profile or other early
failures. Down and Up run in a background goroutine so the caller
returns fast; Up still uses async=true so the goroutine is short-lived.
The daemon's Up RPC previously always blocked in waitForUp (up to 50s)
until the engine connected. The UI does not need this — status updates
already flow through the SubscribeStatus stream.
Add bool async = 4 to UpRequest. When true the daemon starts
connectWithRetryRuns and returns immediately; the CLI path (async=false,
the default) is unchanged.
ProfileSwitcher.SwitchActive now sets Async:true so all three RPCs
(Status, Switch, Down, Up) return quickly. The background goroutine and
its associated race condition are removed entirely.
Both the tray and the React Profiles page previously had separate
switching logic: the tray applied a status-aware reconnect policy
(Down for error states, Up only when previously Connected/Connecting),
while the React page always called Switch + Up unconditionally with no
Down for LoginFailed/NeedsLogin/SessionExpired.
Introduce a single ProfileSwitcher service that encapsulates the full
reconnect policy. SwitchActive queries the current daemon status, calls
Switch, and launches Down/Up in a background goroutine so the caller
returns immediately after the Switch RPC completes. Both the tray and
the React Profiles page now delegate to this service.
Export the daemon status string constants (StatusConnected, etc.) from
the services package so tray.go no longer duplicates them as private
constants.
When the active profile was in LoginFailed, NeedsLogin, or SessionExpired,
switching to another profile left the daemon holding stale management/signal
errors. The new profile inherited the error state from the previous one.
Two fixes:
1. server.go Down(): reset statusRecorder management/signal errors so the
next Up() starts with a clean status snapshot instead of the previous
profile's error state.
2. tray.go switchProfile(): add NeedsLogin/LoginFailed/SessionExpired to
the needsDown set. Down() is called to flush stale daemon state, but
Up() is not — the user initiates login on the new profile manually.
The Profiles submenu label now reflects the active profile name instead
of the static "Profiles" text. A disabled email item appears directly
below it in the main menu, matching the legacy Fyne/systray behaviour.
Email is read from the per-profile state file via profilemanager in the
UI process — not through the daemon RPC — because the daemon runs as
root and its getConfigDir() resolves to the root home directory, making
the user-owned state file inaccessible from the daemon side.
The ui-wails -> ui rename deleted the fyne installer assets but left the
NSIS and WiX scripts pointing at client/ui/assets/netbird.ico, which broke
the Windows Installer CI job. Point both scripts at the Wails-side icon
(client/ui/build/windows/icon.ico) and restore banner.bmp into the new
build directory so the NSIS welcome/finish sidebar keeps rendering.
Wails v3 alpha's setMenuItemBitmap on darwin calls NSMenuItem.setImage
from whichever thread invokes SetBitmap — unlike the sibling setters
for label/disabled/hidden/checked, which dispatch_sync onto the main
queue. The off-thread AppKit call doesn't redraw, so the coloured
status dot stayed stale until the user closed and reopened the menu.
Force a tray.SetMenu rebuild after updating the bitmap; the rebuild
runs processMenu inside InvokeSync, which applies the bitmap to a fresh
NSMenuItem on the main thread and macOS picks it up immediately.
Picking a profile from the tray submenu only ran SwitchProfile on the
daemon, so the in-flight retry loop kept dialing the previous profile's
management server. The fix is to follow up Switch with Down+Up, but only
when the daemon was actively trying to be online — Connected or
Connecting. Idle / NeedsLogin / LoginFailed / SessionExpired stay as
deliberate waiting points so a profile pick doesn't surprise the user
with an SSO redirect or flip an intentionally offline daemon online.
The decision table lives in the switchProfile godoc.
Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on
darwin instead of mutating the one attached to the parent menu item at
initial setup, so the visible Profiles entries stayed frozen on the
empty snapshot captured when the tray was registered: clicks reached
the new Go MenuItem objects (and the daemon SwitchProfile RPC ran), but
the checkmark never moved and reopening the menu still showed the old
selection.
Cache the top-level menu and call tray.SetMenu(t.menu) after each
loadProfiles refresh; macosSystemTray.setMenu clears and rebuilds the
entire NSMenu tree against the cached pointer, which propagates submenu
content changes to the visible menu.
Also adds INFO logs around profile click / SwitchProfile RPC / list
refresh so the active-profile flow is observable end-to-end.