Skip to content
Merged
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
80 changes: 80 additions & 0 deletions docs/I18N.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Internationalization

Available languages: Simplified Chinese (`zh-CN`), English (`en`), and Russian (`ru`).
The language menu also offers Follow system. Manual choices persist locally.

User-owned text (account names, notes, session previews, paths, and skill contents)
must be wrapped in `translate="no"` or `data-i18n-ignore`. The runtime skips both
the text and attributes of those elements and their descendants. Do not place
static UI labels inside these protected containers.

English translations reuse earlier public UI wording from
[InventiveSpark/codex-switcher](https://github.com/InventiveSpark/codex-switcher),
with additional translations for the current features. The English catalog is
checked against every Russian source key for coverage and placeholder counts.

## 中文

### 工作方式

- 简体中文是源码语言,也是无法识别系统语言或没有对应翻译时的默认语言。
- 应用启动时优先读取系统语言;Linux 上如果进程只有 `C/POSIX` 通用语言,则回退到 `/etc/default/locale`,然后在 `src/i18n/index.ts` 的 `appLocales` 中匹配语言前缀。
- 如果用户没有手动选择语言,应用会根据系统语言自动选择已提供的翻译;未识别或未提供翻译时使用简体中文。顶栏菜单只显示可用语言,用户手动选择的语言保存在本机 `localStorage` 中,并覆盖后续自动检测。
- `src/i18n/runtime.ts` 负责翻译 React 渲染出的文本、常用可访问性属性、浏览器对话框和通知。
- Tauri 后端通过 `set_app_locale` 与前端使用相同语言;OAuth 页面、托盘菜单和系统通知放在 `src-tauri/src/i18n/` 中。

### 添加新语言

1. 复制 `src/i18n/ru.ts` 为新的语言文件,实现并导出一个 `UiLocale`。
2. 在 `src/i18n/index.ts` 导入该文件,并向 `appLocales` 添加语言代码、系统语言前缀、本地语言名称、旗帜、翻译对象和 `contributors` 中的翻译贡献者。可以为每位贡献者添加可选的个人主页 URL。
3. 如果该语言需要翻译后端可见文本,复制 `src-tauri/src/i18n/ru.rs`,实现 `BackendLocale`,然后在 `src-tauri/src/i18n/mod.rs` 的 `BACKEND_LOCALES` 中注册。
4. 优先翻译完整句子。动态消息中的 `{}`、`{name}` 等占位符数量必须与中文源文本一致。
5. 运行 `npm run check:i18n`、`npm run build` 和 `cargo check --manifest-path src-tauri/Cargo.toml`。

`contributors` 会在“关于”中自动生成翻译贡献者列表。该列表仅在当前语言不是源语言时显示。每个新的语言文件还必须翻译列表标题 `翻译` 和说明 `感谢帮助应用支持更多语言的贡献者`。语言的本地名称和贡献者姓名不会再次翻译。

不要直接把组件中的中文替换为另一种语言。中文必须继续作为可维护的源码和回退语言。

## English

### How it works

- Simplified Chinese is the source language and the fallback when the system locale is unknown or no translation is available.
- At startup, the app reads the system locale and matches its prefix against `appLocales` in `src/i18n/index.ts`. On Linux, a generic `C/POSIX` process locale falls back to `/etc/default/locale`.
- When the user has not chosen a language manually, the app detects the system locale and selects an available translation; unknown or unavailable locales fall back to Simplified Chinese. The header menu lists only available languages. A manual choice is stored in `localStorage` and disables later automatic switching.
- `src/i18n/runtime.ts` translates React-rendered text, common accessibility attributes, browser dialogs, and notifications.
- The frontend synchronizes the selected locale with Tauri through `set_app_locale`. OAuth pages, native tray items, and system notifications live in `src-tauri/src/i18n/`.

### Adding a language

1. Copy `src/i18n/ru.ts` to a new locale file and export a `UiLocale` implementation.
2. Import it in `src/i18n/index.ts`, then add its locale code, system-language prefixes, native name, flag, translation object, and translation contributors in `contributors` to `appLocales`. Each contributor may have an optional profile URL.
3. If native backend text must be translated, copy `src-tauri/src/i18n/ru.rs`, implement `BackendLocale`, and register it in `BACKEND_LOCALES` in `src-tauri/src/i18n/mod.rs`.
4. Prefer complete messages over fragments. Preserve the number of `{}` and named placeholders from the Chinese source text.
5. Run `npm run check:i18n`, `npm run build`, and `cargo check --manifest-path src-tauri/Cargo.toml`.

The `contributors` entries automatically populate the translation credits in About. The credits are shown only when the active language differs from the source language. Every new locale file must also translate the credits heading `翻译` and description `感谢帮助应用支持更多语言的贡献者`. Native language names and contributor names are displayed as registered and are not translated again.

Do not replace Chinese strings directly in components. Chinese must remain the maintainable source and fallback language.

## Русский

### Как это работает

- Упрощённый китайский остаётся исходным языком и включается, если системная локаль не определена или для неё нет перевода.
- При запуске приложение читает системную локаль и сопоставляет её префикс с реестром `appLocales` в `src/i18n/index.ts`. В Linux при общей локали процесса `C/POSIX` используется `/etc/default/locale`.
- Если пользователь ещё не выбирал язык вручную, приложение определяет системную локаль и выбирает доступный перевод; при неизвестной локали или отсутствии перевода используется упрощённый китайский. В меню шапки отображаются только доступные языки. Ручной выбор сохраняется в `localStorage` и отключает дальнейшее автоматическое переключение.
- `src/i18n/runtime.ts` переводит текст, созданный React, основные атрибуты доступности, браузерные диалоги и уведомления.
- Frontend передаёт выбранную локаль Tauri-командой `set_app_locale`. OAuth-страницы, нативное tray-меню и системные уведомления находятся в `src-tauri/src/i18n/`.

### Как добавить язык

1. Скопируйте `src/i18n/ru.ts` в файл новой локали и экспортируйте реализацию `UiLocale`.
2. Импортируйте её в `src/i18n/index.ts`, затем добавьте в `appLocales` код локали, префиксы системного языка, самоназвание языка, флаг, объект перевода и авторов перевода в `contributors`. Для каждого автора можно указать необязательную ссылку на профиль.
3. Если нужен перевод видимых строк backend, скопируйте `src-tauri/src/i18n/ru.rs`, реализуйте `BackendLocale` и зарегистрируйте локаль в `BACKEND_LOCALES` из `src-tauri/src/i18n/mod.rs`.
4. Предпочитайте переводы целых сообщений фрагментам. Сохраняйте число `{}` и именованных подстановок из исходной китайской строки.
5. Выполните `npm run check:i18n`, `npm run build` и `cargo check --manifest-path src-tauri/Cargo.toml`.

Записи `contributors` автоматически формируют список авторов переводов в разделе «О программе». Список показывается, только когда активный язык отличается от исходного. В файле каждой новой локали также нужно перевести заголовок `翻译` и описание `感谢帮助应用支持更多语言的贡献者`. Самоназвания языков и имена участников выводятся как зарегистрированы и повторно не переводятся.

Не заменяйте китайские строки непосредственно в компонентах. Китайский должен оставаться поддерживаемым исходным и резервным языком.
2 changes: 2 additions & 0 deletions scripts/fixtures/i18n-data.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { installAppLocale } from '../../src/i18n';
import { LanguagePicker } from '../../src/components/LanguagePicker';
import '../../src/App.css';

installAppLocale('ru-RU');

function Fixture() {
const [name, setName] = useState('生产账号A');
return <main style={{ padding: 24 }}>
<LanguagePicker />
<h2>账号</h2>
<p>复制 <span translate="no" title={name} data-testid="account-name">{name}</span></p>
<p translate="no" data-testid="user-notes">客户备注:测试账号。保持原文。</p>
Expand Down
39 changes: 38 additions & 1 deletion scripts/i18n-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,52 @@ function transpileModule(file, context = {}) {

const { resolveAppLocale } = transpileModule('src/i18n/index.ts', {
'./ru': { russianLocale: {} },
'./en': { englishLocale: {} },
'./runtime': { installUiLocale() {} },
});

test('system locale selects Russian and unsupported locales fall back to Chinese', () => {
assert.equal(resolveAppLocale('ru'), 'ru');
assert.equal(resolveAppLocale('ru-RU'), 'ru');
assert.equal(resolveAppLocale('zh-CN'), 'zh-CN');
assert.equal(resolveAppLocale('en-US'), 'zh-CN');
assert.equal(resolveAppLocale('en-US'), 'en');
assert.equal(resolveAppLocale('en-GB'), 'en');
assert.equal(resolveAppLocale('de-DE'), 'zh-CN');
assert.equal(resolveAppLocale(''), 'zh-CN');
assert.equal(resolveAppLocale('en-US', 'ru'), 'ru');
assert.equal(resolveAppLocale('ru-RU', 'zh-CN'), 'zh-CN');
assert.equal(resolveAppLocale('ru-RU', 'en'), 'en');
assert.equal(resolveAppLocale('ru-RU', 'unsupported'), 'ru');
assert.equal(resolveAppLocale('ru-RU', 'auto'), 'ru');
});

const translator = transpileModule('src/i18n/translator.ts');
const { translateEn, englishReplacements } = transpileModule('src/i18n/en.ts', { './translator': translator });
test('English catalog covers every Russian source key and preserves placeholders', () => {
const english = new Map(englishReplacements);
const russianAst = ts.createSourceFile('ru.ts', source, ts.ScriptTarget.Latest, true);
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(russianAst) === 'replacements') {
for (const pair of node.initializer.elements) {
const key = pair.elements[0].text;
assert.ok(english.has(key), `Missing English translation: ${key}`);
const value = english.get(key);
assert.ok(!/[\p{Script=Han}\p{Script=Cyrillic}]/u.test(value), `Untranslated English value: ${key}`);
assert.equal(value.match(/\{[^{}]*\}/g)?.length ?? 0, key.match(/\{[^{}]*\}/g)?.length ?? 0, key);
}
}
ts.forEachChild(node, visit);
}
visit(russianAst);
});

test('English dynamic messages preserve names and URLs', () => {
assert.equal(translateEn('账号'), 'Account');
assert.equal(translateEn('模型列表请求失败: timeout'), 'Model list request failed: timeout');
assert.equal(translateEn('Server 不可达(primary=http://a, fallback=http://b)'), 'Server unreachable (primary=http://a, fallback=http://b)');
assert.equal(translateEn('请求失败: 生产账号A'), 'Request failed: 生产账号A');
assert.equal(translateEn('1小时 2分钟 3秒 后重置'), '1h 2m 3s until reset');
assert.equal(translateEn('3天 2小时 10分钟 后重置'), '3d 2h 10m until reset');
});

test('dynamic backend messages retain values and prefer the most specific template', () => {
Expand Down
65 changes: 65 additions & 0 deletions src-tauri/src/i18n/en.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use super::{BackendLocale, APP_NAME};

pub struct EnglishLocale;

impl BackendLocale for EnglishLocale {
fn oauth_success_html(&self) -> &'static str {
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\
<html lang=\"en\"><body><h1>Authorization successful</h1><p>OpenAI is connected. Close this window and return to the app.</p>\
<script>setTimeout(() => window.close(), 3000)</script></body></html>"
}
fn oauth_failure_response(&self) -> &'static str {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nAuthorization failed: invalid state or missing parameters"
}
fn tray_tooltip_logged_out(&self) -> String {
format!("{APP_NAME} - Not signed in")
}
fn tray_tooltip_account(&self, name: &str, five_hour_left: f64, weekly_left: f64) -> String {
format!("{APP_NAME} - {name} | 5H: {five_hour_left:.0}% Weekly: {weekly_left:.0}%")
}
fn tray_show_main(&self) -> &'static str {
"Open main window"
}
fn tray_next_account(&self) -> &'static str {
"Switch to next account"
}
fn tray_quit(&self) -> &'static str {
"Quit"
}
fn notification_account_banned_subtitle(&self) -> &'static str {
"Account ban detected"
}
fn notification_auto_switch_subtitle(&self) -> &'static str {
"Automatic account switch"
}
fn injected_switch_message(&self, account_name: &str) -> String {
format!("⚡ [Codex Switcher] Switched to {account_name}")
}
fn referral_unsupported_program(&self) -> &'static str {
"Unsupported invitation campaign"
}
fn referral_uncertain_send_suffix(&self) -> &'static str {
"; send result unconfirmed. Check invitation records before retrying"
}
fn referral_network_error(&self, error: &reqwest::Error, uncertain: &str) -> String {
format!("Invitation API network error: {error}{uncertain}")
}
fn referral_non_json_response(&self, status: u16, uncertain: &str) -> String {
format!("Invitation API returned HTTP {status} with non-JSON data. An official desktop login may be required; eligibility cannot be confirmed{uncertain}")
}
fn referral_upstream_rejected(&self) -> &'static str {
"Request rejected by upstream"
}
fn referral_failed_emails(&self, emails: &serde_json::Value) -> String {
format!("; emails: {emails}")
}
fn referral_unrecognized_response(&self, uncertain: &str) -> String {
format!("Unrecognized invitation API response{uncertain}")
}
fn referral_missing_items(&self) -> &'static str {
"Invitation response lacks items; records cannot be confirmed"
}
fn referral_no_available_campaign(&self) -> &'static str {
"No invitation campaign available for this account. Refresh eligibility"
}
}
96 changes: 74 additions & 22 deletions src-tauri/src/i18n/mod.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
mod en;
mod ru;
#[allow(dead_code)]
mod zh_cn;

use en::EnglishLocale;
use ru::RussianLocale;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::RwLock;
use zh_cn::ChineseLocale;

pub const APP_NAME: &str = "Codex Switcher";

trait BackendLocale {
trait BackendLocale: Sync {
fn oauth_success_html(&self) -> &'static str;
fn oauth_failure_response(&self) -> &'static str;
fn tray_tooltip_logged_out(&self) -> String;
Expand All @@ -32,34 +34,65 @@ trait BackendLocale {

const RUSSIAN_LOCALE: RussianLocale = RussianLocale;
const CHINESE_LOCALE: ChineseLocale = ChineseLocale;
const LOCALE_AUTO: u8 = 0;
const LOCALE_ZH_CN: u8 = 1;
const LOCALE_RU: u8 = 2;
static ACTIVE_LOCALE: AtomicU8 = AtomicU8::new(LOCALE_AUTO);
const ENGLISH_LOCALE: EnglishLocale = EnglishLocale;

struct BackendLocaleDefinition {
code: &'static str,
language_prefixes: &'static [&'static str],
locale: &'static dyn BackendLocale,
}

static BACKEND_LOCALES: &[BackendLocaleDefinition] = &[
BackendLocaleDefinition {
code: "en",
language_prefixes: &["en"],
locale: &ENGLISH_LOCALE,
},
BackendLocaleDefinition {
code: "zh-CN",
language_prefixes: &["zh"],
locale: &CHINESE_LOCALE,
},
BackendLocaleDefinition {
code: "ru",
language_prefixes: &["ru"],
locale: &RUSSIAN_LOCALE,
},
];

static ACTIVE_LOCALE: RwLock<Option<&'static dyn BackendLocale>> = RwLock::new(None);

fn locale_by_code(code: &str) -> Option<&'static dyn BackendLocale> {
BACKEND_LOCALES
.iter()
.find(|entry| entry.code.eq_ignore_ascii_case(code))
.map(|entry| entry.locale)
}

fn active_locale() -> &'static dyn BackendLocale {
match ACTIVE_LOCALE.load(Ordering::Relaxed) {
LOCALE_RU => &RUSSIAN_LOCALE,
LOCALE_ZH_CN => &CHINESE_LOCALE,
_ => {
let language = std::env::var("LANG").unwrap_or_default().to_lowercase();
if language.starts_with("ru") {
&RUSSIAN_LOCALE
} else {
&CHINESE_LOCALE
}
if let Ok(active) = ACTIVE_LOCALE.read() {
if let Some(locale) = *active {
return locale;
}
}
let language = std::env::var("LANG").unwrap_or_default().to_lowercase();
BACKEND_LOCALES
.iter()
.find(|entry| {
entry
.language_prefixes
.iter()
.any(|prefix| language.starts_with(prefix))
})
.map(|entry| entry.locale)
.unwrap_or(&CHINESE_LOCALE)
}

#[tauri::command]
pub fn set_app_locale(app: tauri::AppHandle, locale: String) -> Result<(), String> {
let value = match locale.as_str() {
"ru" => LOCALE_RU,
"zh-CN" => LOCALE_ZH_CN,
_ => return Err(format!("Unsupported application locale: {locale}")),
};
ACTIVE_LOCALE.store(value, Ordering::Relaxed);
let selected = locale_by_code(&locale)
.ok_or_else(|| format!("Unsupported application locale: {locale}"))?;
*ACTIVE_LOCALE.write().map_err(|error| error.to_string())? = Some(selected);
crate::tray::update_tray_native_menu(&app).map_err(|error| error.to_string())?;
crate::tray::update_tray_menu(&app);
Ok(())
Expand Down Expand Up @@ -192,3 +225,22 @@ pub fn referral_missing_items() -> &'static str {
pub fn referral_no_available_campaign() -> &'static str {
active_locale().referral_no_available_campaign()
}

#[cfg(test)]
mod tests {
use super::locale_by_code;

#[test]
fn english_native_ui_preserves_account_identity() {
let locale = locale_by_code("en").unwrap();
assert_eq!(locale.tray_show_main(), "Open main window");
assert!(locale.oauth_success_html().contains("lang=\"en\""));
assert!(locale
.tray_tooltip_account("生产账号A", 73.0, 42.0)
.contains("生产账号A"));
assert!(locale
.injected_switch_message("生产账号A")
.contains("生产账号A"));
assert!(locale_by_code("de").is_none());
}
}
Loading