add first run lang detection

This commit is contained in:
Eduard Gert
2026-05-29 14:24:20 +02:00
parent 1985caf993
commit 9dc9e7184e
4 changed files with 53 additions and 15 deletions

View File

@@ -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.
**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.

View File

@@ -3,6 +3,7 @@ import { initReactI18next } from "react-i18next";
import { Events } from "@wailsio/runtime";
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
// 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
// preferences.Store returns the in-memory default "en" when no on-disk
// preferences file exists; if Get() rejects (daemon unreachable) we also
// fall through with "en" so the UI still renders.
// preferences.Store returns an empty language code when no preference has
// ever been persisted — that's the signal for first-run browser-locale
// 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> {
const available = Object.keys(resources);
let language = "en";
let firstRun = false;
try {
const prefs = await Preferences.Get();
if (prefs?.language) {
language = prefs.language;
} else {
firstRun = true;
language = detectBrowserLanguage(available) ?? "en";
}
} catch {
// 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({
lng: language,
fallbackLng: "en",

View File

@@ -111,11 +111,16 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
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{
path: path,
validator: validator,
emitter: emitter,
current: UIPreferences{Language: i18n.DefaultLanguage, ViewMode: DefaultViewMode},
current: UIPreferences{ViewMode: DefaultViewMode},
}
if err := s.load(); err != nil {
@@ -222,9 +227,6 @@ func (s *Store) load() error {
return err
}
if loaded.Language == "" {
loaded.Language = i18n.DefaultLanguage
}
if !loaded.ViewMode.IsValid() {
loaded.ViewMode = DefaultViewMode
}

View File

@@ -79,7 +79,8 @@ func TestStore_DefaultsWhenFileMissing(t *testing.T) {
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")
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) {
@@ -153,13 +154,17 @@ func TestStore_SetLanguageIdempotent(t *testing.T) {
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter)
require.NoError(t, err)
// First call goes from "" (unset) to "en" — real change, one broadcast.
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
// 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")
// Second call 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.
require.NoError(t, s.SetLanguage("en"))
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1,
"re-setting the current language should not broadcast again")
}
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")
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) {