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
1 change: 1 addition & 0 deletions e2e/readme-screenshots.screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const showcaseDocument = readFileSync(path.join(showcaseDirectory, "index.html")

const config = {
version: 1,
language: "cn",
pet: null,
petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] },
window: {
Expand Down
40 changes: 40 additions & 0 deletions e2e/settings.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";

const config = {
version: 1,
language: "cn",
pet: null,
petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] },
window: {
Expand Down Expand Up @@ -194,6 +195,28 @@ test("shows native pet directory paths and opens the default directory", async (
).__TAURI_TEST_COMMANDS__)).toContain("reveal_pet_directory");
});

test("switches languages without translating language names or native paths", async ({ page }) => {
await page.goto("/settings.html");
const language = page.locator("#language");
const directory = page.locator("#extra-directory");

await language.selectOption("en");
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.locator("#current-version")).toHaveText("v1.1.0");
await expect(language.locator('option[value="system"]')).toHaveText("Follow system");
await expect(language.locator('option[value="cn"]')).toHaveText("简体中文");
await expect(language.locator('option[value="en"]')).toHaveText("English");
await expect(directory).toHaveAttribute("placeholder", "C:\\Users\\Tester\\Downloads\\codex-pets");

await language.selectOption("cn");
await expect(page.getByRole("heading", { name: "通用" })).toBeVisible();
await expect(page.locator("#current-version")).toHaveText("v1.1.0");
await expect(language.locator('option[value="system"]')).toHaveText("跟随系统");
await expect(language.locator('option[value="cn"]')).toHaveText("简体中文");
await expect(language.locator('option[value="en"]')).toHaveText("English");
await expect(directory).toHaveAttribute("placeholder", "C:\\Users\\Tester\\Downloads\\codex-pets");
});

