mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 13:49:58 +00:00
update claude.md and rename windowmanager
This commit is contained in:
@@ -3,10 +3,50 @@
|
||||
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/`.
|
||||
|
||||
## Layout
|
||||
- `main.go`, `tray*.go`, `grpc.go` — app entry, system tray, daemon gRPC client.
|
||||
- `services/*.go` — typed Wails services exposed to JS (`Profiles`, `Settings`, `Networks`, `Peers`, `Connection`, `Debug`, `Update`, `Forwarding`). Each method becomes a TS function in `frontend/bindings/.../services/`.
|
||||
- `frontend/bindings/**` — generated, do not edit by hand. Regen via `wails3 generate bindings -clean=true -ts` (from this dir). Triggered by Go code changes.
|
||||
- `frontend/src/` — React app. Route table is `app.tsx`. App shell is `layouts/AppLayout.tsx`; context providers live under `modules/*/Context.tsx`.
|
||||
|
||||
### Go (top-level package `main`)
|
||||
- `main.go` — app entry. Builds the gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, starts the in-process Linux SNI watcher, then the tray, then `peers.Watch`, then `app.Run`. Also wires `--daemon-addr`, `--log-file` (repeatable, defaults to `console`), `--log-level` flags.
|
||||
- `tray.go` — `Tray` struct and its menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns the per-status icon/dot, the Profiles submenu, the Connect/Disconnect swap, the About → Update flow, session-expired toast.
|
||||
- `tray_linux.go` — `init()` that sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process `org.kde.StatusNotifierWatcher` and XEmbed bridge so the tray works on minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without AppIndicator). See "Linux tray support" below.
|
||||
- `tray_watcher_other.go` — no-op stub on non-Linux builds.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. On Unix, SIGUSR1 brings the window forward. On Windows, a named event `Global\NetBirdQuickActionsTriggerEvent` does the same. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — `Conn` is a lazy, mutex-protected gRPC channel to the daemon. One `Conn` is shared by every service. `DaemonAddr()` returns `unix:///var/run/netbird.sock` on Linux/macOS and `tcp://127.0.0.1:41731` on Windows.
|
||||
- `icons.go` — `//go:embed` the tray/window PNGs from `assets/`. macOS uses template variants (`*-macos.png`); Linux ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`).
|
||||
- `desktop/desktop.go` — tiny helper returning `GetUIUserAgent()` (`netbird-desktop-ui/<version>`) for the gRPC dialer.
|
||||
|
||||
### Wails services (`services/*.go`)
|
||||
Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. See "Services rundown" below.
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
- `app.tsx` — top-level routes. Hash router with `/quick`, `/browser-login`, `/update`, `/session-expired`, `/settings` (own layout), and a root `AppLayout` that hosts `Main` and a `*` catch-all.
|
||||
- `layouts/AppLayout.tsx` — composition shell. Wraps `Header + Outlet` in `AppearanceProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`.
|
||||
- `layouts/SettingsLayout.tsx` — used when the settings window opens (route `/settings`).
|
||||
- `modules/*/Context.tsx` — context providers (`appearance`, `auto-update`, `debug-bundle`, `profile`).
|
||||
- `pages/` — full-screen, single-purpose pages opened in popups or via top-level routes (`BrowserLogin`, `SessionExpired`, `Update`, `Debug`).
|
||||
- `screens/` — content shown inside `AppLayout` (`Status`, `Peers`, `Networks`, `Profiles`, `Settings`, `Update`, `QuickActions`, `Debug`).
|
||||
|
||||
### Generated bindings
|
||||
- `frontend/bindings/**` — generated, do not edit by hand. Regenerate via `wails3 generate bindings -clean=true -ts` from this directory after editing any `services/*.go`.
|
||||
|
||||
## 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` / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Update` | `update.go` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin`. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
|
||||
`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`.
|
||||
@@ -14,6 +54,77 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
|
||||
- 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.
|
||||
|
||||
## Events bus
|
||||
|
||||
`main.go` registers four event types so the frontend can subscribe with typed payloads:
|
||||
|
||||
```go
|
||||
application.RegisterEvent[services.Status](services.EventStatus) // "netbird:status"
|
||||
application.RegisterEvent[services.SystemEvent](services.EventSystem) // "netbird:event"
|
||||
application.RegisterEvent[services.UpdateAvailable](services.EventUpdateAvailable) // "netbird:update:available"
|
||||
application.RegisterEvent[services.UpdateProgress](services.EventUpdateProgress) // "netbird:update:progress"
|
||||
```
|
||||
|
||||
Two additional plain-string events flow between Go and JS without a typed payload:
|
||||
|
||||
- `EventTriggerLogin = "trigger-login"` — emitted by the tray (or other Go-side triggers) to ask the frontend's `startLogin()` orchestrator to begin an SSO flow.
|
||||
- `EventBrowserLoginCancel = "browser-login:cancel"` — emitted by the `BrowserLogin` window when the user clicks Cancel or closes the window (red X). `startLogin()` listens and tears down the pending daemon SSO wait.
|
||||
|
||||
Daemon connection status strings (`services/peers.go`) — mirror `internal.Status*` in `client/internal/state.go`:
|
||||
|
||||
```go
|
||||
StatusConnected, StatusConnecting, StatusIdle,
|
||||
StatusNeedsLogin, StatusLoginFailed, StatusSessionExpired,
|
||||
StatusDaemonUnavailable // synthetic, 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's `screens/Profiles.tsx` 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 (`layouts/Header.tsx → WindowManager.OpenSettings`). Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
|
||||
- **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/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.
|
||||
|
||||
Both windows 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 auxiliaries.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
|
||||
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
||||
@@ -56,18 +167,18 @@ Behavioural notes that affect us:
|
||||
- `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.
|
||||
|
||||
### Custom dialogs (frameless child windows)
|
||||
### Frameless / custom-window dialogs (Go side)
|
||||
|
||||
When the native API isn't enough (rich content, form layout, complex validation), open a regular Wails window with dialog-like options. This is done on the **Go side** — `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Key options:
|
||||
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` — fixed-size dialog feel.
|
||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
||||
|
||||
Modal behavior is achieved by calling `parent.SetEnabled(false)` and restoring with `parent.SetEnabled(true)` in `dialog.OnClose`. Communicate results via Wails events (`app.Event.On(...)`, `Events.Emit(...)` on the frontend) or a Go channel.
|
||||
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.
|
||||
|
||||
We are **not currently using custom dialogs** in this repo — the in-app modals (`NewProfileDialog`, etc.) are Radix `Dialog` primitives inside the main webview, which is fine for most flows. Reach for a custom OS window only when content must escape the main window (e.g. a separate auth window) or when modality across windows matters.
|
||||
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.
|
||||
|
||||
## Conventions in this codebase
|
||||
|
||||
@@ -112,6 +223,12 @@ if (r !== "Delete") return;
|
||||
```
|
||||
Compare against the **Label string** returned, not an index.
|
||||
|
||||
### OS notifications
|
||||
|
||||
The tray uses Wails' built-in `notifications` service (`github.com/wailsapp/wails/v3/pkg/services/notifications`). 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`).
|
||||
|
||||
OS 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.
|
||||
|
||||
### Bindings & types
|
||||
Always import generated bindings from `@bindings/services` and types from `@bindings/services/models.js`. The path alias is set up in `tsconfig.json` / `vite.config.ts`.
|
||||
|
||||
@@ -121,14 +238,28 @@ wails3 generate bindings -clean=true -ts
|
||||
```
|
||||
|
||||
### Profile context
|
||||
`modules/profile/ProfileContext.tsx` is the single source of truth for `username`, `activeProfile`, and the `profiles` list. It exposes `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`, and `refresh`. `switchProfile` mirrors `tray.go`: it always issues `Profiles.Switch`, but only calls `Connection.Down` + `Connection.Up` when the daemon was actively online (status `Connected`/`Connecting`). Calling `Up` on an `Idle`/`NeedsLogin` daemon makes it block on the daemon's internal 50s `waitForUp` and return `DeadlineExceeded`. Callers shouldn't bring the connection up themselves.
|
||||
|
||||
`modules/profile/ProfileContext.tsx` is the React-side source of truth for `username`, `activeProfile`, and the `profiles` list. It exposes `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`, and `refresh`.
|
||||
|
||||
Two important nuances:
|
||||
|
||||
1. **Two switch paths exist.** `screens/Profiles.tsx` calls `ProfileSwitcher.SwitchActive` (the Go-side single-source-of-truth path that also drives the optimistic-Connecting paint and the Peers suppression filter). `ProfileContext.switchProfile`, used elsewhere, still implements the reconnect policy in TS: it calls `Profiles.Switch` and, only if the daemon was actively online, follows up with `Connection.Down` + `Connection.Up`. The TS path skips `Peers.BeginProfileSwitch` so it won't paint optimistic Connecting through the tray. Prefer `ProfileSwitcher.SwitchActive` for new call sites.
|
||||
|
||||
2. **Don't call `Connection.Up` on an Idle/NeedsLogin daemon.** The daemon's internal 50s `waitForUp` will block until `DeadlineExceeded`. Both switch paths gate `Up` on a previously-online status (Connected/Connecting). Callers should not bring the connection up themselves outside this flow — `Connection.Up` is reserved for the explicit Connect button and the post-switch resume.
|
||||
|
||||
## Build / dev tasks
|
||||
- `task dev` — Wails dev mode (live reload).
|
||||
- `task build` — production build for the current OS (Taskfile dispatches to `darwin/`, `linux/`, `windows/`).
|
||||
- `task generate:bindings` does not exist as a top-level alias — run `wails3 generate bindings -clean=true -ts` directly from this directory.
|
||||
- `task build` — production build for the current OS (`Taskfile.yml` dispatches to `build/{darwin,linux,windows}/Taskfile.yml`).
|
||||
- `task build:server` / `task run:server` / `task build:docker` / `task run:docker` — server-mode (HTTP, no GUI) variants. See `build/Taskfile.yml`.
|
||||
- `task generate:bindings` does **not** exist as a top-level alias — run `wails3 generate bindings -clean=true -ts` directly from this directory.
|
||||
|
||||
CLI flags (parsed in `main.go`):
|
||||
- `--daemon-addr <addr>` — gRPC address, default per `DaemonAddr()` (Unix socket on Linux/macOS, `tcp://127.0.0.1:41731` on Windows).
|
||||
- `--log-file <target>` — repeatable. Each value is `console`, `syslog`, or a file path. First user-provided value drops the seeded `console` default.
|
||||
- `--log-level <level>` — `trace|debug|info|warn|error` (default `info`).
|
||||
|
||||
## Useful references
|
||||
- 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
|
||||
|
||||
557
client/ui/frontend/CLAUDE.md
Normal file
557
client/ui/frontend/CLAUDE.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# NetBird Wails UI — Frontend Working Notes
|
||||
|
||||
This is the React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus two auxiliary windows (`/#/settings` and `/#/browser-login`) opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||||
|
||||
> **Work in progress.** Big chunks of the UI are still mocked, prototyped, or duplicated across screens that pre-date the current AppLayout. Anything marked "prototype" / "mocked" / "legacy" below should be assumed half-wired. The polished surface today is: the main connect toggle, the Settings window, the debug-bundle flow, the auto-update overlay, and the profile selector. Everything else is in flight.
|
||||
|
||||
## Stack
|
||||
|
||||
- **React 18** with `react-dom/client` + `<React.StrictMode>` (app.tsx).
|
||||
- **TypeScript 5.7**, `"strict": true`, `noUnusedLocals: true`, `noImplicitAny: false`, `jsx: react-jsx`.
|
||||
- **Vite 6** + `@vitejs/plugin-react`. Wails ships its own Vite plugin (`@wailsio/runtime/plugins/vite`) that's wired in for binding regen / runtime injection.
|
||||
- **React Router v7** (`HashRouter` — Wails serves a static bundle so hash-based routing avoids server-side fallback).
|
||||
- **Tailwind CSS 3** with `darkMode: "class"`. Class-merging via `cn(...inputs)` (`src/lib/cn.ts` — `twMerge(clsx(inputs))`).
|
||||
- **Radix UI primitives** for Dialog / DropdownMenu / Popover / RadioGroup / ScrollArea / Switch / Tabs / Tooltip / VisuallyHidden / Label.
|
||||
- **framer-motion** for the central connect-toggle animation only.
|
||||
- **lucide-react** for icons. **chroma-js** for the deterministic-color helper. **cmdk** is installed but not currently used.
|
||||
- **`@wailsio/runtime`** for `Dialogs`, `Events`, `Browser`, `Window` APIs.
|
||||
- **Package manager: pnpm** (`pnpm-lock.yaml`). No `package-lock.json` / `yarn.lock`.
|
||||
|
||||
Scripts (`package.json`):
|
||||
|
||||
```
|
||||
pnpm dev # vite dev server (port 9245, host 127.0.0.1)
|
||||
pnpm build:dev # tsc + vite build, mode=development, --minify false
|
||||
pnpm build # tsc + vite build, mode=production
|
||||
pnpm preview # vite preview
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm format # prettier write on src/**
|
||||
pnpm format:check
|
||||
```
|
||||
|
||||
`task dev` from `client/ui/` starts the Wails dev harness, which in turn runs `vite` (port `WAILS_VITE_PORT || 9245`).
|
||||
|
||||
## Path aliases
|
||||
|
||||
`tsconfig.json` and `vite.config.ts` agree on two aliases:
|
||||
|
||||
| Alias | Resolves to |
|
||||
|---|---|
|
||||
| `@/*` | `src/*` |
|
||||
| `@bindings/*` | `bindings/github.com/netbirdio/netbird/client/ui/*` |
|
||||
|
||||
So `import { Connection } from "@bindings/services"` and `import type { Status } from "@bindings/services/models.js"` are the canonical imports. **Don't** hand-write deep `../../bindings/github.com/...` paths — a few legacy screens (`screens/Profiles.tsx`, `pages/Update.tsx`) still do; treat that as a smell.
|
||||
|
||||
Bindings are regenerated from Go via `wails3 generate bindings -clean=true -ts` from `client/ui/`. Don't edit anything under `bindings/`.
|
||||
|
||||
## Routing (app.tsx)
|
||||
|
||||
`HashRouter` with the following routes:
|
||||
|
||||
| Path | Component | Layout | Where it opens |
|
||||
|---|---|---|---|
|
||||
| `/` | `Main` | `AppLayout` | Main window default route |
|
||||
| `/quick` | `QuickActions` | none | Standalone — **prototype**, not currently invoked by the Go side |
|
||||
| `/browser-login` | `BrowserLogin` | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
|
||||
| `/update` | `Update` (pages) | none | Main window during enforced-update install |
|
||||
| `/session-expired` | `SessionExpired` | none | Standalone — **prototype**, no buttons wired |
|
||||
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `AppearanceProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. The order matters — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints the `<UpdatingOverlay/>` so it has to be outermost in terms of z-index but innermost in the tree.
|
||||
|
||||
`SettingsLayout` uses the same provider stack minus the `Header`. It also reserves a 38px `wails-draggable` strip at the top so the macOS traffic-light buttons (the window uses `MacTitleBarHiddenInset`) don't overlap content.
|
||||
|
||||
## Directory layout (src/)
|
||||
|
||||
```
|
||||
app.tsx # entry, routes, <SkeletonTheme>, welcome() console banner
|
||||
globals.css # Tailwind layers + custom CSS variables
|
||||
vite-env.d.ts
|
||||
|
||||
assets/ # flags/, fonts/, logos/ (svg)
|
||||
components/ # presentational primitives — see "Components" below
|
||||
hooks/ # useStatus.ts (currently the only hook here)
|
||||
layouts/ # AppLayout, SettingsLayout, Header, Main, MainRightSide,
|
||||
# Navigation, ConnectionStatus, ConnectionStatusSwitch
|
||||
lib/ # cn (tailwind merge), color (hash → hex), welcome (console art),
|
||||
# MainModuleContext (unused legacy)
|
||||
modules/ # feature folders that own their own contexts/state
|
||||
appearance/ # AppearanceContext (localStorage)
|
||||
auto-update/ # ClientVersionContext + overlays/banners/badges
|
||||
debug-bundle/ # useDebugBundle hook + Provider wrapper
|
||||
peers/ # Peers UI (currently mockPeers; not wired to daemon data)
|
||||
profile/ # ProfileContext
|
||||
settings/ # Settings root + per-tab files + SettingsContext + accent egg
|
||||
skeletons/ # SkeletonSettings
|
||||
pages/ # full-screen single-purpose pages routed via app.tsx
|
||||
BrowserLogin.tsx # auxiliary window content
|
||||
SessionExpired.tsx # prototype, no wiring
|
||||
Update.tsx # enforced-update install screen (real one)
|
||||
Debug.tsx # legacy debug bundle UI, superseded by SettingsTroubleshooting
|
||||
screens/ # in-window screens (mostly legacy; pre-AppLayout era)
|
||||
Status.tsx # legacy detailed status page (not in current route table)
|
||||
Peers.tsx # legacy peer-detail UI (uses real Peers.Get data)
|
||||
Networks.tsx # legacy networks UI
|
||||
Profiles.tsx # uses ProfileSwitcher.SwitchActive (current preferred path)
|
||||
Settings.tsx # legacy — superseded by modules/settings/Settings.tsx
|
||||
Update.tsx # legacy update page (different from pages/Update.tsx)
|
||||
QuickActions.tsx # legacy quick-action panel
|
||||
Debug.tsx # legacy
|
||||
```
|
||||
|
||||
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. Today: `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell).
|
||||
|
||||
## Generated bindings
|
||||
|
||||
Re-exported from `@bindings/services/index.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Connection, Debug, Forwarding, Networks, Peers,
|
||||
ProfileSwitcher, Profiles, Settings, Update, WindowManager,
|
||||
} from "@bindings/services";
|
||||
|
||||
import type {
|
||||
ActiveProfile, Config, ConfigParams, DebugBundleParams, DebugBundleResult,
|
||||
Features, ForwardingRule, LocalPeer, LogLevel, LoginParams, LoginResult,
|
||||
LogoutParams, Network, PeerLink, PeerStatus, PortInfo, PortRange, Profile,
|
||||
ProfileRef, SelectNetworksParams, SetConfigParams, Status, SystemEvent,
|
||||
UpParams, UpdateAvailable, UpdateProgress, UpdateResult, WaitSSOParams,
|
||||
} from "@bindings/services/models.js";
|
||||
```
|
||||
|
||||
Every service method returns a `$CancellablePromise<T>` (Wails3 wrapper) — call `.cancel()` to abort the underlying gRPC call. In practice we `await` them and never call `.cancel()`; the few stream-driven cases use `AbortController` (see `useDebugBundle`).
|
||||
|
||||
## Wails event bus
|
||||
|
||||
Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed payload> }`. The event name strings live next to their usage (no central registry on the TS side).
|
||||
|
||||
| Event name (string) | Payload | Emitted by | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `hooks/useStatus` |
|
||||
| `netbird:event` | `SystemEvent` | `services/peers.go toastStreamLoop` | Not currently subscribed on the TS side — Status is read via `useStatus().status.events` instead. The tray (Go) consumes it for OS notifications. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | `services/peers.go fanOutUpdateEvents` | Not directly subscribed on the TS side; `ClientVersionContext` derives `updateVersion` from `status.events` metadata instead. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | same | Same — drives the tray; Go side opens the `/update` route. |
|
||||
| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `layouts/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
|
||||
| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); not currently used by the frontend |
|
||||
|
||||
If you wire a new daemon-event subscriber on the TS side, prefer subscribing once at the context level rather than per-screen — the Wails event bus is process-wide and each `Events.On` adds an emit-time fan-out.
|
||||
|
||||
## Contexts and state
|
||||
|
||||
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
|
||||
|
||||
### `useStatus` (hooks/useStatus.ts)
|
||||
|
||||
Returns `{ status, error, refresh }`. Fetches `Peers.Get()` once, then re-renders on every `netbird:status` push. `refresh()` is for forcing a re-read after a user action (Connect / Disconnect) so the UI doesn't lag the event stream by a few hundred ms.
|
||||
|
||||
### `ProfileContext` (modules/profile/ProfileContext.tsx)
|
||||
|
||||
Single source of truth for `username`, `activeProfile`, `profiles`. Exposes `refresh`, `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`.
|
||||
|
||||
**Caveat:** `ProfileContext.switchProfile` implements the reconnect policy in TS (Switch + conditional Down/Up gated on previous Connected/Connecting). The Go-side `ProfileSwitcher.SwitchActive` does the same thing **plus** drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. Prefer `ProfileSwitcher.SwitchActive` for new call sites — `screens/Profiles.tsx` already does. The duplicate logic in `ProfileContext` is on the cleanup list.
|
||||
|
||||
### `SettingsContext` (modules/settings/SettingsContext.tsx)
|
||||
|
||||
Loads `SettingsSvc.GetConfig` for the active profile, then debounces every `setField` write (`SAVE_DEBOUNCE_MS = 400`). API:
|
||||
|
||||
- `setField(k, v)` — optimistic update + debounced save. Use for toggles.
|
||||
- `saveField(k, v)` — flush pending + save immediately. Use for explicit Save buttons.
|
||||
- `saveFields(partial)` — same as `saveField` but for multiple keys at once (used by the Advanced tab's batched save).
|
||||
- `saveNow()` — flush pending without changing values.
|
||||
|
||||
While `config` is `null` the provider renders `<SkeletonSettings/>` instead of children — the actual tabs never need to handle a null config.
|
||||
|
||||
**PSK mask quirk:** The daemon returns existing pre-shared keys as `"**********"` in `GetConfig`. Sending the mask back round-trips it into the saved config and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"` so an unrelated toggle save doesn't corrupt the stored PSK.
|
||||
|
||||
### `AppearanceContext` (modules/appearance/AppearanceContext.tsx)
|
||||
|
||||
Pure-frontend UI preferences persisted to `localStorage` under `netbird:appearance`. Fields: `connectionLayout` (`"default" | "switch"`), `expanded` (bool — drives the wide / narrow window mode), `showPeersNav`, `showResourcesNav`, `showExitNodeNav`, `showProfileSelector`, `showSettingsButton`. `Header.tsx` writes `expanded` and resizes the OS window to match (`Window.SetSize(925|380, 615)`).
|
||||
|
||||
### `DebugBundleProvider` + `useDebugBundle` (modules/debug-bundle/)
|
||||
|
||||
Stateful hook driving the debug-bundle flow. Wrapped in a context so the troubleshooting tab inside the Settings window keeps the same stage if the user navigates away and back. Stages:
|
||||
|
||||
```
|
||||
idle → preparing-trace → reconnecting → capturing (per-second countdown) →
|
||||
restoring-level → bundling → uploading → done
|
||||
```
|
||||
|
||||
Cancellable via `AbortController` from any stage. On cancel the original log level is restored best-effort. `NETBIRD_UPLOAD_URL = https://upload.debug.netbird.io/upload-url` is hardcoded.
|
||||
|
||||
### `ClientVersionContext` (modules/auto-update/ClientVersionContext.tsx)
|
||||
|
||||
Reads `Status.events`, finds the most recent event whose metadata carries `new_version_available`, and exposes `{ updateAvailable, updateVersion, triggerUpdate, updating, updateError, dismissUpdateError }`. Mounts `<UpdateAvailableBanner/>` and the `<UpdatingOverlay/>` so any screen inherits the overlay without opting in.
|
||||
|
||||
**Dev preview flags at the top of the file** (flip and save to preview UI states without involving the daemon):
|
||||
|
||||
```ts
|
||||
const FORCE_UPDATE_AVAILABLE = true; // currently TRUE — banner is forced on
|
||||
const FORCE_UPDATING = false;
|
||||
const FORCE_VERSION = "0.65.0";
|
||||
const HIDE_UPDATE_AVAILABLE = false; // hard-hide everything regardless of state
|
||||
const FORCE_ERROR: ForceError = null; // "timeout" | "cancel" | "fail" | null
|
||||
const FORCE_ERROR_MSG = "installer exited with code 1";
|
||||
```
|
||||
|
||||
`FORCE_UPDATE_AVAILABLE = true` means the banner shows in production builds too right now. Flip it back to `false` before a real release. `UpdateAvailableBanner` additionally returns null in `import.meta.env.DEV` to avoid noise during `pnpm dev`.
|
||||
|
||||
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
|
||||
|
||||
The SSO flow is centralised in 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 the daemon needs SSO (`needsSsoLogin`):
|
||||
- `Connection.OpenURL(uri)` opens the verification page in the system browser (honors `$BROWSER`).
|
||||
- `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window.
|
||||
- `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first.
|
||||
- On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`).
|
||||
3. `Connection.Up({})` to bring the new session up.
|
||||
|
||||
Errors that aren't cancellations surface via `Dialogs.Error`.
|
||||
|
||||
This is the only SSO entry point used by the polished Main UI. Legacy screens (`screens/Status.tsx`, `screens/Profiles.tsx`) link to a `/login` route that **does not exist** in `app.tsx` today — those navigations will fall through the `*` catch-all to `/`. Those screens are not part of the live route table, so it doesn't bite users, but don't add a new `useNavigate("/login")` without first wiring an actual route.
|
||||
|
||||
## Components
|
||||
|
||||
`src/components/` holds presentational primitives (no daemon RPCs, no router):
|
||||
|
||||
- **Form / interactive:** `Button`, `IconButton`, `Input` (label + help text + reveal toggle + suffix + readonly + copy slot), `Switch`, `ToggleSwitch`, `FancyToggleSwitch` (label + helpText + value), `Label`, `HelpText`, `SearchInput`, `Tabs`, `VerticalTabs`, `CardSelect`, `CardNavItem`, `Card`.
|
||||
- **Layout / overlays:** `Dialog` (Radix wrapper with `Root/Trigger/Content/Title/Description/Footer`), `BottomSheet`, `Tooltip`, `StatusPanel`.
|
||||
- **Domain-specific:** `NetBirdConnectToggle` (the big animated brand circle — framer-motion + tailwind keyframes), `ProfileSelector`, `NewProfileDialog`, `Avatar`.
|
||||
|
||||
Settings rows mostly use `FancyToggleSwitch` inside `<SectionGroup title=…>`. Section group dimming is handled with `disabled` (greyed + `pointer-events-none`).
|
||||
|
||||
In-app modals (NewProfileDialog, the delete-profile confirm in `screens/Profiles.tsx`) use the Radix `Dialog` primitive inside the main webview. The two auxiliary OS windows (Settings, BrowserLogin) are created by Go via `WindowManager`, not by frontend code.
|
||||
|
||||
## Dialogs convention (recap)
|
||||
|
||||
Errors surface via `Dialogs.Error` from `@wailsio/runtime` with an action-named title:
|
||||
|
||||
```ts
|
||||
await Dialogs.Error({
|
||||
Title: "Save Settings Failed", // not "Error"
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
```
|
||||
|
||||
Confirmations use `Dialogs.Warning` with explicit `Buttons` and compare against the **Label string**, not an index:
|
||||
|
||||
```ts
|
||||
const r = await Dialogs.Warning({
|
||||
Title: "Delete Profile",
|
||||
Message: `Delete "${name}"?`,
|
||||
Buttons: [{ Label: "Cancel", IsCancel: true }, { Label: "Delete", IsDefault: true }],
|
||||
});
|
||||
if (r !== "Delete") return;
|
||||
```
|
||||
|
||||
When **not** to use native dialogs: inline form validation, transient link errors on the dashboard, "partial success" notes inside an otherwise-OK flow. See `../CLAUDE.md` for the full rules. The settings management-URL switch is a good example: `useManagementUrl` shows inline URL-format errors but throws up a `Dialogs.Warning` confirmation when the user is about to flip from self-hosted to NetBird Cloud (because that forces a reconnect/re-login).
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
Custom colors in `tailwind.config.ts`:
|
||||
|
||||
- `nb-gray` — main neutral palette, 50–960. `nb-gray-950` (#181a1d) is the app background; tabs/cards step through 900/910/920/925/935/940 as you go deeper. `DEFAULT` is `nb-gray-950`.
|
||||
- `netbird` — brand orange. `DEFAULT` is `#f68330` (500-ish). Used for primary buttons, focused tab borders, the connect-toggle border ring.
|
||||
- `gray`, `red`, `yellow`, `green`, `blue`, `indigo`, `purple`, `pink` — Flowbite-style 50–900 palettes used in the legacy screens (`screens/*`). Avoid in new code — stick to `nb-gray` + `netbird` + the semantic dot colors (`green-500`, `red-500`, `yellow-500`).
|
||||
|
||||
Background image `bg-conic-netbird` and keyframes `pulse-reverse` / `spin-slow` / `ping-slow` (plus the animations `animate-pulse-slow`, `animate-pulse-slower`, `animate-spin-slow`, `animate-ping-slow`) are used by `NetBirdConnectToggle`.
|
||||
|
||||
Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono) — both shipped under `src/assets/fonts/`.
|
||||
|
||||
## Wails-specific quirks
|
||||
|
||||
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, the UpdatingOverlay). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod.
|
||||
- **`Window.SetSize(w, h)`.** Called from `Header.tsx` to switch between 380-wide and 925-wide layouts. There's a one-time initial sync on mount so localStorage's `expanded` flag wins over the Go-side default of 925.
|
||||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails).
|
||||
|
||||
## Things in flight (don't be surprised by)
|
||||
|
||||
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `MainRightSide` and is what the user sees today; the real-data one isn't wired into the route table.
|
||||
- **`screens/Profiles.tsx`** still imports bindings via the deep relative path. It's the example of the preferred `ProfileSwitcher.SwitchActive` flow but otherwise pre-AppLayout.
|
||||
- **`pages/Debug.tsx`** is the legacy debug-bundle screen. The polished flow is in `modules/settings/SettingsTroubleshooting.tsx` (via `useDebugBundle`). `pages/Debug.tsx` isn't currently routed.
|
||||
- **`pages/Update.tsx`** and **`screens/Update.tsx`** are two different update pages. The route table points at `pages/Update.tsx` (the production one with the 15-minute timeout, daemon-down-grace, and error-mapping). The `screens/Update.tsx` is an older simpler variant.
|
||||
- **`pages/SessionExpired.tsx`** is fully rendered but the Sign-in / Later buttons have no onClick handlers yet.
|
||||
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
|
||||
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
|
||||
- **`lib/MainModuleContext.tsx`** is exported but unused. Candidate for deletion.
|
||||
- **`ConnectionStatus.tsx`** (the non-Switch variant of the main toggle) is local-state-only — it does not call `Connection.Up/Down` and shows hardcoded `peer-hostname.netbird.cloud` / `192.168.0.1`. It's a visual prototype the user can flip to via `connectionLayout` in `AppearanceContext`; **don't rely on it for real connect/disconnect behavior**. The real one is `ConnectionStatusSwitch.tsx`.
|
||||
- **`SettingsAccent`** is a 10-clicks-on-the-version-label easter egg that renders a falling-`TEAMNETBIRD` canvas overlay for 9 seconds. Kept on purpose.
|
||||
|
||||
## Wails Go API reference
|
||||
|
||||
Quick reference for every binding method and model shape exposed to the frontend. Generated from `services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
|
||||
|
||||
Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
|
||||
|
||||
### Imports
|
||||
|
||||
```ts
|
||||
// Services
|
||||
import {
|
||||
Connection, Peers, ProfileSwitcher, Profiles,
|
||||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||||
} from "@bindings/services";
|
||||
|
||||
// Models (types-only)
|
||||
import type {
|
||||
Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
|
||||
Profile, ProfileRef, ActiveProfile,
|
||||
Config, ConfigParams, SetConfigParams, Features,
|
||||
Network, SelectNetworksParams,
|
||||
ForwardingRule, PortInfo, PortRange,
|
||||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||||
} from "@bindings/services/models.js";
|
||||
```
|
||||
|
||||
### Push events
|
||||
|
||||
Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: <payload> }`.
|
||||
|
||||
| Event | Payload | Fires on |
|
||||
|---|---|---|
|
||||
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
|
||||
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
|
||||
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
|
||||
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
|
||||
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow; not currently wired on the TS side. |
|
||||
|
||||
The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
|
||||
|
||||
### `Connection`
|
||||
|
||||
```ts
|
||||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email
|
||||
Connection.Up(p: UpParams): Promise<void> // async on the daemon
|
||||
Connection.Down(): Promise<void>
|
||||
Connection.Logout(p: LogoutParams): Promise<void>
|
||||
Connection.OpenURL(url: string): Promise<void> // honors $BROWSER
|
||||
```
|
||||
|
||||
`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
|
||||
|
||||
Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
|
||||
|
||||
### `Peers`
|
||||
|
||||
```ts
|
||||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||
Peers.Watch(): Promise<void> // already invoked from main.go
|
||||
Peers.BeginProfileSwitch(): Promise<void>
|
||||
Peers.CancelProfileSwitch(): Promise<void>
|
||||
```
|
||||
|
||||
`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
|
||||
|
||||
### `ProfileSwitcher`
|
||||
|
||||
```ts
|
||||
ProfileSwitcher.SwitchActive(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
|
||||
|
||||
Reconnect policy (driven by `prevStatus` captured at entry):
|
||||
|
||||
| Previous status | Action | Optimistic UI | Suppressed events until new flow |
|
||||
|---|---|---|---|
|
||||
| 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) | — |
|
||||
|
||||
### `Profiles`
|
||||
|
||||
```ts
|
||||
Profiles.Username(): Promise<string> // current OS username
|
||||
Profiles.List(username: string): Promise<Profile[]>
|
||||
Profiles.GetActive(): Promise<ActiveProfile>
|
||||
Profiles.Switch(p: ProfileRef): Promise<void> // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
|
||||
Profiles.Add(p: ProfileRef): Promise<void>
|
||||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/<name>.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
|
||||
|
||||
### `Settings`
|
||||
|
||||
```ts
|
||||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||
Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||||
```
|
||||
|
||||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
||||
|
||||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||||
|
||||
`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
|
||||
|
||||
### `Networks`
|
||||
|
||||
```ts
|
||||
Networks.List(): Promise<Network[]>
|
||||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
```
|
||||
|
||||
`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
|
||||
|
||||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||||
|
||||
### `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||||
|
||||
### `Debug`
|
||||
|
||||
```ts
|
||||
Debug.GetLogLevel(): Promise<LogLevel>
|
||||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||
Debug.RevealFile(path: string): Promise<void> // OS file-manager focus
|
||||
```
|
||||
|
||||
**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
|
||||
|
||||
`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
|
||||
|
||||
### `Update`
|
||||
|
||||
```ts
|
||||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the outcome (long-running)
|
||||
Update.Quit(): Promise<void> // 100ms later, app.Quit()
|
||||
```
|
||||
|
||||
Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
|
||||
|
||||
### `WindowManager`
|
||||
|
||||
```ts
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
```
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
### Daemon `Status.status` values
|
||||
|
||||
Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
|
||||
| `"Connecting"` | Up in progress |
|
||||
| `"Connected"` | Tunnel up |
|
||||
| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
|
||||
| `"LoginFailed"` | Previous Login attempt errored |
|
||||
| `"SessionExpired"` | SSO token expired; needs re-Login |
|
||||
| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
|
||||
|
||||
The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
|
||||
|
||||
### Model field reference
|
||||
|
||||
`Status`:
|
||||
```ts
|
||||
{ status, daemonVersion: string;
|
||||
management: PeerLink; signal: PeerLink;
|
||||
local: LocalPeer;
|
||||
peers: PeerStatus[];
|
||||
events: SystemEvent[]; }
|
||||
```
|
||||
|
||||
`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
|
||||
|
||||
`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
|
||||
|
||||
`PeerStatus`:
|
||||
```ts
|
||||
{ ip, pubKey, fqdn, connStatus: string;
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
|
||||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
|
||||
bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
|
||||
relayAddress: string; // set when relayed=true
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[]; }
|
||||
```
|
||||
|
||||
`SystemEvent`:
|
||||
```ts
|
||||
{ id: string;
|
||||
severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
|
||||
category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
|
||||
message: string; // technical / log line
|
||||
userMessage: string; // human-friendly — render this
|
||||
timestamp: number; // unix seconds
|
||||
metadata: Record<string, string>; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
|
||||
```
|
||||
|
||||
`Profile`: `{ name: string; isActive: boolean; email: string }`.
|
||||
|
||||
`Config` (read-only mirror, all required):
|
||||
```ts
|
||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||
disableAutoConnect, serverSshAllowed,
|
||||
rosenpassEnabled, rosenpassPermissive,
|
||||
disableNotifications, lazyConnectionEnabled, blockInbound,
|
||||
networkMonitor, disableClientRoutes, disableServerRoutes,
|
||||
disableDns, disableIpv6, blockLanAccess,
|
||||
enableSshRoot, enableSshSftp,
|
||||
enableSshLocalPortForwarding, enableSshRemotePortForwarding,
|
||||
disableSshAuth: boolean; }
|
||||
```
|
||||
|
||||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
||||
|
||||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||||
|
||||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||||
|
||||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||||
|
||||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||||
|
||||
`PortRange`: `{ start, end: number }` (inclusive).
|
||||
|
||||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||||
|
||||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||||
|
||||
`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
|
||||
|
||||
`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
|
||||
|
||||
`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
|
||||
|
||||
`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
|
||||
|
||||
`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
|
||||
|
||||
`UpdateResult`: `{ success: boolean; errorMsg: string }`.
|
||||
|
||||
`UpdateAvailable`: `{ version: string; enforced: boolean }`.
|
||||
|
||||
`UpdateProgress`: `{ action: string; version: string }`.
|
||||
|
||||
## Useful references
|
||||
|
||||
- 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` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals.
|
||||
@@ -1,168 +0,0 @@
|
||||
# Settings — Tabs & Controls
|
||||
|
||||
Each row has a title and short description. Booleans default to **toggle switch**; pick another control only when noted.
|
||||
|
||||
Tab order: **General · Network · Security · SSH · Advanced · Troubleshooting · About**.
|
||||
|
||||
---
|
||||
|
||||
## 1. General
|
||||
|
||||
App behavior + how the client connects.
|
||||
|
||||
### General
|
||||
|
||||
- **Connect on startup** — `disableAutoConnect` (inverted) · *toggle switch*
|
||||
- Automatically connect to NetBird when the app launches.
|
||||
- **Show notifications** — `disableNotifications` (inverted) · *toggle switch*
|
||||
- Show desktop notifications for connection events and updates.
|
||||
|
||||
### Connection
|
||||
|
||||
- **Management Server** — `managementUrl` · *label + help text + (text input next to inline Save button)*
|
||||
- Help text sits between the label and the input. The NetBird management server this client connects to; saving reconnects to apply the new server. Save button persists explicitly (in addition to the global debounced auto-save) since changing the server triggers a reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Network
|
||||
|
||||
Routing and DNS — how the daemon reaches peers and resolves names.
|
||||
|
||||
### Connectivity
|
||||
|
||||
- **Lazy connections** — `lazyConnectionEnabled` · *toggle switch*
|
||||
- Only establish peer tunnels on first traffic instead of eagerly at startup.
|
||||
- **Network monitor** — `networkMonitor` · *toggle switch*
|
||||
- Reconnect automatically when the host network changes (Wi-Fi switch, VPN, sleep/wake).
|
||||
|
||||
### Routing & DNS
|
||||
|
||||
- **Enable DNS** — `disableDns` (inverted) · *toggle switch*
|
||||
- Apply NetBird-managed DNS settings to the host resolver.
|
||||
- **Enable client routes** — `disableClientRoutes` (inverted) · *toggle switch*
|
||||
- Accept routes advertised by other peers so this client can reach their networks.
|
||||
- **Enable server routes** — `disableServerRoutes` (inverted) · *toggle switch*
|
||||
- Advertise this host's local routes to other peers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Security
|
||||
|
||||
Firewall and on-the-wire encryption — what's blocked and how the tunnel is protected.
|
||||
|
||||
### Firewall
|
||||
|
||||
- **Block inbound traffic** — `blockInbound` · *toggle switch*
|
||||
- Drop all unsolicited inbound traffic on the NetBird interface.
|
||||
- **Block LAN access** — `blockLanAccess` · *toggle switch*
|
||||
- Prevent peers from reaching this host's local network.
|
||||
|
||||
### Encryption
|
||||
|
||||
- **Quantum-resistant encryption** — `rosenpassEnabled` · *toggle switch*
|
||||
- Add a post-quantum key exchange (Rosenpass) on top of WireGuard.
|
||||
- **Permissive mode** — `rosenpassPermissive` · *toggle switch* (nested, only when above is on)
|
||||
- Allow connections to peers without quantum-resistance support.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSH
|
||||
|
||||
NetBird SSH server config. Master switch at the top; sub-toggles greyed out when the master is off.
|
||||
|
||||
### Server
|
||||
|
||||
- **Allow SSH** — `serverSshAllowed` · *toggle switch* (master)
|
||||
- Run the NetBird SSH server on this host so other peers can connect to it.
|
||||
|
||||
### Capabilities
|
||||
|
||||
- **Allow root login** — `enableSshRoot` · *toggle switch*
|
||||
- Permit incoming SSH sessions to authenticate as `root`.
|
||||
- **Enable SFTP** — `enableSshSftp` · *toggle switch*
|
||||
- Allow file transfers over the NetBird SSH server.
|
||||
- **Local port forwarding** — `enableSshLocalPortForwarding` · *toggle switch*
|
||||
- Allow clients to forward local ports through this host.
|
||||
- **Remote port forwarding** — `enableSshRemotePortForwarding` · *toggle switch*
|
||||
- Allow clients to expose remote ports back through this host.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Disable SSH auth** — `disableSshAuth` · *toggle switch*
|
||||
- Skip JWT authentication for incoming SSH sessions. **Insecure — diagnostics only.**
|
||||
- **JWT cache TTL** — `sshJwtCacheTtl` · *number input (seconds)*
|
||||
- How long verified JWTs are cached before re-validation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Advanced
|
||||
|
||||
Power-user knobs: tunnel security, interface tuning, and log verbosity.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pre-shared key** — `preSharedKey` · *label + help text + password input with reveal toggle*
|
||||
- Help text sits between the label and the input. Optional WireGuard pre-shared key for an extra layer of symmetric encryption; must match the value on every peer.
|
||||
|
||||
### Interface
|
||||
|
||||
- **Name** — `interfaceName` · *text input*
|
||||
- Name of the WireGuard network interface created on this host.
|
||||
- **WireGuard Port** — `wireguardPort` · *number input*
|
||||
- Local UDP port the WireGuard interface listens on.
|
||||
- **MTU** — `mtu` · *number input*
|
||||
- Maximum transmission unit for the WireGuard interface.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
Everything you reach for when something is wrong.
|
||||
|
||||
### Debug bundle
|
||||
|
||||
Friendly intro line on top: *"A debug bundle helps NetBird support investigate connection problems. It's a zip file with logs and system details from this device."*
|
||||
|
||||
Toggle rows:
|
||||
|
||||
- **Anonymize personal data** — `anonymize` · *toggle switch* · default **on**
|
||||
- Replace IPs, hostnames, and peer names before saving.
|
||||
- **Include system info** — `systemInfo` · *toggle switch* · default **on**
|
||||
- Include OS, kernel, network interfaces, and routing tables.
|
||||
- **Send to NetBird support** — *toggle switch* · default **off**
|
||||
- Uploads the bundle to a hardcoded NetBird endpoint (`NETBIRD_UPLOAD_URL` constant). On success the user gets a short upload key to share with support. Local copy is always kept too.
|
||||
- **Capture detailed (trace) logs** — *toggle switch* · default **off**
|
||||
- Nested *Capture for [N] minutes* number input (1–30, suffix "min", default 3).
|
||||
- When enabled, the daemon's log level is switched to trace, NetBird is brought down and back up, the UI captures for the configured duration, the original log level is restored, then the bundle is created with `logFileCount: 5` (vs 1 in plain mode).
|
||||
- User-facing warning baked into the help text: "NetBird will briefly disconnect."
|
||||
|
||||
**Create bundle** — primary button. Disabled while running. Shows "Creating bundle…" label.
|
||||
|
||||
### Status / result block
|
||||
|
||||
Renders below the button while running and after completion.
|
||||
|
||||
- **Running** — bordered card with spinner + stage text. Stages: *Switching to trace logging…* → *Reconnecting NetBird…* → *Capturing logs — m:ss / m:ss* (countdown) → *Restoring previous log level…* → *Building bundle…* → *Uploading to NetBird…* (last only when upload toggle on; trace stages skipped when trace off).
|
||||
- **Done — uploaded**: bordered card with the upload key in a copyable code block + "Share this key with NetBird support so they can find your bundle.". Below, a smaller card with the local path + Copy + Reveal (file://) buttons + admin-privilege note.
|
||||
- **Done — local only**: single card with "Bundle saved to:" + path + Copy + Reveal + admin note.
|
||||
- **Partial — upload failed**: red banner ("Upload failed: <reason>. The bundle is still saved locally.") above the local path card.
|
||||
- **Error** (no bundle produced): red banner with the error message + a **Try again** button next to Create.
|
||||
|
||||
---
|
||||
|
||||
## 7. About
|
||||
|
||||
Two-row layout. Top row pairs the app icon with the product name + versions; everything else stacks below full-width.
|
||||
|
||||
**Top row** (icon left, info right):
|
||||
|
||||
1. **App icon** — `netbird-app-icon.svg`, `w-24 h-24`, rounded corners, subtle border (`border-nb-gray-800`).
|
||||
2. **NetBird** heading + version lines:
|
||||
- **GUI v{x.y.z}** — from `frontend/package.json` at build time
|
||||
- **Client v{x.y.z}** — from `Status.daemonVersion`
|
||||
|
||||
**Below the top row**, in order:
|
||||
|
||||
3. **Update banner** *(visible only when an event in `Status.events` carries `metadata["new_version_available"]`)* — "Version X.Y.Z is available." + a **What's new?** link → GitHub release page for that version, plus a **Restart now** primary button → `Update.Trigger()`.
|
||||
4. **Copyright** — "© {current year} NetBird. All Rights Reserved." (year from `new Date().getFullYear()`).
|
||||
5. **Legal links** — Imprint · Privacy · CLA · Terms of Service. Each opens via Wails `Browser.OpenURL` with `window.open` fallback.
|
||||
@@ -1,278 +0,0 @@
|
||||
# Wails Go API surface for the React frontend
|
||||
|
||||
All bindings live under `frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/`. Import them as:
|
||||
|
||||
```ts
|
||||
import { Connection, Peers, Networks, Settings, Profiles, Debug, Update, Forwarding } from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services";
|
||||
import * as $models from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services/models";
|
||||
```
|
||||
|
||||
Every method returns `$CancellablePromise<T>` (Wails3 wrapper around a Promise — call `.cancel()` to abort the underlying gRPC stream / call).
|
||||
|
||||
## Push events
|
||||
|
||||
Subscribe with the Wails event API: `import { Events } from "@wailsio/runtime"`.
|
||||
|
||||
| Event name | Payload type | Fires on |
|
||||
|---|---|---|
|
||||
| `netbird:status` | `Status` | Daemon connection-state change (Connected / Connecting / Disconnected / Idle), peer-list change, address change, management/signal flip. **Replaces polling**. |
|
||||
| `netbird:event` | `SystemEvent` | One push per daemon-emitted event (DNS/network/auth/connectivity/system). Drives toasts and the event log. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version. Show the update menu/banner. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | `action:"show"` → open the update progress page; `action:"hide"` → close. |
|
||||
|
||||
Calling `Peers.Watch()` once at boot starts both backend stream loops; both self-restart with backoff on errors.
|
||||
|
||||
## Connection lifecycle — `Connection`
|
||||
|
||||
```ts
|
||||
Connection.Up(p: UpParams): Promise<void>
|
||||
Connection.Down(): Promise<void>
|
||||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email/userInfo
|
||||
Connection.Logout(p: LogoutParams): Promise<void>
|
||||
```
|
||||
|
||||
- **Up flow**: call `Login` first; if `LoginResult.needsSsoLogin === true` open `verificationUriComplete` in the browser, then call `WaitSSOLogin` with `{ userCode: LoginResult.userCode, hostname: ... }`. Once that resolves call `Up`.
|
||||
- **Down flow**: just `Down()`. The daemon transitions to `Idle`.
|
||||
|
||||
```ts
|
||||
class LoginParams { profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }
|
||||
class LoginResult { needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }
|
||||
class WaitSSOParams { userCode, hostname: string }
|
||||
class UpParams { profileName, username: string }
|
||||
class LogoutParams { profileName, username: string }
|
||||
```
|
||||
|
||||
## Status / peer list — `Peers`
|
||||
|
||||
```ts
|
||||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||
Peers.Watch(): Promise<void> // call once at boot to enable push events
|
||||
```
|
||||
|
||||
```ts
|
||||
class Status {
|
||||
status: string // "Idle" | "Connecting" | "Connected" | "SessionExpired" (see below)
|
||||
daemonVersion: string
|
||||
management: PeerLink
|
||||
signal: PeerLink
|
||||
local: LocalPeer
|
||||
peers: PeerStatus[]
|
||||
events: SystemEvent[]
|
||||
}
|
||||
|
||||
class PeerLink {
|
||||
url: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
class LocalPeer {
|
||||
ip, pubKey, fqdn: string
|
||||
networks: string[]
|
||||
}
|
||||
|
||||
class PeerStatus {
|
||||
ip, pubKey, fqdn: string
|
||||
connStatus: string // "Connected" | "Connecting" | "Idle"
|
||||
connStatusUpdateUnix: number // unix seconds
|
||||
relayed: boolean
|
||||
localIceCandidateType, remoteIceCandidateType: string
|
||||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string
|
||||
bytesRx, bytesTx: number
|
||||
latencyMs: number
|
||||
relayAddress: string // populated when relayed
|
||||
lastHandshakeUnix: number
|
||||
rosenpassEnabled: boolean
|
||||
networks: string[]
|
||||
}
|
||||
|
||||
class SystemEvent {
|
||||
id: string
|
||||
severity: string // "info" | "warning" | "error" | "critical"
|
||||
category: string // "network" | "dns" | "authentication" | "connectivity" | "system"
|
||||
message: string // technical / log message
|
||||
userMessage: string // human-friendly message — render this
|
||||
timestamp: number // unix seconds
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
### Connection-state values
|
||||
|
||||
The `Status.status` field uses these literal strings (from the daemon):
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"Idle"` | Disconnected — Up not invoked, or Down completed |
|
||||
| `"Connecting"` | Up in progress |
|
||||
| `"Connected"` | Tunnel up |
|
||||
| `"SessionExpired"` | SSO token expired — needs Login again |
|
||||
|
||||
(The Fyne UI also reads a synthetic `"Error"` label for some failed states; check `events` for details.)
|
||||
|
||||
### ICE candidate type values
|
||||
|
||||
`localIceCandidateType` / `remoteIceCandidateType` are pion/ICE strings: `"host"`, `"srflx"`, `"prflx"`, `"relay"`, or `""` while connecting.
|
||||
|
||||
## Networks — `Networks`
|
||||
|
||||
```ts
|
||||
Networks.List(): Promise<Network[]>
|
||||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
```
|
||||
|
||||
```ts
|
||||
class Network {
|
||||
id, range: string // range is a CIDR
|
||||
selected: boolean
|
||||
domains: string[] // empty unless this is a domain network
|
||||
resolvedIps: Record<string, string[]> // domain -> IPs
|
||||
}
|
||||
|
||||
class SelectNetworksParams {
|
||||
networkIds: string[]
|
||||
append: boolean // false = replace selection, true = merge with existing
|
||||
all: boolean // true = ignore networkIds and target every network (Select-All / Deselect-All)
|
||||
}
|
||||
```
|
||||
|
||||
The Fyne UI's All / Overlapping / Exit-node tabs are filters over the same `List()` result:
|
||||
- **Exit-node**: `range === "0.0.0.0/0" || range === "::/0"`
|
||||
- **Overlapping**: client-side detection of CIDR overlap among `range` values
|
||||
- **All**: everything
|
||||
|
||||
## Forwarding / exposed services — `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
```ts
|
||||
class ForwardingRule {
|
||||
protocol: string // "tcp" | "udp"
|
||||
destinationPort: PortInfo
|
||||
translatedAddress, translatedHostname: string
|
||||
translatedPort: PortInfo
|
||||
}
|
||||
|
||||
class PortInfo { // exactly one field is populated
|
||||
port?: number
|
||||
range?: PortRange
|
||||
}
|
||||
|
||||
class PortRange { start, end: number }
|
||||
```
|
||||
|
||||
## Profiles — `Profiles`
|
||||
|
||||
```ts
|
||||
Profiles.List(username: string): Promise<Profile[]>
|
||||
Profiles.GetActive(): Promise<ActiveProfile>
|
||||
Profiles.Switch(p: ProfileRef): Promise<void>
|
||||
Profiles.Add(p: ProfileRef): Promise<void>
|
||||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||
Profiles.Username(): Promise<string> // current OS username
|
||||
```
|
||||
|
||||
```ts
|
||||
class Profile { name: string; isActive: boolean }
|
||||
class ProfileRef { profileName, username: string }
|
||||
class ActiveProfile { profileName, username: string }
|
||||
```
|
||||
|
||||
## Settings / config — `Settings`
|
||||
|
||||
```ts
|
||||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||
Settings.SetConfig(p: SetConfigParams): Promise<void>
|
||||
Settings.GetFeatures(): Promise<Features>
|
||||
```
|
||||
|
||||
```ts
|
||||
class ConfigParams { profileName, username: string } // identifies which profile's config
|
||||
|
||||
class Config {
|
||||
managementUrl, adminUrl, configFile, logFile, preSharedKey: string
|
||||
interfaceName: string; wireguardPort, mtu: number
|
||||
disableAutoConnect, serverSshAllowed: boolean
|
||||
rosenpassEnabled, rosenpassPermissive: boolean
|
||||
disableNotifications, lazyConnectionEnabled, blockInbound: boolean
|
||||
networkMonitor, disableClientRoutes, disableServerRoutes: boolean
|
||||
disableDns, blockLanAccess: boolean
|
||||
enableSshRoot, enableSshSftp: boolean
|
||||
enableSshLocalPortForwarding, enableSshRemotePortForwarding: boolean
|
||||
disableSshAuth: boolean
|
||||
sshJwtCacheTtl: number
|
||||
}
|
||||
|
||||
class SetConfigParams {
|
||||
// identity (always required)
|
||||
profileName, username: string
|
||||
// any field below is optional — only the ones you set are pushed to the daemon
|
||||
managementUrl?, adminUrl?, ...
|
||||
// ... same shape as Config
|
||||
}
|
||||
|
||||
class Features {
|
||||
// feature flags from the daemon — hide UI sections when these are true
|
||||
disableProfiles, disableUpdateSettings, disableNetworks: boolean
|
||||
}
|
||||
```
|
||||
|
||||
`SetConfig` is partial — supply only the fields you want to change, plus `profileName` + `username`. Booleans use Go pointer-presence under the hood; on the TS side undefined / missing means "leave as-is".
|
||||
|
||||
## Debug bundle / log level — `Debug`
|
||||
|
||||
```ts
|
||||
Debug.GetLogLevel(): Promise<LogLevel>
|
||||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||
```
|
||||
|
||||
```ts
|
||||
class LogLevel { level: string } // "trace" | "debug" | "info" | "warning" | "error" | "panic"
|
||||
|
||||
class DebugBundleParams {
|
||||
anonymize: boolean
|
||||
systemInfo: boolean
|
||||
uploadUrl: string // empty string = no upload
|
||||
logFileCount: number // 0 = default
|
||||
}
|
||||
|
||||
class DebugBundleResult {
|
||||
path: string // local path of the generated bundle
|
||||
uploadedKey: string // populated when uploadUrl was set
|
||||
uploadFailureReason: string // populated on upload error
|
||||
}
|
||||
```
|
||||
|
||||
## Update flow — `Update`
|
||||
|
||||
```ts
|
||||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the install outcome (long-running)
|
||||
```
|
||||
|
||||
```ts
|
||||
class UpdateResult { success: boolean; errorMsg: string }
|
||||
|
||||
class UpdateAvailable { // payload of "netbird:update:available"
|
||||
version: string
|
||||
enforced: boolean // true = management server requires it
|
||||
}
|
||||
|
||||
class UpdateProgress { // payload of "netbird:update:progress"
|
||||
action: string // "show" | "hide"
|
||||
version: string
|
||||
}
|
||||
```
|
||||
|
||||
Typical flow:
|
||||
1. Listen for `"netbird:update:available"` → show the "Update X.Y.Z" affordance.
|
||||
2. User clicks → call `Update.Trigger()`.
|
||||
3. The page that shows the install progress polls `GetInstallerResult()` (15-min timeout). On `success: true` the daemon will exit; the app should `app.Quit()` (or restart). On `success: false` show `errorMsg`.
|
||||
|
||||
## Toast notifications
|
||||
|
||||
The tray sends OS notifications via `application/services/notifications` automatically for `netbird:event` events that have `userMessage`. The frontend doesn't need to do anything for that; the data is also delivered via `netbird:event` if you want to render an in-window log.
|
||||
Reference in New Issue
Block a user