[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."
}

209
client/ui/i18n/bundle.go Normal file
View File

@@ -0,0 +1,209 @@
//go:build !android && !ios && !freebsd && !js
// Package i18n carries the translation domain: the BCP-47 LanguageCode
// type, the per-language Language metadata, and the Bundle that loads and
// serves translation strings for both the tray (Go) and the React UI
// (via the Wails-bound services.I18n facade).
//
// No Wails or daemon dependencies — this package can be tested and used
// standalone. The locale tree is passed in as an fs.FS so the embed
// directive can live in the main binary alongside the rest of the
// embedded assets.
package i18n
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"sync"
log "github.com/sirupsen/logrus"
)
const (
// localeIndexFile sits at the locale tree root and lists every shipped
// language with its display name. Adding a new language means dropping
// a new <code>/common.json bundle and appending a row to this index.
localeIndexFile = "_index.json"
// commonBundleFile is the per-language translation bundle. Single
// namespace for now ("common") — split later if the key set grows
// enough to warrant per-screen bundles.
commonBundleFile = "common.json"
)
// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). Carried
// as a named string so the compiler distinguishes a language code from a
// translation key or an arbitrary user-supplied string in function
// signatures; JSON serialisation is unchanged (still a plain string).
type LanguageCode string
// DefaultLanguage is used when no preference is on disk and as the fallback
// bundle for missing keys.
const DefaultLanguage LanguageCode = "en"
// ErrUnsupportedLanguage is returned when a caller asks for a language
// that has no bundle loaded.
var ErrUnsupportedLanguage = errors.New("unsupported language")
// Language describes one shipped UI locale. DisplayName is shown in the
// picker in its own script (so a Hungarian user sees "Magyar" even when
// the current UI language is English).
type Language struct {
Code LanguageCode `json:"code"`
DisplayName string `json:"displayName"`
EnglishName string `json:"englishName"`
}
// localeIndex is the on-disk shape of _index.json.
type localeIndex struct {
Languages []Language `json:"languages"`
}
// Bundle holds the parsed translation bundles. Loaded once at construction
// and never mutated, so concurrent readers (tray menu rebuilds + Wails
// service calls) don't need to coordinate beyond the RW mutex.
type Bundle struct {
mu sync.RWMutex
languages []Language
bundles map[LanguageCode]map[string]string
}
// NewBundle parses _index.json plus every <code>/common.json file in the
// locale tree. Hard-fails only when the default language is missing —
// individual locales without a bundle are dropped with a warning so the
// rest of the product keeps shipping.
func NewBundle(localesFS fs.FS) (*Bundle, error) {
idx, err := loadLocaleIndex(localesFS)
if err != nil {
return nil, fmt.Errorf("load locale index: %w", err)
}
bundles := make(map[LanguageCode]map[string]string, len(idx.Languages))
available := make([]Language, 0, len(idx.Languages))
for _, l := range idx.Languages {
b, err := loadBundle(localesFS, l.Code)
if err != nil {
log.Warnf("skip language %q: %v", l.Code, err)
continue
}
bundles[l.Code] = b
available = append(available, l)
}
if _, ok := bundles[DefaultLanguage]; !ok {
return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage)
}
sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code })
return &Bundle{
languages: available,
bundles: bundles,
}, nil
}
// Languages returns the list of available locales as a copy.
func (b *Bundle) Languages() []Language {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]Language, len(b.languages))
copy(out, b.languages)
return out
}
// HasLanguage reports whether a bundle is loaded for the given code.
// preferences.Store uses this to validate SetLanguage input.
func (b *Bundle) HasLanguage(code LanguageCode) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.bundles[code]
return ok
}
// BundleFor returns the full key->text map for one language as a copy.
// The Wails facade exposes this to React so the frontend can drive its
// own translation library (i18next, etc.) off the same source bundles.
func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
b.mu.RLock()
defer b.mu.RUnlock()
bundle, ok := b.bundles[code]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code)
}
out := make(map[string]string, len(bundle))
for k, v := range bundle {
out[k] = v
}
return out, nil
}
// 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 — a missed key is visible
// in the UI rather than blank.
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
b.mu.RLock()
defer b.mu.RUnlock()
if v, ok := b.bundles[lang][key]; ok {
return applyPlaceholders(v, args)
}
if lang != DefaultLanguage {
if v, ok := b.bundles[DefaultLanguage][key]; ok {
return applyPlaceholders(v, args)
}
}
return key
}
// applyPlaceholders substitutes {name} occurrences in s using args interpreted
// as flat name/value pairs. Odd-length args lists drop the trailing item with
// a debug log — preferable to a hard error since the caller is internal code.
func applyPlaceholders(s string, args []string) string {
if len(args) == 0 {
return s
}
if len(args)%2 != 0 {
log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args))
args = args[:len(args)-1]
}
for j := 0; j < len(args); j += 2 {
s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1])
}
return s
}
func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) {
data, err := fs.ReadFile(localesFS, localeIndexFile)
if err != nil {
return nil, err
}
var idx localeIndex
if err := json.Unmarshal(data, &idx); err != nil {
return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err)
}
if len(idx.Languages) == 0 {
return nil, errors.New("no languages declared")
}
return &idx, nil
}
func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) {
p := path.Join(string(code), commonBundleFile)
data, err := fs.ReadFile(localesFS, p)
if err != nil {
return nil, err
}
var bundle map[string]string
if err := json.Unmarshal(data, &bundle); err != nil {
return nil, fmt.Errorf("parse %s: %w", p, err)
}
return bundle, nil
}

View File