test("shows an error when the default pet directory cannot be opened", async ({ page }) => {
await page.goto("/settings.html");
await page.evaluate(() => (
Expand Down Expand Up @@ -271,6 +294,23 @@ test("downloads a signed update and offers installation", async ({ page }) => {
await expect(page.locator("#about-update-dot")).toBeHidden();
});

test("keeps the downloaded update state when switching languages", async ({ page }) => {
await page.goto("/settings.html#about");
await page.getByRole("button", { name: "检查更新" }).click();
await expect(page.locator("#update-status-card")).toHaveAttribute("data-state", "ready");

await page.getByRole("tab", { name: /通用/ }).click();
await page.locator("#language").selectOption("en");
await page.getByRole("tab", { name: /About/ }).click();

await expect(page.locator("#current-version")).toHaveText("v1.1.0");
await expect(page.locator("#update-status-title")).toHaveText("v1.2.0 is ready");
await expect(page.locator("#update-status-detail")).toHaveText(
"The update package was downloaded and passed signature verification. It is safe to install.",
);
await expect(page.getByRole("button", { name: "Install and Restart" })).toBeVisible();
});

test("keeps the update status tile still while the progress ring spins", async ({ page }) => {
await page.goto("/settings.html#about");
await page.locator("#update-status-card").evaluate((card) => {
Expand Down
1 change: 1 addition & 0 deletions e2e/status.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test";

const config = {
version: 1,
language: "cn",
pet: null,
petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] },
window: {
Expand Down
50 changes: 50 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub version: u8,
#[serde(default)]
pub language: LanguagePreference,
pub pet: Option<SelectedPet>,
pub pet_sources: PetSourcesConfig,
pub window: WindowConfig,
Expand All @@ -26,6 +28,15 @@ pub struct AppConfig {
pub claude_code: ClaudeCodeConfig,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum LanguagePreference {
#[default]
System,
En,
Cn,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectedPet {
Expand Down Expand Up @@ -117,6 +128,7 @@ impl Default for AppConfig {
fn default() -> Self {
Self {
version: 1,
language: LanguagePreference::System,
pet: None,
pet_sources: PetSourcesConfig {
scan_codex_builtin: true,
Expand Down Expand Up @@ -258,7 +270,44 @@ pub fn ensure_private_dir(path: &Path) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use serde::de::{Error as _, IgnoredAny, MapAccess, Visitor};
use serde::Deserializer as _;
use serde_json::json;
use std::{collections::HashSet, fmt};

struct UniqueTranslationKeys;

impl<'de> Visitor<'de> for UniqueTranslationKeys {
type Value = usize;

fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("an i18n JSON object with unique top-level keys")
}

fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut keys = HashSet::new();
while let Some(key) = map.next_key::<String>()? {
if !keys.insert(key.clone()) {
return Err(A::Error::custom(format!("duplicate i18n key: {key}")));
}
map.next_value::<IgnoredAny>()?;
}
Ok(keys.len())
}
}

#[test]
fn i18n_message_keys_are_unique() {
let source = include_str!("../../src/i18n/messages.json");
let mut deserializer = serde_json::Deserializer::from_str(source);
let key_count = deserializer
.deserialize_map(UniqueTranslationKeys)
.expect("i18n messages must be valid JSON with unique top-level keys");
assert!(key_count > 0);
}

#[test]
fn new_display_fields_have_backward_compatible_defaults() {
Expand Down Expand Up @@ -291,6 +340,7 @@ mod tests {
})).unwrap();
assert!(!app.claude_code.hooks_enabled);
assert!(app.claude_code.show_live_status);
assert_eq!(app.language, LanguagePreference::System);
}

#[cfg(unix)]
Expand Down
71 changes: 68 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod window_drag;
use base64::Engine;
use config::{AppConfig, WindowConfig};
use pet_catalog::CatalogResult;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
Expand Down Expand Up @@ -44,10 +44,27 @@ fn set_debug_dock_icon() {

struct TrayMenuState {
show_pet: CheckMenuItem<tauri::Wry>,
settings: MenuItem<tauri::Wry>,
always_on_top: CheckMenuItem<tauri::Wry>,
mouse_passthrough: CheckMenuItem<tauri::Wry>,
lock_position: CheckMenuItem<tauri::Wry>,
launch_at_login: CheckMenuItem<tauri::Wry>,
quit: MenuItem<tauri::Wry>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NativeMessages {
settings_title: String,
status_title: String,
debug_title: String,
show_pet: String,
settings: String,
always_on_top: String,
mouse_passthrough: String,
lock_position: String,
launch_at_login: String,
quit: String,
}

struct StatusWindowState(Mutex<f64>);
Expand Down Expand Up @@ -312,6 +329,45 @@ fn sync_tray_menu(app: &tauri::AppHandle, value: &WindowConfig) {
}
}

#[tauri::command]
fn sync_native_i18n(app: tauri::AppHandle, value: NativeMessages) -> Result<(), String> {
if let Some(menu) = app.try_state::<TrayMenuState>() {
menu.show_pet
.set_text(value.show_pet)
.map_err(|error| error.to_string())?;
menu.settings
.set_text(value.settings)
.map_err(|error| error.to_string())?;
menu.always_on_top
.set_text(value.always_on_top)
.map_err(|error| error.to_string())?;
menu.mouse_passthrough
.set_text(value.mouse_passthrough)
.map_err(|error| error.to_string())?;
menu.lock_position
.set_text(value.lock_position)
.map_err(|error| error.to_string())?;
menu.launch_at_login
.set_text(value.launch_at_login)
.map_err(|error| error.to_string())?;
menu.quit
.set_text(value.quit)
.map_err(|error| error.to_string())?;
}
for (label, title) in [
("settings", value.settings_title),
("status", value.status_title),
("pet-debug", value.debug_title),
] {
if let Some(window) = app.get_webview_window(label) {
window
.set_title(&title)
.map_err(|error| error.to_string())?;
}
}
Ok(())
}

fn toggle_window_setting(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
let mut value = config::load()?;
capture_main_position(app, &mut value.window)?;
Expand Down Expand Up @@ -545,11 +601,17 @@ fn probe_hook(agent: String) -> Result<hook_server::HookRuntimeStatus, String> {

fn show_aux_window(app: &tauri::AppHandle, kind: &str) -> Result<(), String> {
let (label, url, title, width, height) = match kind {
"settings" => ("settings", "settings.html", "Agent Cat 设置", 920.0, 720.0),
"settings" => (
"settings",
"settings.html",
"Agent Cat Settings",
920.0,
720.0,
),
"pet-debug" => (
"pet-debug",
"pet-debug.html",
"Agent Cat 动画测试器",
"Agent Cat Animation Tester",
980.0,
780.0,
),
Expand Down Expand Up @@ -633,10 +695,12 @@ fn setup_tray(app: &tauri::App, value: &AppConfig) -> Result<(), String> {
.map_err(|error| error.to_string())?;
app.manage(TrayMenuState {
show_pet,
settings,
always_on_top,
mouse_passthrough,
lock_position,
launch_at_login,
quit,
});
let mut builder = TrayIconBuilder::with_id("agent-cat-tray")
.menu(&menu)
Expand Down Expand Up @@ -736,6 +800,7 @@ pub fn run() {
apply_window_settings,
apply_config_preview,
sync_status_window,
sync_native_i18n,
save_main_position,
reset_main_position,
autostart_status,
Expand Down
27 changes: 27 additions & 0 deletions src/i18n/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import messages from "./messages.json";
import { getLanguage, resolveLanguage, setLanguage, t } from ".";

describe("i18n", () => {
it("contains complete English and Chinese translations", () => {
for (const entry of Object.values(messages)) {
expect(entry.en.trim()).not.toBe("");
expect(entry.cn.trim()).not.toBe("");
}
});

it("resolves system Chinese variants and defaults other languages to English", () => {
expect(resolveLanguage("system", "zh-CN")).toBe("cn");
expect(resolveLanguage("system", "zh-TW")).toBe("cn");
expect(resolveLanguage("system", "en-US")).toBe("en");
expect(resolveLanguage("cn", "en-US")).toBe("cn");
});

it("translates and interpolates named parameters", () => {
setLanguage("cn");
expect(getLanguage()).toBe("cn");
expect(t("{agent} Connection", { agent: "Codex" })).toBe("Codex 连接");
setLanguage("en");
expect(t("{agent} Connection", { agent: "Codex" })).toBe("Codex Connection");
});
});
Loading
Loading