Files
netbird/client/ui/CLAUDE.md
Eduard Gert 1932b76f5b update stuff
2026-05-13 16:28:51 +02:00

8.3 KiB

NetBird Wails UI — Working Notes

This is the Wails v3 desktop UI for NetBird. Go services live in services/; the React/TS frontend lives in frontend/; bindings between them are generated under frontend/bindings/.

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.

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.

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.

Message dialogs

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.

Custom dialogs (frameless child windows)

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 sideapp.Window.NewWithOptions(application.WebviewWindowOptions{...}). Key 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.
  • 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 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.

Conventions in this codebase

Errors → native dialogs

We surface user-actionable errors via Dialogs.Error rather than red inline text. This started with the profile selector and applies broadly to operation failures (config save, profile switch, debug bundle, update, etc.).

Pattern:

try {
    await SomeSvc.Operation(...);
} catch (e) {
    await Dialogs.Error({
        Title: "Operation Failed",  // short, action-named
        Message: e instanceof Error ? e.message : String(e),
    });
}

Title rules:

  • Action-named, short: "Switch Profile Failed", "Save Settings Failed", "Debug Bundle Failed".
  • Not "Error" / "Something went wrong" — the dialog already says that visually.

When not to use a native dialog:

  • Form validation (Input.tsx, URL-format checks, etc.) — inline next to the field. Native dialogs are too heavy for keystroke-driven feedback.
  • Status/result chrome on a dedicated screen — e.g. the /update and /login pages can show a brief "Update failed" header in addition to the dialog, so the screen isn't blank after dismissal.
  • Transient link errors on the dashboard (e.g. link.error on a management/signal card) — these flap in/out as the daemon recovers; an inline indicator is more appropriate than a dialog.
  • Result notifications inside a success flow — e.g. "bundle saved but upload failed" can stay inline since the operation otherwise succeeded.

Confirmations

Use Dialogs.Warning with explicit Buttons:

const r = await Dialogs.Warning({
    Title: "Delete Profile",
    Message: `Are you sure you want to delete "${name}"?`,
    Buttons: [
        { Label: "Cancel", IsCancel: true },
        { Label: "Delete", IsDefault: true },
    ],
});
if (r !== "Delete") return;

Compare against the Label string returned, not an index.

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.

After editing any services/*.go (or the underlying proto), regenerate:

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.

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.

Useful references