@@ -0,0 +1,156 @@
//go:build !android && !ios && !freebsd && !js
package i18n
import (
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeLocales returns an in-memory FS that mirrors the real
// frontend/src/i18n/locales layout (root-level _index.json plus
// <code>/common.json bundles). Used by every Bundle test so we don't
// depend on the embedded production bundles staying stable.
func fakeLocales() fstest.MapFS {
return fstest.MapFS{
"_index.json": {Data: []byte(`{
"languages": [
{"code": "en", "displayName": "English", "englishName": "English"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
]
}`)},
"en/common.json": {Data: []byte(`{
"tray.menu.connect": "Connect",
"tray.menu.installVersion": "Install version {version}",
"notify.update.body": "NetBird {version} is available."
}`)},
"hu/common.json": {Data: []byte(`{
"tray.menu.connect": "Csatlakozás",
"tray.menu.installVersion": "{version} telepítése"
}`)},
}
}
func TestBundle_LoadsAllLanguages(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
langs := b.Languages()
require.Len(t, langs, 2)
codes := []LanguageCode{langs[0].Code, langs[1].Code}
assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded")
}
func TestBundle_TranslateLooksUpKey(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect"))
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"))
}
func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
assert.Equal(t, "Install version 1.2.3",
b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"),
"placeholders should substitute by name")
assert.Equal(t, "1.2.3 telepítése",
b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3"))
}
func TestBundle_TranslateFallsBackToEnglish(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
// notify.update.body is missing from the hu bundle; English fallback
// applies so the user always sees a populated label rather than the
// raw key.
got := b.Translate("hu", "notify.update.body", "version", "9.9.9")
assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle")
}
func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"),
"unknown key should return the key itself for debugability")
}
func TestBundle_BundleForReturnsCopy(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
m, err := b.BundleFor("en")
require.NoError(t, err)
require.NotEmpty(t, m, "BundleFor should return populated map for known language")
m["tray.menu.connect"] = "Mutated"
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"),
"BundleFor must return a copy, not the live map")
}
func TestBundle_BundleForUnknownLanguage(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
_, err = b.BundleFor("xx")
assert.ErrorIs(t, err, ErrUnsupportedLanguage)
}
func TestBundle_HasLanguage(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
assert.True(t, b.HasLanguage("en"))
assert.True(t, b.HasLanguage("hu"))
assert.False(t, b.HasLanguage("de"))
}
func TestBundle_MissingDefaultBundleFails(t *testing.T) {
// Without an en bundle we have nothing to fall back to, so construction
// must hard-fail. Catches packaging accidents where someone drops the
// English locale.
fs := fstest.MapFS{
"_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)},
"hu/common.json": {Data: []byte(`{"k":"v"}`)},
}
_, err := NewBundle(fs)
require.Error(t, err)
assert.Contains(t, err.Error(), "default language")
}
func TestBundle_MissingBundleSkipsLanguage(t *testing.T) {
// A language declared in the index but missing its bundle file is
// dropped from Languages with a warning — adding a new language must
// be a two-step process (declare + ship), not declare-only.
fs := fstest.MapFS{
"_index.json": {Data: []byte(`{"languages":[
{"code":"en","displayName":"English","englishName":"English"},
{"code":"de","displayName":"Deutsch","englishName":"German"}
]}`)},
"en/common.json": {Data: []byte(`{"k":"v"}`)},
}
b, err := NewBundle(fs)
require.NoError(t, err)
langs := b.Languages()
require.Len(t, langs, 1)
assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped")
assert.False(t, b.HasLanguage("de"))
}
func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
// Trailing dangling arg should be dropped, not panic — preserves UI
// stability when a caller passes an unpaired placeholder by mistake.
got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra")
assert.Equal(t, "Install version 1.2.3", got)
}

133
client/ui/localizer.go Normal file
View File

@@ -0,0 +1,133 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"strings"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
)
// Localizer is the tray's bridge to the i18n bundle and preferences store.
// It caches the active language so every menu-build pass and notification
// call can resolve a key without re-querying preferences, and it owns
// the preference-subscription lifecycle so consumers don't have to.
//
// Kept in the main package (not i18n/) because StatusLabel maps daemon
// status enum strings (services.StatusIdle, services.StatusDaemonUnavailable)
// to translations — pulling those into i18n would invert the dependency
// direction.
type Localizer struct {
bundle *i18n.Bundle
store *preferences.Store
mu sync.RWMutex
lang i18n.LanguageCode
unsubscribe func()
}
// NewLocalizer seeds the active language from the on-disk preference so
// the first menu render is already in the right locale. Either argument
// may be nil — useful for tests/dry-runs — in which case Translate falls
// back to the raw key and Watch is a no-op.
func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer {
l := &Localizer{
bundle: bundle,
store: store,
lang: i18n.DefaultLanguage,
}
if store != nil {
if p := store.Get(); p.Language != "" {
l.lang = p.Language
}
}
return l
}
// Language returns the BCP-47 code currently driving translations.
func (l *Localizer) Language() i18n.LanguageCode {
l.mu.RLock()
defer l.mu.RUnlock()
return l.lang
}
// T resolves key in the current language with optional {placeholder}/value
// argument pairs. When no bundle is wired the key is returned as-is so
// callers always get a non-empty string.
func (l *Localizer) T(key string, args ...string) string {
if l == nil || l.bundle == nil {
return key
}
l.mu.RLock()
lang := l.lang
l.mu.RUnlock()
return l.bundle.Translate(lang, key, args...)
}
// Watch subscribes to preference changes; cb fires for each new language
// (after the Localizer's own cached language has been updated, so cb can
// call l.T to render with the new locale). Safe to call once per
// Localizer; later calls overwrite the previous subscription.
func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) {
if l.store == nil {
return
}
ch, unsubscribe := l.store.Subscribe()
l.mu.Lock()
if l.unsubscribe != nil {
l.unsubscribe()
}
l.unsubscribe = unsubscribe
l.mu.Unlock()
go func() {
for p := range ch {
if p.Language == "" {
continue
}
l.mu.Lock()
if l.lang == p.Language {
l.mu.Unlock()
continue
}
l.lang = p.Language
l.mu.Unlock()
log.Infof("localizer: language switched to %s", p.Language)
if cb != nil {
cb(p.Language)
}
}
}()
}
// Close drops the preference subscription. Currently unused (the tray
// lives for the whole process) but kept so a future shutdown path can
// release the channel cleanly.
func (l *Localizer) Close() {
l.mu.Lock()
defer l.mu.Unlock()
if l.unsubscribe != nil {
l.unsubscribe()
l.unsubscribe = nil
}
}
// StatusLabel maps a daemon status string to its user-facing tray label.
// Idle and the daemon-unavailable sentinel get translated phrasing; every
// other status passes through verbatim (matches the legacy behaviour of
// surfacing the raw daemon enum for the connecting/needs-login states).
func (l *Localizer) StatusLabel(status string) string {
switch {
case status == "", strings.EqualFold(status, services.StatusIdle):
return l.T("tray.status.disconnected")
case strings.EqualFold(status, services.StatusDaemonUnavailable):
return l.T("tray.status.daemonUnavailable")
}
return status
}

