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 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.
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.
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.
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.