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.
The status snapshot tore down on every management retry because
state.Status() blanks the status when an error is wrapped, and the
SubscribeStatus stream propagated that as FailedPrecondition. The UI
treated any stream error as "daemon not running" and flickered the tray
to Not running between retries.
Disconnect was also unresponsive: Down set Idle before the retry
goroutine exited, which then overwrote it with Set(Connecting) on the
next attempt; the backoff sleep (up to 15s) wasn't context-aware, so the
goroutine kept running long after actCancel.
- buildStatusResponse falls back to the underlying status (via new
state.CurrentStatus) instead of breaking the stream on wrapped errors.
- UI only flips to DaemonUnavailable on codes.Unavailable / non-status
errors, so a live daemon returning FailedPrecondition is not reported
as down.
- connect retry uses backoff.WithContext so actCancel interrupts the
inter-attempt sleep, and skips Wrap(err) when the dial fails due to
ctx cancellation.
- Down sets Idle after waiting for giveUpChan, so the retry goroutine
can no longer race the disconnect.
- Tray hides Connect during Connecting and keeps Disconnect enabled so
the user can abort an in-flight connection attempt.
Mirror the main branch's profile list: a Profiles submenu populated
from the daemon's ListProfiles RPC, with the active profile shown as
a checked entry and a click on any other entry switching to it via
SwitchProfile.
The initial fill is deferred to the Common.ApplicationStarted hook
because Menu.Update() short-circuits while app.running is false and
the Wails3 macOS impl would nil-deref on early-startup InvokeSync.
Show a small dot next to the first menu entry that reflects the
daemon state: green for Connected, yellow for Connecting, blue for
NeedsLogin/SessionExpired, red for LoginFailed/Error, grey for
Idle/Disconnected and dark grey for DaemonUnavailable. PNGs are 24x24
with a pHYs chunk declaring 144 DPI so NSImage renders them at 12 pt
while keeping retina-sharp pixel data; circles are supersampled 8x for
smooth edges.
Idle now surfaces as "Disconnected" in the menu label, daemon-status
literals moved to status* constants, and Exit Node / Resources are
gated on the Connected state instead of just daemon availability.
Port IPv6 overlay support (#5631) into the Wails UI:
- Add DisableIPv6 config toggle to Settings (NetworkTab + services)
- Filter ::/0 alongside 0.0.0.0/0 as an exit-node route
- Suppress duplicate v6 default-route notifications in tray
Without marking the error as backoff.Permanent the outer retry re-enters
connect(), which resets the daemon state from NeedsLogin to Connecting
and makes the tray flicker between the two until the user logs in.
The tray now switches to a dedicated lock icon when the daemon reports
NeedsLogin, SessionExpired or LoginFailed — the latter mirrors the CLI,
which groups these three statuses together as "needs authentication"
and prints the same "Run netbird up" prompt. The macOS template variant
reuses the existing error-macos PNG because the project's macOS tray
PNGs use a 2-color (black + transparent) convention that rsvg-convert
of the badge-style SVG sources can't reproduce. The earlier badge-style
SVG sketches in assets/svg/ are removed (they were marked as reference
only and never matched the shipping PNG design).
The status stream emits a synthetic StatusDaemonUnavailable when the
gRPC client or stream cannot be established, fired once per outage and
cleared on the next real snapshot. The tray maps it to a "Not running"
status label, switches the icon to the error variant, hides
Connect/Disconnect (neither would work without the daemon), and
disables Settings, Networks and Create Debug Bundle so the user is not
routed to pages that would just fail to load.
Left-click on the tray icon now opens the menu on every platform — the
window is reached through a new "Open NetBird" entry. Only the action
that matches the current daemon state is shown: Connect when
disconnected, Disconnect when connected. The main window starts hidden
and is only surfaced via the tray, single-instance launch, or daemon
events.
Follow-up to the rename commit: the previous commit moved the files but
the post-mv string substitutions (Go imports, frontend bindings, CI
config paths) were not re-staged so they slipped through. This commit
applies those edits and removes the fyne dependencies from go.mod/go.sum
now that the legacy fyne UI is gone.
Removes the legacy fyne-based client/ui implementation and renames the
Wails replacement (client/ui-wails) to take its place at client/ui. Go
imports, frontend bindings, CI workflows, goreleaser configs and the
windows .syso icon path are updated to follow the rename.
The daemon ignored an empty OptionalPreSharedKey, so a UI/CLI request to
clear the pre-shared key was silently dropped. Pass the pointer through
unconditionally — profilemanager already handles the empty-string case.
The Wails notifications service reads HKCU\Software\Classes\AppUserModelId\
<AppName>\CustomActivator on first startup; if present it uses that GUID
as the toast activator CLSID, otherwise it generates a fresh UUID and
writes it back. Without an installer-supplied value the per-machine GUID
diverges from the ToastActivatorCLSID baked into the Start Menu and
Desktop shortcuts, and the COM activator never fires when a toast is
clicked. Seed the same CLSID the shortcuts use so the two sides match.
Windows uses application.Options.Name as the toast AppUserModelID and as
the registry path the Wails notifier reads/writes its CustomActivator
under (HKCU\Software\Classes\AppUserModelId\<Name>). The MSI installer
seeds those under "NetBird"; with the previous "netbird-ui" Name the app
would have written under a different identity and the toast activator
CLSID the installer pre-registers would have been orphaned.
Adds the missing info line ("Your client version is older than the
auto-update version set in Management. Updating client to: <version>.")
and replaces the spinner with the legacy 1-second dot animation
(Updating./.../...). Terminal-state wording now matches the Fyne UI
exactly: 15 min timeout, canceled, and "Update failed: <err>".
Ports mapInstallError from client/ui/update.go so daemon errors that
embed "deadline exceeded" / "canceled" hit the right branch instead of
falling through as a generic failure.
Detects the daemon dropping mid-upgrade (the legacy success signal):
if GetInstallerResult fails for 5s straight, call the new Update.Quit
service method to exit, mirroring app.Quit() in showInstallerResult.
client/ui-wails/main.go embeds all:frontend/dist, which is produced by
the frontend build and gitignored. Lint runs don't build the frontend,
so the directory is missing in CI and golangci-lint fails the typecheck.
Create a placeholder file before linting so the embed has something to
match.
The file's build constraint excludes freebsd, so the freebsd cases in
IsUnixDesktopClient and OpenURL were unreachable — staticcheck (SA4032)
fails the pre-push lint. Linux is the only Unix-desktop GOOS this
package compiles for, so collapse both checks accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A pending WaitSSOLogin parks the daemon on an OAuth UserCode forever
once the user closes the browser without completing the flow. The
frontend can't unblock that on its own — it needs the daemon to fire
its own actCancel(). Three fixes work together:
- Login() now issues a Down() before kicking off the new flow so a
previously-stuck WaitSSOLogin is unwedged before we ask the daemon
for fresh OAuth info.
- The Login page's Cancel button calls Down() before navigating away,
so abandoning the flow mid-browser actually settles the daemon's
in-flight WaitSSOLogin instead of leaving it pinned.
- Status keeps the Login button visible whenever we aren't Connected
(including Connecting), so a UI restart that finds the daemon stuck
in Connecting still has a one-click recovery path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the Fyne client's login path: the daemon Login RPC now defaults
ProfileName/Username from GetActiveProfile + the OS user and sets
IsUnixDesktopClient on Linux/FreeBSD so the daemon picks the SSO
browser flow. A new OpenURL service launches the user's default
browser via xdg-open / open / rundll32 (Fyne's openURL helper) — the
embedded WebKit's window.open silently fails for external URLs.
The frontend gains a Login page that drives the full Login →
window.open via OpenURL → WaitSSOLogin → Up sequence with progress
states. Status surfaces a Login button while the daemon reports
NeedsLogin/SessionExpired, and the tray's status row stops being a
purely-decorative label: it becomes a clickable Login entry whenever
re-authentication is required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the per-submenu focus-out handler with a shared idle-deferred
recheck: when any popup loses focus, ask after the next event-loop
turn whether *any* of our popups still owns toplevel focus. If none
does, the user clicked outside the menu tree, so close every popup at
once instead of leaking the parent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recursively walk dbusmenu children-display="submenu" entries when
flattening the SNI menu so the GTK popup can render nested items.
The C side renders submenu folders as labeled buttons that open a
child popup window aligned to the anchor row, kept on-screen with
horizontal flipping; the top-level popup no longer self-destructs
when focus transfers to one of its own submenus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [client] iOS: structured ResolvedIPs collection for domain routes
Replace comma-joined ResolvedIPs string with a gomobile-friendly
ResolvedIPs collection (Add/Get/Size), mirroring the Android bridge
in client/android/network_domains.go.
This allows the iOS app to match domain-route resolved IPs against
connected peer routes without parsing CSV strings, fixing the route
status indicator for dynamic (DNS) routes.
* [client] iOS: align dynamic route exposure with Android bridge
For dynamic (DNS) routes the Swift side previously received
"invalid Prefix" as the Network value, forcing UI code to special-case
that sentinel. The Android bridge uses Domains.SafeString() instead so
peer.routes entries (which also derive from Domains.SafeString()) match
directly. Mirror that here.
Also fix the resolved IP lookup: resolvedDomains is keyed by the
resolved domain (e.g. api.ipify.org), not the configured pattern
(e.g. *.ipify.org). Group entries by ParentDomain like the daemon does
in client/server/network.go, so wildcard route patterns get their
resolved IPs populated.
Restore the legacy Fyne UI's two disabled "GUI: x.y.z" / "Daemon: a.b.c"
entries under About so users (and support) can read the running
versions from the tray. The GUI line is baked in at build time via
version.NetbirdVersion() — the same -ldflags chain the rest of the
repo uses. The daemon line starts as "—" and is rewritten in
applyStatus on every Status snapshot whose DaemonVersion differs from
the last one we recorded, so a daemon restart with a new build
(e.g. after an enforced update) updates the menu automatically.
Drive-by: rename the local variable that shadowed the version package
in handleUpdate so the import resolves cleanly.
Wails3's Linux systray hands the icon off to whatever process owns
org.kde.StatusNotifierWatcher on the session bus. Bare WMs (Fluxbox,
OpenBox, i3, dwm, sway, vanilla GNOME without the AppIndicator
extension) ship no watcher, so the icon registration silently fails
and the tray never appears — leaving a tray-only app like NetBird
unreachable.
Add a Linux-only watcher fallback that claims the watcher name when
nobody else does, plus an XEmbed bridge so legacy X11 system trays
(_NET_SYSTEM_TRAY_S0) can still render the icon. Both no-op on other
platforms via build tags.
Pieces:
- tray_watcher_linux.go: claims org.kde.StatusNotifierWatcher on a
private session bus, exports the bare RegisterStatusNotifierItem /
RegisterStatusNotifierHost surface, and spins up an XEmbed host per
registered SNI item.
- xembed_host_linux.go: per-item event loop. Polls X11 events with a
50ms ticker, listens for the SNI NewIcon signal, dispatches Activate
/ context menu through dbusmenu (com.canonical.dbusmenu).
- xembed_tray_linux.{c,h}: the X11/cairo native bits. Window is created
with CopyFromParent visual + ParentRelative background so transparent
pixels show the toolbar beneath instead of solid black on 24-bit
trays. cairo paints the IconPixmap with OVER blending so per-pixel
alpha is honoured against the parent-relative base. GTK3 owns the
context-menu popup; menu items round-trip through dbusmenu Event.
- tray_linux.go: forces WEBKIT_DISABLE_DMABUF_RENDERER=1 in init() so
developers running `task dev` / launching the binary directly get the
same software rendering path the .desktop launcher already enables;
the deb/rpm Exec wrapper covers installed users.
- tray_watcher_other.go and xembed_host_other.go: build-tag stubs so
main.go's startStatusNotifierWatcher() compiles on every platform.
- main.go: calls startStatusNotifierWatcher() before NewTray so the
Wails systray's RegisterStatusNotifierItem call hits a watcher we
control on bare WMs.
- build/linux/netbird-ui.desktop: regenerated by `task build` to wrap
the dev launcher's Exec line with the WEBKIT_DISABLE_DMABUF_RENDERER
env, matching what the tray_linux.go init does at runtime.
Adapted from work originally prototyped on the prototype/ui-wails branch.
Tested on Fluxbox (Debian 13): the icon appears in the slit/toolbar with
the toolbar's background showing through transparent pixels, left-click
opens the window, right-click brings up the GTK popup of the dbusmenu
items.
Port the Fyne UI's onSessionExpire 1:1 to the Wails tray so an SSO token
expiry no longer leaves the user staring at a stale peer list. When
applyStatus sees the transition into the daemon's StatusSessionExpired,
fire a single OS notification (the lastStatus guard rate-limits it to
the transition itself, mirroring the Fyne sendNotification flag) and
bring the main window forward on the /login route so the frontend can
drive the renewed SSO flow. The Fyne client achieved the same end with
a runSelfCommand "login-url" helper; here the window is already
in-process so we route to it directly.
The previous attempt added client/ui-wails/main.go to the file path
exclude list, but golangci-lint v2's path filter only suppresses
issues from rule-based linters; the typecheck pre-pass that compiles
the package still runs and fails with "pattern all:frontend/dist: no
matching files found" before any rule fires.
Replace the path-level skip with a targeted exclusions.rules entry
that matches just that diagnostic on just that file. The rest of
client/ui-wails (services/, tray.go, grpc.go, ...) keeps being linted
normally.
Validated locally by deleting frontend/dist and running
`golangci-lint run client/ui-wails/...` — 0 issues with this config.
NewTray's eight-parameter signature crossed Sonar's seven-parameter
threshold once Update joined the dependency list. Bundle the six service
pointers (Connection, Settings, Profiles, Peers, Notifier, Update) into
a TrayServices struct, leaving NewTray with three arguments — the two
Wails platform handles plus the service bag. Tray.svc replaces the
individual fields; call sites use t.svc.Connection etc.
Adding another service later is now a one-line struct change instead
of a NewTray signature break.
The previous fix added /client/ui-wails to the grep -v / Where-Object
filter, but go list aborts at the first broken package and emits an
empty stdout when client/ui-wails/main.go's //go:embed all:frontend/dist
fails to resolve. The command substitution then expands to nothing, and
`go test` falls back to the repo root — which has no Go files and fails
the job.
`go list -e` keeps listing remaining packages after a parse error, so
the existing path-based filter now actually does its job.
Touches all three test workflows (Linux native + docker, Darwin, Windows).
pnpm-lock.yaml and package-lock.json embed package hashes that look
like English words to codespell (e.g. "nD" -> "and"), causing false
positives that can't be fixed because the lockfile is auto-generated.
Add the standard lockfile patterns to the skip list alongside the
existing go.mod/go.sum/proxy-web entries.