move locales to client/ui/i18n

This commit is contained in:
Eduard Gert
2026-05-26 12:34:01 +02:00
parent 67a1f3c4fe
commit 91e0520f27
12 changed files with 30 additions and 21 deletions

View File

@@ -99,9 +99,9 @@ The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel
## Localisation (i18n)
The locale tree under `frontend/src/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
Adding a language: drop a `<code>/common.json`, append a row to `_index.json`, add the static import in `frontend/src/i18n/index.ts`, rebuild. Embed lives in `client/ui/main.go`'s `embed.FS`.
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
Package layout:
- `client/ui/i18n/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`.

View File

@@ -80,7 +80,7 @@ The Header's "more" dropdown owns a `viewMode: "default" | "advanced"` `useState
## Localisation (i18n)
Bootstrap lives in `src/i18n/index.ts` and is awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, statically imports every bundle JSON (`en/common.json`, `de/common.json`, `hu/common.json` today), initialises i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to the `netbird:preferences:changed` Wails event so a flip from any window (tray, settings, another renderer) calls `i18next.changeLanguage` here.
Bootstrap lives in `src/lib/i18n.ts` and is awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, statically imports every bundle JSON (`en/common.json`, `de/common.json`, `hu/common.json` today) from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), initialises i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to the `netbird:preferences:changed` Wails event so a flip from any window (tray, settings, another renderer) calls `i18next.changeLanguage` here.
**No first-run detection.** When no preferences file exists, `Preferences.Get()` returns `{language: "en"}` from the Go-side in-memory default. The frontend treats `en` as the fallback (i18next `fallbackLng: "en"`) and users pick a different language via the picker in `SettingsGeneral`. The Go store persists on the first explicit `SetLanguage`.
@@ -117,9 +117,9 @@ if (result !== confirmLabel) return;
Compare against the variable, never against an English literal.
**Bundle files.** Keys live in `src/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
**Adding a language.** Drop `src/i18n/locales/<code>/common.json` and append the row to `src/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship**`LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/i18n/index.ts` discovers bundles via `import.meta.glob('./locales/*/common.json', { eager: true })`, so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship**`LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
**Language picker.** `src/modules/settings/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to.

View File

@@ -40,7 +40,7 @@ Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers rece
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/i18n/index.ts` subscribes and calls `i18next.changeLanguage`. |
| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. |
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. |
@@ -177,7 +177,7 @@ I18n.Languages(): Promise<Language[]> // from _index.json
I18n.Bundle(code: LanguageCode): Promise<Record<string,string>> // full key→text map
```
Source of truth is `frontend/src/i18n/locales/`. The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS.
Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS.
## `Preferences`
@@ -186,7 +186,7 @@ Preferences.Get(): Promise<UIPreferences> // { language: strin
Preferences.SetLanguage(code: LanguageCode): Promise<void> // rejects on unknown code
```
`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/i18n/index.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read.
`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read.
## Daemon `Status.status` values

View File

@@ -4,14 +4,18 @@ import { Events } from "@wailsio/runtime";
import { Preferences, I18n } from "@bindings/services";
// Vite glob-imports every shipped bundle at build time. Adding a language
// only requires dropping the new folder under src/i18n/locales/ and the
// row in _index.json — no edit to this file. The `eager: true` import
// keeps the bundles inlined in the main JS chunk, same shape as a static
// import. Path is relative on purpose — alias-based globs (`@/…`) silently
// resolve to an empty match in some Vite dev-mode setups.
// Vite glob-imports every shipped bundle at build time. The locales tree
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
// and the React app share one JSON source. Adding a language only
// requires dropping the new folder there and the row in `_index.json` —
// no edit to this file. The `eager: true` import keeps the bundles
// inlined in the main JS chunk, same shape as a static import. Path is
// relative on purpose — alias-based globs (`@/…`) silently resolve to an
// empty match in some Vite dev-mode setups. `server.fs.allow` in
// `vite.config.ts` whitelists the parent directory so the dev server
// serves the JSON.
const bundleModules = import.meta.glob<Record<string, string>>(
"../i18n/locales/*/common.json",
"../../../i18n/locales/*/common.json",
{ eager: true, import: "default" },
);

View File

@@ -19,5 +19,10 @@ export default defineConfig({
host: "127.0.0.1",
port: 9245,
strictPort: true,
fs: {
// The i18n bundles live at ../i18n/locales (shared with the Go tray).
// Whitelist the parent dir so Vite's dev server serves them.
allow: [path.resolve(__dirname, ".."), __dirname],
},
},
});

View File

@@ -11,7 +11,7 @@ import (
)
// fakeLocales returns an in-memory FS that mirrors the real
// frontend/src/i18n/locales layout (root-level _index.json plus
// client/ui/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 {

View File

@@ -32,7 +32,7 @@ var assets embed.FS
// 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
//go:embed all:i18n/locales
var localesRoot embed.FS
// stringList is a flag.Value that collects repeated string flags. The first
@@ -137,7 +137,7 @@ func main() {
// 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")
localesFS, err := fs.Sub(localesRoot, "i18n/locales")
if err != nil {
log.Fatalf("locate locales fs: %v", err)
}

View File

@@ -24,9 +24,9 @@ import (
)
// 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.
// itself lives in 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