Files
netbird/client/ui/frontend/WAILS-API.md
2026-05-15 16:22:14 +02:00

15 KiB

Wails Go API reference (frontend)

Reference for every binding method and model shape exposed to the frontend. Generated from client/ui/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

// Services
import {
  Connection, Peers, ProfileSwitcher, Profiles,
  Settings, Networks, Forwarding, Debug, Update, WindowManager,
  I18n, Preferences,
} 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";

// i18n / preferences models live in sibling packages, not services/models
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import type { UIPreferences } from "@bindings/preferences/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:preferences:changed { language: string } Fires after every successful Preferences.SetLanguage (including the caller's own window). src/i18n/index.ts subscribes and calls i18next.changeLanguage.
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

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

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

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

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

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

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

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

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

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

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.

I18n

I18n.Languages(): Promise<Language[]>                       // from _index.json
I18n.Bundle(code: LanguageCode): Promise<Record<string,string>>  // full key→text map

Source of truth is frontend/src/i18n/locales/. The frontend's i18next bootstrap doesn't need I18n.Bundle at runtime (bundles are statically imported by Vite), but the language picker reads I18n.Languages() so the list matches _index.json without duplicating it in TS.

Preferences

Preferences.Get(): Promise<UIPreferences>                   // { language: string }
Preferences.SetLanguage(code: LanguageCode): Promise<void>  // rejects on unknown code

SetLanguage validates against the loaded i18n.Bundle, persists to os.UserConfigDir()/netbird/ui-preferences.json, and emits netbird:preferences:changed. The frontend's src/i18n/index.ts listens to that event and calls i18next.changeLanguage so a flip in any window paints in all of them. Missing preferences file → defaults to en, written on first read.

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:

{ 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:

{ 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:

{ 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):

{ 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 }.