[client/ui] Introduce localisation (i18n + preferences) feature packages

Adds a tray + React translation pipeline driven by a single JSON locale
tree (frontend/src/i18n/locales) embedded into the Go binary. The tray
re-renders on language switch via a Localizer that subscribes to the
preferences store.

Layout:
- client/ui/i18n: Bundle, LanguageCode, Language, errors, embedded-FS
  loader. Pure domain, no Wails/daemon deps.
- client/ui/preferences: Store + UIPreferences for user-scope UI state,
  persisted under os.UserConfigDir()/netbird/ui-preferences.json with
  atomic writes and a subscribe/broadcast channel.
- client/ui/services: thin Wails-binding facades (services.I18n,
  services.Preferences) so React sees ctx-first signatures.
- client/ui/localizer.go: tray bridge that owns the active language,
  exposes T()/StatusLabel() and re-paints the menu on prefs change.
- tray.go: every user-facing const replaced by translation keys via
  t.loc.T(...); menu rebuild + state replay on language switch.
- main.go: //go:embed all:frontend/src/i18n/locales, wires Bundle ->
  Store -> Localizer -> Wails facades in order.

Frontend API exposed via Wails bindings: I18n.Languages, I18n.Bundle,
Preferences.Get, Preferences.SetLanguage, plus the
netbird:preferences:changed event.

Includes regenerated Wails TS bindings (peers/profileswitcher/etc.
re-emitted as part of the build) and en/hu seed bundles.
This commit is contained in:
Zoltan Papp
2026-05-15 11:08:19 +02:00
parent c0b0eeb6ab
commit 17cae1a75c
18 changed files with 1449 additions and 108 deletions

View File

@@ -0,0 +1,64 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* I18n holds the embedded translation bundles and serves them to both the
* tray (Translate, used on every menu rebuild) and the frontend (Bundle,
* optional Wails-bound endpoint for runtime fetches). The bundles are
* loaded once at construction and never mutated.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* Bundle returns the full key->text map for one language. Bound to React via
* Wails as an optional endpoint — the frontend can either import the JSON
* files directly through Vite or fetch them through this RPC. The returned
* map is a copy.
*/
export function Bundle(code: string): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(1780869897, code).then(($result: any) => {
return $$createType0($result);
});
}
/**
* HasLanguage reports whether a bundle is loaded for the given code.
* Preferences.SetLanguage uses this to validate input.
*/
export function HasLanguage(code: string): $CancellablePromise<boolean> {
return $Call.ByID(3348861033, code);
}
/**
* Languages returns the list of available locales. Bound to React via Wails
* so the settings page can populate its language selector.
*/
export function Languages(): $CancellablePromise<$models.Language[]> {
return $Call.ByID(768152924).then(($result: any) => {
return $$createType2($result);
});
}
/**
* Translate resolves key for the given language with a placeholder pass.
* Args must come in {placeholderName, value} pairs (e.g. "version", "1.2.3"
* substitutes "{version}"). Unknown keys fall back to the default language;
* if even that fails, the key itself is returned (debug-friendly — a missed
* key is visible in the UI rather than blank).
*/
export function Translate(lang: string, key: string, ...args: string[]): $CancellablePromise<string> {
return $Call.ByID(2739709937, lang, key, args);
}
// Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $models.Language.createFrom;
const $$createType2 = $Create.Array($$createType1);

View File

