mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 20:39:08 +02:00
add first run lang detection
This commit is contained in:
@@ -113,7 +113,7 @@ The `ViewModeProvider` (`src/lib/viewMode.tsx`, mounted in `AppLayout`) owns a `
|
|||||||
|
|
||||||
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.
|
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`.
|
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go-side "unset" signal — `preferences.Store` no longer pre-fills a default). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each tag, and picks the first base code (`de` from `de-DE`) that has a shipped bundle — then calls `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back without re-detecting. If nothing matches (or the store is unreachable) the session falls through to `en`. From the second launch onward, the Go-side persisted value wins and detection is skipped. The tray (`localizer.go`) treats empty as English via its own fallback to `i18n.DefaultLanguage` so the first menu render before SetLanguage round-trips is still readable.
|
||||||
|
|
||||||
The frontend deliberately uses **no `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job (settings via `SettingsContext.save → SetConfig`, language via `Preferences.SetLanguage`). The previous wide-panel and settings-tab persistence experiments were removed; every window opens at its baseline state.
|
The frontend deliberately uses **no `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job (settings via `SettingsContext.save → SetConfig`, language via `Preferences.SetLanguage`). The previous wide-panel and settings-tab persistence experiments were removed; every window opens at its baseline state.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
|
|
||||||
import { Preferences, I18n } from "@bindings/services";
|
import { Preferences, I18n } from "@bindings/services";
|
||||||
|
import { LanguageCode } from "@bindings/i18n/models.js";
|
||||||
|
|
||||||
// Vite glob-imports every shipped bundle at build time. The locales tree
|
// Vite glob-imports every shipped bundle at build time. The locales tree
|
||||||
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
|
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
|
||||||
@@ -27,21 +28,51 @@ for (const path in bundleModules) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||||
|
// and returns the first base code ("de" from "de-DE") that has a shipped
|
||||||
|
// bundle. Returns null when none match, so the caller can fall back to
|
||||||
|
// English. We only ever match against the lowercased base — region tags
|
||||||
|
// don't have separate bundles today.
|
||||||
|
function detectBrowserLanguage(available: string[]): string | null {
|
||||||
|
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||||
|
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||||
|
);
|
||||||
|
for (const tag of tags) {
|
||||||
|
const base = tag.toLowerCase().split("-")[0];
|
||||||
|
if (available.includes(base)) return base;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// initI18n is awaited from app.tsx before the first render. The Go-side
|
// initI18n is awaited from app.tsx before the first render. The Go-side
|
||||||
// preferences.Store returns the in-memory default "en" when no on-disk
|
// preferences.Store returns an empty language code when no preference has
|
||||||
// preferences file exists; if Get() rejects (daemon unreachable) we also
|
// ever been persisted — that's the signal for first-run browser-locale
|
||||||
// fall through with "en" so the UI still renders.
|
// detection. We pick a shipped bundle that matches navigator.language /
|
||||||
|
// navigator.languages (falling back to "en" when nothing matches) and
|
||||||
|
// fire-and-forget the persist via Preferences.SetLanguage so subsequent
|
||||||
|
// launches read the value back without re-detecting.
|
||||||
export async function initI18n(): Promise<void> {
|
export async function initI18n(): Promise<void> {
|
||||||
|
const available = Object.keys(resources);
|
||||||
let language = "en";
|
let language = "en";
|
||||||
|
let firstRun = false;
|
||||||
try {
|
try {
|
||||||
const prefs = await Preferences.Get();
|
const prefs = await Preferences.Get();
|
||||||
if (prefs?.language) {
|
if (prefs?.language) {
|
||||||
language = prefs.language;
|
language = prefs.language;
|
||||||
|
} else {
|
||||||
|
firstRun = true;
|
||||||
|
language = detectBrowserLanguage(available) ?? "en";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Daemon / preferences store unreachable — fall through with "en".
|
// Daemon / preferences store unreachable — fall through with "en".
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (firstRun) {
|
||||||
|
// Fire-and-forget: the chosen language already drives this session;
|
||||||
|
// persisting just locks it in so the next launch skips detection.
|
||||||
|
void Preferences.SetLanguage(language as LanguageCode).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
await i18next.use(initReactI18next).init({
|
await i18next.use(initReactI18next).init({
|
||||||
lng: language,
|
lng: language,
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
|
|||||||
@@ -111,11 +111,16 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
|
|||||||
return nil, fmt.Errorf("resolve preferences path: %w", err)
|
return nil, fmt.Errorf("resolve preferences path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language starts empty — the absence of a value is the signal the
|
||||||
|
// frontend uses on first launch to detect the browser locale and call
|
||||||
|
// SetLanguage. Consumers that need an effective language (tray
|
||||||
|
// Localizer, i18n.Bundle.Translate) already fall back to
|
||||||
|
// i18n.DefaultLanguage when the code is empty.
|
||||||
s := &Store{
|
s := &Store{
|
||||||
path: path,
|
path: path,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
emitter: emitter,
|
emitter: emitter,
|
||||||
current: UIPreferences{Language: i18n.DefaultLanguage, ViewMode: DefaultViewMode},
|
current: UIPreferences{ViewMode: DefaultViewMode},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.load(); err != nil {
|
if err := s.load(); err != nil {
|
||||||
@@ -222,9 +227,6 @@ func (s *Store) load() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if loaded.Language == "" {
|
|
||||||
loaded.Language = i18n.DefaultLanguage
|
|
||||||
}
|
|
||||||
if !loaded.ViewMode.IsValid() {
|
if !loaded.ViewMode.IsValid() {
|
||||||
loaded.ViewMode = DefaultViewMode
|
loaded.ViewMode = DefaultViewMode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ func TestStore_DefaultsWhenFileMissing(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got := s.Get()
|
got := s.Get()
|
||||||
assert.Equal(t, i18n.DefaultLanguage, got.Language, "default language should be served when no file is on disk")
|
assert.Equal(t, i18n.LanguageCode(""), got.Language, "language must be empty when no file is on disk so the frontend can detect the browser locale")
|
||||||
|
assert.Equal(t, DefaultViewMode, got.ViewMode, "view-mode default should still apply")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) {
|
func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) {
|
||||||
@@ -153,13 +154,17 @@ func TestStore_SetLanguageIdempotent(t *testing.T) {
|
|||||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter)
|
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// First call goes from "" (unset) to "en" — real change, one broadcast.
|
||||||
require.NoError(t, s.SetLanguage("en"))
|
require.NoError(t, s.SetLanguage("en"))
|
||||||
|
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1,
|
||||||
|
"first SetLanguage from unset should broadcast")
|
||||||
|
|
||||||
// SetLanguage to the current value is a no-op — no disk write, no
|
// Second call is a no-op — no disk write, no broadcast. Without this
|
||||||
// broadcast. Without this guard the tray would re-render the menu on
|
// guard the tray would re-render the menu on every cosmetic re-save of
|
||||||
// every cosmetic re-save of the preferences file.
|
// the preferences file.
|
||||||
assert.Empty(t, emitter.calledWith(EventPreferencesChanged),
|
require.NoError(t, s.SetLanguage("en"))
|
||||||
"setting the current language should not broadcast")
|
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1,
|
||||||
|
"re-setting the current language should not broadcast again")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStore_CorruptFileFallsBackToDefault(t *testing.T) {
|
func TestStore_CorruptFileFallsBackToDefault(t *testing.T) {
|
||||||
@@ -173,7 +178,7 @@ func TestStore_CorruptFileFallsBackToDefault(t *testing.T) {
|
|||||||
require.NoError(t, err, "corrupt file should not fail construction")
|
require.NoError(t, err, "corrupt file should not fail construction")
|
||||||
|
|
||||||
got := s.Get()
|
got := s.Get()
|
||||||
assert.Equal(t, i18n.DefaultLanguage, got.Language, "corrupt JSON should leave the default in place")
|
assert.Equal(t, i18n.LanguageCode(""), got.Language, "corrupt JSON should leave the empty (unset) default in place so the frontend can re-detect")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStore_UnsubscribeStopsUpdates(t *testing.T) {
|
func TestStore_UnsubscribeStopsUpdates(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user