From 17cae1a75c0f5975064340f4a0a309363091ea62 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 15 May 2026 11:08:19 +0200 Subject: [PATCH] [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. --- .../netbird/client/ui/services/i18n.ts | 64 +++++ .../netbird/client/ui/services/index.ts | 6 + .../netbird/client/ui/services/models.ts | 61 +++++ .../netbird/client/ui/services/preferences.ts | 47 ++++ .../wailsapp/wails/v3/internal/eventcreate.ts | 14 +- .../wailsapp/wails/v3/internal/eventdata.d.ts | 1 + .../ui/frontend/src/i18n/locales/_index.json | 6 + .../frontend/src/i18n/locales/en/common.json | 34 +++ .../frontend/src/i18n/locales/hu/common.json | 34 +++ client/ui/i18n/bundle.go | 209 +++++++++++++++ client/ui/i18n/bundle_test.go | 156 +++++++++++ client/ui/localizer.go | 133 ++++++++++ client/ui/main.go | 37 +++ client/ui/preferences/store.go | 221 ++++++++++++++++ client/ui/preferences/store_test.go | 220 ++++++++++++++++ client/ui/services/i18n.go | 34 +++ client/ui/services/preferences.go | 32 +++ client/ui/tray.go | 248 +++++++++++------- 18 files changed, 1449 insertions(+), 108 deletions(-) create mode 100644 client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/i18n.ts create mode 100644 client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/preferences.ts create mode 100644 client/ui/frontend/src/i18n/locales/_index.json create mode 100644 client/ui/frontend/src/i18n/locales/en/common.json create mode 100644 client/ui/frontend/src/i18n/locales/hu/common.json create mode 100644 client/ui/i18n/bundle.go create mode 100644 client/ui/i18n/bundle_test.go create mode 100644 client/ui/localizer.go create mode 100644 client/ui/preferences/store.go create mode 100644 client/ui/preferences/store_test.go create mode 100644 client/ui/services/i18n.go create mode 100644 client/ui/services/preferences.go diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/i18n.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/i18n.ts new file mode 100644 index 000000000..ebf67d817 --- /dev/null +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/i18n.ts @@ -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 { + 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 { + 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); diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts index a50cb9322..beab6e525 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts @@ -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, diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts index d561338bf..ec94816f0 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts @@ -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 = {}) { + 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); + } +} + /** * 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 = {}) { + 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); + } +} + /** * UpParams selects the profile the daemon should bring up. */ diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/preferences.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/preferences.ts new file mode 100644 index 000000000..0dc1c9835 --- /dev/null +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/preferences.ts @@ -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 { + 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; diff --git a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts index ac44c000f..c4e5ed3fe 100644 --- a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts +++ b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -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(); diff --git a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 3737f620b..7f2eddbc6 100644 --- a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -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; diff --git a/client/ui/frontend/src/i18n/locales/_index.json b/client/ui/frontend/src/i18n/locales/_index.json new file mode 100644 index 000000000..263e4f491 --- /dev/null +++ b/client/ui/frontend/src/i18n/locales/_index.json @@ -0,0 +1,6 @@ +{ + "languages": [ + {"code": "en", "displayName": "English", "englishName": "English"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"} + ] +} diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json new file mode 100644 index 000000000..068f3e6a1 --- /dev/null +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -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." +} diff --git a/client/ui/frontend/src/i18n/locales/hu/common.json b/client/ui/frontend/src/i18n/locales/hu/common.json new file mode 100644 index 000000000..c219d4566 --- /dev/null +++ b/client/ui/frontend/src/i18n/locales/hu/common.json @@ -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." +} diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go new file mode 100644 index 000000000..047cddc96 --- /dev/null +++ b/client/ui/i18n/bundle.go @@ -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 /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 /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 +} diff --git a/client/ui/i18n/bundle_test.go b/client/ui/i18n/bundle_test.go new file mode 100644 index 000000000..360124dfd --- /dev/null +++ b/client/ui/i18n/bundle_test.go @@ -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 +// /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) +} diff --git a/client/ui/localizer.go b/client/ui/localizer.go new file mode 100644 index 000000000..048cd1bc9 --- /dev/null +++ b/client/ui/localizer.go @@ -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 +} diff --git a/client/ui/main.go b/client/ui/main.go index 5a811a9a7..ec444d0da 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -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 /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) diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go new file mode 100644 index 000000000..e86465105 --- /dev/null +++ b/client/ui/preferences/store.go @@ -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 +} diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go new file mode 100644 index 000000000..ec057e15c --- /dev/null +++ b/client/ui/preferences/store_test.go @@ -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) +} diff --git a/client/ui/services/i18n.go b/client/ui/services/i18n.go new file mode 100644 index 000000000..21a20a8a5 --- /dev/null +++ b/client/ui/services/i18n.go @@ -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) +} diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go new file mode 100644 index 000000000..3d9ba1364 --- /dev/null +++ b/client/ui/services/preferences.go @@ -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) +} diff --git a/client/ui/tray.go b/client/ui/tray.go index cc947c919..80be67e4a 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -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//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 {