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
28 changes: 26 additions & 2 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager, WindowEvent};

const TRAY_ID: &str = "codex_plus_tray";

static APP_EXITING: AtomicBool = AtomicBool::new(false);
const TRAY_MENU_SHOW: &str = "tray_show_main";
const TRAY_MENU_QUIT: &str = "tray_quit_app";
Expand Down Expand Up @@ -107,7 +109,8 @@ pub fn run() {
commands::apply_pure_api_injection,
commands::clear_relay_injection,
manager_exit_app,
manager_hide_to_tray
manager_hide_to_tray,
update_tray_labels
])
.run(tauri::generate_context!());
if let Err(error) = run_result {
Expand All @@ -125,7 +128,7 @@ fn install_tray<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Result<()> {
let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出程序", true, None::<&str>)?;
let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?;

let mut tray_builder = TrayIconBuilder::new()
let mut tray_builder = TrayIconBuilder::with_id(TRAY_ID)
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
Expand Down Expand Up @@ -195,6 +198,27 @@ fn manager_hide_to_tray<R: tauri::Runtime>(window: tauri::WebviewWindow<R>) {
let _ = window.hide();
}

#[tauri::command]
fn update_tray_labels<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
show_label: String,
quit_label: String,
window_title: String,
) {
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let show_item = MenuItem::with_id(&app, TRAY_MENU_SHOW, &show_label, true, None::<&str>);
let quit_item = MenuItem::with_id(&app, TRAY_MENU_QUIT, &quit_label, true, None::<&str>);
if let (Ok(show), Ok(quit)) = (show_item, quit_item) {
if let Ok(menu) = Menu::with_items(&app, &[&show, &quit]) {
let _ = tray.set_menu(Some(menu));
}
}
}
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_title(&window_title);
}
}

fn show_main_window<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize();
Expand Down
1,146 changes: 583 additions & 563 deletions apps/codex-plus-manager/src/App.tsx

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions apps/codex-plus-manager/src/components/ProviderPresetSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import type { ProviderPreset, RelayProtocol } from "../presets";
import { PRESETS } from "../presets";
import { t, tf } from "@/i18n";

