From 558769e671661537760a998e7d23fbf8d8f67351 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 29 May 2026 16:58:08 +0200 Subject: [PATCH] localize window titles, fix size for windows (and other platforms?) --- .../src/components/dialog/ConfirmDialog.tsx | 4 +- .../frontend/src/hooks/useAutoSizeWindow.ts | 4 +- client/ui/i18n/locales/de/common.json | 6 ++ client/ui/i18n/locales/en/common.json | 6 ++ client/ui/i18n/locales/hu/common.json | 6 ++ client/ui/main.go | 3 +- client/ui/services/windowmanager.go | 100 ++++++++++++++++-- 7 files changed, 118 insertions(+), 11 deletions(-) diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index 212289e0b..56a2516a0 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -1,4 +1,6 @@ import { ReactNode, forwardRef } from "react"; +import {cn} from "@/lib/cn.ts"; +import {isMacOS} from "@/lib/platform.ts"; // ConfirmDialog is the shared layout wrapper used by dialog-style window // surfaces (SessionExpired, SessionAboutToExpire, …). Purely a layout @@ -23,7 +25,7 @@ export const ConfirmDialog = forwardRef( >
{children}
diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index 3bcfcddc9..34f0ec26e 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef } from "react"; import { Window } from "@wailsio/runtime"; +import {isMacOS} from "@/lib/platform.ts"; // useAutoSizeWindow resizes the current Wails window so its height matches // the measured height of the content element the returned ref is attached @@ -21,7 +22,8 @@ export function useAutoSizeWindow(width: number) { if (!el) return; let shown = false; const apply = () => { - const h = Math.ceil(el.getBoundingClientRect().height); + let h = Math.ceil(el.getBoundingClientRect().height); + h = isMacOS() ? h : h - el.getBoundingClientRect().height; if (h <= 0) return; void Window.SetSize(width, h) .then(() => { diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 1e6032fb5..a5a3753fb 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -294,6 +294,12 @@ "update.page.complete": "Update abgeschlossen", "update.page.failed": "Update fehlgeschlagen", + "window.title.settings": "Einstellungen", + "window.title.signIn": "Anmeldung", + "window.title.sessionExpired": "Sitzung abgelaufen", + "window.title.sessionExpiring": "Sitzung läuft ab", + "window.title.updating": "Aktualisierung", + "browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort", "browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?", "browserLogin.tryAgain": "Erneut versuchen", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 5a8cadbab..bac7b1f5c 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -312,6 +312,12 @@ "update.page.complete": "Update complete", "update.page.failed": "Update failed", + "window.title.settings": "Settings", + "window.title.signIn": "Sign-in", + "window.title.sessionExpired": "Session Expired", + "window.title.sessionExpiring": "Session Expiring", + "window.title.updating": "Updating", + "browserLogin.title": "Continue in your browser to complete the login", "browserLogin.notSeeing": "Not seeing the browser tab?", "browserLogin.tryAgain": "Try again", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index de11c6468..79921ea67 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -294,6 +294,12 @@ "update.page.complete": "Frissítés kész", "update.page.failed": "Frissítés sikertelen", + "window.title.settings": "Beállítások", + "window.title.signIn": "Bejelentkezés", + "window.title.sessionExpired": "Munkamenet lejárt", + "window.title.sessionExpiring": "Munkamenet lejár", + "window.title.updating": "Frissítés", + "browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez", "browserLogin.notSeeing": "Nem látja a böngésző fülét?", "browserLogin.tryAgain": "Próbálja újra", diff --git a/client/ui/main.go b/client/ui/main.go index 08544097b..cc8c9bbd9 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -140,7 +140,8 @@ func main() { // (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close // so they don't linger as hidden windows that Wails's macOS dock-reopen // handler would pop back up. - windowManager := services.NewWindowManager(app, window) + windowManager := services.NewWindowManager(app, window, bundle, prefStore) + windowManager.WatchLanguage(prefStore) app.RegisterService(application.NewService(windowManager)) // Register an in-process StatusNotifierWatcher so the tray works on diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 0e9c92119..e936bde55 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -9,8 +9,20 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" ) +// LanguageSubscriber delivers UI preference changes (currently only the +// language flip; reusing preferences.UIPreferences keeps the channel +// payload identical to preferences.Store.Subscribe). The runtime +// implementation is *preferences.Store. WindowManager uses this to keep +// the long-lived Settings window title in the active language. +type LanguageSubscriber interface { + Subscribe() (<-chan preferences.UIPreferences, func()) +} + // EventTriggerLogin asks the frontend's startLogin() orchestrator to begin // an SSO flow. Emitted by the tray (Login menu item, session expired) since // the tray can't call JS directly. @@ -118,6 +130,8 @@ func DialogWindowOptions(name, title, url string) application.WebviewWindowOptio type WindowManager struct { app *application.App mainWindow *application.WebviewWindow + translator ErrorTranslator + prefs LanguagePreference settings *application.WebviewWindow browserLogin *application.WebviewWindow sessionExpired *application.WebviewWindow @@ -131,19 +145,39 @@ type WindowManager struct { mu sync.Mutex } +// title resolves a window-title i18n key in the user's current language. +// Falls back to the raw key when the translator or prefs are missing +// (mirrors services.Connection.translateShort) — a deliberate fail-loud +// signal that a key is missing from the bundle. +func (s *WindowManager) title(key string) string { + if s.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if s.prefs != nil { + if pref := s.prefs.Get().Language; pref != "" { + lang = pref + } + } + return s.translator.Translate(lang, key) +} + // NewWindowManager wires the manager to the main app. `mainWindow` is the // up-front-created webview the user interacts with from the tray — used to // pick the BrowserLogin window's display so the sign-in popup follows the -// user onto the screen they're already looking at. +// user onto the screen they're already looking at. `translator` + `prefs` +// resolve the user-facing window titles in the active UI language; both +// may be nil (callers in tests can omit them), in which case title() falls +// back to the raw i18n key. // // The Settings window is created here, hidden, so the first OpenSettings // call paints instantly instead of paying webview construction + asset load // at click time. -func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow) *WindowManager { - s := &WindowManager{app: app, mainWindow: mainWindow} +func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference) *WindowManager { + s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs} s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", - Title: "Settings", + Title: s.title("window.title.settings"), Width: 900, Height: 640, Hidden: true, @@ -169,6 +203,56 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo return s } +// WatchLanguage subscribes to UI preference changes and re-applies the +// localised title to every live auxiliary window whenever the language +// flips. The eagerly-created Settings window outlives its first paint, so +// without this its title would stay frozen in the language it was created +// in; the on-demand dialog windows (BrowserLogin, Session*, InstallProgress) +// can also coexist with a Settings-driven language change. Safe to call +// once at startup; subsequent calls overwrite the previous subscription. +// No-op when sub is nil. +func (s *WindowManager) WatchLanguage(sub LanguageSubscriber) { + if sub == nil { + return + } + ch, _ := sub.Subscribe() + go func() { + var last i18n.LanguageCode + for p := range ch { + if p.Language == "" || p.Language == last { + continue + } + last = p.Language + s.retitleAll() + } + }() +} + +// retitleAll re-applies the localised title to every currently-alive +// auxiliary window. Reads the window pointers under s.mu so a concurrent +// Open*/Close* can't observe a torn slice. SetTitle itself dispatches to +// the OS UI thread, so calling it from this goroutine is safe. +func (s *WindowManager) retitleAll() { + s.mu.Lock() + type pair struct { + win *application.WebviewWindow + key string + } + wins := []pair{ + {s.settings, "window.title.settings"}, + {s.browserLogin, "window.title.signIn"}, + {s.sessionExpired, "window.title.sessionExpired"}, + {s.sessionAboutToExpire, "window.title.sessionExpiring"}, + {s.installProgress, "window.title.updating"}, + } + s.mu.Unlock() + for _, p := range wins { + if p.win != nil { + p.win.SetTitle(s.title(p.key)) + } + } +} + // OpenSettings asks the (already-mounted, currently-hidden) settings window // to land on `tab` and bring itself to front. Empty `tab` lands on General. // @@ -211,7 +295,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { screen = sc } } - opts := DialogWindowOptions("browser-login", "NetBird Sign-in", startURL) + opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL) // SSO popup deliberately is NOT always-on-top — the user moves // between the browser tab and our popup; pinning it would obscure // the browser at the moment they need to interact with it. @@ -321,7 +405,7 @@ func (s *WindowManager) OpenSessionExpired() { defer s.mu.Unlock() if s.sessionExpired == nil { s.sessionExpired = s.app.Window.NewWithOptions( - DialogWindowOptions("session-expired", "NetBird", "/#/dialog/session-expired"), + DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired"), ) s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() @@ -356,7 +440,7 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) { startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds) if s.sessionAboutToExpire == nil { s.sessionAboutToExpire = s.app.Window.NewWithOptions( - DialogWindowOptions("session-about-to-expire", "NetBird", startURL), + DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL), ) s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() @@ -402,7 +486,7 @@ func (s *WindowManager) OpenInstallProgress(version string) { if s.installProgress == nil { s.hideOtherWindowsLocked("install-progress") s.installProgress = s.app.Window.NewWithOptions( - DialogWindowOptions("install-progress", "NetBird", startURL), + DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL), ) s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock()