mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 05:39:56 +00:00
[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:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
client/ui/frontend/src/i18n/locales/_index.json
Normal file
6
client/ui/frontend/src/i18n/locales/_index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
}
|
||||
34
client/ui/frontend/src/i18n/locales/en/common.json
Normal file
34
client/ui/frontend/src/i18n/locales/en/common.json
Normal 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."
|
||||
}
|
||||
34
client/ui/frontend/src/i18n/locales/hu/common.json
Normal file
34
client/ui/frontend/src/i18n/locales/hu/common.json
Normal 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
209
client/ui/i18n/bundle.go
Normal 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
|
||||
}
|
||||
156
client/ui/i18n/bundle_test.go
Normal file
156
client/ui/i18n/bundle_test.go
Normal 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
133
client/ui/localizer.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
221
client/ui/preferences/store.go
Normal file
221
client/ui/preferences/store.go
Normal 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
|
||||
}
|
||||
220
client/ui/preferences/store_test.go
Normal file
220
client/ui/preferences/store_test.go
Normal 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)
|
||||
}
|
||||
34
client/ui/services/i18n.go
Normal file
34
client/ui/services/i18n.go
Normal 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)
|
||||
}
|
||||
32
client/ui/services/preferences.go
Normal file
32
client/ui/services/preferences.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user