export type RelayProfile = {
id: string;
Expand All @@ -26,10 +27,10 @@ export type RelayProfile = {
export type PresetPatch = Partial<RelayProfile>;

const categoryLabels: Record<string, string> = {
official: "官方",
cn_official: "中国官方",
aggregator: "聚合/中转",
third_party: "第三方",
official: t("官方"),
cn_official: t("中国官方"),
aggregator: t("聚合/中转"),
third_party: t("第三方"),
};

const initialFor = (name: string): string => {
Expand Down Expand Up @@ -86,21 +87,21 @@ export function ProviderPresetSelector({
type="button"
>
<span className="preset-toggle-label">
从预设模板创建
{t("从预设模板创建")}
<span className="preset-toggle-count">
{collapsed ? `${PRESETS.length} 个供应商` : ""}
{collapsed ? tf("{0} 个供应商", [PRESETS.length]) : ""}
</span>
</span>
<span className="preset-toggle-arrow">{collapsed ? "▾" : "▴"}</span>
</button>

{!collapsed && (
<div className="preset-grid" role="region" aria-label="供应商预设列表">
<div className="preset-grid" role="region" aria-label={t("供应商预设列表")}>
<div className="preset-search">
<span className="preset-search-icon">⌕</span>
<input
className="preset-search-input"
placeholder="搜索供应商…"
placeholder={t("搜索供应商…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
Expand All @@ -109,7 +110,7 @@ export function ProviderPresetSelector({

{filtered.length === 0 && (
<div className="preset-empty">
没有匹配「{query}」的供应商
{t("没有匹配「")}{query}{t("」的供应商")}
</div>
)}

Expand Down
801 changes: 801 additions & 0 deletions apps/codex-plus-manager/src/i18n-en.ts

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions apps/codex-plus-manager/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Lightweight source-text-keyed i18n for the Codex++ manager UI.
//
// The app is authored in Chinese. Every user-facing Chinese literal is wrapped
// with `t("中文")` (plain strings) or `tf("前缀 {0}", [expr])` (interpolated
// strings) by tools/i18n-codemod.mjs. When the active language is English we
// look the source text up in the English dictionary; otherwise we return the
// original Chinese, so Chinese stays the zero-overhead default.
//
// Language is resolved once at module load. Many Chinese literals live in
// module-level constants (route tables, preset labels, …) that evaluate a
// single time at import, so a live in-place swap can't reach them. Switching
// language therefore persists the choice and reloads the webview, which is
// instant for a local Tauri window and guarantees every literal — module-level
// or render-level — re-evaluates under the new language.

import { EN_BACKEND, EN_BACKEND_PATTERNS, EN_PLAIN, EN_TEMPLATE } from "@/i18n-en";

export type Language = "zh" | "en";

const STORAGE_KEY = "codex-plus-lang";

function resolveInitialLanguage(): Language {
try {
return window.localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "zh";
} catch {
return "zh";
}
}

// Resolved once per webview load. Do not mutate at runtime — use setLanguage,
// which persists and reloads so module-level literals pick up the change.
const LANG: Language = resolveInitialLanguage();

export function getLanguage(): Language {
return LANG;
}

/** Translate a plain Chinese literal. Falls back to the source text. */
export function t(zh: string): string {
if (LANG !== "en") return zh;
const plain = EN_PLAIN[zh] ?? EN_BACKEND[zh];
if (plain) return plain;
for (const [re, replacement] of EN_BACKEND_PATTERNS) {
if (re.test(zh)) return zh.replace(re, replacement);
}
return zh;
}

/**
* Translate an interpolated literal. `key` carries `{0}`,`{1}`… placeholders in
* the original (Chinese) order; `args` are the runtime values for each. In
* Chinese we substitute into the key itself; in English we substitute into the
* looked-up template (also falling back to the key).
*/
export function tf(key: string, args: Array<string | number>): string {
const template = LANG === "en" ? EN_TEMPLATE[key] ?? key : key;
return template.replace(/\{(\d+)\}/g, (match, index) => {
const value = args[Number(index)];
return value === undefined || value === null ? match : String(value);
});
}

/** Persist a new language and reload so every literal re-evaluates under it. */
export function setLanguage(language: Language): void {
try {
window.localStorage.setItem(STORAGE_KEY, language);
} catch {
// Ignore storage failures; the reload below simply keeps the old value.
}
window.location.reload();
}

/** Flip between Chinese and English. */
export function toggleLanguage(): void {
setLanguage(LANG === "en" ? "zh" : "en");
}
203 changes: 203 additions & 0 deletions tools/i18n-codemod.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// i18n codemod for codex-plus-manager.
//
// Wraps every Chinese (CJK) UI string in the React frontend with a translation
// helper:
// - plain string / no-substitution template -> t("中文")
// - template literal with ${...} -> tf("前缀 {0} 后缀", [expr0, ...])
// - JSX text -> {t("中文")} (whitespace preserved)
// JSX attribute string values are wrapped in braces: title={t("...")}.
//
// Comments are never touched (they are not runtime-visible and need no toggle).
//
// Edits never overlap: a node that gets wrapped is not descended into. Chinese
// nested inside a template's ${...} interpolation is still translated, because
// each interpolated expression is recursively transformed when the tf() call is
// built (see transform()).
//
// Usage:
// node tools/i18n-codemod.mjs # dry run: writes tools/i18n-keys.json
// node tools/i18n-codemod.mjs --write # apply edits in place
//
// The script loads TypeScript from the manager app's node_modules.

import { createRequire } from "node:module";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const appRoot = path.join(repoRoot, "apps", "codex-plus-manager");
const require = createRequire(path.join(appRoot, "package.json"));
const ts = require("typescript");

const WRITE = process.argv.includes("--write");

// model-windows.ts is intentionally excluded: its only Chinese string is a
// test-only error message, and model-windows.test.ts imports the module in
// isolation under `node --test`, where an "@/i18n" dependency would not resolve.
const FILES = [
"src/App.tsx",
"src/components/ProviderPresetSelector.tsx",
];

const CJK = /[㐀-䶿一-鿿 -〿＀-￯]/;
const hasCjk = (s) => CJK.test(s);

// Collected dictionary keys across all files.
const plainKeys = new Set();
const templateKeys = new Set();

function isPropertyNamePosition(node) {
const p = node.parent;
if (!p) return false;
if (ts.isPropertyAssignment(p) && p.name === node) return true;
if (ts.isComputedPropertyName(p)) return true;
if (ts.isImportDeclaration(p) || ts.isExportDeclaration(p)) return true;
if (ts.isImportSpecifier(p) || ts.isExportSpecifier(p)) return true;
if (ts.isModuleDeclaration(p)) return true;
return false;
}

function isAlreadyWrapped(node) {
const p = node.parent;
if (p && ts.isCallExpression(p) && ts.isIdentifier(p.expression)) {
if (p.expression.text === "t" || p.expression.text === "tf") return true;
}
return false;
}

function inJsxAttribute(node) {
const p = node.parent;
return p && ts.isJsxAttribute(p) && p.initializer === node;
}

// State for the file currently being processed.
let sourceText = "";
let needsImport = false;

/**
* Collect non-overlapping wrapping edits within `node`'s subtree. Each edit is
* { start, end, text } in absolute source offsets. A wrapped node is never
* descended into; template interpolations are handled via recursive transform.
*/
function collect(node, edits) {
if (ts.isJsxText(node)) {
const raw = node.getText();
if (hasCjk(raw)) {
const lead = raw.match(/^\s*/)[0];
const trail = raw.match(/\s*$/)[0];
const core = raw.slice(lead.length, raw.length - trail.length);
if (core && hasCjk(core)) {
plainKeys.add(core);
edits.push({
start: node.getStart(),
end: node.getEnd(),
text: `${lead}{t(${JSON.stringify(core)})}${trail}`,
});
needsImport = true;
}
}
return;
}

if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && hasCjk(node.text)) {
if (!isPropertyNamePosition(node) && !isAlreadyWrapped(node)) {
plainKeys.add(node.text);
const call = `t(${JSON.stringify(node.text)})`;
edits.push({
start: node.getStart(),
end: node.getEnd(),
text: inJsxAttribute(node) ? `{${call}}` : call,
});
needsImport = true;
}
return; // string literals carry no CJK-bearing children
}

if (ts.isTemplateExpression(node) && hasCjk(node.getText()) && !isAlreadyWrapped(node)) {
let key = node.head.text;
const args = [];
node.templateSpans.forEach((span, i) => {
key += `{${i}}`;
key += span.literal.text;
// Recursively transform the interpolated expression so nested CJK (e.g.
// a ternary with Chinese branches) is translated inside the tf() arg.
args.push(transform(span.expression));
});
templateKeys.add(key);
const call = `tf(${JSON.stringify(key)}, [${args.join(", ")}])`;
edits.push({
start: node.getStart(),
end: node.getEnd(),
text: inJsxAttribute(node) ? `{${call}}` : call,
});
needsImport = true;
return; // interpolations already handled recursively above
}

ts.forEachChild(node, (child) => collect(child, edits));
}

/** Return the fully-transformed source text for a single subtree. */
function transform(node) {
const start = node.getStart();
const end = node.getEnd();
const edits = [];
collect(node, edits);
edits.sort((a, b) => b.start - a.start);
let out = sourceText.slice(start, end);
for (const e of edits) {
out = out.slice(0, e.start - start) + e.text + out.slice(e.end - start);
}
return out;
}

function processFile(relPath) {
const abs = path.join(appRoot, relPath);
sourceText = readFileSync(abs, "utf8");
needsImport = false;
const sf = ts.createSourceFile(abs, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);

const edits = [];
collect(sf, edits);

if (!WRITE) return edits.length;

edits.sort((a, b) => b.start - a.start);
let out = sourceText;
for (const e of edits) {
out = out.slice(0, e.start) + e.text + out.slice(e.end);
}

if (needsImport && !/from "@\/i18n"/.test(out)) {
const importStmt = `import { t, tf } from "@/i18n";`;
const lastImport = [...out.matchAll(/^import .*?;[ \t]*$/gms)].pop();
if (lastImport) {
const insertAt = lastImport.index + lastImport[0].length;
out = out.slice(0, insertAt) + "\n" + importStmt + out.slice(insertAt);
} else {
out = importStmt + "\n" + out;
}
}

writeFileSync(abs, out, "utf8");
return edits.length;
}

let total = 0;
for (const f of FILES) {
const n = processFile(f);
total += n;
console.log(`${f}: ${n} edits`);
}

const keysPath = path.join(__dirname, "i18n-keys.json");
writeFileSync(
keysPath,
JSON.stringify({ plain: [...plainKeys].sort(), template: [...templateKeys].sort() }, null, 2),
"utf8",
);
console.log(`\nTotal edits: ${total}`);
console.log(`Plain keys: ${plainKeys.size}, Template keys: ${templateKeys.size}`);
console.log(`Keys written to ${keysPath}`);
Loading