View File

@@ -6,6 +6,7 @@ import (
"context"
"embed"
"flag"
"io/fs"
"log"
"runtime"
"strings"
@@ -14,6 +15,8 @@ import (
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/util"
)
@@ -21,6 +24,15 @@ import (
//go:embed all:frontend/dist
var assets embed.FS
// localesFS roots the i18n translation bundles. Embedded from the same
// directory the React app imports, so a single JSON source drives both
// the tray (Go) and the in-window UI (Vite imports the files directly).
// The `all:` prefix is required so _index.json is included — //go:embed
// silently drops files whose names start with "_" or "." otherwise.
//
//go:embed all:frontend/src/i18n/locales
var localesRoot embed.FS
// stringList is a flag.Value that collects repeated string flags. The first
// time the user passes -log-file the seeded default ("console") is dropped;
// subsequent passes append. Lets the user replace or extend the log target
@@ -48,6 +60,7 @@ func init() {
application.RegisterEvent[services.SystemEvent](services.EventSystem)
application.RegisterEvent[services.UpdateAvailable](services.EventUpdateAvailable)
application.RegisterEvent[services.UpdateProgress](services.EventUpdateProgress)
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
}
func main() {
@@ -115,6 +128,27 @@ func main() {
notifier := notifications.New()
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
// localesFS reroots the embedded tree at the locales directory itself
// so the bundle sees _index.json and <lang>/common.json at the top
// level (the //go:embed path is rooted at the package, not the leaf
// dir).
localesFS, err := fs.Sub(localesRoot, "frontend/src/i18n/locales")
if err != nil {
log.Fatalf("locate locales fs: %v", err)
}
// Build the domain layer first, then wrap it in the Wails-bound
// services. The Bundle satisfies preferences.LanguageValidator so
// SetLanguage rejects codes that have no shipped translation.
bundle, err := i18n.NewBundle(localesFS)
if err != nil {
log.Fatalf("init i18n bundle: %v", err)
}
prefStore, err := preferences.NewStore(bundle, app.Event)
if err != nil {
log.Fatalf("init preferences store: %v", err)
}
localizer := NewLocalizer(bundle, prefStore)
app.RegisterService(application.NewService(connection))
app.RegisterService(application.NewService(settings))
app.RegisterService(application.NewService(services.NewNetworks(conn)))
@@ -125,6 +159,8 @@ func main() {
app.RegisterService(application.NewService(peers))
app.RegisterService(application.NewService(notifier))
app.RegisterService(application.NewService(profileSwitcher))
app.RegisterService(application.NewService(services.NewI18n(bundle)))
app.RegisterService(application.NewService(services.NewPreferences(prefStore)))
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "NetBird",
@@ -173,6 +209,7 @@ func main() {
Notifier: notifier,
Update: update,
ProfileSwitcher: profileSwitcher,
Localizer: localizer,
})
listenForShowSignal(context.Background(), tray)

View File

@@ -0,0 +1,221 @@
//go:build !android && !ios && !freebsd && !js
// Package preferences holds user-scope UI state that is independent of the
// daemon profile: language, and any future toggles the React UI exposes to
// the user. The Store reads from and writes to a JSON file under
// os.UserConfigDir(), validates input against an injected language
// validator (typically *i18n.Bundle), and broadcasts changes to in-process
// subscribers (tray) plus an optional Wails emitter (frontend).
//
// No Wails dependency — the emitter is consumed through a minimal
// interface so the package can be tested without spinning up Wails.
package preferences
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/util"
)
// preferencesFileName is the JSON file holding user-scope UI preferences.
// Stored under os.UserConfigDir()/netbird so it lives in the OS-user's
// writable config dir, not the daemon's root-owned state. Per-OS-user,
// shared across all daemon profiles.
const preferencesFileName = "ui-preferences.json"
// EventPreferencesChanged fires whenever the on-disk preferences are
// updated (from any source). The payload is the fresh UIPreferences value.
// Wails registers this name in init() so the React frontend can subscribe.
const EventPreferencesChanged = "netbird:preferences:changed"
// 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.
type UIPreferences struct {
Language i18n.LanguageCode `json:"language"`
}
// LanguageValidator is the dependency Store needs to reject SetLanguage
// inputs that have no shipped bundle. *i18n.Bundle satisfies it directly.
type LanguageValidator interface {
HasLanguage(code i18n.LanguageCode) bool
}
// Emitter is the dependency Store needs to broadcast changes to the
// frontend. *application.EventProcessor (Wails) satisfies it; tests pass
// nil or a fake.
type Emitter interface {
Emit(name string, data ...any) bool
}
// Store is the user-scope UI preferences store. Read at app start,
// updated by the React settings page (via the Wails-bound facade), and
// observed by the tray which re-renders its menu in the new language.
type Store struct {
path string
mu sync.RWMutex
current UIPreferences
subsMu sync.Mutex
subs []chan UIPreferences
validator LanguageValidator
emitter Emitter
}
// NewStore loads preferences from disk (creating a default file when
// none exists). The validator is consulted on SetLanguage; pass nil to
// skip validation (used by the unit tests). The emitter is optional —
// when set, SetLanguage broadcasts EventPreferencesChanged.
func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
path, err := preferencesPath()
if err != nil {
return nil, fmt.Errorf("resolve preferences path: %w", err)
}
s := &Store{
path: path,
validator: validator,
emitter: emitter,
current: UIPreferences{Language: i18n.DefaultLanguage},
}
if err := s.load(); err != nil {
log.Warnf("load ui preferences from %s: %v (using defaults)", path, err)
}
return s, nil
}
// Get returns a copy of the current preferences.
func (s *Store) Get() UIPreferences {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current
}
// SetLanguage validates and persists a new language preference, then
// broadcasts the change to internal subscribers (tray) and the emitter
// (frontend).
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
if lang == "" {
return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage)
}
if s.validator != nil && !s.validator.HasLanguage(lang) {
return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang)
}
s.mu.Lock()
if s.current.Language == lang {
s.mu.Unlock()
return nil
}
next := s.current
next.Language = lang
if err := s.persistLocked(next); err != nil {
s.mu.Unlock()
return fmt.Errorf("persist preferences: %w", err)
}
s.current = next
s.mu.Unlock()
s.broadcast(next)
return nil
}
// 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.
func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
ch := make(chan UIPreferences, 4)
s.subsMu.Lock()
s.subs = append(s.subs, ch)
s.subsMu.Unlock()
unsubscribe := func() {
s.subsMu.Lock()
defer s.subsMu.Unlock()
for i, c := range s.subs {
if c == ch {
s.subs = append(s.subs[:i], s.subs[i+1:]...)
close(ch)
return
}
}
}
return ch, unsubscribe
}
// load reads the on-disk file into current. A missing file is not an
// error (we keep the in-memory default); malformed contents are reported
// so the caller can log+continue with the default.
func (s *Store) load() error {
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
return nil
}
var loaded UIPreferences
if _, err := util.ReadJson(s.path, &loaded); err != nil {
return err
}
if loaded.Language == "" {
loaded.Language = i18n.DefaultLanguage
}
s.mu.Lock()
s.current = loaded
s.mu.Unlock()
return nil
}
// persistLocked writes the candidate preferences atomically. Caller must
// hold s.mu (write lock); the lock is not released here so the in-memory
// state is updated only after a successful write.
func (s *Store) persistLocked(v UIPreferences) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
}
return util.WriteJson(context.Background(), s.path, v)
}
// broadcast fans the new value out to internal subscribers and to the
// frontend emitter. Subscribers with a full buffer are skipped — the tray
// only cares about the latest value, so dropping intermediate frames
// during a burst is safe.
func (s *Store) broadcast(v UIPreferences) {
s.subsMu.Lock()
subs := make([]chan UIPreferences, len(s.subs))
copy(subs, s.subs)
s.subsMu.Unlock()
for _, ch := range subs {
select {
case ch <- v:
default:
log.Debugf("preferences subscriber channel full; dropping update")
}
}
if s.emitter != nil {
s.emitter.Emit(EventPreferencesChanged, v)
}
}
// preferencesPath resolves os.UserConfigDir()/netbird/ui-preferences.json.
func preferencesPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "netbird", preferencesFileName), nil
}

