Integrates main's MDM configuration-profile feature and adapts it to the
Wails UI (this branch had already replaced the Fyne UI).
Conflict resolution:
- go.mod/go.sum: take main's deps; howett.net/plist pinned to v1.0.2-... (tidy)
- client/proto/daemon.pb.go: regenerated from the merged daemon.proto
- client/internal/peer/status.go: union of ipToKey (main) + sessionExpiresAt (HEAD)
- client/server/server.go: main's intent/liveness model (connectionGoroutineRunning,
clientRunning no longer cleared by the goroutine) + empty-PSK guard
- client/ui/client_ui.go, client/ui/profile.go: removed (dead Fyne UI)
MDM port (backend + tray):
- services/settings.go: expose MDMManagedFields plus a managedFields map keyed
by Config field names so the settings form can gate a control without
translating mdm.Key* names
- tray: gate Profiles / Exit Node menus on DisableProfiles / DisableNetworks via
GetFeatures, refreshed on the config_changed system event (replaces the legacy
2s poll); localized MDM policy-applied toast in all shipped locales
- client/proto/metadata.go: shared constants for the config_changed /
policy_applied event markers
PreSharedKey: GetConfig now returns preSharedKeySet (bool) instead of the masked
value; the settings form provides its own placeholder and sends a new key only
when the user types one.
When the daemon is set to debug/trace, the GUI now automatically writes a
rotated gui-client.log in the user's config dir and the daemon's debug bundle
collects it. The UI learns the level both at startup (daemon already in debug)
and live, by piggybacking the existing SubscribeEvents stream: the daemon
publishes a marked log-level-changed SystemEvent (and a per-subscription
snapshot), which DaemonFeed routes to guilog.DebugLog instead of an OS toast.
The UI registers its log path via a new RegisterUILog RPC so the root daemon,
which can't resolve the user's config dir, knows where to find the file.
Manual --log-file (any value) disables the daemon-driven file logging.
Fix: client/ui SetLogLevel looked up proto.LogLevel_value with the lowercase
logrus name, which never matched the uppercase enum keys and silently fell back
to INFO — so trace/debug requests from the bundle flow had no effect.
Use the shared logrus logger alias and carry the JS origin in a dedicated
"ui" log field instead of inlining a [ui ...] tag in the message, keeping
frontend logs distinct from the Go-caller source.
logoutFromProfile failed hard when the management server returned NotFound
(peer already deleted from the dashboard), blocking both profile logout and
profile removal. Treat NotFound as success — the peer is already gone, so
deregistering it is already satisfied.
Also drop the user-side per-profile state file on logout. The account email is
sourced from <profile>.state.json (written by the CLI after SSO login), which
the root daemon can't reach, so logout left a stale email showing in the UI.
Connection.Logout now removes it from the UI process after a successful logout;
the next SSO login recreates it.
On minimal window managers (fluxbox et al, the in-process XEmbed-tray
path) the WM neither centers small windows nor restores their position
across a hide -> show round-trip, so the main, Settings, and dialog
windows opened in the top-left corner instead of centered.
These windows are created Hidden, so Wails' Linux/GTK4 backend skips its
post-Show centering pass (gated on !Hidden) and InitialPosition has no
effect on an unrealized window. Re-center from Go after Show, gated on
the minimal-WM environment via a recenterOnShow predicate (set to
xembedTrayAvailable on Linux, nil on macOS/Windows where the WM handles
placement). centerWhenReady polls from a background goroutine until the
move actually lands -- Center() moves via raw X11, which no-ops while the
GdkSurface is still nil and GTK4 realizes it asynchronously after Show().
Also reorder xembed_host_linux.go so the static helpers (xembedTrayAvailable,
goMenuItemClicked) sit at the end, after the constructor and methods.
The daemon emits no dedicated profile-changed RPC event, and a profile
add/remove doesn't move the connection status, so the UI's SubscribeStatus
path never fired for CLI-driven `netbird profile add|remove` (and the tray's
iconChanged guard would swallow it anyway). The tray menu and the React
profile list stayed stale until the next status-string transition.
AddProfile/RemoveProfile now publish a marked INFO/SYSTEM event over
SubscribeEvents (metadata kind=profile-list-changed, empty userMessage so it
stays silent). The UI's dispatchSystemEvent recognises the marker and
re-emits the existing EventProfileChanged, which the tray's loadProfiles and
React's ProfileContext.refresh already subscribe to — so both surfaces
refresh from a single signal that originates in the shared daemon handler
(covering both CLI and UI-initiated removals). No proto change.
Also drop a stray, build-breaking `app.Updater` line in main.go.
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.
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.
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.
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
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.
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.
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.
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.