@@ -4,8 +4,10 @@
import * as Connection from "./connection.js";
import * as Debug from "./debug.js";
import * as Forwarding from "./forwarding.js";
import * as I18n from "./i18n.js";
import * as Networks from "./networks.js";
import * as Peers from "./peers.js";
import * as Preferences from "./preferences.js";
import * as ProfileSwitcher from "./profileswitcher.js";
import * as Profiles from "./profiles.js";
import * as Settings from "./settings.js";
@@ -15,8 +17,10 @@ export {
Connection,
Debug,
Forwarding,
I18n,
Networks,
Peers,
Preferences,
ProfileSwitcher,
Profiles,
Settings,
@@ -32,6 +36,7 @@ export {
DebugBundleResult,
Features,
ForwardingRule,
Language,
LocalPeer,
LogLevel,
LoginParams,
@@ -48,6 +53,7 @@ export {
SetConfigParams,
Status,
SystemEvent,
UIPreferences,
UpParams,
UpdateAvailable,
UpdateProgress,

View File

@@ -344,6 +344,41 @@ export class ForwardingRule {
}
}
/**
* Language describes one shipped UI locale. Code is the BCP-47-ish key the
* frontend and the preferences file use; DisplayName is shown in the language
* picker in its own script (so a Hungarian user sees "Magyar" even when the
* current UI language is English).
*/
export class Language {
"code": string;
"displayName": string;
"englishName": string;
/** Creates a new Language instance. */
constructor($$source: Partial<Language> = {}) {
if (!("code" in $$source)) {
this["code"] = "";
}
if (!("displayName" in $$source)) {
this["displayName"] = "";
}
if (!("englishName" in $$source)) {
this["englishName"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Language instance from a string or object.
*/
static createFrom($$source: any = {}): Language {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Language($$parsedSource as Partial<Language>);
}
}
/**
* LocalPeer mirrors LocalPeerState — what this client looks like on the mesh.
*/
@@ -1038,6 +1073,32 @@ export class SystemEvent {
}
}
/**
* UIPreferences is the user-scope UI state mirrored to disk and to the
* frontend. Pointer-free because the whole document is rewritten on every
* change — there are no per-field partial updates.
*/
export class UIPreferences {
"language": string;
/** Creates a new UIPreferences instance. */
constructor($$source: Partial<UIPreferences> = {}) {
if (!("language" in $$source)) {
this["language"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new UIPreferences instance from a string or object.
*/
static createFrom($$source: any = {}): UIPreferences {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new UIPreferences($$parsedSource as Partial<UIPreferences>);
}
}
/**
* UpParams selects the profile the daemon should bring up.
*/

View File

@@ -0,0 +1,47 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Preferences is the user-scope UI preferences service. Read at app start,
* updated by the React settings page (Wails-bound SetLanguage), and observed
* by the tray which re-renders its menu in the new language.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* Get returns a copy of the current preferences. Bound to React via Wails.
*/
export function Get(): $CancellablePromise<$models.UIPreferences> {
return $Call.ByID(3500743391).then(($result: any) => {
return $$createType0($result);
});
}
/**
* SetLanguage validates and persists a new language preference, then
* broadcasts the change to the frontend and to internal subscribers (tray).
* Bound to React via Wails.
*/
export function SetLanguage(lang: string): $CancellablePromise<void> {
return $Call.ByID(3710099805, lang);
}
/**
* Subscribe returns a channel that receives every persisted change. The
* unsubscribe function closes the channel and removes it from the list;
* callers must not close the channel themselves.
*/
export function Subscribe(): $CancellablePromise<[any, any]> {
return $Call.ByID(2696446779);
}
// Private type creation functions
const $$createType0 = $models.UIPreferences.createFrom;

View File

@@ -13,16 +13,18 @@ import * as services$0 from "../../../../netbirdio/netbird/client/ui/services/mo
function configure() {
Object.freeze(Object.assign($Create.Events, {
"netbird:event": $$createType0,
"netbird:status": $$createType1,
"netbird:update:available": $$createType2,
"netbird:update:progress": $$createType3,
"netbird:preferences:changed": $$createType1,
"netbird:status": $$createType2,
"netbird:update:available": $$createType3,
"netbird:update:progress": $$createType4,
}));
}
// Private type creation functions
const $$createType0 = services$0.SystemEvent.createFrom;
const $$createType1 = services$0.Status.createFrom;
const $$createType2 = services$0.UpdateAvailable.createFrom;
const $$createType3 = services$0.UpdateProgress.createFrom;
const $$createType1 = services$0.UIPreferences.createFrom;
const $$createType2 = services$0.Status.createFrom;
const $$createType3 = services$0.UpdateAvailable.createFrom;
const $$createType4 = services$0.UpdateProgress.createFrom;
configure();

View File

@@ -13,6 +13,7 @@ declare module "@wailsio/runtime" {
namespace Events {
interface CustomEvents {
"netbird:event": services$0.SystemEvent;
"netbird:preferences:changed": services$0.UIPreferences;
"netbird:status": services$0.Status;
"netbird:update:available": services$0.UpdateAvailable;
"netbird:update:progress": services$0.UpdateProgress;

View File

@@ -0,0 +1,6 @@
{
"languages": [
{"code": "en", "displayName": "English", "englishName": "English"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
]
}

View File

@@ -0,0 +1,34 @@
{
"tray.tooltip": "NetBird",
"tray.status.disconnected": "Disconnected",
"tray.status.daemonUnavailable": "Not running",
"tray.status.error": "Error",
"tray.menu.open": "Open NetBird",
"tray.menu.connect": "Connect",
"tray.menu.disconnect": "Disconnect",
"tray.menu.exitNode": "Exit Node",
"tray.menu.networks": "Resources",
"tray.menu.profiles": "Profiles",
"tray.menu.settings": "Settings",
"tray.menu.debugBundle": "Create Debug Bundle",
"tray.menu.about": "About",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Documentation",
"tray.menu.downloadLatest": "Download latest version",
"tray.menu.installVersion": "Install version {version}",
"tray.menu.guiVersion": "GUI: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Quit",
"notify.update.title": "NetBird update available",
"notify.update.body": "NetBird {version} is available.",
"notify.update.enforcedSuffix": " Your administrator requires this update.",
"notify.error.title": "Error",
"notify.error.connect": "Failed to connect",
"notify.error.disconnect": "Failed to disconnect",
"notify.error.switchProfile": "Failed to switch to {profile}",
"notify.sessionExpired.title": "NetBird session expired",
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again."
}

View File

@@ -0,0 +1,34 @@
{
"tray.tooltip": "NetBird",
"tray.status.disconnected": "Lekapcsolva",
"tray.status.daemonUnavailable": "Nem fut",
"tray.status.error": "Hiba",
"tray.menu.open": "NetBird megnyitása",
"tray.menu.connect": "Csatlakozás",
"tray.menu.disconnect": "Bontás",
"tray.menu.exitNode": "Kilépő csomópont",
"tray.menu.networks": "Erőforrások",
"tray.menu.profiles": "Profilok",
"tray.menu.settings": "Beállítások",
"tray.menu.debugBundle": "Hibakeresési csomag készítése",
"tray.menu.about": "Névjegy",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Dokumentáció",
"tray.menu.downloadLatest": "Legfrissebb verzió letöltése",
"tray.menu.installVersion": "{version} verzió telepítése",
"tray.menu.guiVersion": "Felület: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Kilépés",
"notify.update.title": "NetBird frissítés elérhető",
"notify.update.body": "Elérhető a NetBird {version}.",
"notify.update.enforcedSuffix": " A rendszergazda kötelezővé tette ezt a frissítést.",
"notify.error.title": "Hiba",
"notify.error.connect": "Csatlakozás sikertelen",
"notify.error.disconnect": "Bontás sikertelen",
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra."
}