View File

@@ -0,0 +1,220 @@
//go:build !android && !ios && !freebsd && !js
package preferences
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/ui/i18n"
)
// fakeValidator implements LanguageValidator for tests so we don't need a
// fully-loaded i18n.Bundle.
type fakeValidator struct{ ok map[i18n.LanguageCode]bool }
func (f fakeValidator) HasLanguage(code i18n.LanguageCode) bool { return f.ok[code] }
// recordingEmitter captures Emit calls so tests can assert the broadcast
// fired.
type recordingEmitter struct {
mu sync.Mutex
calls []emitCall
}
type emitCall struct {
name string
data []any
}
func (r *recordingEmitter) Emit(name string, data ...any) bool {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, emitCall{name: name, data: data})
return true
}
func (r *recordingEmitter) calledWith(name string) []emitCall {
r.mu.Lock()
defer r.mu.Unlock()
var out []emitCall
for _, c := range r.calls {
if c.name == name {
out = append(out, c)
}
}
return out
}
// withTempConfigDir reroots os.UserConfigDir() at a temporary directory by
// pointing the OS-specific env vars there. Restored automatically by
// t.Setenv.
func withTempConfigDir(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
switch runtime.GOOS {
case "darwin":
t.Setenv("HOME", tmp)
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "Library", "Application Support"), 0o755))
case "windows":
t.Setenv("AppData", tmp)
default:
t.Setenv("XDG_CONFIG_HOME", tmp)
}
return tmp
}
func TestStore_DefaultsWhenFileMissing(t *testing.T) {
withTempConfigDir(t)
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
require.NoError(t, err)
got := s.Get()
assert.Equal(t, i18n.DefaultLanguage, got.Language, "default language should be served when no file is on disk")
}
func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) {
withTempConfigDir(t)
emitter := &recordingEmitter{}
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, emitter)
require.NoError(t, err)
ch, unsubscribe := s.Subscribe()
defer unsubscribe()
require.NoError(t, s.SetLanguage("hu"))
got := s.Get()
assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should reflect the SetLanguage value")
select {
case v := <-ch:
assert.Equal(t, i18n.LanguageCode("hu"), v.Language, "subscriber should receive the new value")
case <-time.After(time.Second):
t.Fatal("subscriber timed out waiting for update")
}
emits := emitter.calledWith(EventPreferencesChanged)
require.Len(t, emits, 1, "Emit should fire exactly once per SetLanguage")
payload, ok := emits[0].data[0].(UIPreferences)
require.True(t, ok, "emitter payload should be UIPreferences")
assert.Equal(t, i18n.LanguageCode("hu"), payload.Language)
}
func TestStore_LoadFromDisk(t *testing.T) {
withTempConfigDir(t)
path, err := preferencesPath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(`{"language":"hu"}`), 0o644))
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil)
require.NoError(t, err)
got := s.Get()
assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should load language from existing file")
}
func TestStore_UnsupportedLanguageRejected(t *testing.T) {
withTempConfigDir(t)
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
require.NoError(t, err)
err = s.SetLanguage("xx")
require.Error(t, err, "unknown language must be rejected")
assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage)
err = s.SetLanguage("")
assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage, "empty language code must be rejected")
}
func TestStore_NoValidatorAcceptsAnything(t *testing.T) {
withTempConfigDir(t)
s, err := NewStore(nil, nil)
require.NoError(t, err)
require.NoError(t, s.SetLanguage("fr"))
got := s.Get()
assert.Equal(t, i18n.LanguageCode("fr"), got.Language)
}
func TestStore_SetLanguageIdempotent(t *testing.T) {
withTempConfigDir(t)
emitter := &recordingEmitter{}
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter)
require.NoError(t, err)
require.NoError(t, s.SetLanguage("en"))
// SetLanguage to the current value is a no-op — no disk write, no
// broadcast. Without this guard the tray would re-render the menu on
// every cosmetic re-save of the preferences file.
assert.Empty(t, emitter.calledWith(EventPreferencesChanged),
"setting the current language should not broadcast")
}
func TestStore_CorruptFileFallsBackToDefault(t *testing.T) {
withTempConfigDir(t)
path, err := preferencesPath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o644))
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
require.NoError(t, err, "corrupt file should not fail construction")
got := s.Get()
assert.Equal(t, i18n.DefaultLanguage, got.Language, "corrupt JSON should leave the default in place")
}
func TestStore_UnsubscribeStopsUpdates(t *testing.T) {
withTempConfigDir(t)
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, nil)
require.NoError(t, err)
ch, unsubscribe := s.Subscribe()
unsubscribe()
require.NoError(t, s.SetLanguage("hu"))
select {
case _, ok := <-ch:
assert.False(t, ok, "channel should be closed after unsubscribe")
case <-time.After(time.Second):
t.Fatal("expected closed channel, got nothing")
}
}
func TestStore_FileShapeIsJSON(t *testing.T) {
withTempConfigDir(t)
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil)
require.NoError(t, err)
require.NoError(t, s.SetLanguage("hu"))
path, err := preferencesPath()
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
var parsed UIPreferences
require.NoError(t, json.Unmarshal(data, &parsed), "on-disk file must be valid JSON")
assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language)
}
func TestStore_ErrUnsupportedSentinel(t *testing.T) {
// Verifies callers can match on the sentinel error rather than parsing
// strings — protects against accidental %v -> %w changes that would
// silently break errors.Is.
err := errors.New("inner")
wrapped := errors.Join(i18n.ErrUnsupportedLanguage, err)
assert.ErrorIs(t, wrapped, i18n.ErrUnsupportedLanguage)
}

