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.
37 KiB
NetBird Wails UI — Working Notes
This is the Wails v3 desktop UI for NetBird. Go services live in services/; the React/TS frontend lives in frontend/; bindings between them are generated under frontend/bindings/.
Keep these notes current. When working in this directory with Claude, update this file (and
frontend/CLAUDE.mdfor frontend-only changes) whenever you add a service, change an event name, shift a convention, rename a key directory, or land any other change that future-you would want to know about before reading the code. The goal is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
Layout
Go (top-level package main)
main.go— app entry. Builds the shared gRPCConn, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray →peers.Watch→app.Run. CLI flags:--daemon-addr,--log-file(repeatable; default is empty so "no flag" is distinguishable from explicit--log-file console— when emptyparseFlagsAndInitLogfalls back toconsoleforInitLogand returnsuserSetLogFile=false),--log-level(trace|debug|info|warn|error, defaultinfo). See "GUI debug logging" below for whatuserSetLogFilegates.tray.go—Traystruct + menu. Subscribes toEventStatus,EventSystem,EventUpdateAvailable,EventUpdateProgress. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.- Tray menu updates go through
relayoutMenu(whole-tree rebuild), never in-place submenu mutation. Any dynamic menu change — Profiles submenu (tray_profiles.go loadProfiles→ caches rows underprofilesMu, thenfillProfileSubmenu), Exit Node submenu (tray_exitnodes.go refreshExitNodes→fillExitNodeSubmenu), daemon-version row (tray_status.go), and the About → Update row (tray_update.go applyState→onMenuChangecallback) — rebuilds the entire menu viaTray.relayoutMenu(buildMenu()+ repaint cached state + singlet.tray.SetMenu). Serialised bymenuMu. Why: on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (GetLayoutfor that submenu id) and never re-fetches it on aLayoutUpdated(parent=0)signal — so the oldsubmenu.Clear()+Add()left both the visible rows AND the click→id mapping frozen on the first snapshot. BecauseClear()+Add()allocates fresh monotonic item ids each time (Wailsmenuitem.go), clicks then sent ids the rebuiltitemMapno longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch).buildMenu()allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed viadbus-monitor: a re-opened submenu issued noGetLayoutuntil its container id changed. The whole-treeSetMenualso subsumes the older darwin detached-NSMenu workaround.fill*Submenuhelpers are pure UI (read caches, no daemon fetch, noSetMenu) sorelayoutMenunever recurses back into the fetchers. tray_linux.go—init()setsWEBKIT_DISABLE_DMABUF_RENDERER=1(blank-white window on VMs / minimal WMs) andWEBKIT_DISABLE_COMPOSITING_MODE=1(Intel/Mesa SIGSEGV ing_application_runvia unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. AlsoWEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1when unprivileged userns are blocked.tray_watcher_linux.go,xembed_host_linux.go,xembed_tray_linux.{c,h}— in-process SNI watcher + XEmbed bridge for minimal WMs. SeeLINUX-TRAY.md.signal_unix.go/signal_windows.go—listenForShowSignal. Unix uses SIGUSR1; Windows uses a named eventGlobal\NetBirdQuickActionsTriggerEvent. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.grpc.go— lazy, mutex-protected gRPCConnshared by every service.DaemonAddr():unix:///var/run/netbird.sockon Linux/macOS,tcp://127.0.0.1:41731on Windows.icons.go—//go:embedtray/window PNGs. macOS uses template variants (*-macos.png); Linux uses a monochrome black/white pair (*-mono.pngblack for light panels,*-mono-dark.pngwhite for dark panels); Windows reuses the colored light PNG (multi-frame.iconever redrew on Wails3'sNIM_MODIFY). The*-mono*set is generated from the macOS template silhouettes (states differ by shape, not color);tray_icon.go iconForStatebranches onruntime.GOOS(linux→ mono, else colored).- Linux mono icon theme selection — Wails v3's Linux SNI backend ignores
SetDarkModeIcon(itssetDarkModeIconjust callssetIcon, last-write-wins — seepkg/application/systemtray_linux.go), and the SNI spec carries no panel light/dark hint. Sotray_theme_linux.godetects the desktop colour scheme itself andiconForStatepicks black-vs-white, withapplyIconpushing a singleSetIconon Linux (noSetDarkModeIcon). Detection order: freedesktop Settings portal (org.freedesktop.portal.Settings.Readoforg.freedesktop.appearance/color-scheme: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to theGTK_THEMEenv var (:darksuffix ⇒ dark) → else default dark (suits the common dark panel). A private session-busSettingChangedsubscription repaints live on theme flips.Tray.panelDark func() boolis seeded bystartTrayTheme()(Linux only;tray_theme_other.gois a no-op stub) before the firstapplyIcon;panelIsDark()returns true whenpanelDarkis nil.
Wails services (services/*.go)
Each service is registered via app.RegisterService(application.NewService(svc)). Every method becomes a TS function in frontend/bindings/.../services/. Frontend-facing details (TS signatures, push events, models) are in frontend/WAILS-API.md. After editing any services/*.go or the proto, regenerate with wails3 generate bindings -clean=true -ts (or pnpm bindings from frontend/). frontend/bindings/** is gitignored.
For frontend-side conventions (routing, providers, contexts) see frontend/CLAUDE.md.
Services rundown
All services live in services/ and assume a build tag !android && !ios && !freebsd && !js. Each takes a shared DaemonConn (conn.go) and is registered in main.go.
| Service | File | Responsibility |
|---|---|---|
Connection |
connection.go |
Login / WaitSSOLogin / Up / Down / Logout / OpenURL. Up is always async (Async: true); status flows back through Peers. Login Down-resets the daemon first to dislodge a stale WaitSSOLogin. OpenURL honors $BROWSER. |
Settings |
settings.go |
GetConfig / SetConfig (partial update — pointer fields are sent, nil fields preserved) / GetFeatures (operator-disabled UI surfaces). |
Profiles |
profile.go |
Username / List / GetActive / Switch / Add / Remove. List populates Email from the user-side state file (profilemanager.NewProfileManager().GetProfileState) — the daemon runs as root and can't read it. |
ProfileSwitcher |
profileswitcher.go |
SwitchActive — the single entry point both tray and frontend should use for profile flips. Applies the reconnect policy (see "Profile switching" below), mirrors the daemon switch into the user-side profilemanager, drives optimistic feedback via Peers.BeginProfileSwitch. |
Peers |
peers.go |
Daemon status snapshot + two long-running streams (SubscribeStatus → EventStatus, SubscribeEvents → EventSystem). Emits synthetic StatusDaemonUnavailable when the socket is unreachable. Owns the profile-switch suppression filter (BeginProfileSwitch / CancelProfileSwitch / shouldSuppress). Fan-outs update metadata into dedicated EventUpdateAvailable / EventUpdateProgress events. |
Networks |
network.go |
List / Select / Deselect of routed networks. |
Forwarding |
forwarding.go |
List exposed/forwarded services from the daemon's reverse-proxy table. |
Debug |
debug.go |
Bundle (debug bundle creation + optional upload) / `Get |
Update |
update.go |
GetState / Trigger (enforced installer) / GetInstallerResult / Quit. The install-progress UI lives in its own auxiliary window (/#/dialog/install-progress), opened by WindowManager.OpenInstallProgress — the daemon goes unreachable mid-install so it can't be inside the main window. |
WindowManager |
windowmanager.go |
OpenSettings(tab) / OpenBrowserLogin(uri) / CloseBrowserLogin / OpenSessionExpiration(seconds) / CloseSessionExpiration / OpenInstallProgress(version) / CloseInstallProgress / OpenWelcome / CloseWelcome / OpenError(title, message) / CloseError / OpenMain. OpenSettings("") opens the General tab; pass a tab id (e.g. "profiles") to deep-link, encoded as ?tab=… in the start URL. OpenInstallProgress is AlwaysOnTop and hides every other visible window for the duration of the install (restored on close). OpenMain is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and destroyed on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
I18n |
i18n.go |
Thin facade over i18n.Bundle. Languages() returns the shipped locales (_index.json); Bundle(code) returns the full key→text map for one language so the React layer can drive its own translation library. |
Preferences |
preferences.go |
Thin facade over preferences.Store. Get() returns {language, viewMode, onboardingCompleted}; SetLanguage(code) validates against i18n.Bundle.HasLanguage and persists; SetViewMode(mode) validates against the known set (default/advanced) and persists; SetOnboardingCompleted(bool) persists the welcome-window dismissal. All broadcast netbird:preferences:changed. main.go reads viewMode from the store to size the main window at startup. |
Autostart |
autostart.go |
Thin facade over Wails' app.Autostart (*application.AutostartManager). Supported() / IsEnabled() / SetEnabled(bool) — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, HKCU\…\Run on Windows, XDG .desktop on Linux) is the single source of truth — nothing is mirrored to the preferences file. Enable registers the running executable with no extra args (the app comes up hidden into the tray). Affects the graphical UI only, not the daemon/background service. Supported() is false on server/mobile builds (ErrAutostartNotSupported); the React toggle in SettingsGeneral.tsx hides itself when false. |
DaemonConn is defined in services/conn.go; ptrStr (string-to-*string helper for proto pointer fields) lives there too.
Daemon proto
- Proto source:
../proto/daemon.proto. Generated Go in../proto/*.pb.go. - Regen:
cd ../proto && protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative daemon.proto - Pinned versions (see
daemon.pb.goheader):protoc v7.34.1,protoc-gen-go v1.36.6. CI'sproto-version-checkworkflow fails on mismatch. - After proto regen, also regen Wails bindings so the TS layer picks up new fields.
GUI debug logging
When the daemon is put into debug/trace level, the GUI automatically writes a rotated gui-client.log in os.UserConfigDir()/netbird/ and the daemon's debug bundle collects it. Pieces:
uilogpath.go(packagemain) —uiLogPath()resolves the path;newDebugLog(userSetLogFile)builds theguilog.DebugLog(disabled when the user passed--log-file, or when the config dir can't be resolved).guilog/debuglog.go—guilog.DebugLog(own package, not a Wails service — no React binding) — owns the file-logging side effects.Apply(level): on debug/trace attachesgui-client.logalongside the console (viautil.SetLogOutputs, MultiWriter, rotated by timberjack like the daemon log) and raises the logrus level; on a higher level detaches the file and restoresinfo. Idempotent (fileOnguard) so the startup replay + a racing change-event are harmless. Thegui-client.logis left on disk on quit (rotated by timberjack) for the debug bundle — there's no shutdown cleanup.Path()returns "" when disabled so the daemon won't try to collect a file the GUI never writes.- Activation rule: any
--log-file(evenconsole) is a manual override → the controller is disabled and never touches logging. Only the absence of--log-fileenables the daemon-drivengui-client.log. This is whyparseFlagsAndInitLogseeds the flag default empty (Layout note above). - Level signalling — no new stream. The daemon publishes a marked
SystemEvent(metadata[proto.MetadataKindKey]==proto.MetadataKindLogLevelChanged,metadata[proto.MetadataLevelKey]==<logrus name>) on the existingSubscribeEventsstream.DaemonFeed.dispatchSystemEventrecognises the marker and routes it to the controller'sApplyinstead of an OS toast (same pattern asproto.MetadataKindProfileListChanged). The controller is injected intoNewDaemonFeed(aservices.LogControllerinterface — no exported setter, so the Wails-boundDaemonFeedgains no binding), andDaemonFeedre-registers the UI log path (RegisterUILogRPC) on every event-stream (re)connect, so a daemon restart re-learns it. Startup case (daemon already in debug) is covered daemon-side:Server.SubscribeEvents(client/server/event.go) sends the current level once to each new subscriber;SetLogLevelpublishes on change (publishLogLevelChangedinclient/server/server.go). The metadata key/value markers live inclient/proto/metadata.goso producer (daemon) and consumer (UI) share one definition. - Daemon side of the bundle:
Server.RegisterUILogstores the path inServer.uiLogPath;DebugBundlepasses it todebug.GeneratorDependencies.UILogPath;BundleGenerator.addUILogaddsgui-client.log+ rotated siblings (addRotatedLogFiles(dir, "gui-client")— the glob is prefix-parametrised so it doesn't collide with the daemon's ownclient*.log.*). Missing file is non-fatal (the GUI only writes it while in debug). - JS-side logs/errors already reach the same logrus pipeline via
frontend/src/lib/logs.ts→services.UILog.Log, so once the file is attached, frontend console/unhandledrejection/erroroutput is captured too. Go-side uncaught-goroutine panics go to stderr only (out of scope).
Events bus
main.go registers five typed events for the frontend: netbird:status (Status), netbird:event (SystemEvent), netbird:profile:changed (ProfileRef), netbird:update:available (UpdateAvailable), netbird:update:progress (UpdateProgress). netbird:profile:changed fires from ProfileSwitcher.SwitchActive after a successful daemon-side switch — both the React ProfileContext and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events:
EventTriggerLogin = "trigger-login"— tray asking the frontend'sstartLogin()to begin an SSO flow. The tray does not show the main window when emitting — the hidden webview is alive and subscribed, sostartLoginruns and the only visible surface is the BrowserLogin popup it opens.EventBrowserLoginCancel = "browser-login:cancel"— theBrowserLoginwindow's Cancel button or red-X close.startLogin()listens and tears down the daemon's pendingWaitSSOLogin.preferences.EventPreferencesChanged = "netbird:preferences:changed"— emitted after every successfulSetLanguage(payload{language}). Both the tray menu rebuild and the Reacti18next.changeLanguagesubscribe so a flip from any window paints everywhere.EventSettingsOpen = "netbird:settings:open"(payload: tab string, e.g."general"/"profiles") — emitted byWindowManager.OpenSettings(tab)to set the active tab before Go callsShow/Focus. The matching reset-to-General on close lives in the React side viadocument.visibilitychange(Wails events from the Go close hook raceHideand flash the previous tab for one frame).
Daemon connection status strings (services/peers.go) mirror internal.Status* in client/internal/state.go: Connected, Connecting, Idle, NeedsLogin, LoginFailed, SessionExpired, plus the synthetic DaemonUnavailable emitted by Peers when the socket is unreachable.
Profile switching
services/profileswitcher.go is the single source of truth for the reconnect policy. Both the tray (tray.go switchProfile) and the frontend (via modules/profiles/ProfileContext.tsx's switchProfile, which modules/profiles/ProfilesTab.tsx and the header ProfileDropdown go through) call ProfileSwitcher.SwitchActive; identical inputs give identical state transitions.
Reconnect policy (driven by prevStatus from Peers.Get):
| Previous status | Action | Optimistic UI | Suppressed events until new flow begins |
|---|---|---|---|
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
| Idle | Switch only | (no change) | — |
Only Connected/Connecting trigger Peers.BeginProfileSwitch. That:
- Sets a 30s
switchInProgressguard. - Emits a synthetic
Status{Status: StatusConnecting}so both tray and React paint immediately. - Tells
statusStreamLoopto drop the daemon's stale Connected updates (peer count drops as the engine tears down) and the transient Idle in between Down and the new Up.
shouldSuppress releases the guard as soon as a status that signals the new flow began arrives:
- Suppressed: Connected, Idle
- Pass through and clear: Connecting / NeedsLogin / LoginFailed / SessionExpired / DaemonUnavailable
- Timeout fallback: 30s elapsed → clear flag, emit normally.
Peers.CancelProfileSwitch aborts the suppression — called by tray.go handleDisconnect so the user's "Disconnect while Connecting" click paints through immediately.
Also: ProfileSwitcher.SwitchActive mirrors the daemon switch into the user-side profilemanager (~/Library/Application Support/netbird/active_profile). The CLI's netbird up reads this file and sends the resolved profile name back; if it diverges from the daemon's /var/lib/netbird/active_profile.json, the daemon silently flips back. Mirror failures don't abort the switch — surfaced as a warning.
Auxiliary windows (WindowManager)
The main window is created up front in main.go. Auxiliary windows are created on demand by services.WindowManager:
-
Settings (
/#/settings) — opened from the header gear icon (pages/main/Header.tsx → WindowManager.OpenSettings("")), the tray's Settings menu entry (tray.go openSettings), and the profile dropdown's "Manage Profiles" entry (WindowManager.OpenSettings("profiles"), which sets?tab=profilesin the start URL —Settings.tsxreads it viauseSearchParams). The window hosts every settings tab — including Profiles (ProfilesTab.tsx,UserCircleicon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go throughWindowManagerso the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window viaSetURL("/#/settings"), which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. Unlike the other auxiliary windows, Settings is created eagerly (hidden) insideNewWindowManagerand hides on close instead of being destroyed — first open is instant. The window stays at a single URL (/#/settings) forever;OpenSettings(tab)does not callSetURL. Instead it emitsnetbird:settings:openwith the target tab (empty →"general"), then callsShow/Focus.SettingsPagekeeps the active tab in React local state and listens for the event to switch. Reset-on-close lives in the React side, not the Go close hook:SettingsPagelistens fordocument.visibilitychangeand resets the tab to General when the page goes hidden. Doing it viaEvent.Emitfrom the close hook didn't work — the dispatch goroutine racesHide, the JS listener often runs only after the nextShow, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlierSetURLpath re-loaded the WKWebView entirely, re-mounting theAppLayoutprovider stack and visibly flashing theSettingsSkeletonwhileSettingsContextre-fetched config.) -
BrowserLogin (
/#/dialog/browser-login?uri=…) — opened by the connection toggle's SSO flow (pages/main/ConnectionStatusSwitch.tsx). 460×440, fixed size. The close button (red X) firesEventBrowserLoginCancelso the JS-sidestartLogin()can tear down the daemon's pendingWaitSSOLogin.WindowManager.CloseBrowserLogincloses it programmatically when the flow completes. -
SessionExpiration (
/#/dialog/session-expiration?seconds=<n>) — opened byWindowManager.OpenSessionExpiration(seconds). 460×380, fixed size,AlwaysOnTop: true. The React-side buttons close the window viaWindowManager.CloseSessionExpirationand (for Sign-in / Stay-connected) emitEventTriggerLoginso the main window'sstartLogin()orchestrator handles the SSO flow. Triggered by the tray today:tray_session.go openSessionExpirationfires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, andopenSessionExtendFlowopens it on tray-row click seeded with the live remaining time. Multi-monitor aware — targets the display the OS cursor is currently on viaWindowManager.getScreenBasedOnCursorPosition, which queries the native cursor location per-OS throughgetCursorPosition(services/cursor_{darwin,windows,linux,other}.go):NSEvent.mouseLocationflipped against the primary's frame height on macOS,w32.GetCursorPos+ ScreenManagerPhysicalToDipPointon Windows, X11XQueryPointeron Linux. The X11 query covers Wayland sessions too via XWayland, which ships by default on every supported Linux target. Verified distro coverage: Windows, macOS, Ubuntu 22.04 + 24.04 (GNOME-Wayland default + XWayland), Fedora 40 (GNOME-Wayland + XWayland), Debian 12 (GNOME default + XWayland), Arch Linux (any DE/compositor + XWayland), Linux Mint (Cinnamon-Xorg → Xorg direct), GNOME (Xorg + Wayland), Fluxbox (Xorg, exercised by the xembed-tray test path). Falls back gracefully (no panic, no error) to the main window's screen, then the OS default, when the cursor can't be resolved (headless / no DISPLAY / pure-Wayland-without-XWayland). Both first-create and re-show go through a single helper,WindowManager.centerOnCursorScreen: synchronous SetPosition first (covers full desktops and re-show with a still-alive GTK surface), then on minimal WMs (recenterOnShow— the Fluxbox/XEmbed-tray path) the same ~1s realize-detection retry loopcenterWhenReadyuses, because Wails' Linux SetPosition silently no-ops against a nil GdkSurface and Fluxbox would otherwise leave the window on the primary monitor. -
InstallProgress (
/#/dialog/install-progress?version=<v>) — opened byWindowManager.OpenInstallProgress(version)fromClientVersionContext(force-install branch oninstallingflip, user-driven enforced branch fromtriggerUpdate). 360-wide auto-sized viauseAutoSizeWindow,AlwaysOnTop. Owns its own polling loop againstUpdate.GetInstallerResultwith the 5-second daemon-down-grace (sustained gRPC failure = success → callUpdate.Quit()). Hides every other visible window on open (restored on close). -
Welcome (
/#/dialog/welcome) — first-launch onboarding window opened byWindowManager.OpenWelcome()frommain.go'sApplicationStartedhook, gated byprefStore.Get().OnboardingCompletedso it only fires on a fresh install. Auto-sized viauseAutoSizeWindow, centered (InitialPosition: WindowCentered), inheritsAlwaysOnTopfromDialogWindowOptions. Two-step state machine: (1) tray-screenshot pitch with the per-OS tray icon; (2) Cloud-vs-self-hosted segmented control with optional URL input — only rendered whenshouldShowManagementStepreturns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flipsPreferences.SetOnboardingCompleted(true), callsWindowManager.OpenMain(), thenWindowManager.CloseWelcome(). -
Error (
/#/dialog/error?message=<m>) — the app's single error surface, opened byWindowManager.OpenError(title, message). This replaced the native OS MessageBox outright: the frontenderrorDialog({Title, Message})wrapper inlib/errors.tsnow drives this window (same name/signature as before, so call sites were untouched), and the nativeDialogs.Error/Warning/Info/Questionwrappers plus the WindowsDetachedworkaround were deleted (nothing called warning/info/question). Frameless NetBird chrome,AlwaysOnTop(inherited fromDialogWindowOptions), auto-sized to the variable-length message viauseAutoSizeWindow.titleis the window's chrome title — set Go-side as"NetBird - <title>"(empty falls back to the localised "Error"), not shown in the body — so it's excluded fromretitleAll(a language flip must not clobber the live error title).messageis the body text, carried as a query param (errorDialogURLquery-escapes it so newlines/&in formatted daemon errors survive intouseSearchParams). The left-aligned body is just the dangerSquareIcon+ message + a bottom-right Close button. A second error while one is open updates the live window (SetTitle+SetURL) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) callsWindowManager.CloseError(). Note the behaviour change vs the old native box:errorDialog()resolves as soon as the window opens (it no longer blocks until dismissed). macOS caveat: the window usesMacTitleBarHiddenInset, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) are destroyed on close (mutex-guarded singleton; closing hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a RegisterHook close interceptor (e.Cancel(); Hide()) to keep the webview warm.
On macOS, main.go overrides Wails' default applicationShouldHandleReopen listener (which shows every hidden window — see pkg/application/events_common_darwin.go) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
The main window is hidden on close (the WindowClosing hook calls e.Cancel(); window.Hide()). The user reaches "really quit" through the tray → Quit menu entry.
Localisation (i18n)
The locale tree under client/ui/i18n/locales/ is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go i18n package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: _index.json lists shipped languages (code / displayName / englishName); <code>/common.json per language. en/common.json must exist (the Bundle loader hard-fails without it); languages listed in _index.json without a bundle are skipped with a warning. Placeholders are single-braced ("Install version {version}") — Go substitutes via Bundle.Translate(lang, key, "name", value, ...); React uses i18next with interpolation: { prefix: "{", suffix: "}" }.
Bundle shape is Chrome-extension JSON: each key maps to { "message": "...", "description": "..." }, not a bare string. description is translator context for Crowdin (which reads it natively from the source file) and is ignored at runtime — only en/common.json needs descriptions; target bundles carry just message. Both loaders strip back to a flat key→message map: Go's loadBundle (bundle.go) unmarshals into map[string]bundleEntry and flattens (so BundleFor/Translate signatures are unchanged); frontend/src/lib/i18n.ts maps each entry's message into the i18next resources. When editing a string, edit message; when a key's purpose isn't obvious from its name, add/update its description so translators (and screenshots auto-tagging) have context.
Adding a language: drop a <code>/common.json under client/ui/i18n/locales/, append a row to _index.json, rebuild. Go reads the tree via //go:embed all:i18n/locales in client/ui/main.go; Vite reads it via the ../../../i18n/locales/*/common.json glob in frontend/src/lib/i18n.ts, with server.fs.allow in vite.config.ts whitelisting the parent dir so the dev server can serve files outside frontend/.
Package layout:
client/ui/i18n/— pureLanguageCode/Language/Bundleloader. No Wails / no daemon. Reads the tree from anfs.FSpassed in bymain.go.client/ui/preferences/—StorepersistsUIPreferences{language}toos.UserConfigDir()/netbird/ui-preferences.json(per-OS-user, shared across daemon profiles). Validates against an injectedLanguageValidator(*i18n.Bundle). No file → in-memory defaulten, persisted on firstSetLanguage. Broadcasts via in-process pub/sub + optional Wails event emitter.services/i18n.go+services/preferences.go— Wails facades. Preferences emitsnetbird:preferences:changed(payload{language}) on everySetLanguage.
Key conventions: tray.* / notify.* (Go-side), common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpiration.* / peers.* (frontend). Keep keys stable — renames cascade everywhere.
Linux tray support
The in-process StatusNotifierWatcher + XEmbed host that lets the tray work on minimal WMs is detailed in LINUX-TRAY.md (sibling). Touch that doc when modifying tray_watcher_linux.go / xembed_host_linux.go / xembed_tray_linux.{c,h}.
Wails Dialogs (frontend, @wailsio/runtime)
The app no longer uses native @wailsio/runtime Dialogs.* message boxes — errors go through the custom Error window (see below), confirmations through the in-app useConfirm() modal. WAILS-DIALOGS.md (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (OpenFile/SaveFile) ever be needed.
Conventions in this codebase
Errors → custom Error window
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend errorDialog({Title, Message}) helper in frontend/src/lib/errors.ts (alongside formatErrorMessage), which opens the custom always-on-top Error auxiliary window (WindowManager.OpenError, /#/dialog/error — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name errorDialog and its {Title, Message} shape are unchanged from when it wrapped the native Dialogs.Error, so call sites were untouched; the native Dialogs.Error/Warning/Info/Question wrappers and the Windows Detached workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app useConfirm() modal (contexts/DialogContext.tsx), which resolves to a boolean.
Skip dialogs entirely for: inline form validation (Input.tsx, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
OS notifications
The tray uses Wails' built-in notifications service. One notifications.NotificationService is created in main.go and passed into TrayServices.Notifier. Notification IDs are prefixed for coalescing: netbird-update-<version>, netbird-event-<id>, netbird-tray-error, netbird-session-expired. Notifications are gated by the user's "Notifications" toggle (cached in Tray.notificationsEnabled, seeded from Settings.GetConfig at boot). Severity == "critical" events bypass the gate, mirroring the legacy Fyne event.Manager.
All sends go through safeSendNotification (tray_notify.go) — never call Notifier.SendNotification/SendNotificationWithActions directly. It swallows both errors and panics. The panic guard is load-bearing on Linux: Wails' notifier connects to the session bus in its ServiceStartup, and when that connect fails (headless box, no/unreachable DBUS_SESSION_BUS_ADDRESS, UI launched outside a desktop session) Wails logs the error but leaves the service registered with a nil *dbus.Conn. The next send then nil-derefs deep inside godbus (Conn.getSerial), and because sends run on a Wails event-dispatch goroutine the panic is fatal to the whole process — observed as a "catastrophic failure" crash on a Linux Mint VM when an update toast fired (tray_update.go sendUpdateNotification). recover() turns it into a logged no-op. The tray_session.go plain-notification fallback keys off the returned error, so a recovered panic (returns nil) correctly skips the fallback — the bus is dead, the plain send would panic too.
Profile switching invariants
ProfileSwitcher.SwitchActive is the only switch path on the TS side — ProfileContext.switchProfile is the single TS wrapper, and modules/profiles/ProfilesTab.tsx + the header ProfileDropdown both go through it. The Go side captures prevStatus, drives the optimistic-Connecting paint via Peers.BeginProfileSwitch, mirrors into the user-side profilemanager, and conditionally fires Down/Up per the reconnect-policy table above.
Never call Connection.Up on an Idle/NeedsLogin daemon — the daemon's internal 50s waitForUp blocks until DeadlineExceeded. Connection.Up from the frontend is reserved for the explicit Connect button (ConnectionStatusSwitch.connect) and the post-SSO resume inside startLogin; the gating for profile-switch reconnects lives Go-side in ProfileSwitcher.SwitchActive.
Build / dev tasks
task dev (Wails dev, live reload), task build (prod build for the current OS, dispatches to build/{darwin,linux,windows}/Taskfile.yml), task build:server / run:server / build:docker / run:docker (server-mode variants in build/Taskfile.yml). No task generate:bindings alias — run wails3 generate bindings -clean=true -ts directly from this directory. CLI flags + log-target semantics are documented in the main.go bullet under "Layout".
Both windows:build and windows:build:console (the latter outputs bin/netbird-ui-console.exe linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour DEV=true, which drops the -tags production flag. The production tag is what disables the WebKit/WebView2 DevTools inspector — so DEV=true is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with CGO_ENABLED=1 task windows:build:console DEV=true.
Useful references
WAILS-DIALOGS.md(sibling) — full@wailsio/runtimeDialogsAPI + per-OS behaviour + frameless-window pattern.LINUX-TRAY.md(sibling) — StatusNotifierWatcher + XEmbed host details.frontend/WAILS-API.md— frontend-facing binding signatures and model shapes.- Wails v3 dialog docs: https://v3.wails.io/features/dialogs/message/ and https://v3.wails.io/features/dialogs/custom/ (may 403 from some clients).
- Wails v3 multiple-windows guidance: https://v3.wails.io/learn/multiple-windows/
- Authoritative TS signatures:
frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts. - Wails examples: https://github.com/wailsapp/wails/tree/master/v3/examples/dialogs