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 infrontend/bindings/.../services/.frontend/bindings/**— generated, do not edit by hand. Regen viawails3 generate bindings -clean=true -ts(from this dir). Triggered by Go code changes.frontend/src/— React app. Route table isapp.tsx. App shell islayouts/AppLayout.tsx; context providers live undermodules/*/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.goheader):protoc v7.34.1,protoc-gen-go v1.36.6. CI'sproto-version-checkworkflow fails on mismatch. - After proto regen, also regen Wails bindings so the TS layer picks up new fields.
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. EachButtonis{ Label?, IsCancel?, IsDefault? }.IsCancelis what Esc/⌘. triggers;IsDefaultis what Enter triggers.Detached?: boolean— whentrue, 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
Labelyou passed (e.g.if (result !== "Delete") return;). Buttons[]on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.Dialogs.Errorplays the platform error sound and uses the platform error icon. Don't use it for confirmations — useDialogs.WarningorDialogs.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
busyflag.
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 side — app.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: trueinitially, thendialog.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
/updateand/loginpages 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.erroron 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 todarwin/,linux/,windows/).task generate:bindingsdoes not exist as a top-level alias — runwails3 generate bindings -clean=true -tsdirectly from this directory.
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).
- Authoritative TS signatures:
frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts. - Wails examples: https://github.com/wailsapp/wails/tree/master/v3/examples/dialogs