From b13a354c2f66d69f40942ac0a27d793822d30ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 08:32:03 +0000 Subject: [PATCH] feat(i18n): add runtime-switchable language support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an internationalization mechanism for the overlay app and its plugins, with runtime language switching and per-plugin translation files. Core (shared singleton in @loadout/ui): - Add i18next + react-i18next; new packages/ui/src/i18n.ts hosts one i18next instance. Because plugin bundles resolve @loadout/ui to the shell's __LOADOUT_SDK global, the shell and every plugin share that instance — setLanguage() re-renders the whole tree with no reload. - Expose initI18n, setLanguage, ensureNamespace, normalizeLocale, usePluginTranslation, SUPPORTED_LANGUAGES (en-gb, zh-cn) and DEFAULT. Detection / persistence / override: - First-run detection: host OS locale (new getSystemLocale RPC) -> navigator.language -> English, normalized to a supported code and persisted under the `language` user-config key. - Settings > General gains a Language selector that switches at runtime. Translation files: - Shell strings live under the `app` namespace in apps/loadout-overlay/src/overlay/i18n/.json (statically bundled). - Plugins ship an i18n/.json folder (flat key/value, one file per language, plugin id = namespace), served by the loader at /plugins//i18n/.json and lazy-loaded on first use; missing keys fall back to English. Proof of mechanism (full English source + partial Chinese): - Wire the Settings screen and the battery-tracker plugin to t() keys. - This PR is the mechanism only; bulk translation is left to a follow-up. Docs + tests: - Document the convention in CLAUDE.md. - Unit-test normalizeLocale; keep battery-tracker spec green by resolving its assertions against the shipped en-gb.json. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XEKUyS4sptP4hoLgKCDxxu --- CLAUDE.md | 35 ++++ apps/loadout-overlay/src/bun/rpc-handlers.ts | 10 + .../src/overlay/components/Settings.tsx | 182 ++++++++-------- .../src/overlay/i18n/en-gb.json | 91 ++++++++ .../src/overlay/i18n/zh-cn.json | 39 ++++ apps/loadout-overlay/src/overlay/lib/host.ts | 10 + .../src/overlay/lib/i18n-setup.ts | 97 +++++++++ apps/loadout-overlay/src/overlay/main.tsx | 13 +- apps/loadout-overlay/src/webview/main.tsx | 10 + apps/loadout/src/loader/routes/plugins.ts | 39 ++++ bun.lock | 12 ++ packages/ui/package.json | 4 +- packages/ui/src/i18n.test.ts | 42 ++++ packages/ui/src/i18n.ts | 194 ++++++++++++++++++ packages/ui/src/index.ts | 21 ++ plugins/battery-tracker/app.spec.tsx | 17 ++ plugins/battery-tracker/app.tsx | 76 +++---- plugins/battery-tracker/i18n/en-gb.json | 35 ++++ plugins/battery-tracker/i18n/zh-cn.json | 30 +++ 19 files changed, 834 insertions(+), 123 deletions(-) create mode 100644 apps/loadout-overlay/src/overlay/i18n/en-gb.json create mode 100644 apps/loadout-overlay/src/overlay/i18n/zh-cn.json create mode 100644 apps/loadout-overlay/src/overlay/lib/i18n-setup.ts create mode 100644 packages/ui/src/i18n.test.ts create mode 100644 packages/ui/src/i18n.ts create mode 100644 plugins/battery-tracker/i18n/en-gb.json create mode 100644 plugins/battery-tracker/i18n/zh-cn.json diff --git a/CLAUDE.md b/CLAUDE.md index b46b6c68..11b12782 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,41 @@ CEF's DevTools live on `http://localhost:9222` in dev (baked in via `electrobun.config.ts` → `build.linux.chromiumFlags`). Attach Chromium or use CDP directly. +## Internationalization (i18n) + +Runtime-switchable translations are driven by a single shared i18next +instance that lives in `@loadout/ui` (`packages/ui/src/i18n.ts`). Because +plugin bundles resolve `@loadout/ui` to the shell's `__LOADOUT_SDK` +global, the shell and every plugin share that one instance — calling +`setLanguage(code)` re-renders the whole tree, no reload. + +- **Language codes** are lowercase BCP-47-ish (`en-gb`, `zh-cn`) and match + the translation filenames. English (`en-gb`) is the source + fallback. + Supported languages: `SUPPORTED_LANGUAGES` in `packages/ui/src/i18n.ts`. +- **Shell strings** live under the `app` namespace in + `apps/loadout-overlay/src/overlay/i18n/.json` (statically bundled). + Use `const { t } = useTranslation("app")`. +- **Plugin strings**: each plugin ships an `i18n/` folder with one + `.json` per language (flat `key: "value"` pairs — same schema for + every plugin). The plugin id is its i18next namespace. In `app.tsx`: + + ```tsx + import { usePluginTranslation } from "@loadout/ui"; + const { t } = usePluginTranslation("my-plugin-id"); + {t("some_key")} + ``` + + Files are served by the loader at `/plugins//i18n/.json` and + lazy-loaded on first use; missing keys fall back to English. +- **Detection / override**: the active language is persisted in user + config under `language`. It's detected once at first run (host OS locale + → `navigator.language` → English) in + `apps/loadout-overlay/src/overlay/lib/i18n-setup.ts`, and the user can + override it in Settings → General → Language. + +When adding a new plugin, ship at least `i18n/en-gb.json` and wrap visible +strings in `t()`. `battery-tracker` is the reference implementation. + ## Skill routing When the user's request matches an available skill, ALWAYS invoke it using the Skill diff --git a/apps/loadout-overlay/src/bun/rpc-handlers.ts b/apps/loadout-overlay/src/bun/rpc-handlers.ts index ca030206..a164a8d5 100644 --- a/apps/loadout-overlay/src/bun/rpc-handlers.ts +++ b/apps/loadout-overlay/src/bun/rpc-handlers.ts @@ -87,6 +87,16 @@ export function buildRpcHandlers(deps: RpcHandlerDeps) { }, toggle: async (): Promise => requestToggle(deps.state), isGamescopeMode: async () => deps.gamescopeMode, + // OS locale for first-run language detection. Read from the host's + // environment (the webview's navigator.language can be a generic CEF + // default under gamescope). The overlay normalizes whatever string + // this returns to a supported language code, falling back to English. + getSystemLocale: async (): Promise => + process.env.LANG || + process.env.LC_ALL || + process.env.LC_MESSAGES || + process.env.LANGUAGE || + "", // Liveness ping from the webview (~1×/s). The freeze watchdog uses the // last-seen time to tell a healthy overlay from a hung one. Fire-and- // forget: returns nothing, never throws. diff --git a/apps/loadout-overlay/src/overlay/components/Settings.tsx b/apps/loadout-overlay/src/overlay/components/Settings.tsx index c5e1a9df..5e388829 100644 --- a/apps/loadout-overlay/src/overlay/components/Settings.tsx +++ b/apps/loadout-overlay/src/overlay/components/Settings.tsx @@ -1,5 +1,17 @@ import { useState, useEffect, useMemo } from "react"; -import { PluginProvider, TabBar, Slider, Button, Select, Toggle, useBackend } from "@loadout/ui"; +import { + PluginProvider, + TabBar, + Slider, + Button, + Select, + Toggle, + useBackend, + useTranslation, + setLanguage, + SUPPORTED_LANGUAGES, + DEFAULT_LANGUAGE, +} from "@loadout/ui"; import { useSidebarAutoCollapseSetting } from "../hooks/useSidebarCollapse"; import { useEnabledPlugins } from "../hooks/useEnabledPlugins"; import { useConfigValue, getConfigValue, setConfigValue } from "../lib/userConfig"; @@ -61,13 +73,9 @@ function normalizeTheme(theme: string | undefined): string { return theme && THEME_IDS.includes(theme) ? theme : "midnight"; } -const TABS = [ - { id: "general", label: "General" }, - { id: "plugins", label: "Plugins" }, - { id: "controller", label: "Controller" }, -] as const; +const TAB_IDS = ["general", "plugins", "controller"] as const; -type TabId = (typeof TABS)[number]["id"]; +type TabId = (typeof TAB_IDS)[number]; /** Reads the persisted theme (synchronous — backed by the userConfig * in-memory cache, which is seeded from its localStorage mirror at @@ -174,21 +182,15 @@ function ThemeSwatch({ type ActionStatus = "idle" | "arming" | "running" | "success" | "error"; interface MaintenanceAction { - /** Title shown to the left of the button. */ - title: string; - /** Sub-copy explaining what the action does. */ - description: string; - /** Default button label (idle state). */ - idleLabel: string; - /** Label while the RPC is in flight. */ - runningLabel: string; - /** Label on success — cleared back to idle after 3s. */ - successLabel: string; + /** Base i18n key under the `app` namespace — the row reads + * `.{title,description,idle,running,success}`. */ + i18nKey: string; /** RPC to call after the user confirms. */ invoke: () => Promise<{ success: boolean; error?: string }>; } function MaintenanceActionRow({ action }: { action: MaintenanceAction }) { + const { t } = useTranslation("app"); const [status, setStatus] = useState("idle"); const [errorMsg, setErrorMsg] = useState(null); @@ -225,11 +227,11 @@ function MaintenanceActionRow({ action }: { action: MaintenanceAction }) { } const label = - status === "arming" ? "Click again to confirm" - : status === "running" ? action.runningLabel - : status === "success" ? action.successLabel - : status === "error" ? "Failed" - : action.idleLabel; + status === "arming" ? t("settings.maintenance.arming") + : status === "running" ? t(`${action.i18nKey}.running`) + : status === "success" ? t(`${action.i18nKey}.success`) + : status === "error" ? t("settings.maintenance.failed") + : t(`${action.i18nKey}.idle`); const variant = status === "arming" || status === "error" ? "danger" @@ -239,9 +241,9 @@ function MaintenanceActionRow({ action }: { action: MaintenanceAction }) { return (
-
{action.title}
+
{t(`${action.i18nKey}.title`)}
- {action.description} + {t(`${action.i18nKey}.description`)} {errorMsg && ( {errorMsg} )} @@ -259,57 +261,32 @@ function MaintenanceActionRow({ action }: { action: MaintenanceAction }) { } const EXPORT_LOGS_ACTION: MaintenanceAction = { - title: "Save logs to file", - description: - "Dumps the UI and server logs into a timestamped file in your Downloads folder — attach it when reporting an issue.", - idleLabel: "Save logs", - runningLabel: "Saving...", - successLabel: "Saved to Downloads", + i18nKey: "settings.maintenance.export_logs", invoke: exportLogs, }; const RESTART_SERVER_ACTION: MaintenanceAction = { - title: "Restart plugin server", - description: "Reloads every plugin backend — fixes a stuck plugin without a system reboot.", - idleLabel: "Restart server", - runningLabel: "Restarting...", - successLabel: "Restarted", + i18nKey: "settings.maintenance.restart_server", invoke: restartServer, }; const RESTART_STEAM_ACTION: MaintenanceAction = { - title: "Restart Steam", - description: "Restarts the Steam process without rebooting. Use this if Steam crashed or froze after applying a CSS theme.", - idleLabel: "Restart Steam", - runningLabel: "Restarting...", - successLabel: "Restarted", + i18nKey: "settings.maintenance.restart_steam", invoke: restartSteam, }; const UNFREEZE_STEAM_ACTION: MaintenanceAction = { - title: "Unfreeze Steam", - description: "Sends SIGCONT to the Steam process. Use this if Steam's menu is visible but buttons don't respond after closing the overlay.", - idleLabel: "Unfreeze Steam", - runningLabel: "Unfreezing...", - successLabel: "Unfrozen", + i18nKey: "settings.maintenance.unfreeze_steam", invoke: forceUnfreezeSteam, }; const SHUTDOWN_ACTION: MaintenanceAction = { - title: "Shut down", - description: "Powers the device off via systemctl poweroff.", - idleLabel: "Shut down", - runningLabel: "Shutting down...", - successLabel: "Shutting down", + i18nKey: "settings.maintenance.shutdown", invoke: systemShutdown, }; const REBOOT_ACTION: MaintenanceAction = { - title: "Restart device", - description: "Reboots the device via systemctl reboot.", - idleLabel: "Restart device", - runningLabel: "Restarting...", - successLabel: "Restarting", + i18nKey: "settings.maintenance.reboot", invoke: systemReboot, }; @@ -334,12 +311,7 @@ const REBOOT_ACTION: MaintenanceAction = { function ClearDataCachesActionRow() { const { call } = useBackend("__broadcast"); const action: MaintenanceAction = { - title: "Clear all data caches", - description: - "Wipes every plugin's cached external API responses (ProtonDB, HowLongToBeat, SteamGridDB, …) so the next view re-fetches.", - idleLabel: "Clear caches", - runningLabel: "Clearing...", - successLabel: "Cleared", + i18nKey: "settings.maintenance.clear_caches", invoke: async () => { try { const res = (await call("clearExternalCache")) as { @@ -415,8 +387,10 @@ function SettingsInner({ plugins, onShowWelcome, }: Required> & { onShowWelcome?: () => void }) { + const { t } = useTranslation("app"); const [tab, setTab] = useState("general"); const [theme, setTheme] = useConfigValue("theme", loadTheme()); + const [language, setLanguageConfig] = useConfigValue("language", DEFAULT_LANGUAGE); const [startupView, setStartupView] = useConfigValue("startupView", "home"); const [autoCollapseSidebar, setAutoCollapseSidebar] = useSidebarAutoCollapseSetting(); const [shortcuts, setShortcuts] = useState(null); @@ -436,6 +410,13 @@ function SettingsInner({ getControllerShortcuts().then(setShortcuts).catch(() => {}); }, []); + function handleLanguageChange(code: string) { + // Persist the choice and switch the live i18next language — every + // mounted shell + plugin consumer re-renders, no reload needed. + setLanguageConfig(code); + void setLanguage(code); + } + function handleShortcutChange(key: keyof ControllerShortcuts, value: string) { if (!shortcuts) return; const updated = { ...shortcuts, [key]: stringToAction(value) }; @@ -448,7 +429,11 @@ function SettingsInner({
{/* Tabs */}
- setTab(id as TabId)} /> + ({ id, label: t(`settings.tab.${id}`) }))} + activeTab={tab} + onTabChange={(id) => setTab(id as TabId)} + />
{/* General tab */} @@ -456,10 +441,10 @@ function SettingsInner({ <> {/* Appearance */}
-

Appearance

+

{t("settings.section.appearance")}

- UI Scale + {t("settings.ui_scale")} {scale.toFixed(2)}x
+ {/* Language */} +
+

{t("settings.section.language")}

+
+
+
+
{t("settings.section.language")}
+
+ {t("settings.language_desc")} +
+
+
-
Welcome tour
+
{t("settings.welcome_tour")}
- Re-opens the first-boot intro and plugin picker. + {t("settings.welcome_tour_desc")}
- +
)}
@@ -509,13 +518,13 @@ function SettingsInner({ {/* Sidebar */}
-

Sidebar

+

{t("settings.section.sidebar")}

-
Auto-collapse on focus
+
{t("settings.auto_collapse")}
- Shrinks the sidebar to just icons when you're interacting with a plugin page. + {t("settings.auto_collapse_desc")}
@@ -526,7 +535,7 @@ function SettingsInner({ {/* Theme — four Loadout themes with big preview swatch cards */}
-

Theme

+

{t("settings.section.theme")}

{LOADOUT_THEMES.find((t) => t.id === theme)?.name ?? "Midnight"} @@ -545,7 +554,7 @@ function SettingsInner({ {/* Maintenance */}
-

Maintenance

+

{t("settings.section.maintenance")}

@@ -559,10 +568,10 @@ function SettingsInner({ {/* About */}
-

About

+

{t("settings.section.about")}

- Version + {t("settings.version")} {VERSION}
@@ -575,16 +584,19 @@ function SettingsInner({

- Installed Plugins + {t("settings.installed_plugins")}

- {enabledCount} of {sortedPlugins.length} enabled + {t("settings.plugins_enabled_count", { + enabled: enabledCount, + total: sortedPlugins.length, + })}
{sortedPlugins.length === 0 && (
- No plugins installed. + {t("settings.no_plugins")}
)} {sortedPlugins.map((plugin) => { @@ -608,7 +620,7 @@ function SettingsInner({ {plugin.name}
- {plugin.subtitle || plugin.description || "No description."} + {plugin.subtitle || plugin.description || t("settings.no_description")}
@@ -628,11 +640,11 @@ function SettingsInner({ {tab === "controller" && (

- Controller Shortcuts + {t("settings.controller_shortcuts")}

- Hold the Guide button and press a face button to trigger an action. + {t("settings.controller_hint")}

{shortcuts && BUTTON_LABELS.map(({ key, label }) => ( @@ -645,7 +657,7 @@ function SettingsInner({ /> ))} {!shortcuts && ( -
Loading...
+
{t("settings.loading")}
)}
diff --git a/apps/loadout-overlay/src/overlay/i18n/en-gb.json b/apps/loadout-overlay/src/overlay/i18n/en-gb.json new file mode 100644 index 00000000..84248336 --- /dev/null +++ b/apps/loadout-overlay/src/overlay/i18n/en-gb.json @@ -0,0 +1,91 @@ +{ + "settings": { + "tab": { + "general": "General", + "plugins": "Plugins", + "controller": "Controller" + }, + "section": { + "appearance": "Appearance", + "language": "Language", + "homepage": "Homepage", + "sidebar": "Sidebar", + "theme": "Theme", + "maintenance": "Maintenance", + "about": "About" + }, + "ui_scale": "UI Scale", + "language_desc": "Choose the display language. Where a translation is missing, text falls back to English.", + "on_startup": "On startup", + "startup": { + "home": "Open homepage", + "last_tab": "Resume last view" + }, + "welcome_tour": "Welcome tour", + "welcome_tour_desc": "Re-opens the first-boot intro and plugin picker.", + "show_welcome": "Show welcome screen", + "auto_collapse": "Auto-collapse on focus", + "auto_collapse_desc": "Shrinks the sidebar to just icons when you're interacting with a plugin page.", + "version": "Version", + "installed_plugins": "Installed Plugins", + "plugins_enabled_count": "{{enabled}} of {{total}} enabled", + "no_plugins": "No plugins installed.", + "no_description": "No description.", + "controller_shortcuts": "Controller Shortcuts", + "controller_hint": "Hold the Guide button and press a face button to trigger an action.", + "loading": "Loading...", + "maintenance": { + "export_logs": { + "title": "Save logs to file", + "description": "Dumps the UI and server logs into a timestamped file in your Downloads folder — attach it when reporting an issue.", + "idle": "Save logs", + "running": "Saving...", + "success": "Saved to Downloads" + }, + "clear_caches": { + "title": "Clear all data caches", + "description": "Wipes every plugin's cached external API responses (ProtonDB, HowLongToBeat, SteamGridDB, …) so the next view re-fetches.", + "idle": "Clear caches", + "running": "Clearing...", + "success": "Cleared" + }, + "restart_server": { + "title": "Restart plugin server", + "description": "Reloads every plugin backend — fixes a stuck plugin without a system reboot.", + "idle": "Restart server", + "running": "Restarting...", + "success": "Restarted" + }, + "restart_steam": { + "title": "Restart Steam", + "description": "Restarts the Steam process without rebooting. Use this if Steam crashed or froze after applying a CSS theme.", + "idle": "Restart Steam", + "running": "Restarting...", + "success": "Restarted" + }, + "unfreeze_steam": { + "title": "Unfreeze Steam", + "description": "Sends SIGCONT to the Steam process. Use this if Steam's menu is visible but buttons don't respond after closing the overlay.", + "idle": "Unfreeze Steam", + "running": "Unfreezing...", + "success": "Unfrozen" + }, + "shutdown": { + "title": "Shut down", + "description": "Powers the device off via systemctl poweroff.", + "idle": "Shut down", + "running": "Shutting down...", + "success": "Shutting down" + }, + "reboot": { + "title": "Restart device", + "description": "Reboots the device via systemctl reboot.", + "idle": "Restart device", + "running": "Restarting...", + "success": "Restarting" + }, + "arming": "Click again to confirm", + "failed": "Failed" + } + } +} diff --git a/apps/loadout-overlay/src/overlay/i18n/zh-cn.json b/apps/loadout-overlay/src/overlay/i18n/zh-cn.json new file mode 100644 index 00000000..365f54b9 --- /dev/null +++ b/apps/loadout-overlay/src/overlay/i18n/zh-cn.json @@ -0,0 +1,39 @@ +{ + "settings": { + "tab": { + "general": "通用", + "plugins": "插件", + "controller": "手柄" + }, + "section": { + "appearance": "外观", + "language": "语言", + "homepage": "主页", + "sidebar": "侧边栏", + "theme": "主题", + "maintenance": "维护", + "about": "关于" + }, + "ui_scale": "界面缩放", + "language_desc": "选择显示语言。缺少翻译时将回退为英文。", + "on_startup": "启动时", + "startup": { + "home": "打开主页", + "last_tab": "恢复上次视图" + }, + "auto_collapse": "聚焦时自动折叠", + "version": "版本", + "installed_plugins": "已安装插件", + "plugins_enabled_count": "已启用 {{enabled}} / {{total}}", + "controller_shortcuts": "手柄快捷键", + "controller_hint": "按住 Guide 键并按下一个面板按键即可触发操作。", + "loading": "加载中…", + "maintenance": { + "restart_server": { + "title": "重启插件服务", + "description": "重新加载所有插件后端 — 无需重启系统即可修复卡住的插件。", + "idle": "重启服务" + } + } + } +} diff --git a/apps/loadout-overlay/src/overlay/lib/host.ts b/apps/loadout-overlay/src/overlay/lib/host.ts index f9b394c0..ee016fd0 100644 --- a/apps/loadout-overlay/src/overlay/lib/host.ts +++ b/apps/loadout-overlay/src/overlay/lib/host.ts @@ -45,6 +45,16 @@ export async function isGamescopeMode(): Promise { return result === true; } +/** + * Read the host OS locale (e.g. `zh_CN.UTF-8`) for first-run language + * detection. Returns `""` outside Electrobun (standalone dev / tests) so + * the caller falls back to `navigator.language`. + */ +export async function getSystemLocale(): Promise { + const result = await rpcInvoke("getSystemLocale"); + return typeof result === "string" ? result : ""; +} + /** * Restart the backend `loadout.service` via the Bun host's diff --git a/apps/loadout-overlay/src/overlay/lib/i18n-setup.ts b/apps/loadout-overlay/src/overlay/lib/i18n-setup.ts new file mode 100644 index 00000000..89b6bcb9 --- /dev/null +++ b/apps/loadout-overlay/src/overlay/lib/i18n-setup.ts @@ -0,0 +1,97 @@ +/** + * Overlay-side wiring for the shared i18n core in `@loadout/ui`. + * + * Keeps all the overlay/backend specifics (statically-bundled shell + * strings, the loader fetch for plugin translation files, OS-locale + * detection) out of `@loadout/ui` so that package stays portable. The + * core only knows how to switch languages and stitch namespaces together. + */ + +import { initI18n, normalizeLocale, DEFAULT_LANGUAGE } from "@loadout/ui"; +import { getConfigValue, setConfigValue } from "./userConfig"; +import { apiUrl } from "./backend"; +import { getSystemLocale } from "./host"; +import enGb from "../i18n/en-gb.json"; +import zhCn from "../i18n/zh-cn.json"; + +const LANGUAGE_CONFIG_KEY = "language"; + +/** + * Shell strings, bundled statically by Vite under the `app` namespace. + * Shape: `{ [lng]: { app: { …keys } } }`. + */ +const appResources = { + "en-gb": { app: enGb as Record }, + "zh-cn": { app: zhCn as Record }, +} as unknown as Record>>; + +/** + * Fetch a plugin's translation bundle from the loader + * (`/plugins//i18n/.json`). Returns `null` on 404 / parse + * error — the core then leaves that namespace untranslated and keys fall + * back to English. + */ +async function loadNamespaceResource( + lng: string, + ns: string, +): Promise | null> { + try { + const res = await fetch(apiUrl(`/plugins/${ns}/i18n/${lng}.json`)); + if (!res.ok) return null; + return (await res.json()) as Record; + } catch { + return null; + } +} + +/** + * First-run language detection: prefer the host OS locale (reliable under + * gamescope), fall back to the webview's `navigator.language`, then to + * English. The result is normalized to a supported language code. + */ +export async function detectInitialLanguage(): Promise { + let raw = ""; + try { + raw = await getSystemLocale(); + } catch { + raw = ""; + } + if (!raw && typeof navigator !== "undefined") { + raw = navigator.language || (navigator.languages && navigator.languages[0]) || ""; + } + return normalizeLocale(raw); +} + +/** + * Synchronous best-effort init for first paint — mirrors how the theme is + * applied before render. Reads the persisted language from the userConfig + * mirror (instant after the first boot) and initializes i18n with the + * statically-bundled shell strings so the UI never flashes raw keys. + * Fire-and-forget; {@link initOverlayI18n} reconciles afterwards. + */ +export function seedOverlayI18n(): void { + const stored = getConfigValue(LANGUAGE_CONFIG_KEY, "").trim(); + const language = stored ? normalizeLocale(stored) : DEFAULT_LANGUAGE; + void initI18n({ language, appResources, loadNamespaceResource }); +} + +/** + * Resolve the active language and initialize i18n. Call once at boot, + * after `loadUserConfig()` so the persisted choice (if any) is available. + * + * - If the user has set a language, use it. + * - Otherwise detect from the OS/browser, persist it, and use that. + */ +export async function initOverlayI18n(): Promise { + const stored = getConfigValue(LANGUAGE_CONFIG_KEY, "").trim(); + let language = stored ? normalizeLocale(stored) : ""; + + if (!language) { + language = await detectInitialLanguage(); + // Persist the detected default so future boots are stable and the + // Settings selector reflects the active language. + setConfigValue(LANGUAGE_CONFIG_KEY, language); + } + + await initI18n({ language, appResources, loadNamespaceResource }); +} diff --git a/apps/loadout-overlay/src/overlay/main.tsx b/apps/loadout-overlay/src/overlay/main.tsx index 9058c69a..29a7faa6 100644 --- a/apps/loadout-overlay/src/overlay/main.tsx +++ b/apps/loadout-overlay/src/overlay/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./App"; import { applyTheme } from "./components/Settings"; import { getConfigValue, loadUserConfig } from "./lib/userConfig"; +import { seedOverlayI18n, initOverlayI18n } from "./lib/i18n-setup"; import { initBackend } from "./lib/backend"; import { runStartupInits } from "./lib/pluginInit"; import { ensureConnected, subscribe } from "@loadout/ui/ws-client"; @@ -15,6 +16,11 @@ import { ensureConnected, subscribe } from "@loadout/ui/ws-client"; // after the first. applyTheme(getConfigValue("theme", "dark")); +// Seed i18n from the persisted language (mirror-cached) before first +// render so the UI doesn't flash untranslated keys. Reconciled with the +// authoritative on-disk config + first-run detection in boot(). +seedOverlayI18n(); + const root = createRoot(document.getElementById("root")!); root.render(); @@ -33,7 +39,12 @@ async function boot() { // Pull user config off disk once the backend is reachable and // re-apply the theme in case it changed since the last boot. loadUserConfig() - .then(() => applyTheme(getConfigValue("theme", "dark"))) + .then(() => { + applyTheme(getConfigValue("theme", "dark")); + // Reconcile language with the on-disk config and run first-run + // OS-locale detection (persists the default if unset). + return initOverlayI18n(); + }) .catch((err) => console.warn("[main] loadUserConfig failed:", err)); subscribe({ plugin: "__system", diff --git a/apps/loadout-overlay/src/webview/main.tsx b/apps/loadout-overlay/src/webview/main.tsx index 7ee6d687..d4cc3ef6 100644 --- a/apps/loadout-overlay/src/webview/main.tsx +++ b/apps/loadout-overlay/src/webview/main.tsx @@ -26,6 +26,7 @@ import { App, navigateOverlay } from "@overlay/App"; import { applyTheme } from "@overlay/components/Settings"; import { initBackend } from "@overlay/lib/backend"; import { getConfigValue, loadUserConfig } from "@overlay/lib/userConfig"; +import { seedOverlayI18n, initOverlayI18n } from "@overlay/lib/i18n-setup"; import { runStartupInits } from "@overlay/lib/pluginInit"; import { ensureConnected, subscribe } from "@loadout/ui/ws-client"; import { showOverlay } from "@overlay/lib/host"; @@ -75,6 +76,11 @@ window.__electroview = electro; // mirror at module load — instant on any boot after the first. applyTheme(getConfigValue("theme", "dark")); +// Seed i18n from the persisted language (mirror-cached) before first +// render so the UI doesn't flash untranslated keys. Reconciled with the +// authoritative on-disk config + first-run detection in boot(). +seedOverlayI18n(); + // Catch-all error surfaces — we want every uncaught error to reach the // dev console with a consistent prefix so `scripts/capture-screenshots` // and grep over the log can pinpoint the crash site. The PluginHost / @@ -115,6 +121,10 @@ async function boot() { loadUserConfig() .then(() => { applyTheme(getConfigValue("theme", "dark")); + // Reconcile language with the on-disk config and run first-run + // OS-locale detection (persists the default if unset). Fire-and- + // forget — the seed already initialized i18n for first paint. + void initOverlayI18n(); // First run (fresh install): the user hasn't completed the welcome // flow or set a wake button, so open the overlay ourselves to show // it. The installer also pokes the loader's /show, but that races diff --git a/apps/loadout/src/loader/routes/plugins.ts b/apps/loadout/src/loader/routes/plugins.ts index b72bbf48..2ed7e9f6 100644 --- a/apps/loadout/src/loader/routes/plugins.ts +++ b/apps/loadout/src/loader/routes/plugins.ts @@ -44,6 +44,7 @@ export const tokenRoute: RouteHandler = { const APP_BUNDLE_PATTERN = /^\/plugins\/([^/]+)\/app-bundle\.js$/; const ASSET_PATTERN = /^\/plugins\/([^/]+)\/assets\/(.+)$/; +const I18N_PATTERN = /^\/plugins\/([^/]+)\/i18n\/([^/]+\.json)$/; export const pluginAppBundleRoute: RouteHandler = { name: "plugins.app-bundle", @@ -106,9 +107,47 @@ export const pluginAssetRoute: RouteHandler = { }, }; +/** + * Serve a plugin's translation files from its `i18n/` folder: + * `/plugins//i18n/.json` (e.g. `en-gb.json`). Same + * path-traversal guard as the asset route — the resolved path must stay + * inside the plugin's `i18n` directory. A missing file returns 404, which + * the i18n layer treats as "namespace not translated" and falls back to + * English. + */ +export const pluginI18nRoute: RouteHandler = { + name: "plugins.i18n", + match: (_req, url) => I18N_PATTERN.test(url.pathname), + async handle(_req, url, ctx) { + const match = url.pathname.match(I18N_PATTERN)!; + const pluginId = match[1]; + const fileName = decodeURIComponent(match[2]); + if (!ctx.plugins.has(pluginId)) { + return new Response("Not Found", { status: 404 }); + } + const i18nRoot = resolve(join(ctx.pluginsDir, pluginId, "i18n")); + const target = resolve(join(i18nRoot, fileName)); + if (target !== i18nRoot && !target.startsWith(i18nRoot + sep)) { + return new Response("Forbidden", { status: 403 }); + } + const file = Bun.file(target); + if (!(await file.exists())) { + return new Response("Not Found", { status: 404 }); + } + return new Response(file, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + }, + }); + }, +}; + export const pluginsRoutes: RouteHandler[] = [ pluginsListRoute, tokenRoute, pluginAppBundleRoute, pluginAssetRoute, + pluginI18nRoute, ]; diff --git a/bun.lock b/bun.lock index 09c99e37..2f63ce67 100644 --- a/bun.lock +++ b/bun.lock @@ -162,6 +162,8 @@ "@loadout/types": "workspace:*", "@noriginmedia/norigin-spatial-navigation": "^3.0.0", "fuzzysort": "^3.1.0", + "i18next": "^26.3.3", + "react-i18next": "^17.0.8", }, "peerDependencies": { "react": ">=18", @@ -988,10 +990,14 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1140,6 +1146,8 @@ "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], + "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1210,8 +1218,12 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 4915e08b..ced8111d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,9 @@ "@noriginmedia/norigin-spatial-navigation": "^3.0.0", "@loadout/types": "workspace:*", "@loadout/steam-paths": "workspace:*", - "fuzzysort": "^3.1.0" + "fuzzysort": "^3.1.0", + "i18next": "^26.3.3", + "react-i18next": "^17.0.8" }, "peerDependencies": { "react": ">=18" diff --git a/packages/ui/src/i18n.test.ts b/packages/ui/src/i18n.test.ts new file mode 100644 index 00000000..b78dd359 --- /dev/null +++ b/packages/ui/src/i18n.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "bun:test"; +import { normalizeLocale, DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "./i18n"; + +describe("normalizeLocale", () => { + it("matches a supported code exactly (case/separator-insensitive)", () => { + expect(normalizeLocale("zh-cn")).toBe("zh-cn"); + expect(normalizeLocale("ZH-CN")).toBe("zh-cn"); + expect(normalizeLocale("zh_CN")).toBe("zh-cn"); + expect(normalizeLocale("en-gb")).toBe("en-gb"); + }); + + it("strips encoding/modifier suffixes from POSIX locales", () => { + expect(normalizeLocale("zh_CN.UTF-8")).toBe("zh-cn"); + expect(normalizeLocale("en_GB.UTF-8")).toBe("en-gb"); + expect(normalizeLocale("en_GB@euro")).toBe("en-gb"); + }); + + it("falls back to the language prefix when the region differs", () => { + // Any Chinese variant maps to the one supported Chinese locale. + expect(normalizeLocale("zh")).toBe("zh-cn"); + expect(normalizeLocale("zh-TW")).toBe("zh-cn"); + expect(normalizeLocale("zh-Hans")).toBe("zh-cn"); + // Any English variant maps to the supported English locale. + expect(normalizeLocale("en")).toBe("en-gb"); + expect(normalizeLocale("en-US")).toBe("en-gb"); + }); + + it("defaults to English for unsupported / empty / C locales", () => { + expect(normalizeLocale("fr-FR")).toBe(DEFAULT_LANGUAGE); + expect(normalizeLocale("")).toBe(DEFAULT_LANGUAGE); + expect(normalizeLocale(undefined)).toBe(DEFAULT_LANGUAGE); + expect(normalizeLocale("C")).toBe(DEFAULT_LANGUAGE); + expect(normalizeLocale("POSIX")).toBe(DEFAULT_LANGUAGE); + }); + + it("only ever returns a supported code", () => { + const codes = SUPPORTED_LANGUAGES.map((l) => l.code); + for (const input of ["xx", "de", "ja-JP", "zh", "en-au", ""]) { + expect(codes).toContain(normalizeLocale(input)); + } + }); +}); diff --git a/packages/ui/src/i18n.ts b/packages/ui/src/i18n.ts new file mode 100644 index 00000000..fff55331 --- /dev/null +++ b/packages/ui/src/i18n.ts @@ -0,0 +1,194 @@ +/** + * Shared i18n core. Lives in `@loadout/ui` on purpose: plugin bundles + * resolve `@loadout/ui` to the shell's `globalThis.__LOADOUT_SDK` + * singleton (see apps/loadout/src/loader/inject-builder.ts), so the host + * shell and every plugin share ONE i18next instance and ONE React. That + * means `setLanguage()` re-renders the whole tree — shell + every mounted + * plugin root — with no per-plugin provider wiring. + * + * Resource model: + * - The shell registers its own strings under the `app` namespace at + * init (`appResources`), bundled statically by Vite. + * - Each plugin uses its plugin id as a namespace and ships + * `i18n/.json` files served by the loader. They're fetched + * lazily the first time a plugin calls `usePluginTranslation(id)` via + * the injected `loadNamespaceResource` fetcher (kept out of this + * package so `@loadout/ui` stays free of overlay/backend specifics). + * + * Language codes are lowercase BCP-47-ish (`en-gb`, `zh-cn`) to match the + * per-plugin `i18n/.json` filenames; i18next runs with + * `lowerCaseLng: true` so `en-GB` and `en-gb` resolve the same file. + */ + +import i18n from "i18next"; +import { initReactI18next, useTranslation, Trans } from "react-i18next"; +import { useEffect, useReducer } from "react"; + +export interface SupportedLanguage { + /** Lowercase locale code — matches the `i18n/.json` filename. */ + code: string; + /** Human-readable label shown in the language picker. */ + label: string; +} + +/** Languages the app ships. English is the source + fallback. */ +export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [ + { code: "en-gb", label: "English" }, + { code: "zh-cn", label: "中文 (简体)" }, +]; + +export const DEFAULT_LANGUAGE = "en-gb"; + +const APP_NAMESPACE = "app"; + +/** Fetcher for a namespace's resources, injected at init by the shell. */ +export type LoadNamespaceResource = ( + lng: string, + ns: string, +) => Promise | null>; + +let loadNamespaceResource: LoadNamespaceResource = async () => null; + +/** Tracks `${lng}:${ns}` combos already fetched so we never refetch. */ +const loadedBundles = new Set(); + +/** + * Resolve an arbitrary OS / browser locale string to the closest + * supported code. Handles `zh_CN.UTF-8`, `en_GB`, `zh-Hans`, etc. + * Falls back to {@link DEFAULT_LANGUAGE} when nothing matches. + */ +export function normalizeLocale(raw: string | undefined | null): string { + if (!raw) return DEFAULT_LANGUAGE; + // `zh_CN.UTF-8` / `en_GB@euro` → `zh-cn` / `en-gb` + const cleaned = raw + .trim() + .toLowerCase() + .replace(/[_]/g, "-") + .replace(/[.@].*$/, ""); + if (!cleaned || cleaned === "c" || cleaned === "posix") return DEFAULT_LANGUAGE; + + const codes = SUPPORTED_LANGUAGES.map((l) => l.code); + // Exact match first (`zh-cn`). + if (codes.includes(cleaned)) return cleaned; + // Then language-prefix match (`zh`, `zh-hans`, `zh-tw` → `zh-cn`). + const lang = cleaned.split("-")[0]; + const prefixMatch = codes.find((c) => c.split("-")[0] === lang); + if (prefixMatch) return prefixMatch; + return DEFAULT_LANGUAGE; +} + +/** True once {@link initI18n} has run. */ +export function isI18nInitialized(): boolean { + return i18n.isInitialized; +} + +export interface InitI18nOptions { + language: string; + /** `{ [lng]: { app: { key: value } } }` — bundled shell strings. */ + appResources: Record>>; + loadNamespaceResource: LoadNamespaceResource; +} + +/** + * Initialize the shared i18next instance. Idempotent — calling it again + * just refreshes the resource loader and switches language. + */ +export async function initI18n(opts: InitI18nOptions): Promise { + loadNamespaceResource = opts.loadNamespaceResource; + + if (i18n.isInitialized) { + await i18n.changeLanguage(opts.language); + return; + } + + await i18n.use(initReactI18next).init({ + lng: opts.language, + fallbackLng: DEFAULT_LANGUAGE, + lowerCaseLng: true, + defaultNS: APP_NAMESPACE, + ns: [APP_NAMESPACE], + resources: opts.appResources, + interpolation: { escapeValue: false }, + react: { + useSuspense: false, + // Re-render components when a plugin namespace bundle is added at + // runtime (we load them manually via addResourceBundle, not a + // backend, so the default 'languageChanged'-only binding misses them). + bindI18nStore: "added", + }, + }); +} + +/** + * Ensure a namespace's resources are loaded for the active language and + * the fallback. Idempotent and safe to call repeatedly. No-op if the + * bundle is already present (e.g. the statically-bundled `app` ns). + */ +export async function ensureNamespace(ns: string): Promise { + const langs = new Set([i18n.language, DEFAULT_LANGUAGE]); + await Promise.all( + [...langs].map(async (lng) => { + if (!lng) return; + const key = `${lng}:${ns}`; + if (loadedBundles.has(key)) return; + if (i18n.hasResourceBundle(lng, ns)) { + loadedBundles.add(key); + return; + } + loadedBundles.add(key); // mark before await so concurrent callers don't double-fetch + try { + const res = await loadNamespaceResource(lng, ns); + if (res) i18n.addResourceBundle(lng, ns, res, true, true); + } catch { + // Missing translation file → namespace stays untranslated and + // keys fall back to English (or the raw key). Not fatal. + loadedBundles.delete(key); + } + }), + ); +} + +/** + * Switch the active language at runtime and make sure every + * already-active namespace has resources for the new language. All + * mounted `useTranslation` / `usePluginTranslation` consumers re-render. + */ +export async function setLanguage(code: string): Promise { + await i18n.changeLanguage(code); + // Re-hydrate namespaces the user has already touched in the new language. + const active = (i18n.options.ns as string[] | undefined) ?? [APP_NAMESPACE]; + await Promise.all(active.map((ns) => ensureNamespace(ns))); +} + +/** Current active language code. */ +export function getLanguage(): string { + return i18n.language || DEFAULT_LANGUAGE; +} + +/** + * Plugin-facing translation hook. Lazily loads the plugin's + * `i18n/.json` (namespace = plugin id) and returns a `t` scoped to + * it. Re-loads on language change so runtime switching Just Works. + * + * const { t } = usePluginTranslation("battery-tracker"); + * {t("power_flow")} + */ +export function usePluginTranslation(pluginId: string) { + const result = useTranslation(pluginId, { useSuspense: false }); + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + const lng = result.i18n.language; + + useEffect(() => { + let alive = true; + ensureNamespace(pluginId).then(() => { + if (alive) forceUpdate(); + }); + return () => { + alive = false; + }; + }, [pluginId, lng]); + + return result; +} + +export { i18n, useTranslation, Trans }; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 41ded671..faab6d78 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,6 +72,27 @@ export type { ResolvedKey, KeystrokeHandler } from "./keyboard"; export { notify, TOAST_EVENT } from "./notify"; export type { ToastKind, NotifyOptions, ToastEventDetail } from "./notify"; +// i18n — shared singleton (host shell + every plugin share one instance). +export { + i18n, + initI18n, + setLanguage, + getLanguage, + ensureNamespace, + normalizeLocale, + isI18nInitialized, + usePluginTranslation, + useTranslation, + Trans, + SUPPORTED_LANGUAGES, + DEFAULT_LANGUAGE, +} from "./i18n"; +export type { + SupportedLanguage, + InitI18nOptions, + LoadNamespaceResource, +} from "./i18n"; + export * as Steam from "./steam"; export { navigate, navigateBack, closeSideMenus, navigateToPage } from "./navigation"; diff --git a/plugins/battery-tracker/app.spec.tsx b/plugins/battery-tracker/app.spec.tsx index 1bffd2e5..8dc45e0c 100644 --- a/plugins/battery-tracker/app.spec.tsx +++ b/plugins/battery-tracker/app.spec.tsx @@ -13,13 +13,30 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; // module. bun's mock.module is NOT hoisted (unlike vitest's vi.mock). import * as actualUi from "@loadout/ui"; import { waitFor } from "../../test/render"; +import enGb from "./i18n/en-gb.json"; const callMock = mock((_method: string) => Promise.resolve(null)); const eventHandlers = new Map void>(); +// i18n isn't initialized in the unit-test environment, so the real +// `usePluginTranslation` would return raw keys. Resolve against the +// shipped en-gb.json instead — this keeps the text assertions meaningful +// AND doubles as a check that every key the UI uses exists in the source. +const enStrings = enGb as Record; +function testT(key: string, opts?: Record): string { + let s = enStrings[key] ?? key; + if (opts) { + for (const [k, v] of Object.entries(opts)) { + s = s.replace(`{{${k}}}`, String(v)); + } + } + return s; +} + mock.module("@loadout/ui", () => ({ ...actualUi, PluginProvider: ({ children }: any) => children, + usePluginTranslation: () => ({ t: testT, i18n: { language: "en-gb" }, ready: true }), useBackend: () => ({ call: callMock, useEvent: ({ event, handler }: any) => { diff --git a/plugins/battery-tracker/app.tsx b/plugins/battery-tracker/app.tsx index 90a2b1c3..8ee39643 100644 --- a/plugins/battery-tracker/app.tsx +++ b/plugins/battery-tracker/app.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { FaBatteryFull, FaBolt } from "react-icons/fa6"; -import { Button, Spinner, useBackend, mountComponent } from "@loadout/ui"; +import { Button, Spinner, useBackend, mountComponent, usePluginTranslation } from "@loadout/ui"; import type { BatteryInfo, HistoryEntry } from "./lib/battery"; export const icon = FaBatteryFull; @@ -35,11 +35,12 @@ function percentageColor(pct: number, charging: boolean): string { /** Stacked-bar history visualization. Shows the last N entries as * thin bars; thicker top cap for charging samples. */ function HistoryChart({ history }: { history: HistoryEntry[] }) { + const { t } = usePluginTranslation("battery-tracker"); const entries = history.slice(-30); if (entries.length === 0) { return (
- No history yet. Data is recorded every minute. + {t("no_history")}
); } @@ -105,6 +106,7 @@ function MetricTile({ // --------------------------------------------------------------------------- function BatteryTracker() { + const { t } = usePluginTranslation("battery-tracker"); const { call, useEvent } = useBackend("battery-tracker"); const [battery, setBattery] = useState(null); @@ -149,10 +151,10 @@ function BatteryTracker() {
-
Error
+
{t("error")}
{error}
- +
@@ -185,14 +187,16 @@ function BatteryTracker() {
- CHARGE + {t("charge")}
{charging ? ( - CHARGING + {t("charging")} ) : ( - {battery.timeRemainingFormatted} remaining + + {t("remaining", { time: battery.timeRemainingFormatted })} + )}
@@ -223,40 +227,38 @@ function BatteryTracker() { {/* POWER FLOW + HEALTH + DETAILS */}
-
Power Flow
+
{t("power_flow")}
- - + +
- {charging - ? "Pack is absorbing energy from the adapter. Charge rate depends on TDP budget and thermals." - : "Pack is discharging. Lowering TDP or brightness is the fastest way to extend runtime."} + {charging ? t("charging_desc") : t("discharging_desc")}
-
Health
+
{t("health")}
● {healthPct}%
- +
- Capacity + {t("capacity")} {battery.energyFullWh.toFixed(1)} / {battery.energyFullDesignWh.toFixed(1)} Wh @@ -272,45 +274,45 @@ function BatteryTracker() {
{healthPct >= 90 - ? "Battery is healthy. No action needed." + ? t("health_good") : healthPct >= 75 - ? "Moderate wear. Consider enabling charge limit to slow degradation." - : "Significant wear. Replacement may be worth considering."} + ? t("health_moderate") + : t("health_poor")}
-
Details
+
{t("details")}
- Design capacity + {t("design_capacity")} {battery.energyFullDesignWh.toFixed(1)} Wh
- Full-charge capacity + {t("full_charge_capacity")} {battery.energyFullWh.toFixed(1)} Wh
- Current charge + {t("current_charge")} {battery.energyNowWh.toFixed(1)} Wh
- Voltage + {t("voltage")} {voltage.toFixed(2)} V
- Status + {t("status")} {battery.status}
- Time remaining + {t("time_remaining")} {battery.timeRemainingFormatted}
-
Charge History (30 min)
- +
{t("charge_history")}
+
@@ -326,6 +328,7 @@ function BatteryTracker() { /** Compact battery display for the dashboard home widget. */ function BatteryWidget() { + const { t } = usePluginTranslation("battery-tracker"); const { call, useEvent } = useBackend("battery-tracker"); const [battery, setBattery] = useState(null); @@ -337,7 +340,7 @@ function BatteryWidget() { }); }, [call]); - if (!battery) return
Loading battery…
; + if (!battery) return
{t("loading")}
; const charging = battery.status === "Charging"; const pct = Math.round(battery.percentage); @@ -362,11 +365,11 @@ function BatteryWidget() { }} >
- BATTERY + {t("battery")}
{charging ? (
- CHARGING + {t("charging")}
) : hasTimeLeft ? (
{timeLeft}
@@ -403,7 +406,7 @@ function BatteryWidget() { }} > {hasDraw ? `${charging ? "+" : "-"}${drawW.toFixed(1)} W` : ""} - {hasHealth ? `Health: ${healthPct}%` : ""} + {hasHealth ? t("health_short", { pct: healthPct }) : ""}
)}
@@ -415,11 +418,12 @@ function BatteryWidget() { // --------------------------------------------------------------------------- function Header() { + const { t } = usePluginTranslation("battery-tracker"); return (
-

Battery

+

{t("title")}

- Real-time power monitoring + {t("subtitle")}
); diff --git a/plugins/battery-tracker/i18n/en-gb.json b/plugins/battery-tracker/i18n/en-gb.json new file mode 100644 index 00000000..d440d933 --- /dev/null +++ b/plugins/battery-tracker/i18n/en-gb.json @@ -0,0 +1,35 @@ +{ + "title": "Battery", + "subtitle": "Real-time power monitoring", + "error": "Error", + "retry": "Retry", + "charge": "CHARGE", + "charging": "CHARGING", + "remaining": "{{time}} remaining", + "power_flow": "Power Flow", + "watts": "WATTS", + "volts": "VOLTS", + "amps": "AMPS", + "charging_desc": "Pack is absorbing energy from the adapter. Charge rate depends on TDP budget and thermals.", + "discharging_desc": "Pack is discharging. Lowering TDP or brightness is the fastest way to extend runtime.", + "health": "Health", + "capacity_metric": "CAPACITY", + "wear": "WEAR", + "capacity": "Capacity", + "health_good": "Battery is healthy. No action needed.", + "health_moderate": "Moderate wear. Consider enabling charge limit to slow degradation.", + "health_poor": "Significant wear. Replacement may be worth considering.", + "details": "Details", + "design_capacity": "Design capacity", + "full_charge_capacity": "Full-charge capacity", + "current_charge": "Current charge", + "voltage": "Voltage", + "status": "Status", + "time_remaining": "Time remaining", + "charge_history": "Charge History (30 min)", + "refresh": "Refresh", + "no_history": "No history yet. Data is recorded every minute.", + "battery": "BATTERY", + "loading": "Loading battery…", + "health_short": "Health: {{pct}}%" +} diff --git a/plugins/battery-tracker/i18n/zh-cn.json b/plugins/battery-tracker/i18n/zh-cn.json new file mode 100644 index 00000000..e4c70a0c --- /dev/null +++ b/plugins/battery-tracker/i18n/zh-cn.json @@ -0,0 +1,30 @@ +{ + "title": "电池", + "subtitle": "实时电量监控", + "error": "错误", + "retry": "重试", + "charge": "电量", + "charging": "充电中", + "remaining": "剩余 {{time}}", + "power_flow": "电力流向", + "watts": "瓦特", + "volts": "伏特", + "amps": "安培", + "health": "健康度", + "capacity_metric": "容量", + "wear": "损耗", + "capacity": "容量", + "details": "详情", + "design_capacity": "设计容量", + "full_charge_capacity": "满充容量", + "current_charge": "当前电量", + "voltage": "电压", + "status": "状态", + "time_remaining": "剩余时间", + "charge_history": "充电历史(30 分钟)", + "refresh": "刷新", + "no_history": "暂无历史记录。每分钟记录一次数据。", + "battery": "电池", + "loading": "正在加载电池…", + "health_short": "健康度:{{pct}}%" +}