Files
netbird/client/ui/i18n/bundle.go
Maycon Santos 91acb8147c [management,client] 0.75.0 release with new desktop UI (#6473)
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
2026-07-06 13:47:16 +02:00

197 lines
5.3 KiB
Go

//go:build !android && !ios && !freebsd && !js
// Package i18n loads and serves translation strings for both the tray (Go)
// and the React UI (via the services.I18n facade).
//
// The locale tree is passed in as an fs.FS so the embed directive can live in
// the main binary.
package i18n
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"sync"
log "github.com/sirupsen/logrus"
)
const (
localeIndexFile = "_index.json"
// commonBundleFile shape is Chrome-extension JSON (key -> "message" plus
// optional Crowdin "description"); loadBundle flattens to key->message.
commonBundleFile = "common.json"
)
// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...).
type LanguageCode string
// DefaultLanguage is the fallback bundle for missing keys and the default
// when no preference is on disk.
const DefaultLanguage LanguageCode = "en"
var ErrUnsupportedLanguage = errors.New("unsupported language")
// Language describes one shipped UI locale. DisplayName is in the locale's
// own script (a Hungarian entry reads "Magyar" regardless of UI language).
type Language struct {
Code LanguageCode `json:"code"`
DisplayName string `json:"displayName"`
EnglishName string `json:"englishName"`
}
type localeIndex struct {
Languages []Language `json:"languages"`
}
// Bundle holds the parsed translation bundles. Loaded once at construction
// and never mutated.
type Bundle struct {
mu sync.RWMutex
languages []Language
bundles map[LanguageCode]map[string]string
}
// NewBundle parses _index.json plus every <code>/common.json in the locale
// tree. Hard-fails only when the default language is missing; other locales
// without a bundle are dropped with a warning.
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 a copy of the available locales.
func (b *Bundle) Languages() []Language {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]Language, len(b.languages))
copy(out, b.languages)
return out
}
func (b *Bundle) HasLanguage(code LanguageCode) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.bundles[code]
return ok
}
// BundleFor returns a copy of the full key->text map for one language.
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 lang, substituting args given as name/value
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
// the default language, then to the key itself so a miss is visible in the UI.
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} in s using args as flat name/value
// pairs. An odd-length args drops the trailing item.
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
}
// bundleEntry is one translation key on disk; Description is Crowdin context,
// ignored at runtime.
type bundleEntry struct {
Message string `json:"message"`
Description string `json:"description,omitempty"`
}
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 entries map[string]bundleEntry
if err := json.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("parse %s: %w", p, err)
}
bundle := make(map[string]string, len(entries))
for k, e := range entries {
bundle[k] = e.Message
}
return bundle, nil
}