Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.json` (statically bundled).
Use `const { t } = useTranslation("app")`.
- **Plugin strings**: each plugin ships an `i18n/` folder with one
`<code>.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");
<span>{t("some_key")}</span>
```

Files are served by the loader at `/plugins/<id>/i18n/<code>.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
Expand Down
10 changes: 10 additions & 0 deletions apps/loadout-overlay/src/bun/rpc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export function buildRpcHandlers(deps: RpcHandlerDeps) {
},
toggle: async (): Promise<boolean> => 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<string> =>
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.
Expand Down
182 changes: 97 additions & 85 deletions apps/loadout-overlay/src/overlay/components/Settings.tsx

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions apps/loadout-overlay/src/overlay/i18n/en-gb.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
39 changes: 39 additions & 0 deletions apps/loadout-overlay/src/overlay/i18n/zh-cn.json
Original file line number Diff line number Diff line change
@@ -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": "重启服务"
}
}
}
}
10 changes: 10 additions & 0 deletions apps/loadout-overlay/src/overlay/lib/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export async function isGamescopeMode(): Promise<boolean> {
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<string> {
const result = await rpcInvoke("getSystemLocale");
return typeof result === "string" ? result : "";
}


/**
* Restart the backend `loadout.service` via the Bun host's
Expand Down
97 changes: 97 additions & 0 deletions apps/loadout-overlay/src/overlay/lib/i18n-setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> },
"zh-cn": { app: zhCn as Record<string, unknown> },
} as unknown as Record<string, Record<string, Record<string, string>>>;

/**
* Fetch a plugin's translation bundle from the loader
* (`/plugins/<id>/i18n/<lng>.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<Record<string, string> | null> {
try {
const res = await fetch(apiUrl(`/plugins/${ns}/i18n/${lng}.json`));
if (!res.ok) return null;
return (await res.json()) as Record<string, string>;
} 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<string> {
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<string>(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<void> {
const stored = getConfigValue<string>(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 });
}
13 changes: 12 additions & 1 deletion apps/loadout-overlay/src/overlay/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +16,11 @@ import { ensureConnected, subscribe } from "@loadout/ui/ws-client";
// after the first.
applyTheme(getConfigValue<string>("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(<App />);
Expand All @@ -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<string>("theme", "dark")))
.then(() => {
applyTheme(getConfigValue<string>("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",
Expand Down
10 changes: 10 additions & 0 deletions apps/loadout-overlay/src/webview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,6 +76,11 @@ window.__electroview = electro;
// mirror at module load — instant on any boot after the first.
applyTheme(getConfigValue<string>("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 /
Expand Down Expand Up @@ -115,6 +121,10 @@ async function boot() {
loadUserConfig()
.then(() => {
applyTheme(getConfigValue<string>("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
Expand Down
Loading
Loading