From 9dc9e7184eae1f471f940a8fb30002efd977072c Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 29 May 2026 14:24:20 +0200 Subject: [PATCH] add first run lang detection --- client/ui/frontend/CLAUDE.md | 2 +- client/ui/frontend/src/lib/i18n.ts | 37 ++++++++++++++++++++++++++--- client/ui/preferences/store.go | 10 ++++---- client/ui/preferences/store_test.go | 19 +++++++++------ 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index e32796643..27b3623b8 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -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. diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index b0a81b93f..5029dbb91 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -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 { + 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", diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index 92ab73990..f8ca17dc2 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -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 } diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go index ec057e15c..0d1cc7b54 100644 --- a/client/ui/preferences/store_test.go +++ b/client/ui/preferences/store_test.go @@ -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) {