View File

@@ -0,0 +1,34 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"github.com/netbirdio/netbird/client/ui/i18n"
)
// I18n is the Wails-bound facade over i18n.Bundle. It exists only to give
// the binding generator a service type with the context.Context-first
// signatures it expects; the translation logic, locale loading and the
// LanguageCode type all live in client/ui/i18n.
type I18n struct {
bundle *i18n.Bundle
}
func NewI18n(bundle *i18n.Bundle) *I18n {
return &I18n{bundle: bundle}
}
// Languages exposes the list of shipped locales to the frontend so the
// settings page can populate its language picker.
func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) {
return s.bundle.Languages(), nil
}
// Bundle returns the full key->text map for one language, letting the
// React side drive its own translation library (i18next, etc.) off the
// same source bundles the tray uses.
func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) {
return s.bundle.BundleFor(code)
}

View File

@@ -0,0 +1,32 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// Preferences is the Wails-bound facade over preferences.Store. The store
// itself owns persistence and the subscription channel; this type just
// re-exposes Get and SetLanguage with the context.Context-first signature
// the Wails binding generator wants.
type Preferences struct {
store *preferences.Store
}
func NewPreferences(store *preferences.Store) *Preferences {
return &Preferences{store: store}
}
// Get returns the current user-scope preferences.
func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) {
return s.store.Get(), nil
}
// SetLanguage validates and persists a new UI language.
func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error {
return s.store.SetLanguage(lang)
}

