mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +00:00
remove mdm test scripts and other markdown files
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh api *)",
|
||||
"Bash(wails3 generate *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
# 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.md` for 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 gRPC `Conn`, 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 empty `parseFlagsAndInitLog` falls back to `console` for `InitLog` and returns `userSetLogFile=false`), `--log-level` (`trace|debug|info|warn|error`, default `info`). See "GUI debug logging" below for what `userSetLogFile` gates.
|
||||
- `dock_darwin.go` / `dock_other.go` — flips the macOS dock entry to follow window visibility. Wails seeds `Mac.ActivationPolicy` at startup but exposes no runtime setter, so `dock_darwin.go` calls `[NSApp setActivationPolicy:]` directly via cgo (with `activateIgnoringOtherApps:` on the 0→1 transition only — without it a newly-shown window opens behind whatever app the user is currently focused on; gating on the transition stops auxiliaries opening on top of an already-visible main from stealing focus). **The Go side is one call** — `main.go` does `initDockObserver()` from the `ApplicationStarted` hook (so NSApp is up). Everything else lives in cgo: the observer registers default-`NSNotificationCenter` block subscriptions for `NSWindowDidChangeOcclusionStateNotification` and `NSWindowWillCloseNotification` (`object:nil` so windows created later are picked up automatically — no per-window plumbing on the Go side, no `WindowManager` constructor wiring), each firing `refreshDockPolicy`. `refreshDockPolicy` `dispatch_async`s a recount onto the main queue, enumerates `[NSApp windows]`, filters by Wails's `WebviewWindow` subclass, and asks each for `NSWindow.isVisible` — true iff the window is ordered in **regardless of occlusion**. That's the fix for the occlusion problem we'd otherwise hit relying on Wails's WindowHide event (Wails maps `windowDidChangeOcclusionState` to it, so covering our window with another app would have looked like a hide). `dispatch_async` also defers past any pending `orderOut:` / `close:` from the originating notification, so destroy-on-close auxiliaries have actually left `[NSApp windows]` by the time we look. A `lastDockState` static int on the C side suppresses no-op flips (and the `activate` focus-steal that goes with them). Linux/Windows: `dock_other.go` is a no-op `initDockObserver()` since their panels already gate taskbar entries on window visibility.
|
||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `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 — status-text transitions (`tray_status.go applyStatus`, which also folds in daemon-version and session-deadline changes into one aggregated relayout per push), Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no 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 via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
|
||||
- **Tray concurrency model (menuMu domain).** Wails dispatches `Event.On` listeners and menu `OnClick` callbacks on **fresh goroutines** (so `applyStatus` runs are concurrent with each other and with relayouts), and Wails `MenuItem` setters are not goroutine-safe. Hence `t.menu` and every `*Item`/`*Submenu` field on `Tray` are `menuMu`-owned: `buildMenu` reassigns them all on each relayout, and repaints happen from the caches inside `relayoutMenu` (caches are committed *before* the menu is touched, which makes a write on an orphaned pre-relayout item self-healing). Two sanctioned out-of-lock accesses: the Connect/Disconnect `OnClick` closures capture their own item, and `refreshSessionExpiresLabel` (30s ticker) snapshots `sessionExpiresItem` under `menuMu`. `relayoutMenu` is the **only post-startup `tray.SetMenu` call site** — never push a menu pointer snapshotted outside `menuMu` (it can reinstall a stale tree); `applyStatusIndicator` is `SetBitmap`-only, the relayout's trailing `SetMenu` is what repaints the macOS dot. Tray code never runs on the OS main thread, so taking `menuMu` from any goroutine can't deadlock the setters' `dispatch_sync`/`InvokeSync`.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when 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. See `LINUX-TRAY.md`.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
|
||||
- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux uses a monochrome black/white pair (`*-mono.png` black for light panels, `*-mono-dark.png` white for dark panels); Windows reuses the colored light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). The `*-mono*` set is generated from the macOS template silhouettes (states differ by shape, not color); `tray_icon.go iconForState` branches on `runtime.GOOS` (`linux` → mono, else colored).
|
||||
- **Linux mono icon theme selection** — Wails v3's Linux SNI backend ignores `SetDarkModeIcon` (its `setDarkModeIcon` just calls `setIcon`, last-write-wins — see `pkg/application/systemtray_linux.go`), and the SNI spec carries no panel light/dark hint. So `tray_theme_linux.go` detects the desktop colour scheme itself and `iconForState` picks black-vs-white, with `applyIcon` pushing a single `SetIcon` on Linux (no `SetDarkModeIcon`). Detection order: freedesktop **Settings portal** (`org.freedesktop.portal.Settings.Read` of `org.freedesktop.appearance`/`color-scheme`: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to the **`GTK_THEME`** env var (`:dark` suffix ⇒ dark) → else default dark (suits the common dark panel). A private session-bus `SettingChanged` subscription repaints live on theme flips. `Tray.panelDark func() bool` is seeded by `startTrayTheme()` (Linux only; `tray_theme_other.go` is a no-op stub) before the first `applyIcon`; `panelIsDark()` returns true when `panelDark` is 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|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"; the platform body lives in `debug_reveal_{windows,other}.go`). **Windows:** the bundle is written by the daemon (SYSTEM) into `C:\Windows\SystemTemp`, whose ACL denies the logged-in user — a plain `explorer /select` can't traverse it. So the Windows reveal elevates via `shell32!ShellExecuteW` with the `runas` verb (UAC prompt); on decline/failure (`HINSTANCE <= 32`) it falls back to an unelevated `explorer <parentDir>`. |
|
||||
| `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.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow 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`** (package `main`) — `uiLogPath()` resolves the path; `newDebugLog(userSetLogFile)` builds the `guilog.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 attaches `gui-client.log` alongside the console (via `util.SetLogOutputs`, MultiWriter, rotated by timberjack like the daemon log) and raises the logrus level; on a higher level detaches the file and restores `info`. Idempotent (`fileOn` guard) so the startup replay + a racing change-event are harmless. The `gui-client.log` is 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` (even `console`) is a manual override → the controller is disabled and never touches logging. Only the *absence* of `--log-file` enables the daemon-driven `gui-client.log`. This is why `parseFlagsAndInitLog` seeds 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 existing `SubscribeEvents` stream. `DaemonFeed.dispatchSystemEvent` recognises the marker and routes it to the controller's `Apply` instead of an OS toast (same pattern as `proto.MetadataKindProfileListChanged`). The controller is injected into `NewDaemonFeed` (a `services.LogController` interface — no exported setter, so the Wails-bound `DaemonFeed` gains no binding), and `DaemonFeed` **re-registers the UI log path** (`RegisterUILog` RPC) 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; `SetLogLevel` publishes on change (`publishLogLevelChanged` in `client/server/server.go`). The metadata key/value markers live in `client/proto/metadata.go` so producer (daemon) and consumer (UI) share one definition.
|
||||
- **Daemon side of the bundle:** `Server.RegisterUILog` stores the path in `Server.uiLogPath`; `DebugBundle` passes it to `debug.GeneratorDependencies.UILogPath`; `BundleGenerator.addUILog` adds `gui-client.log` + rotated siblings (`addRotatedLogFiles(dir, "gui-client")` — the glob is prefix-parametrised so it doesn't collide with the daemon's own `client*.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`/`error` output 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's `startLogin()` to begin an SSO flow. The tray does **not** show the main window when emitting — the hidden webview is alive and subscribed, so `startLogin` runs and the only visible surface is the BrowserLogin popup it opens.
|
||||
- `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`.
|
||||
- `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere.
|
||||
- `EventSettingsOpen = "netbird:settings:open"` (payload: tab string, e.g. `"general"` / `"profiles"`) — emitted by `WindowManager.OpenSettings(tab)` to set the active tab before Go calls `Show`/`Focus`. The matching reset-to-General on close lives in the React side via `document.visibilitychange` (Wails events from the Go close hook race `Hide` and 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:
|
||||
1. Sets a 30s `switchInProgress` guard.
|
||||
2. Emits a synthetic `Status{Status: StatusConnecting}` so both tray and React paint immediately.
|
||||
3. Tells `statusStreamLoop` to 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=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, 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 through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/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) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps 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: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, 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 earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-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) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
|
||||
- **SessionExpiration** (`/#/dialog/session-expiration?seconds=<n>`) — opened by `WindowManager.OpenSessionExpiration(seconds)`. 460×380, fixed size, `AlwaysOnTop: true`. The React-side buttons close the window via `WindowManager.CloseSessionExpiration` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Triggered by the tray today: `tray_session.go openSessionExpiration` fires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, and `openSessionExtendFlow` opens it on tray-row click seeded with the live remaining time. **Multi-monitor aware** — targets the display the OS cursor is currently on via `WindowManager.getScreenBasedOnCursorPosition`, which queries the native cursor location per-OS through `getCursorPosition` (`services/cursor_{darwin,windows,linux,other}.go`): `NSEvent.mouseLocation` flipped against the primary's frame height on macOS, `w32.GetCursorPos` + ScreenManager `PhysicalToDipPoint` on Windows, X11 `XQueryPointer` on 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 loop `centerWhenReady` uses, 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 by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
|
||||
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. 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 when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
|
||||
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/errors.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is 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 from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + 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) calls `WindowManager.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 uses `MacTitleBarHiddenInset`, 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/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`.
|
||||
- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
|
||||
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
|
||||
|
||||
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/runtime` `Dialogs` API + 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
|
||||
@@ -1,10 +0,0 @@
|
||||
# Linux tray support (StatusNotifierWatcher + XEmbed)
|
||||
|
||||
Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control.
|
||||
|
||||
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
|
||||
**X error handling.** Xlib's default protocol-error handler calls `exit()`, so a single async X error from a tray race (tray manager window dying between `xembed_find_tray` and `xembed_dock`; `XMoveWindow` on a popup the WM already destroyed) would take the whole UI process down. `xembed_install_error_handlers()` (`xembed_tray_linux.c`) installs process-global logging no-op handlers via `XSetErrorHandler`/`XSetIOErrorHandler`; it's idempotent and called from the Go side after every `XOpenDisplay` (`newXembedHost`, `xembedTrayAvailable`). Because the handler slot is process-global, GTK's GDK init can clobber it (whoever installs last wins) — so the popup code's raw Xlib calls **on GDK's Display** (`x11_move_window`, the submenu `XTranslateCoordinates`) are additionally wrapped in GDK's own `gdk_x11_display_error_trap_push`/`_pop_ignored`, which is independent of the global handler. (Those GDK X11 backend calls warn `-Wdeprecated-declarations` in GTK4, like the existing `gdk_x11_surface_get_xid`/`gdk_x11_display_get_xdisplay` calls — expected, still functional.)
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
||||
`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
|
||||
See `CLAUDE.md` for project conventions on *when* to use these (errors vs. inline validation, confirmation flow, etc.).
|
||||
|
||||
## Message dialogs
|
||||
|
||||
```ts
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
|
||||
await Dialogs.Info({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Warning({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Error({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Question({ Title, Message, Buttons?, Detached? });
|
||||
```
|
||||
|
||||
All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses.
|
||||
|
||||
`MessageDialogOptions` fields:
|
||||
- `Title?: string` — window title (short).
|
||||
- `Message?: string` — the body text.
|
||||
- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers.
|
||||
- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS).
|
||||
|
||||
## File dialogs
|
||||
|
||||
`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc).
|
||||
|
||||
## Per-OS behavior
|
||||
|
||||
| Platform | Behavior |
|
||||
|---|---|
|
||||
| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. |
|
||||
| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. |
|
||||
| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. |
|
||||
|
||||
Behavioural notes that affect us:
|
||||
- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`).
|
||||
- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.
|
||||
- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`.
|
||||
- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag.
|
||||
|
||||
## Frameless / custom-window dialogs (Go side)
|
||||
|
||||
When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options:
|
||||
- `Parent` — attach to a parent so OS treats it as a child.
|
||||
- `AlwaysOnTop: true` — float above the parent.
|
||||
- `Frameless: true` — no titlebar/chrome.
|
||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
||||
|
||||
We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window.
|
||||
|
||||
In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon.
|
||||
@@ -1,187 +0,0 @@
|
||||
# NetBird Wails UI — Frontend Working Notes
|
||||
|
||||
The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||||
|
||||
> **Keep these notes current.** Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase.
|
||||
|
||||
## Stack & tooling
|
||||
|
||||
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by the connect toggle. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
|
||||
|
||||
## Path aliases & bindings
|
||||
|
||||
`@/*` → `src/*`, `@bindings/*` → `bindings/github.com/netbirdio/netbird/client/ui/*` (set in both `tsconfig.json` and `vite.config.ts`). Canonical imports: `from "@bindings/services"` (functions) and `from "@bindings/services/models.js"` (types).
|
||||
|
||||
`bindings/` is gitignored and fully generated. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` fails until you run `pnpm bindings` (or `wails3 generate bindings -clean=true -ts` from `client/ui/`) once. `wails3 dev` regenerates on its own.
|
||||
|
||||
## Routing (`app.tsx`)
|
||||
|
||||
`HashRouter`. Dialog routes are grouped under a parent `<Route path="dialog">` (URL grouping only, no shared layout); the two in-window routes sit under `<AppLayout>`. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`.
|
||||
|
||||
| Path | Component (module) | Layout | Window |
|
||||
|---|---|---|---|
|
||||
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Settings auxiliary window |
|
||||
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | SSO browser-wait, always-on-top |
|
||||
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Install progress, always-on-top |
|
||||
| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Session expiry warning, always-on-top |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | First-launch onboarding |
|
||||
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | App's single error surface, always-on-top |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in `services/windowmanager.go` — see `../CLAUDE.md`. Frontend-relevant notes per window:
|
||||
|
||||
- **Settings** — opened via `WindowManager.OpenSettings(tab)`. The window stays at `/#/settings` for its whole lifetime (no `SetURL` between opens, so `AppLayout`'s providers never remount). Active tab is React local state in `SettingsPage`, set from the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is driven in React by a `document.visibilitychange` listener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which races `Hide` and flashes the previous tab for one frame).
|
||||
- **install-progress** — owns the install-result polling + 5s daemon-down-grace, calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (user-driven enforced branch) and on the `installing` flip from `netbird:update:state` (force-install branch).
|
||||
- **session-expiration** — `?seconds=` drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emit `trigger-login`; Logout calls `Connection.Logout`.
|
||||
- **welcome** — opened from Go's `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
- **error** — `errorDialog({Title, Message})` in `lib/errors.ts` opens this (not a native OS box). `title` is the window chrome title (set Go-side, not in the body); `message` is read from `useSearchParams` and rendered next to a danger `SquareIcon`, with a Close button (Escape also closes → `WindowManager.CloseError()`).
|
||||
|
||||
## Layouts
|
||||
|
||||
`AppLayout` is the only router-level layout. It mounts the shared provider stack and renders `<Outlet/>`:
|
||||
|
||||
```
|
||||
DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider
|
||||
```
|
||||
|
||||
- `DialogProvider` is outermost (and outside the daemon gate) so `useConfirm()` works regardless of daemon state.
|
||||
- `StatusProvider` owns the single `DaemonFeed.Get` + `netbird:status` subscription and **only renders its children when the daemon is reachable** — otherwise it short-circuits to `<DaemonUnavailableOverlay/>`. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return.
|
||||
- Order matters: `SettingsContext` (mounted in `SettingsPage`) depends on `ProfileContext`; `ClientVersionContext` reads `StatusContext` events.
|
||||
|
||||
`AppRightPanel` (in `layouts/`) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it).
|
||||
|
||||
Page-specific chrome and providers live in the page, not the layout:
|
||||
|
||||
- **`MainPage`** (main window only) mounts `ViewModeProvider` (wraps the whole page — both `MainHeader` and `MainBody` read view mode; it calls `Window.SetSize`, so it must not be visible to the Settings window), `NetworksProvider`, and `PeerDetailProvider`. `NavSectionProvider` is mounted **only** inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer of `useNavSection`.
|
||||
- **`SettingsPage`** owns the `wails-draggable` strip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs.
|
||||
|
||||
## Directory layout (`src/`)
|
||||
|
||||
- `app.tsx` — root render + route table. The canonical registry of every route. Also wires init-time bootstrap (`initLogForwarding`, `welcome`, `initI18n`, `initPlatform`) before first render.
|
||||
- `layouts/` — `AppLayout.tsx` (the only router-level layout) and `AppRightPanel.tsx` (shared content-panel shell).
|
||||
- `modules/<feature>/` — each feature owns its folder: a `*Page.tsx` entry where applicable, plus its local components.
|
||||
- `main/` — `MainPage.tsx`, `MainHeader.tsx`, `MainConnectionStatusSwitch.tsx` (connect toggle + the `startLogin` SSO orchestrator), `MainExitNodeSwitcher.tsx`.
|
||||
- `main/advanced/` — advanced-mode-only surfaces: `Navigation.tsx` (Peers/Networks tab switch) plus `peers/` (`Peers.tsx`, `PeerDetailPanel.tsx`, `PeerFilters.tsx`) and `networks/` (`Networks.tsx`, `NetworkFilters.tsx`). There is no exit-nodes sub-module — exit-node state lives in `NetworksContext` and the UI is `MainExitNodeSwitcher` (shown in default mode too).
|
||||
- `settings/` — `SettingsPage.tsx`, `SettingsNavigation.tsx`, `SettingsSection.tsx`, `SettingsSkeleton.tsx`, and the tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSecurity`, `SettingsSSH`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). The Profiles tab is `modules/profiles/ProfilesTab.tsx`.
|
||||
- `profiles/` — `ProfileDropdown.tsx` (header), `ProfileCreationModal.tsx`, `ProfilesTab.tsx` (settings table), `ProfileAvatar.tsx`. Context in `contexts/ProfileContext.tsx`. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + `useManagementUrl`); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the `managementUrl` onto it (keyed by profile name, before switching), then switches. Row actions confirm via `useConfirm()`.
|
||||
- `welcome/` — `WelcomeDialog.tsx` (orchestrator) + `WelcomeStepTray.tsx`, `WelcomeStepManagement.tsx`. The management step renders only when active profile is `"default"`, the profile email is empty, and the management URL is cloud-default-or-empty (`shouldShowManagementStep`). Self-hosted URL reachability is a soft warning (`useManagementUrl.checkManagementUrlReachable`) — the user can re-click Continue to proceed past a failed check.
|
||||
- `login/` — `LoginWaitingForBrowserDialog.tsx` (SSO browser-wait window).
|
||||
- `session/` — `SessionExpirationDialog.tsx`.
|
||||
- `auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`.
|
||||
- `error/` — `ErrorDialog.tsx`.
|
||||
- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `MdmContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `components/` — presentational primitives, no daemon RPCs, no router:
|
||||
- `buttons/` — `Button`, `IconButton`.
|
||||
- `inputs/` — `Input`, `SearchInput`.
|
||||
- `dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation, usually driven via `useConfirm()`).
|
||||
- `switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
|
||||
- `typography/` — `Label`, `HelpText`.
|
||||
- `empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`, `DaemonUnavailableOverlay`.
|
||||
- Flat at root: `Badge`, `CopyToClipboard`, `DropdownMenu`, `SquareIcon`, `Tooltip`, `TruncatedText`, `VerticalTabs`, `LanguagePicker`, `ManagementServerSwitch`.
|
||||
- `hooks/` — `useAutoSizeWindow.ts` (auto-size + `Window.Show` for auxiliary dialogs), `useKeyboardShortcut.ts`, `useManagementUrl.ts` (management-URL helpers: `CLOUD_MANAGEMENT_URL`, `isValidManagementUrl`, `normalizeManagementUrl`, `isNetbirdCloud`, `checkManagementUrlReachable`).
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts` (`formatErrorMessage` + the `errorDialog({Title, Message})` window wrapper), `formatters.ts` (byte/latency/relative-time + `shortenDns`), `sorting.ts` (`reconcileOrder` — order-preserving list reconciliation shared by the peers/networks/profiles lists), `i18n.ts`, `logs.ts` (forwards console + uncaught errors to the Go log pipeline), `platform.ts` (`isMacOS`/`isWindows`), `welcome.ts`.
|
||||
- `assets/` — fonts, logos, flags.
|
||||
|
||||
## Wails event bus
|
||||
|
||||
Subscribe with `Events.On(name, handler)`; the handler receives `{ data: <typed payload> }`. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each `Events.On` adds an emit-time fan-out.
|
||||
|
||||
| Event name | Payload | Emitted by | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `netbird:status` | `Status` | `services/peers.go` | `StatusContext` (the only subscriber) |
|
||||
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `ProfileContext` — refreshes so a tray-initiated switch paints in the UI |
|
||||
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing` |
|
||||
| `netbird:settings:open` | `string` (tab id) | `services/windowmanager.go OpenSettings` (before `Show`) | `SettingsPage` — `setActive(e.data)`. Reset-on-close is the `visibilitychange` listener, not this event. |
|
||||
| `netbird:preferences:changed` | `{ language }` | Go after `SetLanguage` / `SetViewMode` | `lib/i18n.ts` — calls `i18next.changeLanguage` so a flip from any window paints everywhere |
|
||||
| `browser-login:cancel` | (none) | `LoginWaitingForBrowserDialog` Cancel button **or** Go on window close | `MainConnectionStatusSwitch`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
|
||||
| `trigger-login` | (none) | `services.EventTriggerLogin` (reserved; no Go emitter today) | `MainConnectionStatusSwitch` subscribes and runs `startLogin()` |
|
||||
|
||||
`netbird:event`, `netbird:update:available`, and `netbird:update:progress` are emitted Go-side for the tray but **not** subscribed on the TS side — the UI derives the same info from `useStatus().status.events`.
|
||||
|
||||
## Contexts and state
|
||||
|
||||
State that crosses screens/windows lives in context, each provider mounted exactly once.
|
||||
|
||||
- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag.
|
||||
- **`ProfileContext`** — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` + a `refresh()`.
|
||||
- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null`. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect — `save` drops the field when it equals the mask.
|
||||
- **`MdmContext`** — `useMdm()` returns `config.managedFields` as `Record<string, boolean>`, **keyed by the daemon's `mdm.Key*` names exactly as written in the policy source** (`managementURL`, `allowServerSSH`, `preSharedKey`, `wireguardPort`, `rosenpassEnabled`/`Permissive`, `disableClientRoutes`/`disableServerRoutes`, `disableAutoConnect`, `blockInbound`). No GUI-side renaming — what the Group Policy admin writes is what the lookup key is. Mounted in `AppLayout` (under `ProfileProvider`); fetches `Settings.GetConfig` once, re-fetches on the daemon's `netbird:event` `metadata.type=config_changed` push so policy flips paint live. No second copy of the locked *values* — MDM is a global override, so the active profile's resolved `useSettings().config.<field>` already carries the MDM-enforced value. Consumers: Settings tabs hide individual toggles/sections (both rosenpass keys managed ⇒ whole encryption section hidden); `mdm.*` fields are **is-managed flags** — `true` when the field name appears in `MDMManagedFields`, `false` otherwise. Consumers check truthiness (`!mdm.x` to mean "not managed"). The *resolved value* of a managed field is already enforced into `Config`, so consumers read it via `useSettings().config.<field>` and hide the toggle when `mdm.<field>` is true. **Three carve-outs** that carry the resolved value instead, populated outside the reflective is-managed loop in `services/settings.go` `GetRestrictions`: `mdm.managementURL` (string — used by `ProfileCreationModal` / `WelcomeDialog`, which need the URL to render the form); `mdm.allowServerSSH` (`*bool` / `boolean | null` — used by `SettingsNavigation` + `SettingsPage` to gate the SSH tab on `mdm.allowServerSSH ?? !features.disableUpdateSettings`; tri-state needed because the tab gate lives outside `SettingsProvider` and can't read `config.serverSshAllowed`, and MDM-managed-with-value-`false` must hide the tab — falsy fallthrough would leak it); and `mdm.disableAdvancedView` (`bool` — MDM-only on the daemon, no CLI fallback; plumbed via the `GetFeatures` RPC rather than `MDMManagedFields`, so set directly from `featResp.GetDisableAdvancedView()`. Value semantic: `true` iff MDM explicitly disables; the nil/false collapse is fine because both mean "advanced view available"). The three `features.*` gates (`disableProfiles`/`Networks`/`UpdateSettings`) live in `Features` because they accept a CLI-flag fallback in addition to MDM (`--disable-profiles` etc.) — `disableAdvancedView` doesn't, which is why it sits with the MDM-only carve-outs. `ProfileCreationModal` skips the Cloud/self-hosted picker when `managed.managementURL` is set and submits the resolved URL verbatim; `WelcomeDialog` reads `config.managedFields.managementURL` directly (sits outside `AppLayout`) to skip the management step on a fresh install.
|
||||
- **`DebugBundleContext`** — stages `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
|
||||
- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches:
|
||||
1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases.
|
||||
2. `available && enforced && !installing` — user-driven; `triggerUpdate` opens the install-progress window then calls `Update.Trigger()`.
|
||||
3. `available && enforced && installing` — daemon already installing; the flip auto-opens the install-progress window.
|
||||
- **`NetworksContext`** — routed networks + exit nodes derived from `status.networks`; optimistic overrides for instant toggle feedback. **`PeerDetailContext`** — which peer detail panel is open in advanced view. **`NavSectionContext`** — the advanced-mode Peers/Networks tab selection.
|
||||
|
||||
### View mode + no client-side persistence
|
||||
|
||||
`ViewModeProvider` (`contexts/ViewModeContext.tsx`, mounted in `MainPage`) owns `viewMode: "default" | "advanced"`, consumed via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH`: Default 380, Advanced 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is `setFrame:` (frame, incl. ~28px title bar) while initial `windowNew` uses `initWithContentRect:` (content). Passing a constant would chop ~28px off the content area on the first switch. `main.go` opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from `Preferences.Get()` on mount without triggering a resize.
|
||||
|
||||
**No `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job: settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`.
|
||||
|
||||
## Localisation (i18n)
|
||||
|
||||
Bootstrap in `src/lib/i18n.ts`, awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, glob-imports every bundle from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to `netbird:preferences:changed` so a flip from any window calls `i18next.changeLanguage` here.
|
||||
|
||||
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go "unset" signal). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each, and picks the first base code (`de` from `de-DE`) with a shipped bundle — then `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to `en`. From the second launch the persisted value wins.
|
||||
|
||||
**Usage.** Default to the hook:
|
||||
|
||||
```ts
|
||||
import { useTranslation } from "react-i18next";
|
||||
const { t } = useTranslation();
|
||||
t("settings.tabs.general");
|
||||
t("update.card.versionAvailable", { version: updateVersion }); // placeholders
|
||||
```
|
||||
|
||||
Outside React (module-scope event handlers, error titles) import the instance directly: `import i18next from "@/lib/i18n"`.
|
||||
|
||||
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI).
|
||||
|
||||
**Translating bundles.** `client/ui/i18n/TRANSLATING.md` is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/`\n`/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change.
|
||||
|
||||
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` (follow `TRANSLATING.md`) and append the row to `_index.json`. No flag asset is needed — `LanguagePicker.tsx` deliberately ships no flags ("flags represent countries, not languages"). `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale.
|
||||
|
||||
**What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated.
|
||||
|
||||
## Login flow (`startLogin` in `MainConnectionStatusSwitch.tsx`)
|
||||
|
||||
The SSO flow is a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
|
||||
|
||||
1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user.
|
||||
2. If SSO is needed (`needsSsoLogin`):
|
||||
- `WindowManager.OpenBrowserLogin(uri)` opens the sign-in popup (hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`).
|
||||
- The dialog fires `Connection.OpenURL(uri)` from its mount effect (done from the dialog, not `startLogin`, so the browser doesn't race the still-hidden popup).
|
||||
- `Promise.race(WaitSSOLogin, browser-login:cancel)`.
|
||||
- On cancel: cancel the in-flight `WaitSSOLogin` gRPC so the daemon drops the abandoned device code.
|
||||
3. `Connection.Up({})` to bring the new session up.
|
||||
|
||||
`onSettled` (releasing the caller's React-level guard) fires the instant the flow ends — **before** the error dialog — never gated on the dialog. Errors that aren't cancellations surface via `errorDialog`. This is the only SSO entry point; there's no `/login` route — wire any new SSO trigger through here.
|
||||
|
||||
## Dialogs convention
|
||||
|
||||
**Errors → `errorDialog({Title, Message})` from `src/lib/errors.ts`** (which also exports `formatErrorMessage`), never `Dialogs.*` from `@wailsio/runtime`. Despite the name it opens the custom always-on-top `/#/dialog/error` window via `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **`errorDialog()` resolves as soon as the window opens — it does not block until dismissed.**
|
||||
|
||||
For **confirmations**, use `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` mounted at the provider level. Used by the Profiles tab and the management-server cloud switch.
|
||||
|
||||
**Skip dialogs entirely** for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full rationale in `../CLAUDE.md`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background `nb-gray-950`); `netbird` is brand orange (`#f68330`). New code uses `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under `src/assets/fonts/`.
|
||||
|
||||
## Wails-specific quirks
|
||||
|
||||
- **Window dragging.** Class `wails-draggable` on regions that should drag the OS window (headers, the Settings title strip, dialog wrappers). `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Webview asset access.** Reference assets through Vite: `import url from "@/assets/.../foo.svg"`. Absolute filesystem paths don't work in dev or prod.
|
||||
- **`Window.SetSize(w, h)`.** Called from `ViewModeContext`'s `setViewMode`. Height is read fresh from `Window.Size()` and re-passed — see the View mode section for why a constant would shrink the content area.
|
||||
- **Main-window width.** Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (`MainPage` → `isWindows() ? 364 : 380`; see wails/wails#3260).
|
||||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` (legal links) and the BrowserLogin "Try again". `SettingsAbout` has a `window.open` fallback for when Wails refuses (non-http schemes are rejected).
|
||||
|
||||
## Useful references
|
||||
|
||||
- `WAILS-API.md` (sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns `$CancellablePromise<T>` (`await` and ignore `.cancel()` in practice). Regenerate via `pnpm bindings` after any Go-side change.
|
||||
- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
- Wails v3 docs (may 403 from some clients): https://v3.wails.io/
|
||||
- `../CLAUDE.md` — Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals.
|
||||
@@ -130,4 +130,4 @@ A few habits that keep a bundle reading like one product rather than a word-for-
|
||||
|
||||
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
|
||||
How to run the app and switch language: see `../CLAUDE.md` and `../frontend/CLAUDE.md`. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# MDM dev cheatsheet
|
||||
|
||||
Two platform helpers, same field set. The daemon's MDM ticker picks up changes
|
||||
within ≤60s and restarts the engine in-process — no service restart needed.
|
||||
|
||||
## macOS — `mdm-toggle.sh`
|
||||
|
||||
Edits `/Library/Managed Preferences/io.netbird.client.plist`.
|
||||
|
||||
```bash
|
||||
./mdm-toggle.sh tui
|
||||
```
|
||||
|
||||
Keys: number to edit a field, **k** to kick the daemon, **c** to clear the
|
||||
plist, **l** to view recent MDM log lines, **r** to refresh, **q** to quit.
|
||||
|
||||
## Windows — `mdm-toggle.ps1`
|
||||
|
||||
**▶ [Launch the TUI](mdm-tui.bat)** (double-click `mdm-tui.bat` — it self-elevates).
|
||||
|
||||
Edits `HKLM\Software\Policies\NetBird` (the key Group Policy / Intune ADMX /
|
||||
a Registry CSP profile would populate — see `client/mdm/policy_windows.go`).
|
||||
Self-elevates (HKLM writes need admin). Or run it directly:
|
||||
|
||||
```powershell
|
||||
.\mdm-toggle.ps1
|
||||
```
|
||||
|
||||
Same keys as the macOS TUI: number to edit a field, **k** to kick the daemon,
|
||||
**c** to clear the policy key, **r** to refresh, **q** to quit. Registry type
|
||||
mapping (mirrors `readRegistryValue`): `string → REG_SZ`, `integer → REG_DWORD`,
|
||||
`bool → REG_DWORD` (1=true / 0=false), `array → REG_MULTI_SZ`. Value names are
|
||||
case-insensitive (the loader canonicalises against the known key set);
|
||||
service: `Netbird`.
|
||||
|
||||
## Known keys
|
||||
|
||||
- Strings/ints: `managementURL`, `preSharedKey`, `wireguardPort`, `splitTunnelMode` (`allow`/`disallow`)
|
||||
- Array: `splitTunnelApps`
|
||||
- Bools: `rosenpassEnabled`, `rosenpassPermissive`, `disableClientRoutes`, `disableServerRoutes`, `allowServerSSH`, `disableAutoConnect`, `blockInbound`, `disableMetricsCollection`
|
||||
- Feature gates: `disableAdvancedView`, `disableProfiles`, `disableNetworks`, `disableUpdateSettings` (true = hide; false/unset = allow)
|
||||
@@ -1,196 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Interactive tester for NetBird's MDM policy on Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
Edits HKLM\Software\Policies\NetBird (the same registry key the OS would
|
||||
populate from Group Policy / Intune ADMX ingestion / a Registry CSP profile,
|
||||
see client/mdm/policy_windows.go). The MDM ticker re-reads the key within
|
||||
<=60s; the daemon restarts the engine itself in-process - no service restart
|
||||
needed for a change to take effect (the [k] kick only forces an immediate
|
||||
re-read or recovers a wedged daemon).
|
||||
|
||||
Value-type mapping (mirrors readRegistryValue):
|
||||
string -> REG_SZ
|
||||
integer -> REG_DWORD
|
||||
bool -> REG_DWORD (1=true, 0=false) GetBool treats !=0 as true
|
||||
array -> REG_MULTI_SZ
|
||||
|
||||
Requires elevation (writing HKLM). Re-launches itself elevated if needed.
|
||||
|
||||
Keys: number to edit a field, [k] kick the daemon, [c] clear the whole
|
||||
policy key, [r] refresh, [q] quit.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$PolicyPath = 'HKLM:\Software\Policies\NetBird'
|
||||
|
||||
# Known keys and their registry types. Order drives the TUI numbering.
|
||||
$Fields = @(
|
||||
@{ Key = 'managementURL'; Type = 'string' }
|
||||
@{ Key = 'preSharedKey'; Type = 'string' }
|
||||
@{ Key = 'wireguardPort'; Type = 'integer' }
|
||||
@{ Key = 'splitTunnelMode'; Type = 'string' } # allow / disallow
|
||||
@{ Key = 'splitTunnelApps'; Type = 'array' }
|
||||
@{ Key = 'rosenpassEnabled'; Type = 'bool' }
|
||||
@{ Key = 'rosenpassPermissive'; Type = 'bool' }
|
||||
@{ Key = 'disableClientRoutes'; Type = 'bool' }
|
||||
@{ Key = 'disableServerRoutes'; Type = 'bool' }
|
||||
@{ Key = 'allowServerSSH'; Type = 'bool' }
|
||||
@{ Key = 'disableAutoConnect'; Type = 'bool' }
|
||||
@{ Key = 'blockInbound'; Type = 'bool' }
|
||||
@{ Key = 'disableMetricsCollection'; Type = 'bool' }
|
||||
@{ Key = 'disableAdvancedView'; Type = 'bool' }
|
||||
@{ Key = 'disableProfiles'; Type = 'bool' }
|
||||
@{ Key = 'disableNetworks'; Type = 'bool' }
|
||||
@{ Key = 'disableUpdateSettings'; Type = 'bool' }
|
||||
)
|
||||
|
||||
# ---- elevation --------------------------------------------------------------
|
||||
|
||||
function Test-Admin {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
(New-Object Security.Principal.WindowsPrincipal $id).IsInRole(
|
||||
[Security.Principal.WindowsBuiltinRole]::Administrator)
|
||||
}
|
||||
|
||||
if (-not (Test-Admin)) {
|
||||
Write-Host 'Elevation required for HKLM writes - relaunching as admin...' -ForegroundColor Yellow
|
||||
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @(
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"")
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---- ANSI helpers (VT; conhost on Win10+ and Windows Terminal support it) ----
|
||||
|
||||
$IsTty = -not [Console]::IsOutputRedirected
|
||||
$e = [char]27
|
||||
function C($code) { if ($IsTty) { "${e}[${code}m" } else { '' } }
|
||||
$Reset = C '0'; $Bold = C '1'; $Dim = C '2'
|
||||
$Green = C '32'; $Red = C '31'; $Yellow = C '33'; $Cyan = C '36'
|
||||
|
||||
# ---- service ----------------------------------------------------------------
|
||||
|
||||
function Restart-Daemon {
|
||||
$svc = Get-Service -Name 'Netbird', 'netbird' -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $svc) { Write-Host 'Netbird service not found.' -ForegroundColor Red; return }
|
||||
Write-Host "restarting service ($($svc.Name))..."
|
||||
Restart-Service -Name $svc.Name -Force
|
||||
}
|
||||
|
||||
# ---- registry read/write ----------------------------------------------------
|
||||
|
||||
function Ensure-PolicyKey {
|
||||
if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null }
|
||||
}
|
||||
|
||||
function Read-Value {
|
||||
param([string]$Key)
|
||||
if (-not (Test-Path $PolicyPath)) { return $null }
|
||||
$item = Get-ItemProperty -Path $PolicyPath -Name $Key -ErrorAction SilentlyContinue
|
||||
if ($null -eq $item) { return $null }
|
||||
return $item.$Key
|
||||
}
|
||||
|
||||
function Set-Value {
|
||||
param([string]$Key, [string]$Type, $Value)
|
||||
Ensure-PolicyKey
|
||||
switch ($Type) {
|
||||
'array' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([string[]]$Value) -Type MultiString }
|
||||
'integer' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([int]$Value) -Type DWord }
|
||||
'bool' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([int]$Value) -Type DWord }
|
||||
default { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([string]$Value) -Type String }
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-Value {
|
||||
param([string]$Key)
|
||||
if (Test-Path $PolicyPath) {
|
||||
Remove-ItemProperty -Path $PolicyPath -Name $Key -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-PolicyKey {
|
||||
if (Test-Path $PolicyPath) {
|
||||
Remove-Item -Path $PolicyPath -Recurse -Force
|
||||
Write-Host "removed $PolicyPath"
|
||||
} else {
|
||||
Write-Host '(already absent)'
|
||||
}
|
||||
}
|
||||
|
||||
# ---- TUI --------------------------------------------------------------------
|
||||
|
||||
function Render-Value {
|
||||
param($Raw, [string]$Type)
|
||||
if ($null -eq $Raw -or ($Raw -is [string] -and $Raw -eq '')) {
|
||||
return "$Dim-$Reset"
|
||||
}
|
||||
switch ($Type) {
|
||||
'bool' {
|
||||
if ([int]$Raw -ne 0) { "${Green}[+] true$Reset" } else { "${Red}[-] false$Reset" }
|
||||
}
|
||||
'array' { "${Cyan}[$($Raw -join ' ')]$Reset" }
|
||||
default { "$Yellow$Raw$Reset" }
|
||||
}
|
||||
}
|
||||
|
||||
function Render-Screen {
|
||||
Clear-Host
|
||||
Write-Host "$Bold==== NetBird MDM Tester (Windows) ====$Reset"
|
||||
Write-Host "${Dim}key:$Reset $PolicyPath`n"
|
||||
for ($i = 0; $i -lt $Fields.Count; $i++) {
|
||||
$f = $Fields[$i]
|
||||
$raw = Read-Value $f.Key
|
||||
$num = ('[{0,2}]' -f ($i + 1))
|
||||
$val = Render-Value $raw $f.Type
|
||||
Write-Host (" $Bold$num$Reset {0,-30} {1} $Dim({2})$Reset" -f $f.Key, $val, $f.Type)
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host " ${Bold}[k]$Reset kick daemon ${Bold}[c]$Reset clear key ${Bold}[r]$Reset refresh ${Bold}[q]$Reset quit"
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Edit-Field {
|
||||
param([int]$Index)
|
||||
if ($Index -lt 1 -or $Index -gt $Fields.Count) { Write-Host ' (out of range)'; Start-Sleep -Milliseconds 600; return }
|
||||
$f = $Fields[$Index - 1]
|
||||
$cur = Read-Value $f.Key
|
||||
switch ($f.Type) {
|
||||
'bool' {
|
||||
# Cycle: unset -> true -> false -> unset
|
||||
if ($null -eq $cur) { Set-Value $f.Key 'bool' 1 }
|
||||
elseif ([int]$cur -ne 0) { Set-Value $f.Key 'bool' 0 }
|
||||
else { Clear-Value $f.Key }
|
||||
}
|
||||
'array' {
|
||||
Write-Host " current: $($cur -join ' ')"
|
||||
$line = Read-Host ' new array (space-separated, empty to unset)'
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { Clear-Value $f.Key }
|
||||
else { Set-Value $f.Key 'array' ($line -split '\s+' | Where-Object { $_ }) }
|
||||
}
|
||||
default {
|
||||
Write-Host " current: $cur"
|
||||
$line = Read-Host " new $($f.Type) (empty to unset)"
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { Clear-Value $f.Key }
|
||||
else { Set-Value $f.Key $f.Type $line }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'starting MDM tester (q to quit)'
|
||||
while ($true) {
|
||||
Render-Screen
|
||||
$choice = Read-Host '>'
|
||||
switch -Regex ($choice) {
|
||||
'^(q|quit|exit)$' { Write-Host 'bye'; return }
|
||||
'^(k|kick)$' { Restart-Daemon; Start-Sleep -Milliseconds 600 }
|
||||
'^(c|clear)$' { Clear-PolicyKey; Start-Sleep -Milliseconds 600 }
|
||||
'^(r|)$' { } # redraw
|
||||
'^\d+$' { Edit-Field ([int]$choice) }
|
||||
default { Write-Host ' (unknown - number to edit, k/c/r/q)'; Start-Sleep -Milliseconds 600 }
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Dev helper for testing NetBird's MDM policy on macOS.
|
||||
#
|
||||
# Edits /Library/Managed Preferences/io.netbird.client.plist (the same path the
|
||||
# OS would populate from a real Configuration Profile). The MDM ticker observes
|
||||
# the change within ≤60s; the daemon restarts the engine itself in-process.
|
||||
#
|
||||
# Usage:
|
||||
# ./mdm-toggle.sh tui # interactive terminal UI
|
||||
# ./mdm-toggle.sh show # print current plist
|
||||
# ./mdm-toggle.sh clear # wipe (delete) the plist
|
||||
# ./mdm-toggle.sh restart # kick the daemon
|
||||
# ./mdm-toggle.sh logs [-f] # tail daemon log filtered for MDM lines
|
||||
# ./mdm-toggle.sh verify # plist + recent MDM log lines
|
||||
#
|
||||
# Known keys (see client/mdm/policy.go):
|
||||
# managementURL preSharedKey wireguardPort rosenpassEnabled rosenpassPermissive
|
||||
# disableClientRoutes disableServerRoutes allowServerSSH disableAutoConnect
|
||||
# blockInbound disableMetricsCollection splitTunnelMode splitTunnelApps
|
||||
# disableProfiles disableNetworks disableUpdateSettings disableAdvancedView
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PLIST="/Library/Managed Preferences/io.netbird.client.plist"
|
||||
LAUNCHD_LABEL="system/netbird"
|
||||
DAEMON_LOG="/var/log/netbird/client.log"
|
||||
LOG_GREP='MDM|mdm|policy|engine restart|config_changed|managed|RegisterUILog'
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
require_sudo() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
exec sudo --preserve-env=HOME "$0" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_plist() {
|
||||
if [[ ! -f "$PLIST" ]]; then
|
||||
mkdir -p "$(dirname "$PLIST")"
|
||||
/usr/libexec/PlistBuddy -c "Save" "$PLIST" >/dev/null 2>&1 || \
|
||||
/bin/cat >"$PLIST" <<'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict/></plist>
|
||||
EOF
|
||||
chown root:wheel "$PLIST"
|
||||
chmod 0644 "$PLIST"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_daemon() {
|
||||
echo "kicking daemon ($LAUNCHD_LABEL)..."
|
||||
launchctl kickstart -k "$LAUNCHD_LABEL"
|
||||
}
|
||||
|
||||
cmd_show() {
|
||||
if [[ ! -f "$PLIST" ]]; then
|
||||
echo "(no plist at $PLIST — MDM not active)"
|
||||
return
|
||||
fi
|
||||
echo "# $PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Print" "$PLIST"
|
||||
}
|
||||
|
||||
cmd_clear() {
|
||||
if [[ -f "$PLIST" ]]; then
|
||||
rm -f "$PLIST"
|
||||
echo "removed $PLIST"
|
||||
else
|
||||
echo "(already absent)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local follow=0
|
||||
[[ "${1:-}" == "-f" || "${1:-}" == "--follow" ]] && follow=1
|
||||
[[ -f "$DAEMON_LOG" ]] || die "daemon log not found at $DAEMON_LOG"
|
||||
if [[ $follow -eq 1 ]]; then
|
||||
echo "# tailing $DAEMON_LOG (Ctrl-C to stop) — filter: $LOG_GREP"
|
||||
tail -F "$DAEMON_LOG" | grep -E --line-buffered --color=always "$LOG_GREP"
|
||||
else
|
||||
echo "# last MDM-relevant lines from $DAEMON_LOG"
|
||||
grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -30
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_verify() {
|
||||
echo "=== plist ==="
|
||||
if [[ -f "$PLIST" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Print" "$PLIST"
|
||||
else
|
||||
echo "(no plist at $PLIST — MDM not active)"
|
||||
fi
|
||||
echo
|
||||
echo "=== daemon log (last 20 MDM-relevant lines) ==="
|
||||
if [[ -f "$DAEMON_LOG" ]]; then
|
||||
grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -20 \
|
||||
|| echo "(no MDM-related entries yet — wait up to 60s for next ticker fire)"
|
||||
else
|
||||
echo "(daemon log not found at $DAEMON_LOG)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_tui() {
|
||||
local KEYS=(
|
||||
managementURL preSharedKey wireguardPort splitTunnelMode splitTunnelApps
|
||||
rosenpassEnabled rosenpassPermissive disableClientRoutes disableServerRoutes
|
||||
allowServerSSH disableAutoConnect blockInbound disableMetricsCollection
|
||||
disableAdvancedView disableProfiles disableNetworks disableUpdateSettings
|
||||
)
|
||||
local TYPES=(
|
||||
string string integer string array
|
||||
bool bool bool bool
|
||||
bool bool bool bool
|
||||
bool bool bool bool
|
||||
)
|
||||
|
||||
# ANSI helpers; bail to plain text when stdout isn't a tty.
|
||||
local C_RESET="" C_BOLD="" C_DIM="" C_GREEN="" C_RED="" C_YELLOW="" C_CYAN=""
|
||||
if [[ -t 1 ]]; then
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
|
||||
C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m'; C_CYAN=$'\033[36m'
|
||||
fi
|
||||
|
||||
read_value() {
|
||||
local key="$1"
|
||||
[[ -f "$PLIST" ]] || { echo ""; return; }
|
||||
/usr/libexec/PlistBuddy -c "Print :$key" "$PLIST" 2>/dev/null || true
|
||||
}
|
||||
|
||||
render_value() {
|
||||
local raw="$1" type="$2"
|
||||
if [[ -z "$raw" ]]; then
|
||||
printf '%s—%s' "$C_DIM" "$C_RESET"
|
||||
return
|
||||
fi
|
||||
case "$type" in
|
||||
bool)
|
||||
if [[ "$raw" == "true" ]]; then
|
||||
printf '%s✔ true%s' "$C_GREEN" "$C_RESET"
|
||||
else
|
||||
printf '%s✘ false%s' "$C_RED" "$C_RESET"
|
||||
fi ;;
|
||||
array)
|
||||
printf '%s[%s]%s' "$C_CYAN" "$(echo "$raw" | tr '\n' ' ' | tr -s ' ')" "$C_RESET" ;;
|
||||
*)
|
||||
printf '%s%s%s' "$C_YELLOW" "$raw" "$C_RESET" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_screen() {
|
||||
clear
|
||||
printf '%s════ NetBird MDM Tester ════%s\n' "$C_BOLD" "$C_RESET"
|
||||
printf '%splist:%s %s %sdaemon log:%s %s\n\n' \
|
||||
"$C_DIM" "$C_RESET" "$PLIST" "$C_DIM" "$C_RESET" "$DAEMON_LOG"
|
||||
local i
|
||||
for i in "${!KEYS[@]}"; do
|
||||
local key="${KEYS[$i]}" type="${TYPES[$i]}" raw
|
||||
raw="$(read_value "$key")"
|
||||
printf ' %s[%2d]%s %-30s %s%s(%s)%s\n' \
|
||||
"$C_BOLD" $((i+1)) "$C_RESET" "$key" \
|
||||
"$(render_value "$raw" "$type")" \
|
||||
"$C_DIM" "$type" "$C_RESET"
|
||||
done
|
||||
printf '\n'
|
||||
printf ' %s[k]%s kick daemon %s[c]%s clear plist %s[l]%s show recent log %s[r]%s refresh %s[q]%s quit\n' \
|
||||
"$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
edit_field() {
|
||||
local idx="$1"
|
||||
if ! [[ "$idx" =~ ^[0-9]+$ ]] || (( idx < 1 || idx > ${#KEYS[@]} )); then
|
||||
echo " (out of range)"; sleep 0.6; return
|
||||
fi
|
||||
local key="${KEYS[$((idx-1))]}" type="${TYPES[$((idx-1))]}" cur
|
||||
cur="$(read_value "$key")"
|
||||
ensure_plist
|
||||
case "$type" in
|
||||
bool)
|
||||
# Cycle: unset → true → false → unset
|
||||
case "$cur" in
|
||||
"") /usr/libexec/PlistBuddy -c "Add :$key bool true" "$PLIST" ;;
|
||||
"true") /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Add :$key bool false" "$PLIST" ;;
|
||||
"false") /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" ;;
|
||||
esac ;;
|
||||
array)
|
||||
echo " current: $cur"
|
||||
read -r -p " new array (space-separated, empty to unset): " line
|
||||
/usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" 2>/dev/null || true
|
||||
if [[ -n "$line" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Add :$key array" "$PLIST"
|
||||
local i=0 v
|
||||
for v in $line; do
|
||||
/usr/libexec/PlistBuddy -c "Add :$key:$i string $v" "$PLIST"
|
||||
i=$((i+1))
|
||||
done
|
||||
fi ;;
|
||||
*)
|
||||
echo " current: $cur"
|
||||
read -r -p " new $type (empty to unset): " line
|
||||
/usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" 2>/dev/null || true
|
||||
if [[ -n "$line" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Add :$key $type $line" "$PLIST"
|
||||
fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
show_recent_log() {
|
||||
clear
|
||||
printf '%s── recent MDM-related daemon log lines ──%s\n' "$C_BOLD" "$C_RESET"
|
||||
if [[ -f "$DAEMON_LOG" ]]; then
|
||||
grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -25 \
|
||||
|| echo "(no MDM-related entries yet)"
|
||||
else
|
||||
echo "(daemon log not found at $DAEMON_LOG)"
|
||||
fi
|
||||
echo
|
||||
read -r -p "press enter to return " _
|
||||
}
|
||||
|
||||
echo "starting MDM tester (Ctrl-C or q to quit)"
|
||||
while true; do
|
||||
render_screen
|
||||
read -r -p "> " choice || break
|
||||
case "$choice" in
|
||||
q|quit|exit) break ;;
|
||||
k|kick) restart_daemon; sleep 0.6 ;;
|
||||
c|clear) cmd_clear; sleep 0.6 ;;
|
||||
l|logs) show_recent_log ;;
|
||||
r|"") : ;; # redraw
|
||||
*) edit_field "$choice" ;;
|
||||
esac
|
||||
done
|
||||
echo "bye"
|
||||
}
|
||||
|
||||
sub="${1:-}"; shift || true
|
||||
|
||||
case "$sub" in
|
||||
show) cmd_show "$@"; exit 0 ;;
|
||||
logs) require_sudo "$sub" "$@"; cmd_logs "$@"; exit 0 ;;
|
||||
verify) require_sudo "$sub" "$@"; cmd_verify "$@"; exit 0 ;;
|
||||
tui) require_sudo "$sub" "$@"; cmd_tui "$@"; exit 0 ;;
|
||||
restart) require_sudo "$sub" "$@"; restart_daemon; exit 0 ;;
|
||||
clear) require_sudo "$sub" "$@"; cmd_clear "$@"; exit 0 ;;
|
||||
""|-h|--help|help)
|
||||
sed -n '2,18p' "$0"
|
||||
;;
|
||||
*) die "unknown subcommand: $sub (try --help)" ;;
|
||||
esac
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
REM Double-click launcher for the NetBird MDM tester (Windows).
|
||||
REM The .ps1 self-elevates.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0mdm-toggle.ps1"
|
||||
Reference in New Issue
Block a user