From 5929e238762619c3c48c11f508176b656d479d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 24 Aug 2026 17:25:05 +0800 Subject: [PATCH 1/4] feat: add bilingual translation provider ecosystem --- .github/workflows/release.yml | 5 +- README.md | 48 +- README.zh-CN.md | 79 +++ src-tauri/Cargo.lock | 26 + src-tauri/Cargo.toml | 10 +- .../resources/textbooks/starter-en-zh.sqlite3 | Bin 0 -> 12288 bytes src-tauri/src/commands.rs | 80 ++- src-tauri/src/contracts.rs | 39 +- src-tauri/src/overlay.rs | 45 +- src-tauri/src/services/credentials.rs | 136 ++++- src-tauri/src/services/settings.rs | 70 ++- src-tauri/src/services/textbooks.rs | 69 ++- src-tauri/src/services/translation.rs | 478 +++++++++++++++++- src-tauri/src/tray.rs | 164 +++++- src-tauri/tauri.conf.json | 2 +- src/app/App.tsx | 27 +- .../context/ContextualOverlay.test.tsx | 7 + src/components/context/ContextualOverlay.tsx | 33 +- .../quick/QuickTranslatePanel.test.tsx | 8 + src/components/quick/QuickTranslatePanel.tsx | 20 +- .../settings/SettingsPanel.test.tsx | 25 +- src/components/settings/SettingsPanel.tsx | 174 ++++--- src/components/vocabulary/PracticeView.tsx | 42 +- .../vocabulary/RelatedWordsView.tsx | 28 +- src/components/vocabulary/TextbooksView.tsx | 74 +-- .../vocabulary/VocabularyWindow.test.tsx | 43 ++ .../vocabulary/VocabularyWindow.tsx | 50 +- src/contracts/fixtures.json | 7 +- src/contracts/ipc.ts | 15 +- src/i18n/catalog.test.ts | 16 + src/i18n/catalog.ts | 118 +++++ src/main.tsx | 33 +- src/styles/app.css | 48 +- src/styles/tokens.css | 8 + tools/release/audit-macos-bundle.sh | 35 ++ tools/textbooks/build-starter.sh | 9 + tools/textbooks/starter-en-zh.sql | 27 + 37 files changed, 1814 insertions(+), 284 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 src-tauri/resources/textbooks/starter-en-zh.sqlite3 create mode 100644 src/i18n/catalog.test.ts create mode 100644 src/i18n/catalog.ts create mode 100755 tools/release/audit-macos-bundle.sh create mode 100755 tools/textbooks/build-starter.sh create mode 100644 tools/textbooks/starter-en-zh.sql diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bba8bd..f103234 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,7 @@ jobs: codesign --verify --verbose=2 "$app" codesign -dv --verbose=2 "$app" 2>&1 | grep -q '^Signature=' \ || { echo "bundle is unsigned"; exit 1; } + tools/release/audit-macos-bundle.sh "$app" - uses: tauri-apps/tauri-action@v0 env: @@ -83,7 +84,9 @@ jobs: ``` On first run, grant Accessibility permission when prompted, then - add your own Google Cloud Translation API key in Settings. + choose Google, Baidu, or Microsoft Translator in Settings and add + that provider's credentials. Vocabulary Study includes an offline + English to Simplified Chinese starter textbook. The app is ad-hoc signed, so its code identity changes with every release. After installing an update, macOS drops the old grant: diff --git a/README.md b/README.md index b38044a..957e383 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ +
简体中文 · English
+ Highlight a word or a sentence in any application. When you release the mouse, a small translate button appears beside the selection; click it and the translation opens right there, without stealing focus from what you were @@ -42,8 +44,11 @@ tab. discovery, downloaded textbooks, and bidirectional practice. - **Stays out of the way.** Lives in the menu bar with no Dock icon, and can start at login. -- **Bring your own key.** Your Google Cloud Translation API key is stored in the - system Keychain and never reaches the interface layer. +- **English and Simplified Chinese UI.** Switch immediately from Settings or + the menu-bar menu, with CJK typography tuned for desktop reading. +- **Choose your translation service.** Google Cloud, Baidu Translate, and + Microsoft Translator (global or China cloud) share one provider-neutral + native boundary. Provider credentials stay in isolated system-vault slots. Languages: English, Chinese (Simplified), Japanese, Korean, French, German and Spanish, in any direction, plus automatic detection of the source. @@ -62,7 +67,8 @@ The study window brings four tools together: - **My wordbook** keeps the source and translation, language direction, lookup demand, recall score, pronunciation controls, and provenance-backed part-of-speech metadata. Entries can be corrected or removed. -- **Downloaded textbooks** provide five Simplified Chinese learning paths, +- **Downloaded textbooks** start with an embedded offline Simplified Chinese + starter and provide five additional learning paths, including everyday, academic, TOEIC, business, and general-reference vocabulary. A textbook hit is copied into your personal wordbook. - **Related words** combines roots and shared meanings across your wordbook and @@ -119,7 +125,9 @@ runs natively on both Apple Silicon and Intel Macs. ## Setup -Two things are needed before the first translation. +Two things are needed before the first online translation. Vocabulary Study is +usable immediately because its starter English → Simplified Chinese textbook is +embedded in the app. **1. Grant Accessibility permission.** The app reads the selected text and its on-screen position through the macOS Accessibility API. On first launch it will @@ -139,17 +147,23 @@ already running, which is why the warning can remain after the switch is on. > No screen capture is involved and no Screen Recording permission is requested. > The app reads only the text you selected, only after you finish selecting it. -**2. Add a Google Cloud Translation API key.** Open Settings from the menu-bar -menu and choose *Save API Key*. You will need a key from Google Cloud with the -Cloud Translation API enabled and billing configured. +**2. Choose and configure a translation provider.** Open Settings from the +menu-bar menu, choose a service, and use its native credential prompt: + +| Provider | Configuration | Network note | +| --- | --- | --- | +| Baidu Translate | APP ID + secret key | Recommended for users in mainland China | +| Microsoft Translator | Subscription key, cloud profile, optional region | China cloud requires an Azure China account | +| Google Cloud | Cloud Translation API key | Availability depends on the user's network | > [!TIP] -> Restrict the key to the Cloud Translation API and set a quota or budget in -> Google Cloud. The key is yours and every request is billed to your account. +> Restrict each credential to translation, and set the provider's quota or +> budget. Requests use only the service currently selected in Settings; the app +> never silently falls back to another online provider. -The key is entered in a native secure prompt and stored in the macOS Keychain. -It never passes through the user interface layer, is never written to the -settings file, and is sent only in the `x-goog-api-key` header over HTTPS. +Credentials are entered in native secure prompts and stored in the macOS +Keychain. They never pass through the WebView and are never written to the +settings file. ## Usage @@ -161,7 +175,7 @@ settings file, and is sent only in the `x-goog-api-key` header over HTTPS. | Click the speaker icon | Speaks the text with a system voice | | Click the menu-bar icon | Opens the quick translate panel for typed text | | Open Vocabulary Study from the menu-bar menu | Reviews your wordbook, textbooks, connections, and practice queue | -| Menu-bar right-click | Settings, enable/disable, start at login, quit | +| Menu-bar right-click | Settings, UI language, enable/disable, start at login, quit | Nothing is sent anywhere until you click the translate button. Selections in password and other secure fields are ignored. Eligible words and short lexical @@ -226,10 +240,12 @@ src-tauri/src/ docs/ Platform qualification and development notes ``` -The renderer is never given the API key, never reads the selection itself, and +The renderer is never given provider credentials, never reads the selection itself, and communicates only through a narrow set of validated commands. ## Acknowledgements -Built with [Tauri 2](https://tauri.app), React and Rust. Translation is provided -by the [Google Cloud Translation API](https://cloud.google.com/translate). +Built with [Tauri 2](https://tauri.app), React and Rust. Online translation +adapters support [Google Cloud](https://cloud.google.com/translate), +[Baidu Translate](https://fanyi-api.baidu.com/), and +[Microsoft Translator](https://learn.microsoft.com/azure/ai-services/translator/). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..270e2fd --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,79 @@ +
+ +桌面翻译 + +# 桌面翻译 + +**在桌面任意位置选中文字,就地完成翻译。** + +[English](README.md) · 简体中文 + +
+ +桌面翻译是一款轻量的 macOS 菜单栏应用。你在邮件、PDF、终端或浏览器中选中文字后, +应用会在选区旁显示一个小按钮;只有点击按钮后,文本才会发送到你选择的翻译服务。 + +## 主要功能 + +- 划词翻译不会抢走当前应用的焦点,也支持菜单栏中的快速输入面板。 +- 支持英文和简体中文界面,可从菜单栏或设置窗口即时切换。 +- 支持 Google Cloud、百度翻译和微软翻译;微软可选择全球或中国区云环境。 +- API 凭据通过原生安全输入框保存到系统钥匙串,不会进入 WebView 或设置文件。 +- 原文和译文均可使用系统语音朗读。 +- 自动建立本地个人词库,支持词性、发音、相关词、双向测试和记忆分数。 +- 内置一册离线的英汉入门词书;无需下载、无需配置 API 即可开始学习。 + +![个人词库、记忆分数、词性和发音按钮](docs/screenshots/vocabulary-study-wordbook.png) + + + + + + +
简体中文词书书架相关词与来源标记
+ +翻译查询遵循固定顺序:**个人词库 → 当前词书 → 你选择的在线服务**。应用不会在后台 +擅自切换在线服务。在线翻译失败时,会显示当前服务的错误,方便你检查网络、配额或凭据。 + +## 安装 + +从 [最新 Release](https://github.com/Ldsystem/desktop-translator/releases/latest) 下载 `.dmg`, +将应用拖入“应用程序”文件夹。安装包为通用版本,同时支持 Apple 芯片和 Intel Mac, +并已包含界面、数据库运行库和离线入门词书,不需要安装 Node.js、Python 或 SQLite。 + +当前 Release 使用 ad-hoc 签名,首次启动前需要执行: + +```sh +xattr -dr com.apple.quarantine "/Applications/Desktop Translator.app" +``` + +## 初次设置 + +1. 在“系统设置 → 隐私与安全性 → 辅助功能”中允许桌面翻译,然后从菜单栏退出并重新打开。 +2. 在设置中选择翻译服务: + +| 服务 | 需要配置 | 说明 | +| --- | --- | --- | +| 百度翻译 | APP ID、密钥 | 推荐中国大陆用户优先尝试 | +| 微软翻译 | 订阅密钥、云环境,可选区域 | 中国区需要 Azure 中国账号 | +| Google Cloud | Cloud Translation API 密钥 | 可用性取决于本地网络 | + +请在服务商后台限制 API 用途,并设置配额或预算。个人词库、练习结果和下载的词书都保存在 +本机,不会同步到云端;句子式文本只会翻译,不会加入词库。 + +## 开发 + +需要 Node.js 20+、pnpm 10+ 和稳定版 Rust: + +```sh +pnpm install +pnpm tauri dev +``` + +| 命令 | 用途 | +| --- | --- | +| `pnpm check` | TypeScript 检查、前端测试和构建 | +| `pnpm test:platform` | Rust 单元与集成测试 | +| `pnpm tauri build` | 生成自包含的 `.app` 和 `.dmg` | + +详细的架构、平台支持和权限说明请参阅 [英文 README](README.md)。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59262b9..6c9b3be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "crossbeam-channel", "html-escape", "keyring", + "md5", "opencc-fmmseg", "reqwest", "rusqlite", @@ -2394,6 +2395,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" @@ -3373,6 +3380,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -3535,6 +3543,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3786,6 +3800,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.22.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d509376..44fb7d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,13 +16,14 @@ tauri-build = { version = "2.6.3", features = [] } async-trait = "0.1.92" crossbeam-channel = "0.5.16" html-escape = "0.2.15" +md5 = "0.8.0" keyring = { version = "4.1.6", default-features = false, features = ["apple-native-keyring-store", "v1", "windows-native-keyring-store"] } opencc-fmmseg = "0.11.3" # `system-proxy` and `socks` are part of reqwest's defaults and must be kept # explicitly: without them the client only honours HTTP_PROXY-style variables, # which a GUI app launched from Finder never inherits. Users who reach Google # only through a local proxy would see every request time out. -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "socks", "system-proxy"] } +reqwest = { version = "0.13.4", default-features = false, features = ["form", "json", "rustls", "socks", "system-proxy"] } rusqlite = { version = "0.38.0", features = ["bundled"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" @@ -38,3 +39,10 @@ windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Globaliza [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +panic = "abort" +strip = "symbols" diff --git a/src-tauri/resources/textbooks/starter-en-zh.sqlite3 b/src-tauri/resources/textbooks/starter-en-zh.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..ee2b4c00c612c277630a995b9c04c1dab1966cf5 GIT binary patch literal 12288 zcmeHMTWl298D4|eE+yO}K_jXt0f_z+f(s8a1las*#G?zNG4dXLhYV@X|<8D^)$WIdgW? zKD3Y3tYmxVpYNRWpYNQR^M9Y;{N4^J2cn&-)-C3uYaKO?iVE+G(Wt}W5a7>3_+x(1 z_e%2u{;lA?|EI5lW6kilK6rP5|F|Qt75=h6HUl;THUl;THUl;THUl;THUl;THUl;T zHUlOD18Wutk;cY~cN=qJhYWI>sAOd^C#eeeb>Wsb+BfZPkM7>Ic}IJcyAf@Y!9DLnAWF(6yHqtD z-M({o`?mHsV4yprYB^C!5yZ-YRTV;{t*zqS3Y>5j-j_j=%jvt31tsZ!_kOhLe@eSs z+?V82*MOWX&(@?F{YBdRb=Ofx;4gvS25to|21Wxv3G5BL71$bB8(180`XBoL;Qzuu z?LX-s^7s1F{$2iO{44x5zJK@(-|u~&`#$p>^ZnQdd~fBT`jzXt>l{qb{@4uI4A>0V4A>0V4A>0(zcLVb-C0-faP+F$ zUj62Uxw|I=yUajZA3k238V>Gs)+2 z=WDk+8(?#@j0J`Jv&E^S;g_9DQ55XUb*SoIC$$D_e*IHzo1HV3*3*Jlf}r$bwv zOCTTn%7dm`1pcxd$e{7&XJVFg~@(6 zYn0<==MtDXj7o5Vg^8KDJ9l6>w26!Yv>pj|aB^55IvaeE^a*5;%FXv5GCue;xDkgm zkj=}v!t62Q)XDG*cpe()2HhP%D^5<&-5m)%kGs=A3P?S`7`ve#oDM!m+lpCL(T|+g zC(eX6U|2>|d!#flMn>oEO@y8`V^Fem&!3o_Klfqi85#o_Q3Lwm`}!w?;ivJq8BI*( zV9JHr{fLIA@EB?ai5!^n{MlQz>oEnFqz0YX9J``feB`_aG~}42Z|R5 z5D`z}p}K*nNJ^JJ`t!M4hicYhx@9103ZXmv1Rh)#m9D(l1&UKwiUVroxX?a7qJW7U<_r=|g8iYw+Nbl9KajpdUV9 zeDGOlHHmd2mLQe4iuWfXEjW8qkmTkEhe`IqRe1h;#7qV#`pD73*y*}vyx?6x0h%b! z&GZ)!eg@|mT#1pL`CMLu>wN|8nP3wMsj{s07H^)OA0G)lh6e}xGO{Qsa4*jf4%H>_ zE{8FoNdRiM!tBWWmBXPG6e=RoHh1p>{px6K9CHs8HQ%+Tc;mYM$!RonBOV8IcEWw7 z-@T?E8xF;sb#OT#6Mg#RE#t#u5LUAsvkpm@`-~Hx!bmuZIZI1Wne_k-&F{!`qycl0 zmO4A76x@~i#Pt05J&2AhV>jS>D7m?rVSV&4yb)T8GfjgYAgdYJi+=5R@KKyo8l>Px z)Zf3ZPmTwd;1F7$9MBI==(D%OkKhDSP(Vvc9!~sHVfJdc-VA18Tdml?zxdJ7@M0Q7 zWWeT=hmC`yp$Hy0rFNrjY)p+6@9nQ$geiitZunHp%oH!7T^O!&Mj#(4wB?MM1IC4M z7!1~ug!ZUv7SQK0Oha%ZqP?Myd{`60v~}bqISo^u?>`d?;$b`B4(OC}g^3~kllMY3 zG$u=s2J{-Zf3_xoTZ%oZl%5~BuOAqW_%VfIj|g8D@Nv{frpTMF)`wqdBJu;!qeh;q zpciK&Y8rg#;X_uucdy>#jI44v;C__*3S-xcQ#T*pnb2>K!ESm3gaCs+A6|RoeRFkC8p41Nv6enkD1QO}e0Dub$pXXo|X_iLsE;#4n?E0JQE7wI= zzv~^>dY8M}sGg}Ft=?0;xq6xKt?)pY7KVgQVYBc^)wfl@sk&TsuEtf%BX* z4hg@ZU1fyh~VD-{5k&NWGU#is>K0qYJ)SlF7ER z8nAgM#ZoJ>1Y1^%wY(-gwFLD`tFh9K%S*J@9m0kNgl0ucDV~K{Ez|lcZNUnK(iSYt z>W7wBXhyVLD8-l85Y5}^T*(1n3NAbJ6E6#Gbtrpu#+M=aQ-8%aVQr)ysC57cJ7x#S(udrt?gV3To8P>MQNST{1TlWIh;%3We0ZC7MM1g9zL?mpe3hx>t>n< zJ5@{3EX)qu=1nvYa+;OFOHa6EFA9&3Nhg!A@KWF&MHfW$#5M}6>hXOndn^Q} zf|_3t)-FdFO9wHW6(Z6o`n<3bFBcX^7R-`LiRXmJm!jTGrNcmGfJmR%2AUZWKMcid zq1dw~2J0LP!^xibGs5a6XuK@ZG8Tx}HnFE^PejTv5U*@vPcaOVEF8vCF!A+tG+3P@ zy~05Bn&_9h+)>1Z2>A_%TfM}7#6BK8v5r#7ZM4q&? z3F{gWTbw}Qpk<;Z)=K9=WCH{8Dx&#ux_c~vz;ITGXomP2I%lvCs|F6psfgHWIx0~N z7KjrN@fO+>t#TQtr0Q*2CA2l5*|Nfw;aG%}vax15Cj6HftVJytSDMCFniyPoS{SYl zjWwAVTyt6&t^|!gMspWQb!mb4YI9k_db0N>lTiP3kSCk^8_g>y@0R_*P*#XI5>0W+ z1M?&pltqY35pSfqQU8JAO8kgujJ^f1BVl1IeKXD z7^=*Ch(1E;!j1w%asEQ0o;|+AA7CKp4RD4*d@)U*S_VAKvJm1C6Gcn}3&ooXu|;%= zh@y= zEJ*Vr0-gg|dOlV|F+|Zb3@`8#0h*O5@hwz|=1=%799I22j zl6nIrFy?VM=77i!u%MPX5M5{@kUd}_ fEVCfKfKH7{ehb4}0I>=)@f0!vIFRKD@G|0G0YUwA literal 0 HcmV?d00001 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c915159..0f1e25c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,9 +16,9 @@ use crate::{ contracts::{ AppError, AppErrorCode, InstalledTextbook, PracticeDirection, PracticePreferences, RelatedWord, SelectionSnapshot, StudyPracticeOutcome, StudyPracticeQuestion, - TextbookCatalogItem, TextbookEntryPage, TextbookPromotionResult, TranslationRequest, - TranslationResult, UserSettings, ValidateContract, VocabularyEntry, VocabularyProvenance, - VocabularyRevision, VocabularyRevisionKind, + TextbookCatalogItem, TextbookEntryPage, TextbookPromotionResult, TranslationProviderId, + TranslationRequest, TranslationResult, UserSettings, ValidateContract, VocabularyEntry, + VocabularyProvenance, VocabularyRevision, VocabularyRevisionKind, }, coordinator::{CoordinatorEvent, OverlayState}, platform::{ @@ -26,16 +26,16 @@ use crate::{ SpeechAdapter, }, services::{ - credentials::{KeyringVault, VaultCredentialStore}, + credentials::{ProviderCredentialStore, ProviderSecretField}, settings::JsonSettingsStore, study::StudyService, textbooks::{curated_catalog, TextbookStore}, - translation::GoogleTranslationProvider, + translation::ProviderRouter, vocabulary::{ is_vocabulary_eligible, TextbookTranslationProvider, VocabularyStore, VocabularyTranslationProvider, }, - CredentialStore, SettingsStore, TranslationProvider, + SettingsStore, TranslationProvider, }, }; @@ -361,7 +361,8 @@ impl ApplicationCoordinator { /// Long-lived native services shared by validated commands. pub struct RuntimeState { settings: Arc, - credentials: Arc>, + credentials: Arc, + provider_router: Arc, coordinator: Arc, overlay: Arc, vocabulary: Arc, @@ -381,10 +382,10 @@ impl RuntimeState { .map_err(|_| internal_error("Application settings path is unavailable"))? .join("settings.json"); let settings = Arc::new(JsonSettingsStore::with_application_defaults(settings_path)); - let credentials = Arc::new(VaultCredentialStore::application_default()?); - let credential_provider: Arc = credentials.clone(); - let upstream: Arc = - Arc::new(GoogleTranslationProvider::new(credential_provider)?); + let credentials = Arc::new(ProviderCredentialStore::new()); + let settings_service: Arc = settings.clone(); + let provider_router = Arc::new(ProviderRouter::new(settings_service, credentials.clone())?); + let upstream: Arc = provider_router.clone(); let vocabulary_directory = app .path() .app_data_dir() @@ -394,6 +395,7 @@ impl RuntimeState { let database_path = vocabulary_directory.join("vocabulary.sqlite3"); let vocabulary = Arc::new(VocabularyStore::open(&database_path)?); let textbooks = Arc::new(TextbookStore::open(&database_path)?); + textbooks.ensure_bundled_starter(now_epoch_ms())?; let study = Arc::new(StudyService::open( &database_path, vocabulary.clone(), @@ -435,6 +437,7 @@ impl RuntimeState { Ok(Self { settings, credentials, + provider_router, coordinator, overlay, vocabulary, @@ -540,6 +543,8 @@ pub async fn save_settings( let _ = sync_start_at_login(&app, previous.start_at_login); return Err(error); } + let _ = app.emit("settings-changed", &settings); + crate::tray::refresh_window_titles(&app, settings.ui_locale); state.coordinator.update_policy(selection_policy(&settings)); if settings.enabled { if let Err(error) = crate::start_global_monitor(&app) { @@ -559,8 +564,12 @@ pub async fn save_settings( /// Reports only whether a key exists; the key never crosses IPC. #[tauri::command] -pub fn get_credential_status(state: State<'_, RuntimeState>) -> Result<&'static str, AppError> { - Ok(if state.credentials.get_api_key()?.is_some() { +pub fn get_credential_status( + state: State<'_, RuntimeState>, + provider: Option, +) -> Result<&'static str, AppError> { + let selected = provider.unwrap_or(state.settings.load()?.translation_provider); + Ok(if state.credentials.configured(selected)? { "ready" } else { "missing" @@ -569,29 +578,60 @@ pub fn get_credential_status(state: State<'_, RuntimeState>) -> Result<&'static /// Opens a native secure prompt and stores the entered key directly in the OS vault. #[tauri::command] -pub fn prompt_and_save_credential(state: State<'_, RuntimeState>) -> Result { +pub fn prompt_and_save_credential( + state: State<'_, RuntimeState>, + provider: TranslationProviderId, + field: String, +) -> Result { + let secret_field = match field.as_str() { + "api-key" => ProviderSecretField::ApiKey, + "app-id" if provider == TranslationProviderId::Baidu => ProviderSecretField::AppId, + _ => { + return Err(AppError::new( + AppErrorCode::InvalidCredential, + "Unsupported credential field.", + false, + )) + } + }; + let title = match (provider, secret_field) { + (TranslationProviderId::Baidu, ProviderSecretField::AppId) => "Baidu Translation APP ID", + (TranslationProviderId::Baidu, ProviderSecretField::ApiKey) => { + "Baidu Translation Secret Key" + } + (TranslationProviderId::Microsoft, _) => "Microsoft Translator Subscription Key", + _ => "Google Cloud Translation API Key", + }; let Some(mut api_key) = crate::credential_prompt::prompt_secure_text( - "Google Cloud Translation API Key", + title, "The key is stored directly in the operating-system credential vault.", )? else { return Ok(false); }; - let result = state.credentials.set_api_key(&api_key); + let result = state.credentials.set(provider, secret_field, &api_key); api_key.zeroize(); result.map(|_| true) } /// Validates the stored credential without returning credential material. #[tauri::command] -pub async fn test_credential(state: State<'_, RuntimeState>) -> Result<(), AppError> { - state.credentials.test_api_key().await +pub async fn test_credential( + state: State<'_, RuntimeState>, + provider: Option, +) -> Result<(), AppError> { + let selected = provider.unwrap_or(state.settings.load()?.translation_provider); + state.provider_router.test(selected).await } /// Removes the stored credential. #[tauri::command] -pub fn remove_credential(state: State<'_, RuntimeState>) -> Result<(), AppError> { - state.credentials.remove_api_key() +pub fn remove_credential( + state: State<'_, RuntimeState>, + provider: Option, +) -> Result<(), AppError> { + let selected = provider.unwrap_or(state.settings.load()?.translation_provider); + state.credentials.remove_provider(selected) } /// Translates only after the renderer's explicit Translate action. diff --git a/src-tauri/src/contracts.rs b/src-tauri/src/contracts.rs index 69ac64c..8a63272 100644 --- a/src-tauri/src/contracts.rs +++ b/src-tauri/src/contracts.rs @@ -126,6 +126,32 @@ pub enum Theme { Dark, } +/// Application chrome language. Lexical content keeps its own language codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum UiLocale { + #[serde(rename = "en")] + English, + #[serde(rename = "zh-CN")] + SimplifiedChinese, +} + +/// Online translation service selected by the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TranslationProviderId { + Google, + Baidu, + Microsoft, +} + +/// Microsoft Translator offers separate public and China sovereign clouds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MicrosoftCloud { + Global, + China, +} + /// Schema-versioned, non-secret application preferences. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -137,6 +163,11 @@ pub struct UserSettings { pub start_at_login: bool, pub theme: Theme, pub max_selection_code_points: usize, + pub ui_locale: UiLocale, + pub translation_provider: TranslationProviderId, + pub microsoft_cloud: MicrosoftCloud, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub microsoft_region: Option, } /// Validated request passed to a translation provider. @@ -488,13 +519,17 @@ impl ValidateContract for SelectionSnapshot { impl ValidateContract for UserSettings { fn validate(&self) -> Result<(), AppError> { - if self.schema_version != 1 + if self.schema_version != 2 || self.source_language.trim().is_empty() || self.target_language.trim().is_empty() || self.max_selection_code_points == 0 || u64::try_from(self.max_selection_code_points) .map(|value| value > JS_SAFE_INTEGER_MAX) .unwrap_or(true) + || self + .microsoft_region + .as_ref() + .is_some_and(|region| region.trim().is_empty() || region.len() > 64) { return Err(validation_error("settings violate schema constraints")); } @@ -610,7 +645,7 @@ mod tests { let fixtures: Fixtures = serde_json::from_str(raw).expect("fixtures must deserialize"); assert_eq!(fixtures.selection.id, 42); - assert_eq!(fixtures.settings.schema_version, 1); + assert_eq!(fixtures.settings.schema_version, 2); assert_eq!(fixtures.translation_request.selection_id, 42); assert_eq!(fixtures.translation_result.selection_id, 42); assert_eq!(fixtures.study_practice_question.choices[0].value, "短暂的"); diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 42f16f0..d465780 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -244,9 +244,7 @@ impl OverlayController for TauriOverlayController { .lock() .expect("overlay session") .present(selection.clone()); - window - .show() - .map_err(|_| overlay_error("Overlay could not be shown"))?; + show_overlay_without_activation(&window)?; if let Some(selection) = ready_selection { emit_selection(&self.app, selection)?; } @@ -401,6 +399,47 @@ fn ensure_overlay(app: &AppHandle) -> Result { Ok(window) } +#[cfg(not(target_os = "macos"))] +fn show_overlay_without_activation(window: &WebviewWindow) -> Result<(), AppError> { + window + .show() + .map_err(|_| overlay_error("Overlay could not be shown")) +} + +/// Applies the AppKit non-activating style and orders the contextual panel +/// above the current app without bringing Desktop Translator's study window +/// to the foreground. Both selectors must run on AppKit's main thread. +#[cfg(target_os = "macos")] +fn show_overlay_without_activation(window: &WebviewWindow) -> Result<(), AppError> { + use std::{sync::mpsc, time::Duration}; + + use crate::platform::macos::{ + configure_nonactivating_panel, order_front_without_activation, NonActivatingPanelPolicy, + }; + + let panel = window + .ns_window() + .map_err(|_| overlay_error("Native overlay handle is unavailable"))? + as usize; + let (sender, receiver) = mpsc::sync_channel(1); + window + .run_on_main_thread(move || { + let panel = panel as *mut std::ffi::c_void; + // SAFETY: Tauri supplied a live NSWindow-compatible object and the + // closure is dispatched on AppKit's main thread. + let result = unsafe { + configure_nonactivating_panel(panel, NonActivatingPanelPolicy::default()) + .and_then(|_| order_front_without_activation(panel)) + }; + let _ = sender.send(result); + }) + .map_err(|_| overlay_error("Overlay could not be scheduled for display"))?; + receiver + .recv_timeout(Duration::from_secs(2)) + .map_err(|_| overlay_error("Overlay display timed out"))? + .map_err(|_| overlay_error("Overlay could not be shown")) +} + #[cfg(target_os = "windows")] fn position_overlay( window: &WebviewWindow, diff --git a/src-tauri/src/services/credentials.rs b/src-tauri/src/services/credentials.rs index 1e78ed6..c00de9a 100644 --- a/src-tauri/src/services/credentials.rs +++ b/src-tauri/src/services/credentials.rs @@ -8,10 +8,144 @@ use async_trait::async_trait; use reqwest::Client; use crate::{ - contracts::{AppError, AppErrorCode}, + contracts::{AppError, AppErrorCode, TranslationProviderId}, services::CredentialStore, }; +/// Provider-specific secret slots. They are deliberately absent from settings and IPC results. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderSecretField { + ApiKey, + AppId, +} + +impl ProviderSecretField { + fn account(self) -> &'static str { + match self { + Self::ApiKey => "translation-api-key", + Self::AppId => "translation-app-id", + } + } +} + +/// OS-vault facade that isolates every provider and credential field. +pub struct ProviderCredentialStore; + +impl ProviderCredentialStore { + pub fn new() -> Self { + Self + } + + fn vault( + &self, + provider: TranslationProviderId, + field: ProviderSecretField, + ) -> Result { + let service = match provider { + TranslationProviderId::Google => DEFAULT_VAULT_SERVICE.to_owned(), + TranslationProviderId::Baidu => "com.desktop-translator.baidu-translation".to_owned(), + TranslationProviderId::Microsoft => { + "com.desktop-translator.microsoft-translator".to_owned() + } + }; + KeyringVault::new(service, field.account()) + } + + pub fn set( + &self, + provider: TranslationProviderId, + field: ProviderSecretField, + secret: &str, + ) -> Result<(), AppError> { + let value = secret.trim(); + if value.is_empty() || value.len() > 4096 { + return Err(AppError::new( + AppErrorCode::InvalidCredential, + "The credential value is empty or too long.", + false, + )); + } + self.vault(provider, field)?.set_secret(value) + } + + pub fn get( + &self, + provider: TranslationProviderId, + field: ProviderSecretField, + ) -> Result, AppError> { + self.vault(provider, field)?.get_secret() + } + + pub fn remove_provider(&self, provider: TranslationProviderId) -> Result<(), AppError> { + self.vault(provider, ProviderSecretField::ApiKey)? + .remove_secret()?; + if provider == TranslationProviderId::Baidu { + self.vault(provider, ProviderSecretField::AppId)? + .remove_secret()?; + } + Ok(()) + } + + pub fn configured(&self, provider: TranslationProviderId) -> Result { + let key = self + .get(provider, ProviderSecretField::ApiKey)? + .is_some_and(|value| !value.trim().is_empty()); + let app_id = provider != TranslationProviderId::Baidu + || self + .get(provider, ProviderSecretField::AppId)? + .is_some_and(|value| !value.trim().is_empty()); + Ok(key && app_id) + } +} + +impl Default for ProviderCredentialStore { + fn default() -> Self { + Self::new() + } +} + +/// Google-compatible view used by the existing adapter without weakening its narrow trait. +pub struct GoogleCredentialView { + store: Arc, +} + +impl GoogleCredentialView { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CredentialStore for GoogleCredentialView { + fn set_api_key(&self, api_key: &str) -> Result<(), AppError> { + self.store.set( + TranslationProviderId::Google, + ProviderSecretField::ApiKey, + api_key, + ) + } + + fn get_api_key(&self) -> Result, AppError> { + self.store + .get(TranslationProviderId::Google, ProviderSecretField::ApiKey) + } + + async fn test_api_key(&self) -> Result<(), AppError> { + let key = self.get_api_key()?.ok_or_else(|| { + AppError::new( + AppErrorCode::MissingCredential, + "Add a Google API key.", + false, + ) + })?; + GoogleCredentialValidator::new()?.validate(&key).await + } + + fn remove_api_key(&self) -> Result<(), AppError> { + self.store.remove_provider(TranslationProviderId::Google) + } +} + use super::translation::validate_google_api_key; const DEFAULT_VAULT_SERVICE: &str = "com.desktop-translator.google-cloud-translation"; diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 45fed0e..7b6b3f8 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -12,12 +12,15 @@ use std::{ use serde::Deserialize; use crate::{ - contracts::{AppError, AppErrorCode, Theme, UserSettings, ValidateContract}, + contracts::{ + AppError, AppErrorCode, MicrosoftCloud, Theme, TranslationProviderId, UiLocale, + UserSettings, ValidateContract, + }, services::SettingsStore, }; /// Current on-disk settings schema. -pub const SETTINGS_SCHEMA_VERSION: u8 = 1; +pub const SETTINGS_SCHEMA_VERSION: u8 = 2; const MAX_SETTINGS_BYTES: u64 = 64 * 1024; #[derive(Default, Deserialize)] @@ -37,6 +40,17 @@ struct LegacySettingsV0 { max_selection_code_points: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacySettingsV1 { + enabled: bool, + source_language: String, + target_language: String, + start_at_login: bool, + theme: Theme, + max_selection_code_points: usize, +} + /// Returns safe first-run defaults containing no user content. pub fn default_user_settings() -> UserSettings { UserSettings { @@ -47,6 +61,10 @@ pub fn default_user_settings() -> UserSettings { start_at_login: false, theme: Theme::System, max_selection_code_points: 5_000, + ui_locale: UiLocale::English, + translation_provider: TranslationProviderId::Google, + microsoft_cloud: MicrosoftCloud::Global, + microsoft_region: None, } } @@ -114,6 +132,25 @@ impl SettingsStore for JsonSettingsStore { } Ok(settings) } + Some(1) => { + let legacy: LegacySettingsV1 = match serde_json::from_value(value) { + Ok(legacy) => legacy, + Err(_) => return self.replace_with_defaults(), + }; + let migrated = UserSettings { + schema_version: SETTINGS_SCHEMA_VERSION, + enabled: legacy.enabled, + source_language: legacy.source_language, + target_language: legacy.target_language, + start_at_login: legacy.start_at_login, + theme: legacy.theme, + max_selection_code_points: legacy.max_selection_code_points, + ..self.defaults.clone() + }; + migrated.validate().map_err(|_| settings_data_error())?; + self.save(&migrated)?; + Ok(migrated) + } Some(0) | None => { let legacy: LegacySettingsV0 = match serde_json::from_value(value) { Ok(legacy) => legacy, @@ -336,6 +373,35 @@ mod tests { assert_eq!(persisted, migrated); } + #[test] + fn schema_one_migrates_to_english_google_defaults_without_losing_preferences() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("settings.json"); + fs::write( + &path, + r#"{ + "schemaVersion": 1, + "enabled": false, + "sourceLanguage": "en", + "targetLanguage": "zh-CN", + "startAtLogin": true, + "theme": "dark", + "maxSelectionCodePoints": 2048 + }"#, + ) + .expect("legacy fixture"); + let settings = JsonSettingsStore::with_application_defaults(&path) + .load() + .expect("migration"); + assert_eq!(settings.schema_version, 2); + assert_eq!(settings.ui_locale, UiLocale::English); + assert_eq!(settings.translation_provider, TranslationProviderId::Google); + assert_eq!(settings.microsoft_cloud, MicrosoftCloud::Global); + assert_eq!(settings.max_selection_code_points, 2048); + assert!(settings.start_at_login); + assert_eq!(settings.theme, Theme::Dark); + } + #[test] fn malformed_data_is_replaced_without_retaining_raw_content() { let directory = tempfile::tempdir().expect("temp directory"); diff --git a/src-tauri/src/services/textbooks.rs b/src-tauri/src/services/textbooks.rs index 0b29d7f..ddcf170 100644 --- a/src-tauri/src/services/textbooks.rs +++ b/src-tauri/src/services/textbooks.rs @@ -31,6 +31,25 @@ const CURRENT_IMPORT_REVISION: i64 = 1; const WIKDICT_URL: &str = "https://download.wikdict.com/dictionaries/sqlite/2_2026-06/en-zh.sqlite3"; const WIKDICT_SHA256: &str = "16cf69dc8037a8d4dc6bde260142bf0181f9ff0a008d457f26452f1d80ca5ecd"; +const BUNDLED_STARTER_ID: &str = "starter-en-zh-cn-1"; +const BUNDLED_STARTER_BYTES: &[u8] = + include_bytes!("../../resources/textbooks/starter-en-zh.sqlite3"); + +fn bundled_starter_item() -> TextbookCatalogItem { + TextbookCatalogItem { + id: BUNDLED_STARTER_ID.into(), + title: "Everyday English Starter · 日常英语入门".into(), + source_language: "en".into(), + target_language: "zh-CN".into(), + version: "1.0 bundled".into(), + download_url: "https://download.wikdict.com/bundled/starter-en-zh.sqlite3".into(), + expected_bytes: 12_288, + sha256: "5c99f10fb3b4b1eea5212a81cc20e87b391eac2cd60cc2277fc1795feab0acd9".into(), + license: "CC0-1.0".into(), + attribution: "Desktop Translator contributors".into(), + source_url: "https://github.com/Ldsystem/desktop-translator".into(), + } +} #[derive(Clone, Copy)] enum ScopeFormat { @@ -86,6 +105,10 @@ fn catalog_definitions() -> Vec { let wikdict = "WikDict, Wiktionary and DBnary contributors"; let ngsl_source = "https://www.newgeneralservicelist.com/word-lists"; vec![ + CatalogDefinition { + item: bundled_starter_item(), + scope: CatalogScope::All, + }, CatalogDefinition { item: catalog_item( "wikdict-en-zh-2026-06", @@ -188,6 +211,17 @@ impl TextbookStore { }) } + /// Installs the small offline starter on first run and makes it active when no book is active. + pub fn ensure_bundled_starter(&self, now_ms: u64) -> Result<(), AppError> { + if self.get_installed(BUNDLED_STARTER_ID)?.is_none() { + self.install_verified_bytes(&bundled_starter_item(), BUNDLED_STARTER_BYTES, now_ms)?; + } + if !self.list_installed()?.iter().any(|book| book.active) { + self.set_active(Some(BUNDLED_STARTER_ID))?; + } + Ok(()) + } + /// Validates a pinned WikDict SQLite artifact before atomically replacing a version. pub fn install_sqlite( &self, @@ -217,6 +251,12 @@ impl TextbookStore { .find(|definition| definition.item.id == catalog_id) .ok_or_else(|| internal("Textbook catalog item was not found"))?; let catalog = definition.item; + if catalog.id == BUNDLED_STARTER_ID { + self.ensure_bundled_starter(now_ms)?; + return self + .get_installed(BUNDLED_STARTER_ID)? + .ok_or_else(|| internal("Bundled textbook could not be installed")); + } validate_catalog(&catalog)?; let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -1596,23 +1636,42 @@ mod tests { } #[test] - fn curated_catalog_offers_five_pinned_simplified_chinese_choices() { + fn curated_catalog_offers_bundled_and_five_pinned_simplified_chinese_choices() { let catalog = curated_catalog(); - assert_eq!(catalog.len(), 5); + assert_eq!(catalog.len(), 6); + assert_eq!(catalog[0].id, BUNDLED_STARTER_ID); + assert_eq!( + catalog[0].expected_bytes, + BUNDLED_STARTER_BYTES.len() as u64 + ); assert!(catalog.iter().any(|item| item.id.starts_with("ngsl-"))); assert!(catalog.iter().any(|item| item.id.starts_with("nawl-"))); assert!(catalog.iter().any(|item| item.id.starts_with("tsl-"))); assert!(catalog.iter().any(|item| item.id.starts_with("bsl-"))); - assert_eq!(catalog[0].expected_bytes, 5_169_152); + assert_eq!(catalog[1].expected_bytes, 5_169_152); assert_eq!( - catalog[0].sha256, + catalog[1].sha256, "16cf69dc8037a8d4dc6bde260142bf0181f9ff0a008d457f26452f1d80ca5ecd" ); - assert!(catalog[0] + assert!(catalog[1] .download_url .starts_with("https://download.wikdict.com/")); } + #[test] + fn bundled_starter_installs_offline_and_becomes_active() { + let app_db = NamedTempFile::new().expect("app db"); + let store = TextbookStore::open(app_db.path()).expect("store"); + store.ensure_bundled_starter(10).expect("bundled install"); + let installed = store + .get_installed(BUNDLED_STARTER_ID) + .expect("query") + .expect("installed"); + assert!(installed.active); + assert_eq!(installed.target_language, "zh-CN"); + assert!(installed.entry_count >= 75); + } + #[test] fn parses_each_official_learning_scope_format() { let ngsl = parse_scope_words( diff --git a/src-tauri/src/services/translation.rs b/src-tauri/src/services/translation.rs index 3c32152..21bff01 100644 --- a/src-tauri/src/services/translation.rs +++ b/src-tauri/src/services/translation.rs @@ -2,7 +2,13 @@ //! //! This module intentionally emits no request, response, credential, or text logs. -use std::{sync::Arc, time::Duration}; +use std::{ + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; use async_trait::async_trait; use reqwest::{Client, StatusCode}; @@ -10,10 +16,13 @@ use serde::{Deserialize, Serialize}; use crate::{ contracts::{ - AppError, AppErrorCode, LanguageCode, TranslationRequest, TranslationResult, - ValidateContract, + AppError, AppErrorCode, LanguageCode, MicrosoftCloud, TranslationProviderId, + TranslationRequest, TranslationResult, ValidateContract, + }, + services::{ + credentials::{ProviderCredentialStore, ProviderSecretField}, + CredentialStore, SettingsStore, TranslationProvider, }, - services::{CredentialStore, TranslationProvider}, }; const TRANSLATE_ENDPOINT: &str = "https://translation.googleapis.com/language/translate/v2"; @@ -459,6 +468,427 @@ fn internal_error(message: &'static str) -> AppError { AppError::new(AppErrorCode::Internal, message, false) } +const BAIDU_ENDPOINT: &str = "https://fanyi-api.baidu.com/api/trans/vip/translate"; +const MICROSOFT_GLOBAL_ENDPOINT: &str = "https://api.cognitive.microsofttranslator.com"; +const MICROSOFT_CHINA_ENDPOINT: &str = "https://api.translator.azure.cn"; +static BAIDU_SALT: AtomicU64 = AtomicU64::new(1_726_000_000); + +fn microsoft_endpoint(cloud: MicrosoftCloud) -> &'static str { + match cloud { + MicrosoftCloud::Global => MICROSOFT_GLOBAL_ENDPOINT, + MicrosoftCloud::China => MICROSOFT_CHINA_ENDPOINT, + } +} + +fn microsoft_translation_url( + cloud: MicrosoftCloud, + source_language: &str, + target_language: &str, +) -> Result { + let mut url = reqwest::Url::parse(&format!("{}/translate", microsoft_endpoint(cloud))) + .map_err(|_| internal_error("Microsoft Translator endpoint is invalid"))?; + { + let mut query = url.query_pairs_mut(); + query.append_pair("api-version", "3.0"); + query.append_pair("to", target_language); + if !source_language.eq_ignore_ascii_case("auto") { + query.append_pair("from", source_language); + } + } + Ok(url) +} + +/// Selects one explicit provider per request. It never silently changes provider. +pub struct ProviderRouter { + settings: Arc, + google: Arc, + baidu: Arc, + microsoft: Arc, +} + +impl ProviderRouter { + pub fn new( + settings: Arc, + credentials: Arc, + ) -> Result { + let google_credentials: Arc = Arc::new( + super::credentials::GoogleCredentialView::new(credentials.clone()), + ); + Ok(Self { + google: Arc::new(GoogleTranslationProvider::new(google_credentials)?), + baidu: Arc::new(BaiduTranslationProvider::new(credentials.clone())?), + microsoft: Arc::new(MicrosoftTranslationProvider::new( + credentials, + settings.clone(), + )?), + settings, + }) + } + + fn selected(&self) -> Result { + Ok(self.settings.load()?.translation_provider) + } + + pub async fn test(&self, provider: TranslationProviderId) -> Result<(), AppError> { + let request = TranslationRequest { + selection_id: 0, + text: "hello".to_owned(), + source_language: "en".to_owned(), + target_language: "zh-CN".to_owned(), + }; + match provider { + TranslationProviderId::Google => self.google.translate(&request).await, + TranslationProviderId::Baidu => self.baidu.translate(&request).await, + TranslationProviderId::Microsoft => self.microsoft.translate(&request).await, + } + .map(|_| ()) + } +} + +#[async_trait] +impl TranslationProvider for ProviderRouter { + async fn translate(&self, request: &TranslationRequest) -> Result { + match self.selected()? { + TranslationProviderId::Google => self.google.translate(request).await, + TranslationProviderId::Baidu => self.baidu.translate(request).await, + TranslationProviderId::Microsoft => self.microsoft.translate(request).await, + } + } + + async fn supported_languages(&self) -> Result, AppError> { + match self.selected()? { + TranslationProviderId::Google => self.google.supported_languages().await, + TranslationProviderId::Baidu => self.baidu.supported_languages().await, + TranslationProviderId::Microsoft => self.microsoft.supported_languages().await, + } + } +} + +pub struct BaiduTranslationProvider { + client: Client, + credentials: Arc, + endpoint: String, +} + +impl BaiduTranslationProvider { + pub fn new(credentials: Arc) -> Result { + Ok(Self { + client: GoogleTranslationProvider::build_client()?, + credentials, + endpoint: BAIDU_ENDPOINT.to_owned(), + }) + } + + fn credential( + &self, + field: ProviderSecretField, + label: &'static str, + ) -> Result { + self.credentials + .get(TranslationProviderId::Baidu, field)? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppError::new(AppErrorCode::MissingCredential, label, false)) + } +} + +#[derive(Deserialize)] +struct BaiduResponse { + #[serde(default)] + from: String, + #[serde(default)] + trans_result: Vec, + error_code: Option, + #[serde(default)] + error_msg: String, +} + +#[derive(Deserialize)] +struct BaiduTranslation { + dst: String, +} + +fn baidu_language(value: &str) -> &str { + match value { + "zh-CN" | "zh-Hans" => "zh", + "zh-TW" | "zh-Hant" => "cht", + value => value, + } +} + +#[async_trait] +impl TranslationProvider for BaiduTranslationProvider { + async fn translate(&self, request: &TranslationRequest) -> Result { + request.validate()?; + let app_id = self.credential(ProviderSecretField::AppId, "Add your Baidu APP ID.")?; + let api_key = self.credential(ProviderSecretField::ApiKey, "Add your Baidu secret key.")?; + let salt = BAIDU_SALT.fetch_add(1, Ordering::Relaxed).to_string(); + let sign = format!( + "{:x}", + md5::compute(format!("{app_id}{}{salt}{api_key}", request.text)) + ); + let params = [ + ("q", request.text.as_str()), + ("from", baidu_language(&request.source_language)), + ("to", baidu_language(&request.target_language)), + ("appid", app_id.as_str()), + ("salt", salt.as_str()), + ("sign", sign.as_str()), + ]; + let response = self + .client + .post(&self.endpoint) + .form(¶ms) + .send() + .await + .map_err(|error| map_transport_error_named(&error, "Baidu Translate"))?; + let status = response.status(); + let body = read_bounded_body(response).await?; + if !status.is_success() { + return Err(map_generic_http_error(status, "Baidu Translate")); + } + let envelope: BaiduResponse = serde_json::from_slice(&body) + .map_err(|_| internal_error("Baidu Translate returned an invalid response"))?; + if let Some(code) = envelope.error_code.as_deref() { + return Err(map_baidu_error(code, &envelope.error_msg)); + } + let translated_text = envelope + .trans_result + .into_iter() + .map(|item| item.dst) + .collect::>() + .join("\n"); + if translated_text.trim().is_empty() { + return Err(internal_error("Baidu Translate returned no translation")); + } + let detected = (!envelope.from.trim().is_empty()).then_some(envelope.from); + Ok(TranslationResult { + selection_id: request.selection_id, + translated_text, + detected_source_language: detected.clone(), + effective_source_language: if request.source_language.eq_ignore_ascii_case("auto") { + detected.unwrap_or_else(|| "auto".to_owned()) + } else { + request.source_language.clone() + }, + target_language: request.target_language.clone(), + part_of_speech: None, + }) + } + + async fn supported_languages(&self) -> Result, AppError> { + Ok(vec!["auto", "en", "zh-CN", "ja", "ko", "fr", "de", "es"] + .into_iter() + .map(str::to_owned) + .collect()) + } +} + +fn map_baidu_error(code: &str, _provider_message: &str) -> AppError { + match code { + "52001" => AppError::new(AppErrorCode::Timeout, "Baidu Translate timed out.", true), + "54003" => AppError::new( + AppErrorCode::QuotaExceeded, + "Baidu request frequency is limited.", + true, + ), + "54004" => AppError::new( + AppErrorCode::BillingRequired, + "Baidu Translate balance is insufficient.", + false, + ), + "58001" => AppError::new( + AppErrorCode::InvalidLanguagePair, + "Baidu rejected the language pair.", + false, + ), + "52003" | "54001" => AppError::new( + AppErrorCode::InvalidCredential, + "Baidu rejected the credentials.", + false, + ), + _ => AppError::new( + AppErrorCode::ServiceUnavailable, + "Baidu Translate rejected the request.", + false, + ), + } +} + +pub struct MicrosoftTranslationProvider { + client: Client, + credentials: Arc, + settings: Arc, +} + +impl MicrosoftTranslationProvider { + pub fn new( + credentials: Arc, + settings: Arc, + ) -> Result { + Ok(Self { + client: GoogleTranslationProvider::build_client()?, + credentials, + settings, + }) + } +} + +#[derive(Serialize)] +struct MicrosoftRequest<'a> { + text: &'a str, +} + +#[derive(Deserialize)] +struct MicrosoftResponse { + #[serde(default)] + detected_language: Option, + translations: Vec, +} +#[derive(Deserialize)] +struct MicrosoftDetected { + language: String, +} +#[derive(Deserialize)] +struct MicrosoftTranslation { + text: String, +} + +#[async_trait] +impl TranslationProvider for MicrosoftTranslationProvider { + async fn translate(&self, request: &TranslationRequest) -> Result { + request.validate()?; + let key = self + .credentials + .get( + TranslationProviderId::Microsoft, + ProviderSecretField::ApiKey, + )? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::new( + AppErrorCode::MissingCredential, + "Add a Microsoft Translator key.", + false, + ) + })?; + let settings = self.settings.load()?; + let url = microsoft_translation_url( + settings.microsoft_cloud, + &request.source_language, + &request.target_language, + )?; + let mut outbound = self + .client + .post(url) + .header("Ocp-Apim-Subscription-Key", key) + .header(reqwest::header::ACCEPT, "application/json") + .json(&[MicrosoftRequest { + text: &request.text, + }]); + if let Some(region) = settings + .microsoft_region + .filter(|value| !value.trim().is_empty()) + { + outbound = outbound.header("Ocp-Apim-Subscription-Region", region); + } + let response = outbound + .send() + .await + .map_err(|error| map_transport_error_named(&error, "Microsoft Translator"))?; + let status = response.status(); + let body = read_bounded_body(response).await?; + if !status.is_success() { + return Err(map_generic_http_error(status, "Microsoft Translator")); + } + let first: MicrosoftResponse = serde_json::from_slice::>(&body) + .map_err(|_| internal_error("Microsoft Translator returned an invalid response"))? + .into_iter() + .next() + .ok_or_else(|| internal_error("Microsoft Translator returned no translation"))?; + let translated_text = first + .translations + .into_iter() + .next() + .map(|item| item.text) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| internal_error("Microsoft Translator returned no translation"))?; + let detected = first.detected_language.map(|item| item.language); + Ok(TranslationResult { + selection_id: request.selection_id, + translated_text, + detected_source_language: detected.clone(), + effective_source_language: if request.source_language.eq_ignore_ascii_case("auto") { + detected.unwrap_or_else(|| "auto".to_owned()) + } else { + request.source_language.clone() + }, + target_language: request.target_language.clone(), + part_of_speech: None, + }) + } + + async fn supported_languages(&self) -> Result, AppError> { + Ok(vec!["auto", "en", "zh-CN", "ja", "ko", "fr", "de", "es"] + .into_iter() + .map(str::to_owned) + .collect()) + } +} + +fn map_transport_error_named(error: &reqwest::Error, provider: &'static str) -> AppError { + let (code, suffix) = if error.is_timeout() { + (AppErrorCode::Timeout, "timed out") + } else if error.is_connect() { + (AppErrorCode::Offline, "could not be reached") + } else { + ( + AppErrorCode::ServiceUnavailable, + "is temporarily unavailable", + ) + }; + AppError::new(code, format!("{provider} {suffix}."), true) +} + +fn map_generic_http_error(status: StatusCode, provider: &'static str) -> AppError { + if status == StatusCode::UNAUTHORIZED { + AppError::new( + AppErrorCode::InvalidCredential, + format!("{provider} rejected the credential."), + false, + ) + } else if status == StatusCode::FORBIDDEN { + AppError::new( + AppErrorCode::ApiRestricted, + format!("{provider} rejected this account or region."), + false, + ) + } else if status == StatusCode::TOO_MANY_REQUESTS { + AppError::new( + AppErrorCode::QuotaExceeded, + format!("{provider} quota is exhausted."), + false, + ) + } else if status == StatusCode::BAD_REQUEST { + AppError::new( + AppErrorCode::InvalidLanguagePair, + format!("{provider} rejected the language pair."), + false, + ) + } else if status.is_server_error() { + AppError::new( + AppErrorCode::ServiceUnavailable, + format!("{provider} is temporarily unavailable."), + true, + ) + } else { + AppError::new( + AppErrorCode::Internal, + format!("{provider} rejected the request."), + false, + ) + } +} + #[cfg(test)] mod tests { use std::{ @@ -854,6 +1284,46 @@ mod tests { assert_eq!(provider.max_attempts, 1); } + #[test] + fn china_provider_language_and_endpoint_profiles_are_explicit() { + assert_eq!(baidu_language("zh-CN"), "zh"); + assert_eq!(baidu_language("zh-TW"), "cht"); + assert_eq!(baidu_language("en"), "en"); + assert_eq!( + microsoft_endpoint(MicrosoftCloud::China), + "https://api.translator.azure.cn" + ); + assert_eq!( + microsoft_endpoint(MicrosoftCloud::Global), + "https://api.cognitive.microsofttranslator.com" + ); + let url = microsoft_translation_url(MicrosoftCloud::China, "en&category=x", "zh-CN") + .expect("encoded Microsoft URL"); + assert_eq!(url.host_str(), Some("api.translator.azure.cn")); + assert_eq!( + url.query_pairs().collect::>(), + vec![ + ("api-version".into(), "3.0".into()), + ("to".into(), "zh-CN".into()), + ("from".into(), "en&category=x".into()), + ] + ); + } + + #[test] + fn microsoft_and_baidu_success_envelopes_are_provider_local() { + let microsoft: Vec = serde_json::from_str( + r#"[{"detectedLanguage":{"language":"en"},"translations":[{"text":"你好"}]}]"#, + ) + .expect("Microsoft envelope"); + assert_eq!(microsoft[0].translations[0].text, "你好"); + let baidu: BaiduResponse = serde_json::from_str( + r#"{"from":"en","to":"zh","trans_result":[{"src":"hello","dst":"你好"}]}"#, + ) + .expect("Baidu envelope"); + assert_eq!(baidu.trans_result[0].dst, "你好"); + } + /// Networks that reach Google only through a local proxy expose whether the /// production client performs proxy discovery at all. Run it with the proxy /// environment variables removed, so only the operating system's own diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 80fda13..7d26660 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -4,12 +4,12 @@ use tauri::{ image::Image, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - App, AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, + App, AppHandle, Emitter, Listener, Manager, WebviewUrl, WebviewWindowBuilder, }; use crate::{ commands::RuntimeState, - contracts::{AppError, AppErrorCode}, + contracts::{AppError, AppErrorCode, UiLocale}, services::SettingsStore, }; @@ -20,6 +20,35 @@ const START_AT_LOGIN_ID: &str = "start-at-login"; const SETTINGS_ID: &str = "settings"; const STUDY_ID: &str = "study"; const QUIT_ID: &str = "quit"; +const LOCALE_EN_ID: &str = "locale-en"; +const LOCALE_ZH_ID: &str = "locale-zh-cn"; + +struct TrayCopy { + selection: &'static str, + login: &'static str, + study: &'static str, + settings: &'static str, + quit: &'static str, +} + +fn copy(locale: UiLocale) -> TrayCopy { + match locale { + UiLocale::English => TrayCopy { + selection: "Selection Translation", + login: "Start at Login", + study: "Vocabulary Study…", + settings: "Settings…", + quit: "Quit", + }, + UiLocale::SimplifiedChinese => TrayCopy { + selection: "划词翻译", + login: "登录时启动", + study: "词汇学习…", + settings: "设置…", + quit: "退出", + }, + } +} /// Builds the application tray and its stable command menu. pub fn install(app: &mut App) -> Result<(), Box> { @@ -28,10 +57,11 @@ pub fn install(app: &mut App) -> Result<(), Box> { .settings() .load() .map_err(|error| std::io::Error::other(error.message))?; + let labels = copy(settings.ui_locale); let enabled = CheckMenuItem::with_id( app, ENABLED_ID, - "Selection Translation", + labels.selection, true, app.state::().monitoring_enabled(), None::<&str>, @@ -39,15 +69,31 @@ pub fn install(app: &mut App) -> Result<(), Box> { let start_at_login = CheckMenuItem::with_id( app, START_AT_LOGIN_ID, - "Start at Login", + labels.login, true, settings.start_at_login, None::<&str>, )?; - let open_settings = MenuItem::with_id(app, SETTINGS_ID, "Settings…", true, None::<&str>)?; - let open_study = MenuItem::with_id(app, STUDY_ID, "Vocabulary Study…", true, None::<&str>)?; + let open_settings = MenuItem::with_id(app, SETTINGS_ID, labels.settings, true, None::<&str>)?; + let open_study = MenuItem::with_id(app, STUDY_ID, labels.study, true, None::<&str>)?; + let locale_en = CheckMenuItem::with_id( + app, + LOCALE_EN_ID, + "English", + true, + settings.ui_locale == UiLocale::English, + None::<&str>, + )?; + let locale_zh = CheckMenuItem::with_id( + app, + LOCALE_ZH_ID, + "简体中文", + true, + settings.ui_locale == UiLocale::SimplifiedChinese, + None::<&str>, + )?; let separator = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?; + let quit = MenuItem::with_id(app, QUIT_ID, labels.quit, true, None::<&str>)?; let menu = Menu::with_items( app, &[ @@ -55,12 +101,43 @@ pub fn install(app: &mut App) -> Result<(), Box> { &start_at_login, &open_study, &open_settings, + &locale_en, + &locale_zh, &separator, &quit, ], )?; let enabled_item = enabled.clone(); + let login_item = start_at_login.clone(); + let settings_item = open_settings.clone(); + let study_item = open_study.clone(); + let quit_item = quit.clone(); + let locale_en_item = locale_en.clone(); + let locale_zh_item = locale_zh.clone(); + let changed_enabled = enabled.clone(); + let changed_login = start_at_login.clone(); + let changed_settings = open_settings.clone(); + let changed_study = open_study.clone(); + let changed_quit = quit.clone(); + let changed_locale_en = locale_en.clone(); + let changed_locale_zh = locale_zh.clone(); + app.listen("settings-changed", move |event| { + let Ok(settings) = serde_json::from_str::(event.payload()) + else { + return; + }; + let labels = copy(settings.ui_locale); + let _ = changed_enabled.set_text(labels.selection); + let _ = changed_enabled.set_checked(settings.enabled); + let _ = changed_login.set_text(labels.login); + let _ = changed_login.set_checked(settings.start_at_login); + let _ = changed_settings.set_text(labels.settings); + let _ = changed_study.set_text(labels.study); + let _ = changed_quit.set_text(labels.quit); + let _ = changed_locale_en.set_checked(settings.ui_locale == UiLocale::English); + let _ = changed_locale_zh.set_checked(settings.ui_locale == UiLocale::SimplifiedChinese); + }); let mut builder = TrayIconBuilder::new() .tooltip("Desktop Translator") .menu(&menu) @@ -88,6 +165,28 @@ pub fn install(app: &mut App) -> Result<(), Box> { STUDY_ID => { let _ = show_study(app); } + LOCALE_EN_ID => { + let _ = set_locale(app, UiLocale::English); + let labels = copy(UiLocale::English); + let _ = enabled_item.set_text(labels.selection); + let _ = login_item.set_text(labels.login); + let _ = settings_item.set_text(labels.settings); + let _ = study_item.set_text(labels.study); + let _ = quit_item.set_text(labels.quit); + let _ = locale_en_item.set_checked(true); + let _ = locale_zh_item.set_checked(false); + } + LOCALE_ZH_ID => { + let _ = set_locale(app, UiLocale::SimplifiedChinese); + let labels = copy(UiLocale::SimplifiedChinese); + let _ = enabled_item.set_text(labels.selection); + let _ = login_item.set_text(labels.login); + let _ = settings_item.set_text(labels.settings); + let _ = study_item.set_text(labels.study); + let _ = quit_item.set_text(labels.quit); + let _ = locale_en_item.set_checked(false); + let _ = locale_zh_item.set_checked(true); + } QUIT_ID => { let app = app.clone(); tauri::async_runtime::spawn(async move { @@ -126,8 +225,46 @@ pub fn install(app: &mut App) -> Result<(), Box> { Ok(()) } +fn set_locale(app: &AppHandle, locale: UiLocale) -> Result<(), AppError> { + let state = app.state::(); + let mut settings = state.settings().load()?; + settings.ui_locale = locale; + state.settings().save(&settings)?; + app.emit("settings-changed", &settings) + .map_err(|_| tray_error("Interface language could not be refreshed"))?; + refresh_window_titles(app, locale); + if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { + let _ = window.emit("settings-changed", &settings); + let _ = window.reload(); + } + if let Some(window) = app.get_webview_window(STUDY_LABEL) { + let _ = window.emit("settings-changed", &settings); + let _ = window.reload(); + } + Ok(()) +} + +/// Keeps native titles aligned when the locale changes from either settings or tray. +pub(crate) fn refresh_window_titles(app: &AppHandle, locale: UiLocale) { + if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { + let _ = window.set_title(if locale == UiLocale::SimplifiedChinese { + "桌面翻译设置" + } else { + "Desktop Translator Settings" + }); + } + if let Some(window) = app.get_webview_window(STUDY_LABEL) { + let _ = window.set_title(if locale == UiLocale::SimplifiedChinese { + "词汇学习" + } else { + "Vocabulary Study" + }); + } +} + /// Shows or lazily creates the settings WebView. pub fn show_settings(app: &AppHandle) -> Result<(), AppError> { + let locale = app.state::().settings().load()?.ui_locale; let window = if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { window } else { @@ -136,7 +273,11 @@ pub fn show_settings(app: &AppHandle) -> Result<(), AppError> { SETTINGS_LABEL, WebviewUrl::App("index.html?mode=settings".into()), ) - .title("Desktop Translator Settings") + .title(if locale == UiLocale::SimplifiedChinese { + "桌面翻译设置" + } else { + "Desktop Translator Settings" + }) .inner_size(680.0, 760.0) .min_inner_size(520.0, 560.0) .resizable(true) @@ -153,6 +294,7 @@ pub fn show_settings(app: &AppHandle) -> Result<(), AppError> { /// Shows or lazily creates the reusable vocabulary library and practice window. pub fn show_study(app: &AppHandle) -> Result<(), AppError> { + let locale = app.state::().settings().load()?.ui_locale; let window = if let Some(window) = app.get_webview_window(STUDY_LABEL) { window } else { @@ -161,7 +303,11 @@ pub fn show_study(app: &AppHandle) -> Result<(), AppError> { STUDY_LABEL, WebviewUrl::App("index.html?mode=study".into()), ) - .title("Vocabulary Study") + .title(if locale == UiLocale::SimplifiedChinese { + "词汇学习" + } else { + "Vocabulary Study" + }) .inner_size(1040.0, 720.0) .min_inner_size(760.0, 560.0) .resizable(true) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 22dc204..8a72a9e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ ], "category": "Productivity", "shortDescription": "Translate selected text anywhere on your desktop.", - "longDescription": "Desktop Translator watches for a completed text selection in any application, offers a small translate button beside it, and returns a translation from the Google Cloud Translation API without taking focus away from what you are reading.", + "longDescription": "Desktop Translator translates selected text without taking focus, supports Google, Baidu, and Microsoft translation services, and includes a private vocabulary study tool with an offline English to Simplified Chinese starter textbook.", "copyright": "Copyright © 2026 刘升龙 (Ldsystem)", "macOS": { "minimumSystemVersion": "11.0", diff --git a/src/app/App.tsx b/src/app/App.tsx index 6af5d8d..baa7860 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,6 +6,7 @@ import type { PracticeQuestion, RelatedVocabulary, TranslationRequest, + TranslationProviderId, UserSettings, VocabularyEntry, } from "../contracts/ipc"; @@ -26,13 +27,16 @@ import "../styles/app.css"; import { getWindowMode, type WindowMode } from "./windowMode"; const defaultSettings: UserSettings = { - schemaVersion: 1, + schemaVersion: 2, enabled: true, sourceLanguage: "auto", targetLanguage: "en", startAtLogin: false, theme: "system", maxSelectionCodePoints: 5_000, + uiLocale: "en", + translationProvider: "google", + microsoftCloud: "global", }; const ignore = () => undefined; @@ -64,9 +68,10 @@ export interface AppProps { onSpeak?: (text: string, language: LanguageCode) => void; onDismiss?: () => void; onSaveSettings?: (settings: UserSettings) => void; - onSaveCredential?: () => void; - onTestCredential?: () => void; - onRemoveCredential?: () => void; + onSaveCredential?: (provider: TranslationProviderId, field: "api-key" | "app-id") => void; + onProviderChange?: (provider: TranslationProviderId) => void; + onTestCredential?: (provider: TranslationProviderId) => void; + onRemoveCredential?: (provider: TranslationProviderId) => void; onOpenSystemSettings?: () => void; onQuit?: () => void; } @@ -99,12 +104,14 @@ export default function App({ onDismiss = ignore, onSaveSettings = ignore, onSaveCredential = ignore, + onProviderChange = ignore, onTestCredential = ignore, onRemoveCredential = ignore, onOpenSystemSettings = ignore, onQuit = ignore, }: AppProps) { const [settings, setSettings] = useState(initialSettings); + document.documentElement.lang = settings.uiLocale; if (mode === "overlay") { const surfaceClass = @@ -112,9 +119,10 @@ export default function App({ ? "app-surface app-surface--overlay app-surface--trigger" : "app-surface app-surface--overlay"; return ( -
+
+
+
+
{ "Accessibility Permission Required", ); }); + + it("localizes the compact selection surface in Simplified Chinese", () => { + render({ mode: "translating", selection, generation: 1 }, { locale: "zh-CN" }); + + expect(container.querySelector('[role="status"]')?.textContent).toContain("正在翻译…"); + expect(container.querySelector('aside[aria-label="翻译"]')).not.toBeNull(); + }); }); diff --git a/src/components/context/ContextualOverlay.tsx b/src/components/context/ContextualOverlay.tsx index 2ff8aba..6bd2aab 100644 --- a/src/components/context/ContextualOverlay.tsx +++ b/src/components/context/ContextualOverlay.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import type { LanguageCode, TranslationRequest } from "../../contracts/ipc"; +import type { LanguageCode, TranslationRequest, UiLocale } from "../../contracts/ipc"; import type { OverlayState } from "../../state/overlayMachine"; import { errorCopy } from "../errorCopy"; @@ -21,6 +21,7 @@ export const defaultLanguages: readonly LanguageOption[] = [ interface ContextualOverlayProps { state: OverlayState; + locale?: UiLocale; sourceLanguage: "auto" | LanguageCode; targetLanguage: LanguageCode; languages?: readonly LanguageOption[]; @@ -50,6 +51,7 @@ function CloseIcon() { export function ContextualOverlay({ state, + locale = "en", sourceLanguage, targetLanguage, languages = defaultLanguages, @@ -59,6 +61,7 @@ export function ContextualOverlay({ onSpeak, onDismiss, }: ContextualOverlayProps) { + const zh = locale === "zh-CN"; useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape" && state.mode !== "idle" && state.mode !== "disabled") { @@ -87,11 +90,11 @@ export function ContextualOverlay({ if (state.mode === "button-visible") { return ( -
)} @@ -124,14 +127,14 @@ export function ContextualOverlay({
setSource(event.target.value as "auto" | LanguageCode)} > - + {languages.map((language) => (
-
- Theme - {(["system", "light", "dark"] as const).map((theme) => ( - - ))} -
+
+ +
+ {copy.theme} + {(["system", "light", "dark"] as const).map((theme) => ( + + ))} +
+
-

Service Access

-

Manage the key stored by the secure native service.

+

{copy.serviceAccess}

+

{copy.serviceHint}

+
+ + {draft.translationProvider === "microsoft" && ( + <> + + + + )} +
- } + {confirmRemoval ? ( <> @@ -223,18 +254,18 @@ export function SettingsPanel({ className="button button--danger" type="button" onClick={() => { - onRemoveCredential(); + onRemoveCredential(draft.translationProvider); setConfirmRemoval(false); }} > - Confirm Removal + {copy.confirmRemoval} ) : ( @@ -244,7 +275,7 @@ export function SettingsPanel({ onClick={() => setConfirmRemoval(true)} disabled={credentialStatus === "missing"} > - Remove API Key + {copy.remove} )}
@@ -255,27 +286,24 @@ export function SettingsPanel({
-

Privacy & Usage

-

Know what leaves your Mac and when.

+

{copy.privacy}

+

{copy.privacyHint}

    -
  • Selected text is sent to Google only after you choose Translate.
  • -
  • - API usage may incur charges and is subject to billing, quota, and API key - restrictions. -
  • -
  • No content history is stored.
  • +
  • {copy.privacyOne}
  • +
  • {copy.privacyTwo}
  • +
  • {copy.privacyThree}
- +
diff --git a/src/components/vocabulary/PracticeView.tsx b/src/components/vocabulary/PracticeView.tsx index d243ade..3999ee1 100644 --- a/src/components/vocabulary/PracticeView.tsx +++ b/src/components/vocabulary/PracticeView.tsx @@ -1,18 +1,25 @@ import { useEffect, useRef, useState } from "react"; -import type { PartOfSpeech, PracticeDirection, StudyPracticeOutcome, StudyPracticeQuestion } from "../../contracts/ipc"; +import type { PartOfSpeech, PracticeDirection, StudyPracticeOutcome, StudyPracticeQuestion, UiLocale } from "../../contracts/ipc"; import type { StudyApi } from "./VocabularyWindow"; interface PracticeViewProps { api: StudyApi; revision: number; + locale?: UiLocale; } -const directions: { value: PracticeDirection; label: string; note: string }[] = [ - { value: "random", label: "Mix", note: "Both directions" }, - { value: "source-to-target", label: "Word → meaning", note: "Recognition" }, - { value: "target-to-source", label: "Meaning → word", note: "Production" }, -]; +function directions(zh: boolean): { value: PracticeDirection; label: string; note: string }[] { + return zh ? [ + { value: "random", label: "双向混合", note: "随机方向" }, + { value: "source-to-target", label: "单词 → 释义", note: "识别" }, + { value: "target-to-source", label: "释义 → 单词", note: "回忆" }, + ] : [ + { value: "random", label: "Mix", note: "Both directions" }, + { value: "source-to-target", label: "Word → meaning", note: "Recognition" }, + { value: "target-to-source", label: "Meaning → word", note: "Production" }, + ]; +} const partOfSpeechLabels: Record = { adjective: "adj.", adverb: "adv.", article: "art.", conjunction: "conj.", determiner: "det.", @@ -30,7 +37,8 @@ function lexicalTextClass(value: string) { return value.length >= 11 ? "lexical-text lexical-text--long" : "lexical-text"; } -export function PracticeView({ api, revision }: PracticeViewProps) { +export function PracticeView({ api, revision, locale = "en" }: PracticeViewProps) { + const zh = locale === "zh-CN"; const [direction, setDirection] = useState("random"); const [question, setQuestion] = useState(); const [outcome, setOutcome] = useState(); @@ -54,7 +62,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) { }).catch(() => { if (request !== questionRequest.current) return; setQuestion(null); - setError("A practice question could not be prepared."); + setError(zh ? "无法生成练习题。" : "A practice question could not be prepared."); }); }; @@ -62,7 +70,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) { let current = true; void api.getPracticePreferences().then((preferences) => { if (current) setDirection(preferences.direction); - }).catch(() => setError("Your practice preference could not be opened.")); + }).catch(() => setError(zh ? "无法读取练习偏好。" : "Your practice preference could not be opened.")); loadQuestion(); return () => { current = false; }; }, [api]); @@ -82,7 +90,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) { loadQuestion(); }).catch(() => { setFailedDirection(next); - setError("Your practice direction could not be saved."); + setError(zh ? "无法保存练习方向。" : "Your practice direction could not be saved."); }).finally(() => setSaving(false)); }; @@ -93,7 +101,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) { setError(undefined); void api.submitPracticeAnswer(questionValue.entryId, questionValue.direction, answer) .then(setOutcome) - .catch(() => setError("Your answer could not be saved.")) + .catch(() => setError(zh ? "无法保存答案。" : "Your answer could not be saved.")) .finally(() => { submittingRef.current = false; setSubmitting(false); @@ -101,13 +109,13 @@ export function PracticeView({ api, revision }: PracticeViewProps) { }; return
-

Practice

Choose the answer

Questions come from your wordbook. Related words and downloaded textbooks make the choices more challenging.

-
Practice direction{directions.map((item) => )}
- {error &&
{error}
} - {question === undefined ?
Choosing what needs attention…
: question === null ?
You have practised every available word.Come back after recall has faded, or add another word to continue.
:
+

{zh ? "练习" : "Practice"}

{zh ? "选择答案" : "Choose the answer"}

{zh ? "题目来自你的词汇本;相关词和已下载词书会让选项更有挑战性。" : "Questions come from your wordbook. Related words and downloaded textbooks make the choices more challenging."}

+
{zh ? "练习方向" : "Practice direction"}{directions(zh).map((item) => )}
+ {error &&
{error}
} + {question === undefined ?
{zh ? "正在挑选需要复习的词汇…" : "Choosing what needs attention…"}
: question === null ?
{zh ? "当前词汇都已练习完毕。" : "You have practised every available word."}{zh ? "记忆分数回落后再来,或添加新词继续练习。" : "Come back after recall has faded, or add another word to continue."}
:
{question.promptLanguage.toUpperCase()} → {question.answerLanguage.toUpperCase()}
{question.prompt}
-
{question.choices.map((choice) => )}
-
{outcome ? <>
{outcome.correct ? "Correct" : "Review this answer"}{outcome.correct ? `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.` : `The answer is “${outcome.correctAnswer}”.`}
: }
+
{question.choices.map((choice) => )}
+
{outcome ? <>
{outcome.correct ? (zh ? "正确" : "Correct") : (zh ? "复习这个答案" : "Review this answer")}{outcome.correct ? (zh ? `记忆分数现为 ${Math.round(outcome.entry.effectiveRecall)}。` : `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.`) : (zh ? `正确答案是“${outcome.correctAnswer}”。` : `The answer is “${outcome.correctAnswer}”.`)}
: }
}
; } diff --git a/src/components/vocabulary/RelatedWordsView.tsx b/src/components/vocabulary/RelatedWordsView.tsx index 2a9e4e6..1a69fd3 100644 --- a/src/components/vocabulary/RelatedWordsView.tsx +++ b/src/components/vocabulary/RelatedWordsView.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import type { RelatedWord, VocabularyEntry, VocabularyProvenance } from "../../contracts/ipc"; +import type { RelatedWord, UiLocale, VocabularyEntry, VocabularyProvenance } from "../../contracts/ipc"; import { PartOfSpeechBadge } from "./PracticeView"; import type { StudyApi } from "./VocabularyWindow"; @@ -8,10 +8,12 @@ interface RelatedWordsViewProps { anchor?: VocabularyEntry; api: StudyApi; revision: number; + locale?: UiLocale; onBack: () => void; } -export function RelatedWordsView({ anchor, api, revision, onBack }: RelatedWordsViewProps) { +export function RelatedWordsView({ anchor, api, revision, locale = "en", onBack }: RelatedWordsViewProps) { + const zh = locale === "zh-CN"; const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -39,25 +41,25 @@ export function RelatedWordsView({ anchor, api, revision, onBack }: RelatedWords setError(undefined); void api.listRelated(anchor.id) .then((next) => { if (request === relatedRequest.current) setItems(next); }) - .catch(() => { if (request === relatedRequest.current) setError("Related words could not be loaded."); }) + .catch(() => { if (request === relatedRequest.current) setError(zh ? "无法加载相关词。" : "Related words could not be loaded."); }) .finally(() => { if (request === relatedRequest.current) setLoading(false); }); - }, [anchor, api, revision]); + }, [anchor, api, revision, zh]); return
-

Related words

Connections combine your wordbook and every compatible downloaded textbook.

+

{zh ? "相关词" : "Related words"}

{zh ? "关联结果来自你的词汇本和所有兼容的已下载词书。" : "Connections combine your wordbook and every compatible downloaded textbook."}

{provenance.length > 0 &&
- Textbook source details {provenance.length} + {zh ? "词书来源详情" : "Textbook source details"} {provenance.length}
{provenance.map((item) =>
{item.textbookTitle} - Version {item.textbookVersion} · {item.license} + {zh ? "版本" : "Version"} {item.textbookVersion} · {item.license} {item.attribution} - View source + {zh ? "查看来源" : "View source"}
)}
} {error &&
{error}
} - {!anchor ?
Choose a word first.Open a card in My wordbook to make it the connection anchor.
: loading ?
Tracing connections…
: items.length === 0 ?
No compatible connections yet.Try another word as your local collection grows.
:
{items.map((item) =>
- {item.reason === "root" ? "shared root" : "shared meaning"}{item.sourceText}{item.translatedText} - {item.origins.map((origin) => {origin.kind === "personal" ? "Personal" : origin.textbookTitle})} + {!anchor ?
{zh ? "请先选择一个词。" : "Choose a word first."}{zh ? "从“我的词汇本”打开一张卡片作为关联词。" : "Open a card in My wordbook to make it the connection anchor."}
: loading ?
{zh ? "正在查找关联…" : "Tracing connections…"}
: items.length === 0 ?
{zh ? "暂未找到兼容的关联。" : "No compatible connections yet."}{zh ? "随着本地词汇积累,可以尝试其他词。" : "Try another word as your local collection grows."}
:
{items.map((item) =>
+ {item.reason === "root" ? (zh ? "同词根" : "shared root") : (zh ? "同义项" : "shared meaning")}{item.sourceText}{item.translatedText} + {item.origins.map((origin) => {origin.kind === "personal" ? (zh ? "个人词汇本" : "Personal") : origin.textbookTitle})} {item.kind === "textbook" && } + }).catch(() => setError(zh ? "无法添加这个相关词。" : "This related word could not be added.")).finally(() => setAdding(undefined)); + }}>{item.promoted ? (zh ? "已添加" : "Added") : adding === item.textbookEntryId ? (zh ? "正在添加…" : "Adding…") : (zh ? "添加" : "Add")}}
)}
}
; } diff --git a/src/components/vocabulary/TextbooksView.tsx b/src/components/vocabulary/TextbooksView.tsx index e287c16..773923d 100644 --- a/src/components/vocabulary/TextbooksView.tsx +++ b/src/components/vocabulary/TextbooksView.tsx @@ -4,6 +4,7 @@ import type { InstalledTextbook, TextbookCatalogItem, TextbookEntry, + UiLocale, } from "../../contracts/ipc"; import type { StudyApi } from "./VocabularyWindow"; import { PartOfSpeechBadge } from "./PracticeView"; @@ -11,26 +12,27 @@ import { PartOfSpeechBadge } from "./PracticeView"; type ShelfTab = "discover" | "downloaded"; const PAGE_SIZE = 40; -const catalogPresentation: Record = { - "wikdict-en-zh-2026-06": { description: "A broad English reference for lookup, discovery, and uncommon words.", scope: "General reference", count: 30_518 }, - "ngsl-en-zh-1-2": { description: "High-frequency vocabulary for daily reading and conversation.", scope: "Everyday · NGSL", count: 2_809 }, - "nawl-en-zh-1-2": { description: "Vocabulary with high coverage across general academic texts.", scope: "Academic · NAWL", count: 957 }, - "tsl-en-zh-1-2": { description: "A focused service list for TOEIC listening and reading preparation.", scope: "TOEIC · TSL", count: 1_250 }, - "bsl-en-zh-1-20": { description: "High-frequency vocabulary for workplace and business communication.", scope: "Business · BSL", count: 1_744 }, +const catalogPresentation: Record = { + "wikdict-en-zh-2026-06": { description: "A broad English reference for lookup, discovery, and uncommon words.", descriptionZh: "适合查询、拓展和学习非常用词的综合英语词典。", scope: "General reference", scopeZh: "综合参考", count: 30_518 }, + "ngsl-en-zh-1-2": { description: "High-frequency vocabulary for daily reading and conversation.", descriptionZh: "覆盖日常阅读和交流中的高频词汇。", scope: "Everyday · NGSL", scopeZh: "日常 · NGSL", count: 2_809 }, + "nawl-en-zh-1-2": { description: "Vocabulary with high coverage across general academic texts.", descriptionZh: "覆盖一般学术文本中的常用词汇。", scope: "Academic · NAWL", scopeZh: "学术 · NAWL", count: 957 }, + "tsl-en-zh-1-2": { description: "A focused service list for TOEIC listening and reading preparation.", descriptionZh: "面向 TOEIC 听力和阅读备考的精简词表。", scope: "TOEIC · TSL", scopeZh: "TOEIC · TSL", count: 1_250 }, + "bsl-en-zh-1-20": { description: "High-frequency vocabulary for workplace and business communication.", descriptionZh: "覆盖职场和商务交流中的高频词汇。", scope: "Business · BSL", scopeZh: "商务 · BSL", count: 1_744 }, }; -function presentation(item: TextbookCatalogItem) { +function presentation(item: TextbookCatalogItem, zh: boolean) { const curated = catalogPresentation[item.id]; return { - description: item.description ?? curated?.description ?? "A curated English vocabulary reference.", - scope: item.scope ?? curated?.scope ?? "Curated vocabulary", + description: zh ? curated?.descriptionZh ?? item.description ?? "精选英语词汇参考。" : item.description ?? curated?.description ?? "A curated English vocabulary reference.", + scope: zh ? curated?.scopeZh ?? item.scope ?? "精选词汇" : item.scope ?? curated?.scope ?? "Curated vocabulary", count: item.estimatedEntryCount ?? curated?.count, - script: item.script ?? "Simplified Chinese", + script: zh ? "简体中文" : item.script ?? "Simplified Chinese", }; } interface TextbooksViewProps { api: StudyApi; + locale?: UiLocale; } function message(error: unknown, fallback: string) { @@ -40,7 +42,9 @@ function message(error: unknown, fallback: string) { return fallback; } -export function TextbooksView({ api }: TextbooksViewProps) { +export function TextbooksView({ api, locale = "en" }: TextbooksViewProps) { + const zh = locale === "zh-CN"; + const tr = (english: string, chinese: string) => zh ? chinese : english; const [tab, setTab] = useState("discover"); const [catalog, setCatalog] = useState([]); const [downloaded, setDownloaded] = useState([]); @@ -151,24 +155,24 @@ export function TextbooksView({ api }: TextbooksViewProps) {
- -

Downloaded textbook

+ +

{tr("Downloaded textbook", "已下载词书")}

{openBook.title}

{openBook.sourceLanguage.toUpperCase()} → {openBook.targetLanguage.toUpperCase()} · {openBook.entryCount.toLocaleString()} words · {openBook.license}

{ event.preventDefault(); loadEntries(openBook, entrySearch, 0); }}>
- {refreshItem &&
This download predates word details such as parts of speech.
} - {entryError &&
{entryError}
} + {refreshItem &&
{tr("This download predates word details such as parts of speech.", "此版本缺少词性等词汇详情。")}
} + {entryError &&
{entryError}
} {entryLoading ? ( -
Opening this textbook…Reading its local index.
+
{tr("Opening this textbook…", "正在打开词书…")}{tr("Reading its local index.", "正在读取本地索引。")}
) : entries.length === 0 ? ( -
No matching words.Try a shorter spelling or clear the search.
+
{tr("No matching words.", "没有匹配的词汇。")}{tr("Try a shorter spelling or clear the search.", "可缩短拼写或清空搜索条件。")}
) : ( <>
@@ -186,7 +190,7 @@ export function TextbooksView({ api }: TextbooksViewProps) { setAdded((current) => new Set(current).add(entry.id)); api.refreshPersonal(); }).catch((error) => setEntryError(message(error, "This word could not be added."))).finally(() => setAdding(undefined)); - }}>{isAdded ? "Added" : adding === entry.id ? "Adding…" : "Add to my wordbook"} + }}>{isAdded ? tr("Added", "已添加") : adding === entry.id ? tr("Adding…", "添加中…") : tr("Add to my wordbook", "添加到我的词汇本")} ); })} @@ -194,8 +198,8 @@ export function TextbooksView({ api }: TextbooksViewProps) {
{pageStart.toLocaleString()}–{pageEnd.toLocaleString()} of {entryTotal.toLocaleString()}
- - + +
@@ -207,10 +211,10 @@ export function TextbooksView({ api }: TextbooksViewProps) { return (
-

Textbooks

Textbook shelf

Choose a learning path with clear Simplified Chinese meanings, then make one book active.

+

{tr("Textbooks", "词书")}

{tr("Textbook shelf", "词书架")}

{tr("Choose a learning path with clear Simplified Chinese meanings, then make one book active.", "选择带有清晰简体中文释义的学习路径,并将其中一本设为当前词书。")}

- - + +
@@ -222,7 +226,7 @@ export function TextbooksView({ api }: TextbooksViewProps) { const installed = installedById.get(item.id); const update = installed && installed.version !== item.version; const metadataRefresh = installed?.metadataRefreshAvailable === true; - const details = presentation(item); + const details = presentation(item, zh); return
@@ -231,13 +235,13 @@ export function TextbooksView({ api }: TextbooksViewProps) {

{details.description}

{details.scope} - {details.count && {details.count.toLocaleString()} words} + {details.count && {details.count.toLocaleString()} {tr("words", "词")}} {details.script}

{item.attribution}

-
{item.license} · {item.version}View source
+
{item.license} · {item.version}{tr("View source", "查看来源")}
- +
; })}
)} @@ -245,16 +249,16 @@ export function TextbooksView({ api }: TextbooksViewProps) { ) : (
{downloadedError &&
{downloadedError}
} - {downloadedLoading ?
Reading your shelf…
: downloaded.length === 0 ?
No downloaded textbooks yet.Open Discover to add a curated local reference.
: ( + {downloadedLoading ?
{tr("Reading your shelf…", "正在读取词书架…")}
: downloaded.length === 0 ?
{tr("No downloaded textbooks yet.", "尚未下载词书。")}{tr("Open Discover to add a curated local reference.", "前往“发现”添加精选本地词书。")}
: (
{downloaded.map((book) => { const refreshItem = book.metadataRefreshAvailable ? catalog.find((item) => item.id === book.id) : undefined; return
-
{book.active ? "Active textbook" : "Downloaded"}

{book.title}

{book.sourceLanguage.toUpperCase()} → {book.targetLanguage.toUpperCase()} · {book.entryCount.toLocaleString()} words

+
{book.active ? tr("Active textbook", "当前词书") : tr("Downloaded", "已下载")}

{book.title}

{book.sourceLanguage.toUpperCase()} → {book.targetLanguage.toUpperCase()} · {book.entryCount.toLocaleString()} {tr("words", "词")}

- - {refreshItem && } - - {confirmRemove === book.id ? Remove local copy? : } + + {refreshItem && } + + {confirmRemove === book.id ? {tr("Remove local copy?", "移除本地副本?")} : }
})}
)} diff --git a/src/components/vocabulary/VocabularyWindow.test.tsx b/src/components/vocabulary/VocabularyWindow.test.tsx index a5f5959..3100f06 100644 --- a/src/components/vocabulary/VocabularyWindow.test.tsx +++ b/src/components/vocabulary/VocabularyWindow.test.tsx @@ -153,6 +153,38 @@ describe("VocabularyWindow", () => { expect(scroller?.getAttribute("data-scroll-owner")).toBe("study-content"); }); + it("localizes the complete practice experience in Simplified Chinese", async () => { + const api = makeStudyApi({ + getPracticeQuestion: vi.fn().mockResolvedValue({ + entryId: 1, + direction: "target-to-source", + prompt: "知识", + promptLanguage: "zh-CN", + answerLanguage: "en", + promptPartOfSpeech: "noun", + correctAnswer: "knowledge", + choices: [ + { value: "knowledge", partOfSpeech: "noun" }, + { value: "cognizance", partOfSpeech: "noun" }, + ], + }), + }); + act(() => root.render()); + + const practice = [...container.querySelectorAll(".study-nav")] + .find((button) => button.textContent === "练习"); + act(() => practice?.click()); + await flushEffects(); + + expect(container.textContent).toContain("选择答案"); + expect(container.textContent).toContain("双向混合"); + expect(container.textContent).toContain("单词 → 释义"); + expect(container.textContent).toContain("释义 → 单词"); + expect(container.textContent).toContain("检查答案"); + expect(container.textContent).not.toContain("Choose the answer"); + expect(container.textContent).not.toContain("Both directions"); + }); + it("contains document scrolling and gives the content scroller a quiet visual treatment", () => { expect(appCss).toMatch(/\.app-surface\.app-surface--study\s*\{[^}]*position:\s*fixed[^}]*inset:\s*0[^}]*overflow:\s*hidden/s); expect(appCss).not.toMatch(/html,\s*body,\s*#root\s*\{[^}]*overflow:\s*hidden/s); @@ -526,6 +558,17 @@ describe("VocabularyWindow", () => { expect(container.textContent).not.toContain("Connections for hello"); }); + it("localizes the card-scoped related view in Simplified Chinese", async () => { + const api = makeStudyApi(); + act(() => root.render()); + act(() => [...container.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "Open related words for hello")?.click()); + await flushEffects(); + + expect(container.textContent).toContain("返回我的词汇本"); + expect(container.textContent).toContain("hello 的关联"); + expect(container.textContent).not.toContain("Connections for hello"); + }); + it("masks textbook entries above and below the sticky pagination dock", () => { expect(appCss).toMatch(/\.textbook-pagination\s*\{[^}]*isolation:\s*isolate/s); expect(appCss).toMatch(/\.textbook-pagination::before\s*\{[^}]*bottom:\s*-40px[^}]*background:\s*var\(--study-paper\)/s); diff --git a/src/components/vocabulary/VocabularyWindow.tsx b/src/components/vocabulary/VocabularyWindow.tsx index 4f5a411..ce95342 100644 --- a/src/components/vocabulary/VocabularyWindow.tsx +++ b/src/components/vocabulary/VocabularyWindow.tsx @@ -14,6 +14,7 @@ import type { TextbookPromotionResult, VocabularyEntry, VocabularyProvenance, + UiLocale, } from "../../contracts/ipc"; import { PartOfSpeechBadge, PracticeView } from "./PracticeView"; import { RelatedWordsView } from "./RelatedWordsView"; @@ -22,6 +23,7 @@ import { TextbooksView } from "./TextbooksView"; type StudyView = "library" | "related" | "practice" | "textbooks"; interface VocabularyWindowProps { + locale?: UiLocale; entries: readonly VocabularyEntry[]; loading: boolean; error?: string; @@ -141,6 +143,7 @@ function EntryCard({ } export function VocabularyWindow({ + locale = "en", entries, loading, error, @@ -156,6 +159,7 @@ export function VocabularyWindow({ onSubmitAnswer, studyApi, }: VocabularyWindowProps) { + const zh = locale === "zh-CN"; const [view, setView] = useState(question !== undefined ? "practice" : "library"); const [search, setSearch] = useState(""); const [selectedChoice, setSelectedChoice] = useState(); @@ -245,66 +249,66 @@ export function VocabularyWindow({ return (
-
- {managedEntry && studyApi && } + {managedEntry && studyApi && }
); diff --git a/src/contracts/fixtures.json b/src/contracts/fixtures.json index 1ecc9dd..bbc4c58 100644 --- a/src/contracts/fixtures.json +++ b/src/contracts/fixtures.json @@ -10,13 +10,16 @@ "capturedAtEpochMs": 1723464000000 }, "settings": { - "schemaVersion": 1, + "schemaVersion": 2, "enabled": true, "sourceLanguage": "auto", "targetLanguage": "zh-CN", "startAtLogin": false, "theme": "system", - "maxSelectionCodePoints": 5000 + "maxSelectionCodePoints": 5000, + "uiLocale": "en", + "translationProvider": "google", + "microsoftCloud": "global" }, "translationRequest": { "selectionId": 42, diff --git a/src/contracts/ipc.ts b/src/contracts/ipc.ts index 6f20722..d599d6f 100644 --- a/src/contracts/ipc.ts +++ b/src/contracts/ipc.ts @@ -26,6 +26,9 @@ export const partOfSpeechValues = [ export type PartOfSpeech = (typeof partOfSpeechValues)[number]; /** User-selectable application color theme. */ export type Theme = "system" | "light" | "dark"; +export type UiLocale = "en" | "zh-CN"; +export type TranslationProviderId = "google" | "baidu" | "microsoft"; +export type MicrosoftCloud = "global" | "china"; /** Rectangle expressed in global physical screen pixels. */ export interface PhysicalRect { @@ -47,13 +50,17 @@ export interface SelectionSnapshot { /** Schema-versioned, non-secret user preferences persisted by the core. */ export interface UserSettings { - schemaVersion: 1; + schemaVersion: 2; enabled: boolean; sourceLanguage: "auto" | LanguageCode; targetLanguage: LanguageCode; startAtLogin: boolean; theme: Theme; maxSelectionCodePoints: number; + uiLocale: UiLocale; + translationProvider: TranslationProviderId; + microsoftCloud: MicrosoftCloud; + microsoftRegion?: string; } /** Validated translation command payload sent from the overlay to the core. */ @@ -354,7 +361,7 @@ export function isSelectionSnapshot(value: unknown): value is SelectionSnapshot export function isUserSettings(value: unknown): value is UserSettings { return ( isRecord(value) && - value.schemaVersion === 1 && + value.schemaVersion === 2 && typeof value.enabled === "boolean" && isNonEmptyString(value.sourceLanguage) && isNonEmptyString(value.targetLanguage) && @@ -362,6 +369,10 @@ export function isUserSettings(value: unknown): value is UserSettings { (value.theme === "system" || value.theme === "light" || value.theme === "dark") && Number.isSafeInteger(value.maxSelectionCodePoints) && Number(value.maxSelectionCodePoints) > 0 + && (value.uiLocale === "en" || value.uiLocale === "zh-CN") + && ["google", "baidu", "microsoft"].includes(String(value.translationProvider)) + && (value.microsoftCloud === "global" || value.microsoftCloud === "china") + && (value.microsoftRegion === undefined || isNonEmptyString(value.microsoftRegion)) ); } diff --git a/src/i18n/catalog.test.ts b/src/i18n/catalog.test.ts new file mode 100644 index 0000000..fd65df6 --- /dev/null +++ b/src/i18n/catalog.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { messages } from "./catalog"; + +describe("bilingual message catalog", () => { + it("covers the same keys in English and Simplified Chinese", () => { + expect(Object.keys(messages("zh-CN"))).toEqual(Object.keys(messages("en"))); + expect(messages("zh-CN").settings).toBe("设置"); + expect(messages("en").settings).toBe("Settings"); + }); + + it("keeps familiar service abbreviations where they are clearer", () => { + expect(messages("zh-CN").appId).toBe("APP ID"); + expect(messages("zh-CN").apiKey).toContain("API"); + }); +}); diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts new file mode 100644 index 0000000..58c9a54 --- /dev/null +++ b/src/i18n/catalog.ts @@ -0,0 +1,118 @@ +import type { UiLocale } from "../contracts/ipc"; + +const en = { + settings: "Settings", + monitoringOn: "Monitoring On", + monitoringOff: "Monitoring Off", + permissionTitle: "Accessibility Permission Required", + permissionBody: "Allow access in System Settings, then reopen Desktop Translator.", + openSystemSettings: "Open System Settings", + general: "General", + generalHint: "Choose when translation is available.", + enableSelection: "Enable Selection Translation", + enableSelectionHint: "Show the translate control when text is selected.", + startAtLogin: "Start at Login", + startAtLoginHint: "Keep translation ready after you sign in.", + languages: "Languages", + languagesHint: "Set the default direction for new selections.", + sourceLanguage: "Source", + targetLanguage: "Target", + detectAutomatically: "Auto detect", + appearance: "Appearance", + appearanceHint: "Choose language and visual theme.", + interfaceLanguage: "UI language", + theme: "Theme", + system: "System", + light: "Light", + dark: "Dark", + serviceAccess: "Translation service", + serviceHint: "Choose one provider. The app never silently switches services.", + provider: "Provider", + google: "Google Cloud", + baidu: "Baidu Translate", + microsoft: "Microsoft Translator", + microsoftCloud: "Microsoft cloud", + globalCloud: "Global", + chinaCloud: "China", + region: "Region (optional)", + apiKey: "API key", + appId: "APP ID", + secretKey: "Secret key", + notConfigured: "Not configured", + storedSecurely: "Stored securely", + needsAttention: "Needs attention", + testing: "Testing…", + configure: "Configure", + test: "Test", + remove: "Remove", + confirmRemoval: "Confirm removal", + cancel: "Cancel", + privacy: "Privacy & usage", + privacyHint: "Know what leaves your device.", + privacyOne: "Text is sent only to the selected provider after you choose Translate.", + privacyTwo: "Credentials stay in the operating-system vault.", + privacyThree: "Personal vocabulary and practice data stay on this device.", + quit: "Quit Desktop Translator", + save: "Save changes", +} as const; + +const zh: Record = { + settings: "设置", + monitoringOn: "划词翻译已开启", + monitoringOff: "划词翻译已关闭", + permissionTitle: "需要辅助功能权限", + permissionBody: "请在系统设置中允许访问,然后重新打开桌面翻译。", + openSystemSettings: "打开系统设置", + general: "通用", + generalHint: "选择翻译功能何时可用。", + enableSelection: "启用划词翻译", + enableSelectionHint: "选中文本后显示翻译按钮。", + startAtLogin: "登录时启动", + startAtLoginHint: "登录后自动准备翻译功能。", + languages: "语言", + languagesHint: "设置新翻译的默认方向。", + sourceLanguage: "源语言", + targetLanguage: "目标语言", + detectAutomatically: "自动检测", + appearance: "外观", + appearanceHint: "选择界面语言和主题。", + interfaceLanguage: "界面语言", + theme: "主题", + system: "跟随系统", + light: "浅色", + dark: "深色", + serviceAccess: "翻译服务", + serviceHint: "请选择一个服务;应用不会在后台擅自切换。", + provider: "服务商", + google: "Google Cloud", + baidu: "百度翻译", + microsoft: "微软翻译", + microsoftCloud: "微软云环境", + globalCloud: "全球", + chinaCloud: "中国区", + region: "区域(可选)", + apiKey: "API 密钥", + appId: "APP ID", + secretKey: "密钥", + notConfigured: "尚未配置", + storedSecurely: "已安全存储", + needsAttention: "需要处理", + testing: "正在测试…", + configure: "配置", + test: "测试", + remove: "移除", + confirmRemoval: "确认移除", + cancel: "取消", + privacy: "隐私与用量", + privacyHint: "了解哪些数据会离开设备。", + privacyOne: "只有在你点击翻译后,文本才会发送到所选服务。", + privacyTwo: "凭据保存在操作系统的安全凭据库中。", + privacyThree: "个人词库和练习数据仅保存在本机。", + quit: "退出桌面翻译", + save: "保存更改", +}; + +export type Messages = typeof en; +export function messages(locale: UiLocale): Messages { + return (locale === "zh-CN" ? zh : en) as Messages; +} diff --git a/src/main.tsx b/src/main.tsx index a2c050a..d1a9dd0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -44,13 +44,16 @@ interface SelectionEvent { } const fallbackSettings: UserSettings = { - schemaVersion: 1, + schemaVersion: 2, enabled: true, sourceLanguage: "auto", targetLanguage: "en", startAtLogin: false, theme: "system", maxSelectionCodePoints: 5_000, + uiLocale: "en", + translationProvider: "google", + microsoftCloud: "global", }; function runningInTauri() { @@ -133,6 +136,9 @@ export function Bootstrap() { } let disposed = false; + const settingsSubscription = listen("settings-changed", ({ payload }) => { + if (!disposed) setSettings(payload); + }); const subscription = mode === "overlay" ? listen("selection-resolved", ({ payload }) => { @@ -192,6 +198,7 @@ export function Bootstrap() { window.clearInterval(permissionPoll); } void subscription?.then((unlisten) => unlisten()); + void settingsSubscription.then((unlisten) => unlisten()); }; }, [mode]); @@ -438,6 +445,9 @@ export function Bootstrap() { if (!nextSettings.enabled) { speaking.current = false; } + void invoke("get_credential_status", { provider: nextSettings.translationProvider }) + .then(setCredentialStatus) + .catch(() => setCredentialStatus("missing")); }) .catch(async () => { const status = await invoke("get_permission_status").catch( @@ -446,21 +456,28 @@ export function Bootstrap() { setPermissionStatus(status); }); }} - onSaveCredential={() => { - void invoke("prompt_and_save_credential").then((saved) => { + onSaveCredential={(provider, field) => { + void invoke("prompt_and_save_credential", { provider, field }).then((saved) => { if (saved) { - setCredentialStatus("ready"); + void invoke("get_credential_status", { provider }) + .then(setCredentialStatus) + .catch(() => setCredentialStatus("missing")); } }); }} - onTestCredential={() => { + onProviderChange={(provider) => { + void invoke("get_credential_status", { provider }) + .then(setCredentialStatus) + .catch(() => setCredentialStatus("missing")); + }} + onTestCredential={(provider) => { setCredentialStatus("testing"); - void invoke("test_credential") + void invoke("test_credential", { provider }) .then(() => setCredentialStatus("ready")) .catch(() => setCredentialStatus("invalid")); }} - onRemoveCredential={() => { - void invoke("remove_credential").then(() => + onRemoveCredential={(provider) => { + void invoke("remove_credential", { provider }).then(() => setCredentialStatus("missing"), ); }} diff --git a/src/styles/app.css b/src/styles/app.css index 869b77b..2dac02b 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -155,7 +155,7 @@ input:focus-visible, .study-notice { margin-bottom: 20px; border-radius: var(--radius-sm); padding: 12px 14px; } .study-notice--error { border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); color: var(--danger); } .study-empty { display: grid; min-height: 280px; place-content: center; gap: 8px; border: 1px dashed var(--study-rule); border-radius: var(--radius-md); color: var(--ink-muted); text-align: center; } -.study-empty strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.35rem; font-weight: 500; } +.study-empty strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.35rem; font-weight: 500; } .study-empty__action { justify-self: center; margin-top: 10px; } .vocabulary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 14px; } @@ -178,7 +178,7 @@ input:focus-visible, .vocabulary-card__open:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--accent); outline-offset: 5px; } .vocabulary-card__copy { display: grid; align-content: start; gap: 5px; min-width: 0; } .vocabulary-card__word-row { display: grid; grid-template-columns: 36px minmax(0, 1fr); min-height: 38px; align-items: center; gap: 6px; min-width: 0; } -.vocabulary-card__copy strong { color: var(--study-ink); font-family: Georgia, serif; font-size: clamp(1.05rem, 1.7vw, 1.35rem); font-weight: 500; } +.vocabulary-card__copy strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: clamp(1.05rem, 1.7vw, 1.35rem); font-weight: 500; } .vocabulary-card__copy span { color: var(--ink-muted); } .vocabulary-card__copy small { color: var(--ink-muted); font-family: var(--font-utility); font-size: 0.65rem; text-transform: uppercase; } .vocabulary-card__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; } @@ -208,7 +208,7 @@ input:focus-visible, .relation-list { display: grid; gap: 10px; } .relation-list article { display: grid; grid-template-columns: 120px minmax(120px, 0.7fr) minmax(0, 1fr) auto; align-items: center; gap: 16px; border-bottom: 1px solid var(--study-rule); padding: 16px 4px; } -.relation-list article strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.2rem; font-weight: 500; } +.relation-list article strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.2rem; font-weight: 500; } .relation-lexeme, .textbook-entry__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 5px 8px; } .relation-badge { width: fit-content; border-radius: 999px; padding: 5px 9px; font-size: 0.65rem; font-weight: 700; } .relation-badge--root { background: var(--accent-soft); color: var(--accent-strong); } @@ -292,7 +292,7 @@ input:focus-visible, .textbook-entry-list { border-top: 1px solid var(--study-rule); } .textbook-entry { display: grid; grid-template-columns: minmax(140px, 0.8fr) minmax(160px, 1fr) auto; align-items: center; gap: 18px; min-height: 74px; border-bottom: 1px solid var(--study-rule); padding: 10px 4px; } .textbook-entry > div { display: grid; gap: 4px; } -.textbook-entry strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.12rem; font-weight: 500; } +.textbook-entry strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.12rem; font-weight: 500; } .textbook-entry small { color: var(--accent-strong); font-family: var(--font-utility); font-size: 0.68rem; } .textbook-pagination { position: sticky; z-index: 5; bottom: 0; isolation: isolate; display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 16px; border-top: 1px solid var(--study-rule); padding: 10px 4px; background: var(--study-paper); color: var(--ink-muted); font-size: 0.72rem; } .textbook-pagination::before { position: absolute; z-index: -1; top: -22px; right: 0; bottom: -40px; left: 0; background: var(--study-paper); box-shadow: 0 -12px 18px var(--study-paper); content: ""; pointer-events: none; } @@ -305,7 +305,7 @@ input:focus-visible, .practice-prompt { display: grid; gap: 9px; margin: 0; border-bottom: 1px solid var(--study-rule); padding-bottom: 18px; } .practice-prompt > span { width: fit-content; border: 1px solid var(--study-rule); border-radius: 999px; padding: 4px 8px; background: var(--surface); color: var(--ink-muted); font-family: var(--font-utility); font-size: 0.64rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; } .practice-prompt__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; } -.practice-prompt strong { color: var(--study-ink); font-family: Georgia, serif; font-size: clamp(1.75rem, 4.2vw, 3rem); font-weight: 500; line-height: 1.06; } +.practice-prompt strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: clamp(1.75rem, 4.2vw, 3rem); font-weight: 500; line-height: 1.14; } .practice-prompt strong.lexical-text--long { font-size: clamp(1.45rem, 3.7vw, 2.6rem); letter-spacing: -0.018em; } .practice-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 14px; margin-top: 16px; counter-reset: answer; } .practice-choice { display: grid; min-width: 0; min-height: 52px; grid-template-columns: 30px minmax(0, 1fr) auto; align-items: center; gap: 10px; border: 1px solid transparent; border-bottom-color: var(--study-rule); border-radius: 0; padding: 7px 8px; background: transparent; color: var(--ink); cursor: pointer; text-align: left; transition: border-color 140ms ease, background-color 140ms ease; } @@ -1085,6 +1085,36 @@ input:focus-visible, font-size: 0.78rem; } +.field input { + width: 100%; + min-height: var(--target); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + padding: 0 11px; + background: var(--surface-raised); + color: var(--ink); + font: inherit; + font-size: 0.78rem; +} + +.settings-card--appearance { + grid-template-columns: 1fr; + margin-bottom: 10px; +} + +.settings-card--appearance .theme-picker { + border-top: 1px solid var(--line); + padding-top: 14px; +} + +.provider-card { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 10px; + padding: 14px; +} + .field select:hover { border-color: var(--accent); } @@ -1197,9 +1227,11 @@ input:focus-visible, .credential-card { display: flex; + grid-column: 2; min-height: 76px; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 14px; padding: 13px 14px; } @@ -1247,6 +1279,8 @@ input:focus-visible, .button-group { display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 7px; } @@ -1354,6 +1388,10 @@ input:focus-visible, flex-direction: column; } + .credential-card { + grid-column: 1; + } + .button-group { display: grid; grid-template-columns: 1fr 1fr; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index eb3cb5b..662afbe 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -4,6 +4,8 @@ "Avenir Next", Avenir, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; font-synthesis: none; --font-utility: ui-monospace, "SFMono-Regular", "Cascadia Code", monospace; + --font-cjk-sans: "PingFang SC", "Microsoft YaHei UI", "Noto Sans CJK SC", "Source Han Sans SC", sans-serif; + --font-lexical: Georgia, "Songti SC", STSong, "Noto Serif CJK SC", serif; --ink: #17201f; --ink-muted: #66716f; --canvas: #f3f6f4; @@ -33,6 +35,12 @@ --study-rule: #c9d8d2; } +[data-locale="zh-CN"] { + font-family: var(--font-cjk-sans); + font-size: 15px; + line-height: 1.62; +} + [data-theme="light"] { color-scheme: light; } diff --git a/tools/release/audit-macos-bundle.sh b/tools/release/audit-macos-bundle.sh new file mode 100755 index 0000000..4f03e73 --- /dev/null +++ b/tools/release/audit-macos-bundle.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +app=${1:?usage: audit-macos-bundle.sh /path/to/Desktop\ Translator.app} +executable="$app/Contents/MacOS/desktop-translator" +test -x "$executable" +test -f "$app/Contents/Info.plist" +codesign --verify --verbose=2 "$app" + +bytes=$(stat -f %z "$executable") +limit=$((24 * 1024 * 1024)) +if [ "$bytes" -gt "$limit" ]; then + echo "release executable exceeds compactness budget: $bytes > $limit" >&2 + exit 1 +fi + +if otool -L "$executable" | grep -E '/opt/homebrew|/usr/local|\.cargo|node|python'; then + echo "release links a developer-machine runtime" >&2 + exit 1 +fi + +sidecars=$(find "$app/Contents/MacOS" -type f ! -name desktop-translator | wc -l | tr -d ' ') +if [ "$sidecars" -ne 0 ]; then + echo "release contains an undeclared sidecar" >&2 + exit 1 +fi + +external_textbooks=$(find "$app/Contents" -type f -name 'starter-en-zh.sqlite3' | wc -l | tr -d ' ') +if [ "$external_textbooks" -ne 0 ]; then + echo "bundled textbook must be embedded once in the executable" >&2 + exit 1 +fi + +app_kib=$(du -sk "$app" | awk '{print $1}') +echo "bundle audit passed: executable=$bytes bytes app=${app_kib}KiB sidecars=$sidecars external_textbooks=$external_textbooks" diff --git a/tools/textbooks/build-starter.sh b/tools/textbooks/build-starter.sh new file mode 100755 index 0000000..4b30f3a --- /dev/null +++ b/tools/textbooks/build-starter.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +output="$script_dir/../../src-tauri/resources/textbooks/starter-en-zh.sqlite3" +mkdir -p "$(dirname -- "$output")" +rm -f "$output" +sqlite3 "$output" < "$script_dir/starter-en-zh.sql" +shasum -a 256 "$output" +wc -c "$output" diff --git a/tools/textbooks/starter-en-zh.sql b/tools/textbooks/starter-en-zh.sql new file mode 100644 index 0000000..e0ecf53 --- /dev/null +++ b/tools/textbooks/starter-en-zh.sql @@ -0,0 +1,27 @@ +PRAGMA journal_mode=DELETE; +CREATE TABLE simple_translation (written_rep TEXT NOT NULL, trans_list TEXT NOT NULL, max_score REAL, rel_importance REAL); +CREATE TABLE translation (lexentry TEXT, written_rep TEXT NOT NULL, trans_list TEXT NOT NULL, score REAL, is_good INTEGER, importance REAL); +INSERT INTO simple_translation(written_rep, trans_list) VALUES +('ability','能力'),('accept','接受'),('achieve','实现'),('action','行动'),('active','积极的'), +('advice','建议'),('agree','同意'),('allow','允许'),('answer','回答'),('appear','出现'), +('apply','申请;应用'),('arrive','到达'),('attention','注意'),('available','可用的'),('avoid','避免'), +('believe','相信'),('benefit','益处'),('build','建造'),('change','改变'),('choose','选择'), +('clear','清楚的'),('common','常见的'),('compare','比较'),('complete','完成'),('consider','考虑'), +('continue','继续'),('create','创建'),('decide','决定'),('develop','发展'),('different','不同的'), +('difficult','困难的'),('discover','发现'),('early','早的'),('easy','容易的'),('effect','影响'), +('enough','足够的'),('example','例子'),('experience','经验'),('explain','解释'),('follow','跟随'), +('future','未来'),('general','一般的'),('happen','发生'),('important','重要的'),('improve','改进'), +('include','包括'),('increase','增加'),('information','信息'),('interest','兴趣'),('language','语言'), +('learn','学习'),('meaning','含义'),('necessary','必要的'),('notice','注意到'),('option','选项'), +('practice','练习'),('prepare','准备'),('problem','问题'),('provide','提供'),('reason','原因'), +('receive','收到'),('remember','记住'),('result','结果'),('select','选择'),('service','服务'), +('similar','相似的'),('simple','简单的'),('source','来源'),('study','学习'),('support','支持'), +('system','系统'),('textbook','教材'),('translate','翻译'),('understand','理解'),('useful','有用的'), +('value','价值'),('vocabulary','词汇'),('window','窗口'),('word','单词'),('work','工作'); +INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance) +SELECT written_rep || '__verb__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('accept','achieve','agree','allow','apply','arrive','avoid','believe','build','change','choose','compare','complete','consider','continue','create','decide','develop','discover','explain','follow','happen','improve','include','increase','learn','notice','practice','prepare','provide','receive','remember','select','study','support','translate','understand','work'); +INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance) +SELECT written_rep || '__noun__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('ability','action','advice','answer','attention','benefit','effect','example','experience','future','information','interest','language','meaning','option','problem','reason','result','service','source','system','textbook','value','vocabulary','window','word'); +INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance) +SELECT written_rep || '__adjective__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('active','available','clear','common','different','difficult','early','easy','enough','general','important','necessary','similar','simple','useful'); +VACUUM; From 53f54d4eccc504504eef56fbcc2361f7d5d891bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 24 Aug 2026 18:09:33 +0800 Subject: [PATCH 2/4] chore(release): prepare v0.3.0 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 69a2875..9a81883 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "desktop-translator", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6c9b3be..80bdf49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "desktop-translator" -version = "0.2.0" +version = "0.3.0" dependencies = [ "apple-native-keyring-store", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 44fb7d7..888d956 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-translator" -version = "0.2.0" +version = "0.3.0" description = "Lightweight cross-platform selection translator" authors = ["Desktop Translator Contributors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8a72a9e..9900db0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Desktop Translator", - "version": "0.2.0", + "version": "0.3.0", "identifier": "com.desktoptranslator.desktop", "build": { "beforeDevCommand": "pnpm dev", From 8ffa03f63b1ff13830747cbc3d45ac84d3e68121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 24 Aug 2026 18:27:29 +0800 Subject: [PATCH 3/4] fix(i18n): complete Chinese learning copy --- .../context/ContextualOverlay.test.tsx | 14 +++++ src/components/context/ContextualOverlay.tsx | 5 +- src/components/errorCopy.ts | 63 ++++++++++++++++++- .../quick/QuickTranslatePanel.test.tsx | 11 ++++ src/components/quick/QuickTranslatePanel.tsx | 5 +- .../vocabulary/VocabularyWindow.test.tsx | 14 ++++- .../vocabulary/VocabularyWindow.tsx | 33 ++++++---- 7 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/components/context/ContextualOverlay.test.tsx b/src/components/context/ContextualOverlay.test.tsx index d2ec44c..27fc980 100644 --- a/src/components/context/ContextualOverlay.test.tsx +++ b/src/components/context/ContextualOverlay.test.tsx @@ -166,4 +166,18 @@ describe("ContextualOverlay", () => { expect(container.querySelector('[role="status"]')?.textContent).toContain("正在翻译…"); expect(container.querySelector('aside[aria-label="翻译"]')).not.toBeNull(); }); + + it("localizes stable error copy in Simplified Chinese", () => { + render({ + mode: "error-visible", + selection, + generation: 1, + error: { code: "offline", message: "You are offline", retryable: true }, + }, { locale: "zh-CN" }); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("当前处于离线状态"); + expect(alert?.textContent).toContain("请重新连接网络后重试。"); + expect(alert?.textContent).not.toContain("You’re Offline"); + }); }); diff --git a/src/components/context/ContextualOverlay.tsx b/src/components/context/ContextualOverlay.tsx index 6bd2aab..1dc4a79 100644 --- a/src/components/context/ContextualOverlay.tsx +++ b/src/components/context/ContextualOverlay.tsx @@ -62,6 +62,7 @@ export function ContextualOverlay({ onDismiss, }: ContextualOverlayProps) { const zh = locale === "zh-CN"; + const errors = errorCopy(locale); useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape" && state.mode !== "idle" && state.mode !== "disabled") { @@ -194,9 +195,9 @@ export function ContextualOverlay({
-

{errorCopy[state.error.code].title}

+

{errors[state.error.code].title}

{state.error.message}

-

{errorCopy[state.error.code].guidance}

+

{errors[state.error.code].guidance}

{state.error.retryable && (
diff --git a/src/components/vocabulary/VocabularyWindow.test.tsx b/src/components/vocabulary/VocabularyWindow.test.tsx index 3100f06..9cc6f7c 100644 --- a/src/components/vocabulary/VocabularyWindow.test.tsx +++ b/src/components/vocabulary/VocabularyWindow.test.tsx @@ -141,6 +141,18 @@ describe("VocabularyWindow", () => { expect(ruler?.querySelector(".recall-ruler__label")).toBeNull(); }); + it("localizes word-card learning and action labels in Simplified Chinese", () => { + act(() => root.render()); + + expect(container.textContent).toContain("4 次查词"); + expect(container.textContent).toContain("正在形成"); + expect(container.querySelector(".recall-ruler .sr-only")?.textContent).toBe("记忆度 43/100"); + expect(container.querySelector('[aria-label="朗读 hello"]')?.title).toBe("朗读 hello"); + expect(container.querySelector('[aria-label="朗读 hola"]')?.title).toBe("没有已安装的语音支持此语言"); + expect(container.querySelector('[aria-label="管理 hello"]')?.title).toBe("管理词汇"); + expect(container.querySelector('[aria-label="查看 hello 的相关词"]')?.title).toBe("查找相关词"); + }); + it("keeps the frame fixed and gives the active content one explicit scroll owner", () => { act(() => root.render()); @@ -561,7 +573,7 @@ describe("VocabularyWindow", () => { it("localizes the card-scoped related view in Simplified Chinese", async () => { const api = makeStudyApi(); act(() => root.render()); - act(() => [...container.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "Open related words for hello")?.click()); + act(() => [...container.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "查看 hello 的相关词")?.click()); await flushEffects(); expect(container.textContent).toContain("返回我的词汇本"); diff --git a/src/components/vocabulary/VocabularyWindow.tsx b/src/components/vocabulary/VocabularyWindow.tsx index ce95342..9c6ea53 100644 --- a/src/components/vocabulary/VocabularyWindow.tsx +++ b/src/components/vocabulary/VocabularyWindow.tsx @@ -59,14 +59,17 @@ export interface StudyApi { refreshPersonal: () => void; } -const familiarityNames = ["New", "Fragile", "Forming", "Steady", "Strong", "Fluent"]; +const familiarityNames = { + en: ["New", "Fragile", "Forming", "Steady", "Strong", "Fluent"], + "zh-CN": ["新词", "生疏", "正在形成", "稳定", "熟练", "流利"], +} as const; -function RecallRuler({ entry }: { entry: VocabularyEntry }) { +function RecallRuler({ entry, locale }: { entry: VocabularyEntry; locale: UiLocale }) { const recall = Math.round(entry.effectiveRecall); return (
- Recall {recall} out of 100 + {locale === "zh-CN" ? `记忆度 ${recall}/100` : `Recall ${recall} out of 100`}