View File

@@ -16,70 +16,28 @@ import (
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/version"
)
// User-facing strings exposed in the tray, OS notifications and the
// browser-opened URLs. Centralised here so future copy edits and (one
// day) localisation have a single source of truth.
// Translation keys for every user-facing string the tray paints. The text
// itself lives in frontend/src/i18n/locales/<lang>/common.json — both the
// tray and the React UI read from there so a single bundle drives the
// whole product. Keys are referenced by the Tray.tr helper.
// Non-translated identifiers. Notification IDs coalesce duplicate toasts
// (the OS uses them as dedup keys); statusError is a tray-only sentinel
// distinguishing the error-icon state from real daemon status strings;
// URLs are baked-in product links.
const (
trayTooltip = "NetBird"
// Top-level menu entries.
menuStatusDisconnected = "Disconnected"
menuStatusDaemonUnavailable = "Not running"
menuOpenNetBird = "Open NetBird"
menuConnect = "Connect"
menuDisconnect = "Disconnect"
menuExitNode = "Exit Node"
menuNetworks = "Resources"
menuProfiles = "Profiles"
menuQuit = "Quit"
// Settings + diagnostics. The settings page replaces the Fyne tray's
// Settings submenu (per-toggle checkboxes for SSH, auto-connect,
// Rosenpass, lazy connections, block-inbound, notifications); those
// live in the in-window Settings page now.
menuSettings = "Settings"
menuCreateDebugBundle = "Create Debug Bundle"
// About submenu and update flow.
menuAbout = "About"
menuGitHub = "GitHub"
menuDocumentation = "Documentation"
menuDownloadLatestVersion = "Download latest version"
// menuInstallVersionPrefix is rewritten with the target version when
// the management server enforces the update.
menuInstallVersionPrefix = "Install version "
// menuGUIVersionFmt and menuDaemonVersionFmt drive the disabled
// version-info entries under About. The daemon line is "—" until the
// first Status snapshot reports the daemon's version.
menuGUIVersionFmt = "GUI: %s"
menuDaemonVersionFmt = "Daemon: %s"
menuVersionUnknown = "—"
// OS notifications.
notifyUpdateTitle = "NetBird update available"
notifyUpdateBodyFmt = "NetBird %s is available."
notifyUpdateEnforcedSuffix = " Your administrator requires this update."
notifyErrorTitle = "Error"
notifyErrorConnect = "Failed to connect"
notifyErrorDisconnect = "Failed to disconnect"
notifySessionExpiredTitle = "NetBird session expired"
notifySessionExpiredBody = "Your NetBird session has expired. Please log in again."
// Notification IDs (used to coalesce duplicate toasts).
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDSessionExpired = "netbird-session-expired"
// statusError is a tray-only synthetic label used for the error icon;
// it does not come from the daemon and is not exported.
statusError = "Error"
// External URLs.
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
)
@@ -99,6 +57,11 @@ type TrayServices struct {
Notifier *notifications.NotificationService
Update *services.Update
ProfileSwitcher *services.ProfileSwitcher
// Localizer is the tray's bridge to translations. Constructed in main
// from i18n.Bundle + preferences.Store; the Wails-bound facades
// (services.I18n, services.Preferences) are registered separately for
// React and are not needed here.
Localizer *Localizer
}
type Tray struct {
@@ -106,20 +69,25 @@ type Tray struct {
tray *application.SystemTray
window *application.WebviewWindow
svc TrayServices
// loc owns the active language plus the preference subscription. The
// tray talks to it for every translated label (t.loc.T(...)) and
// registers a callback in NewTray that re-renders the menu on a
// language switch.
loc *Localizer
menu *application.Menu
statusItem *application.MenuItem
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
networksItem *application.MenuItem
menu *application.Menu
statusItem *application.MenuItem
upItem *application.MenuItem
downItem *application.MenuItem
exitNodeItem *application.MenuItem
networksItem *application.MenuItem
profileSubmenu *application.Menu
profileSubmenuItem *application.MenuItem
profileEmailItem *application.MenuItem
settingsItem *application.MenuItem
debugItem *application.MenuItem
updateItem *application.MenuItem
daemonVersionItem *application.MenuItem
settingsItem *application.MenuItem
debugItem *application.MenuItem
updateItem *application.MenuItem
daemonVersionItem *application.MenuItem
mu sync.Mutex
connected bool
@@ -148,10 +116,14 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
window: window,
svc: svc,
notificationsEnabled: true,
// Localizer is constructed by main from the i18n.Bundle and
// preferences.Store so the first menu render below is already in
// the right locale — no English flash followed by a re-paint.
loc: svc.Localizer,
}
t.tray = app.SystemTray.New()
t.applyIcon()
t.tray.SetTooltip(trayTooltip)
t.tray.SetTooltip(t.loc.T("tray.tooltip"))
t.menu = t.buildMenu()
t.tray.SetMenu(t.menu)
// Left-click on the tray icon opens the menu on every platform. The
@@ -176,10 +148,88 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
go t.loadProfiles()
})
// Localizer fires this callback after it has already swapped its own
// cached language, so every t.loc.T(...) lookup inside applyLanguage
// runs against the new locale.
t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() })
go t.loadConfig()
return t
}
// applyLanguage re-renders every translated surface using the Localizer's
// current language. Wails dispatches menu/tray APIs onto the platform's
// UI thread internally, so calling them from the Localizer's background
// goroutine is safe; profileLoadMu prevents loadProfiles from racing the
// rebuild.
func (t *Tray) applyLanguage() {
t.tray.SetTooltip(t.loc.T("tray.tooltip"))
t.menu = t.buildMenu()
t.tray.SetMenu(t.menu)
t.reapplyMenuState()
}
// reapplyMenuState walks cached state and re-applies the visibility,
// enablement and label mutations that applyStatus / onUpdateAvailable
// would have performed since the last menu rebuild. Required after
// buildMenu because that constructor returns items in their default
// (disconnected, no-update) shape.
func (t *Tray) reapplyMenuState() {
t.mu.Lock()
connected := t.connected
lastStatus := t.lastStatus
daemonVersion := t.lastDaemonVersion
hasUpdate := t.hasUpdate
updateVersion := t.updateVersion
updateEnforced := t.updateEnforced
exitNodes := append([]string(nil), t.exitNodes...)
t.mu.Unlock()
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
if t.statusItem != nil && lastStatus != "" {
t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus))
t.statusItem.SetEnabled(false)
t.applyStatusIndicator(lastStatus)
}
if t.upItem != nil {
t.upItem.SetHidden(connected || connecting || daemonUnavailable)
t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable)
}
if t.downItem != nil {
t.downItem.SetHidden(!connected && !connecting)
t.downItem.SetEnabled(connected || connecting)
}
if t.exitNodeItem != nil {
t.exitNodeItem.SetEnabled(connected)
}
if t.networksItem != nil {
t.networksItem.SetEnabled(connected)
}
if t.settingsItem != nil {
t.settingsItem.SetEnabled(!daemonUnavailable)
}
if t.debugItem != nil {
t.debugItem.SetEnabled(!daemonUnavailable)
}
if daemonVersion != "" && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))
}
if hasUpdate && t.updateItem != nil {
if updateEnforced {
t.updateItem.SetLabel(t.loc.T("tray.menu.installVersion", "version", updateVersion))
} else {
t.updateItem.SetLabel(t.loc.T("tray.menu.downloadLatest"))
}
t.updateItem.SetHidden(false)
}
if len(exitNodes) > 0 {
t.rebuildExitNodes(exitNodes)
}
go t.loadProfiles()
}
// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event.
// Show() alone is not enough on macOS: makeKeyAndOrderFront skips app
// activation, so a tray-style app's window pops up behind the currently
@@ -201,7 +251,7 @@ func (t *Tray) buildMenu() *application.Menu {
// only. The Connect entry below drives every actionable transition,
// including the SSO re-auth flow for NeedsLogin/SessionExpired
// (the daemon's Up RPC returns NeedsSSOLogin when applicable).
t.statusItem = menu.Add(menuStatusDisconnected).
t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")).
SetEnabled(false).
SetBitmap(iconMenuDotIdle)
@@ -209,16 +259,17 @@ func (t *Tray) buildMenu() *application.Menu {
// The tray icon's left-click handler is intentionally unbound (see
// NewTray for the rationale), so expose the window through an explicit
// menu entry on every platform.
menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() })
menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() })
menu.AddSeparator()
// Profiles submenu is populated asynchronously once the application
// has started — Menu.Update() is a no-op before app.running is true,
// so the initial fill is gated on the ApplicationStarted hook.
t.profileSubmenu = menu.AddSubmenu(menuProfiles)
profilesLabel := t.loc.T("tray.menu.profiles")
t.profileSubmenu = menu.AddSubmenu(profilesLabel)
// profileSubmenuItem is the parent MenuItem whose label is the active
// profile name. AddSubmenu returns the child *Menu, so we retrieve the
// parent *MenuItem via FindByLabel immediately after insertion.
t.profileSubmenuItem = menu.FindByLabel(menuProfiles)
t.profileSubmenuItem = menu.FindByLabel(profilesLabel)
// profileEmailItem shows the account email of the active profile directly
// in the main menu, below the Profiles submenu — matching the behaviour of
// the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a
@@ -229,14 +280,14 @@ func (t *Tray) buildMenu() *application.Menu {
// Only the action that applies to the current state is visible: Connect
// when disconnected, Disconnect when connected. applyStatus swaps them on
// each daemon status change.
t.upItem = menu.Add(menuConnect).OnClick(func(*application.Context) { t.handleConnect() })
t.downItem = menu.Add(menuDisconnect).OnClick(func(*application.Context) { t.handleDisconnect() })
t.upItem = menu.Add(t.loc.T("tray.menu.connect")).OnClick(func(*application.Context) { t.handleConnect() })
t.downItem = menu.Add(t.loc.T("tray.menu.disconnect")).OnClick(func(*application.Context) { t.handleDisconnect() })
t.downItem.SetHidden(true)
menu.AddSeparator()
t.exitNodeItem = menu.Add(menuExitNode).SetEnabled(false)
t.networksItem = menu.Add(menuNetworks).OnClick(func(*application.Context) { t.openRoute("/networks") })
t.exitNodeItem = menu.Add(t.loc.T("tray.menu.exitNode")).SetEnabled(false)
t.networksItem = menu.Add(t.loc.T("tray.menu.networks")).OnClick(func(*application.Context) { t.openRoute("/networks") })
menu.AddSeparator()
@@ -244,30 +295,30 @@ func (t *Tray) buildMenu() *application.Menu {
// block-inbound, auto-connect, notifications) and profile switching
// all live in the in-window Settings page now. The tray menu only
// surfaces the day-to-day actions.
t.settingsItem = menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") })
t.debugItem = menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") })
t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.openRoute("/settings") })
t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") })
menu.AddSeparator()
about := menu.AddSubmenu(menuAbout)
about.Add(menuGitHub).OnClick(func(*application.Context) {
about := menu.AddSubmenu(t.loc.T("tray.menu.about"))
about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) {
_ = t.app.Browser.OpenURL(urlGitHubRepo)
})
about.Add(menuDocumentation).SetEnabled(false)
about.Add(t.loc.T("tray.menu.documentation")).SetEnabled(false)
// Disabled informational entries: the GUI version is baked in at
// build time via -ldflags, the daemon version comes from the first
// Status snapshot and is updated in applyStatus.
about.Add(fmt.Sprintf(menuGUIVersionFmt, version.NetbirdVersion())).SetEnabled(false)
t.daemonVersionItem = about.Add(fmt.Sprintf(menuDaemonVersionFmt, menuVersionUnknown)).SetEnabled(false)
about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false)
t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false)
// Hidden until the daemon emits EventUpdateAvailable. The label is
// rewritten in onUpdateAvailable to match the legacy Fyne UI:
// menuDownloadLatestVersion for opt-in, menuInstallVersionPrefix+version
// when the management server enforces the update.
t.updateItem = about.Add(menuDownloadLatestVersion).OnClick(func(*application.Context) { t.handleUpdate() })
// rewritten in onUpdateAvailable: tray.menu.downloadLatest for opt-in,
// tray.menu.installVersion when the management server enforces the
// update.
t.updateItem = about.Add(t.loc.T("tray.menu.downloadLatest")).OnClick(func(*application.Context) { t.handleUpdate() })
t.updateItem.SetHidden(true)
menu.AddSeparator()
menu.Add(menuQuit).OnClick(func(*application.Context) { t.app.Quit() })
menu.Add(t.loc.T("tray.menu.quit")).OnClick(func(*application.Context) { t.app.Quit() })
return menu
}
@@ -302,7 +353,7 @@ func (t *Tray) handleConnect() {
defer cancel()
if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil {
log.Errorf("connect: %v", err)
t.notifyError(notifyErrorConnect)
t.notifyError(t.loc.T("notify.error.connect"))
t.upItem.SetEnabled(true)
}
}()
@@ -328,7 +379,7 @@ func (t *Tray) handleDisconnect() {
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect: %v", err)
t.notifyError(notifyErrorDisconnect)
t.notifyError(t.loc.T("notify.error.disconnect"))
t.downItem.SetEnabled(true)
}
}()
@@ -400,20 +451,20 @@ func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) {
// because the install starts on click; opt-in updates just
// route the user to the latest release.
if upd.Enforced {
t.updateItem.SetLabel(menuInstallVersionPrefix + upd.Version)
t.updateItem.SetLabel(t.loc.T("tray.menu.installVersion", "version", upd.Version))
} else {
t.updateItem.SetLabel(menuDownloadLatestVersion)
t.updateItem.SetLabel(t.loc.T("tray.menu.downloadLatest"))
}
t.updateItem.SetHidden(false)
}
body := fmt.Sprintf(notifyUpdateBodyFmt, upd.Version)
body := t.loc.T("notify.update.body", "version", upd.Version)
if upd.Enforced {
body += notifyUpdateEnforcedSuffix
body += t.loc.T("notify.update.enforcedSuffix")
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + upd.Version,
Title: notifyUpdateTitle,
Title: t.loc.T("notify.update.title"),
Body: body,
}); err != nil {
log.Debugf("send update notification: %v", err)
@@ -520,14 +571,7 @@ func (t *Tray) applyStatus(st services.Status) {
// Label-only: kept disabled (informational row). Swap the
// displayed text so the user sees a familiar phrase instead
// of the raw daemon enum.
label := st.Status
switch {
case daemonUnavailable:
label = menuStatusDaemonUnavailable
case strings.EqualFold(st.Status, services.StatusIdle):
label = menuStatusDisconnected
}
t.statusItem.SetLabel(label)
t.statusItem.SetLabel(t.loc.StatusLabel(st.Status))
t.statusItem.SetEnabled(false)
t.applyStatusIndicator(st.Status)
}
@@ -579,7 +623,7 @@ func (t *Tray) applyStatus(st services.Status) {
t.rebuildExitNodes(exitNodes)
}
if daemonVersionChanged && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(fmt.Sprintf(menuDaemonVersionFmt, st.DaemonVersion))
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion))
}
if sessionExpiredEnter {
t.handleSessionExpired()
@@ -594,7 +638,7 @@ func (t *Tray) applyStatus(st services.Status) {
// Fyne client's onSessionExpire, which used a runSelfCommand to spawn
// the login-url helper; here the window is already in-process.
func (t *Tray) handleSessionExpired() {
t.notify(notifySessionExpiredTitle, notifySessionExpiredBody, notifyIDSessionExpired)
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
@@ -873,7 +917,7 @@ func (t *Tray) switchProfile(name string) {
return
}
log.Errorf("tray switchProfile: %v", err)
t.notifyError(fmt.Sprintf("Failed to switch to %s", name))
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", name))
return
}
t.loadProfiles()
@@ -899,7 +943,7 @@ func (t *Tray) notify(title, body, id string) {
// failures. Each tray click site already logs the underlying error; this
// adds the user-visible toast.
func (t *Tray) notifyError(message string) {
t.notify(notifyErrorTitle, message, notifyIDTrayError)
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
}
func exitNodesFromStatus(st services.Status) []string {