feat(app): polish desktop UI, expand languages and prepare cloud sync

This commit is contained in:
sim committed 2026-09-09 01:13:16 +08:00
1 parent ee002a15bd
commit 9a88bee5c6
102 files changed
+13295 -1154

No files matched your search

+11 -1
View File
@@ -8,7 +8,7 @@
| 层 | 位置 | 职责 |
| --- | --- | --- |
| 界面(Win/mac/Android) | `src/`(React/TypeScript/i18next,五语言) | 页面、设置、窗口分支;调用 typed IPC,展示快照与事件 |
| 界面(Win/mac/Android) | `src/`(React/TypeScript/i18next,八种界面语言) | 页面、设置、窗口分支;调用 typed IPC,展示快照与事件 |
| Host(Win/mac/Android) | `src-tauri/`(crate `openless`) | `src/lib.rs` 注册命令;适配窗口、热键、音频、凭据、插入、IME 和生命周期 |
| 共享 Core | `crates/openless-core/` | 业务规则、会话、服务调用和数据仓储;通过 trait 接入 Host 能力 |
| Linux Host + UI | `linux-egui/`(crate `openless-linux-egui`) | `backend.rs` 组装 `OpenLessBackend`,`main.rs` 实现 egui/eframe UI,不依赖 Tauri/WebKitGTK |
@@ -36,6 +36,7 @@ flowchart TB
- 桌面:React → 类型化 IPC 门面(`src/lib/ipc/`)→ Tauri command → Core;Core 事件由 Host 转发回界面。
- Linux:egui UI → `LinuxHost`(`lib.rs`:`snapshot` / `subscribe` / `save_settings` / `drain_events` 等)→ `OpenLessBackend` → Core,类型化 Rust 接口,不经 IPC。
- 浏览器预览:provider 公开目录由 Core 生成到 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors` 重新生成;只含公开元数据,无凭据);原生端走同一受启动合同保护的 IPC。
- 语言目录:`contract/language-catalog.json` 是工作语言原生名、识别代码与 Apple locale 的单一来源;React `languageCatalog.ts` 和 Core `language_catalog.rs` 读取同一份数据,识别服务仍在运行时判断具体语种是否可用。界面语言独立保存在 `ol.locale`,启动和切换经既有 `set_remote_locale` 将解析后的语言同步给原生托盘与手机远程输入页。
- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;跨平台合同以 `contract/backend-2.0.json` 为准。
启动时,`src/App.tsx` 经 `src/lib/ipc/shared.ts` 请求 `get_startup_snapshot`,校验合同版本和 backend 运行状态后进入业务界面。Core 事件定义在 `events.rs`,Tauri 的转译入口为 `src-tauri/src/tauri_events.rs`,Linux 直接订阅类型化事件。
@@ -64,6 +65,12 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装
`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。
主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮采用左右/顶部 16px 的对齐基准,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。
Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。
界面启动等待所选语言资源就绪;语言选择持久化到 `ol.locale`,其他 WebView 通过存储事件同步,日期、数字和默认风格展示随语言变化。用户修改的风格名称、说明和内容保持原文。旧版两种强制排版字段只保留数据兼容,界面清除其布局效果,窄屏改由响应式布局处理。
## 6. 存储与外部服务
| 数据或连接 | 所有者与源码入口 |
@@ -73,11 +80,14 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装
| 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 |
| 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host |
| 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 |
| 私有云同步 | Core `cloud_sync.rs` 复用同一 GitHub 登录与官方服务地址,按版本同步词典、纠错、风格包和允许的个人偏好;`cloud_sync_transaction.rs` 在本地恢复失败时回滚文件,凭据和设备配置保持本机所有。合同及边界见 [官方云同步](cloud-sync.md) |
| 风格图标 | React `src/lib/stylePackIcon.ts` 清理上传的 SVG 并转成 PNG;`set_style_pack_icon` / `read_style_pack_icon` 经 Core `style_pack_store.rs` 保存资源、校验读取范围并返回图片 data URL。图标沿用 ZIP 的 64 KiB 限制,与风格包一起导出 |
| 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 |
应用不会把普通听写交给风格包市场后端。市场安装完成后使用本地风格包;官网也不参与应用的业务调用。长期参考数据、训练准备和历史快照不是 Core 的在线训练服务。
macOS 凭据在 `persistence/credentials.rs` 使用单个 `credentials.v2` 钥匙串项目;首次迁移读取旧分块一次并保留旧项目供旧版本使用。读取失败返回错误,不能降级成“未配置”;ASR 配置状态从同一次成功读取的快照生成。其他平台保留各自存储限制与适配。
## 7. 验证入口
以下命令均在 `openless-all/app/` 执行,按变更范围选择:
+50
View File
@@ -0,0 +1,50 @@
# 官方云同步
状态:手动快照同步,HTTP 合同版本 1。客户端需要官方服务同时提供 `/me/sync`;旧版服务返回 404/405/501 时显示暂不支持,不能将其当成空备份。
## 入口与数据边界
[`cloud_sync.rs`](../openless-all/app/crates/openless-core/src/cloud_sync.rs) 从共享 Core 仓储生成快照,使用现有 `MarketplaceConfig` 的官方地址和同一个 Marketplace 登录实例。GitHub OAuth token 只从 `CredentialStore` 读取,不经过 React,不包含在状态、事件或错误中;注销标记同样对云同步生效。带凭据的请求不跟随重定向,正式地址要求 HTTPS,本机 HTTP 仅用于回环测试。
| 同步内容 | 保持在本机的内容 |
| --- | --- |
| 词典、纠正规则、风格包文本与 PNG 图标 | API 密钥、OAuth token、渠道配置 |
| 主题、胶囊、语言、字号、选中风格、其他列明的个人偏好 | 设备权限、输入/模型路径、Agent 可执行文件/工作目录/权限模式、快捷键与远程输入 PIN |
具体允许字段由 [`cloud_sync_types.rs`](../openless-all/app/crates/openless-core/src/cloud_sync_types.rs) 定义;不自动映射整个 `UserPreferences`。图标只读取 Core 拥有的资源目录,云端传输的是最多 64 KiB 的 PNG base64,恢复时重新分配本机文件路径。旧风格包的 JPEG/WebP 图标不进入这一版 PNG 同步字段。
## IPC 与冲突处理
Tauri 命令位于 [`commands/cloud_sync.rs`](../openless-all/app/src-tauri/src/commands/cloud_sync.rs),仅委托 Core:
| 命令 | 参数 | 结果 |
| --- | --- | --- |
| `cloud_sync_status` | 无 | `CloudSyncStatus`:schemaVersion、revision、updatedAt、hasSnapshot、三类数据数量 |
| `cloud_sync_upload` | baseRevision、uiPreferences(locale / fontScale) | 更新后的状态 |
| `cloud_sync_restore` | 无;界面须先确认覆盖本机可同步数据 | status 与 uiPreferences |
| `cloud_sync_delete` | baseRevision;界面须先确认删除云端备份 | 空备份的新状态;本机数据不删除 |
界面只接收数量和版本,不接收完整私有快照。上传与删除使用上次读取的版本,服务端原子比较版本;HTTP 409 转成 `BackendErrorCode::Busy`,`details.reason=revision_conflict`,同时给出 `currentRevision`。客户端不自动改版本重试。上传或删除发生网络错误且无法确认结果时返回 `OutcomeUnknown`,应先刷新状态再决定下一步。
HTTP 请求和存储内容限 2 MiB;输入有条数、文本、PNG 结构及尺寸限制。服务端返回的所有内容均按同一严格 DTO 与限额校验。`translationTargetLanguage` 的空字符串保留“沿用默认输出语言”语义。
## 本地恢复
下载并完整验证后,Core 依次取得设置写入锁、词典锁、纠错锁、风格包锁和偏好锁,生成所有替换文件。凭据和设备字段从当前本机偏好保留;缺少的内置风格包由已有仓储规则补齐。
[`cloud_sync_transaction.rs`](../openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs) 先在目标文件所在目录准备新文件与原文件备份,再依次原子替换。某个写入失败时,以备份反向回滚已经替换的文件;全部文件完成后才更新仓储内存与 PreferencesChanged、VocabularyChanged、StylePacksChanged 事件。若回滚本身失败,保留 `.cloud-sync-*.backup` 并返回 `OutcomeUnknown` 和 `recoveryRequired=true`,不报告成功。
正常完成或成功回滚后删除临时文件。该机制处理进程中可返回的文件错误;它不是 SQLite 式的跨文件崩溃恢复日志。
## 验证
[`cloud_sync_contract.rs`](../openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs) 使用临时 App 数据目录、内存凭据与本机假服务,覆盖同步往返、账号凭据不出界、设备设置保留、版本冲突、删除、错误服务与无效内容,以及恢复准备失败。事务单元测试覆盖中途替换失败后的回滚和回滚失败的真实状态反馈。
在应用目录运行:
```bash
cargo test -p openless-core --locked --test cloud_sync_contract
cargo test -p openless-core --locked --lib cloud_sync_transaction
```
真实服务上线、GitHub 登录和安装包内的设备交互需要另按平台验收;本机假服务测试不代表生产服务已部署。
+1
View File
@@ -17,6 +17,7 @@
## 接口契约
- [官方云同步](cloud-sync.md):可同步字段、GitHub 身份边界、版本冲突、本地恢复与验证。
- [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。
## 平台与运营
+3 -1
View File
@@ -36,11 +36,12 @@
| --- | --- | --- |
| 启动与窗口分支 | `src/main.tsx`、`src/App.tsx` | typed IPC 启动快照;Tauri 配置与运行时窗口 |
| 主界面、页面与设置 | `src/components/FloatingShell.tsx`、`src/pages/`、`src/pages/settings/` | `src/state/` 组织界面状态;业务规则归 Core |
| 多语言、主题、组件 | `src/i18n/`、`src/styles/`、`src/components/` | 五语言文案;tokens/global 样式 |
| 多语言、主题、组件 | `src/i18n/`、`src/styles/`、`src/components/` | 八种界面语言;tokens/global 样式 |
| 新增或调整 IPC | `src/lib/ipc/`、`src-tauri/src/commands/`、`src-tauri/src/lib.rs` | Rust/TypeScript 类型、注册、事件与 `contract/` 同步 |
| 共享业务入口 | `crates/openless-core/src/api.rs` | `events.rs`、`ports.rs`、`domains.rs`、`config.rs` |
| 听写和服务 | Core `dictation_engine.rs`、`provider_*`、`asr/`、`polish.rs` | Host 的录音、插入和本地模型适配 |
| 历史、词库、纠错、风格包 | Core `history.rs`、`vocabulary.rs`、`correction.rs`、`style_pack_store.rs` | Tauri `persistence/` 与对应 command |
| 官方云同步 | Core `cloud_sync.rs`、`cloud_sync_types.rs`、`cloud_sync_validation.rs`、`cloud_sync_transaction.rs` | Tauri `commands/cloud_sync.rs`;GitHub 身份、有限字段与版本冲突见 [云同步合同](cloud-sync.md) |
| Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 |
| Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) |
| Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 |
@@ -60,6 +61,7 @@ Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台
| `src-tauri/tauri.conf.json` / `src-tauri/capabilities/` | 应用元数据、初始窗口、打包与 Tauri 能力权限 |
| `src-tauri/vendor/` | 原生 ASR 引擎与子模块;升级按 [qwen-asr 清单](qwen-asr-submodule-upgrade-checklist.md) |
| `src/lib/ipc/provider-descriptors.generated.json` | Core 导出的公开 provider 目录;生成命令见 [架构](architecture.md) |
| `contract/language-catalog.json` | 工作语言的原生保存值、显示代码、ASR 代码与 Apple locale;前端及 Core 直接共用,新增语种不分别修改三份映射 |
| `src-tauri/gen/` | Tauri 平台生成目录;Android 手写源与合成脚本保留在 `android/`、`scripts/` |
| `node_modules/`、`dist/`、各 `target/` | 依赖和构建产物,不作为源码或 docs 的事实来源 |
@@ -0,0 +1,66 @@
[
{
"code": "zh-Hans",
"nativeName": "简体中文",
"asrCode": "zh",
"appleLocale": "zh-CN",
"aliases": ["zh-CN", "zh"]
},
{
"code": "zh-Hant",
"nativeName": "繁体中文",
"asrCode": "zh",
"appleLocale": "zh-TW",
"aliases": ["繁體中文", "zh-TW"]
},
{ "code": "en", "nativeName": "English", "asrCode": "en", "appleLocale": "en-US" },
{ "code": "ja", "nativeName": "日本語", "asrCode": "ja", "appleLocale": "ja-JP" },
{ "code": "ko", "nativeName": "한국어", "asrCode": "ko", "appleLocale": "ko-KR" },
{ "code": "fr", "nativeName": "Français", "asrCode": "fr", "appleLocale": "fr-FR" },
{ "code": "de", "nativeName": "Deutsch", "asrCode": "de", "appleLocale": "de-DE" },
{ "code": "es", "nativeName": "Español", "asrCode": "es", "appleLocale": "es-ES" },
{ "code": "it", "nativeName": "Italiano", "asrCode": "it", "appleLocale": "it-IT" },
{ "code": "pt", "nativeName": "Português", "asrCode": "pt", "appleLocale": "pt-BR" },
{ "code": "ru", "nativeName": "Русский", "asrCode": "ru", "appleLocale": "ru-RU" },
{ "code": "ar", "nativeName": "العربية", "asrCode": "ar", "appleLocale": "ar-SA" },
{ "code": "vi", "nativeName": "Tiếng Việt", "asrCode": "vi", "appleLocale": "vi-VN" },
{ "code": "th", "nativeName": "ไทย", "asrCode": "th", "appleLocale": "th-TH" },
{ "code": "hi", "nativeName": "हिन्दी", "asrCode": "hi", "appleLocale": "hi-IN" },
{ "code": "nl", "nativeName": "Nederlands", "asrCode": "nl", "appleLocale": "nl-NL" },
{ "code": "pl", "nativeName": "Polski", "asrCode": "pl", "appleLocale": "pl-PL" },
{ "code": "tr", "nativeName": "Türkçe", "asrCode": "tr", "appleLocale": "tr-TR" },
{ "code": "id", "nativeName": "Bahasa Indonesia", "asrCode": "id", "appleLocale": "id-ID" },
{ "code": "ms", "nativeName": "Bahasa Melayu", "asrCode": "ms", "appleLocale": "ms-MY" },
{
"code": "fil",
"nativeName": "Filipino",
"asrCode": "tl",
"appleLocale": "fil-PH",
"aliases": ["Tagalog", "tl"]
},
{ "code": "uk", "nativeName": "Українська", "asrCode": "uk", "appleLocale": "uk-UA" },
{ "code": "cs", "nativeName": "Čeština", "asrCode": "cs", "appleLocale": "cs-CZ" },
{ "code": "sv", "nativeName": "Svenska", "asrCode": "sv", "appleLocale": "sv-SE" },
{ "code": "da", "nativeName": "Dansk", "asrCode": "da", "appleLocale": "da-DK" },
{ "code": "fi", "nativeName": "Suomi", "asrCode": "fi", "appleLocale": "fi-FI" },
{
"code": "nb",
"nativeName": "Norsk",
"asrCode": "no",
"appleLocale": "nb-NO",
"aliases": ["Norsk bokmål", "no"]
},
{ "code": "el", "nativeName": "Ελληνικά", "asrCode": "el", "appleLocale": "el-GR" },
{ "code": "ro", "nativeName": "Română", "asrCode": "ro", "appleLocale": "ro-RO" },
{ "code": "hu", "nativeName": "Magyar", "asrCode": "hu", "appleLocale": "hu-HU" },
{ "code": "he", "nativeName": "עברית", "asrCode": "he", "appleLocale": "he-IL" },
{ "code": "bn", "nativeName": "বাংলা", "asrCode": "bn", "appleLocale": "bn-IN" },
{ "code": "ta", "nativeName": "தமிழ்", "asrCode": "ta", "appleLocale": "ta-IN" },
{ "code": "te", "nativeName": "తెలుగు", "asrCode": "te", "appleLocale": "te-IN" },
{ "code": "ur", "nativeName": "اردو", "asrCode": "ur", "appleLocale": "ur-PK" },
{ "code": "fa", "nativeName": "فارسی", "asrCode": "fa", "appleLocale": "fa-IR" },
{ "code": "ca", "nativeName": "Català", "asrCode": "ca", "appleLocale": "ca-ES" },
{ "code": "hr", "nativeName": "Hrvatski", "asrCode": "hr", "appleLocale": "hr-HR" },
{ "code": "sk", "nativeName": "Slovenčina", "asrCode": "sk", "appleLocale": "sk-SK" },
{ "code": "bg", "nativeName": "Български", "asrCode": "bg", "appleLocale": "bg-BG" }
]
+124 -18
View File
@@ -1830,7 +1830,8 @@ pub struct OpenLessBackend {
style_pack_revision: Arc<AtomicU64>,
preferences: Arc<PreferencesStore>,
preferences_revision: Arc<AtomicU64>,
settings_write_gate: Mutex<()>,
settings_write_gate: Arc<Mutex<()>>,
cloud_sync: Option<crate::cloud_sync::CloudSyncService>,
pending_corrections: Arc<Mutex<Vec<PendingCorrection>>>,
edit_observation_generation: Arc<AtomicU64>,
text_insertions: Arc<Mutex<HashMap<SessionId, TextInsertionPreparation>>>,
@@ -2031,6 +2032,7 @@ impl OpenLessBackend {
let style_pack_revision = Arc::new(AtomicU64::new(0));
let history_revision = Arc::new(AtomicU64::new(0));
let vocabulary_revision = Arc::new(AtomicU64::new(0));
let settings_write_gate = Arc::new(Mutex::new(()));
let voice_sessions = Arc::clone(&deps.services.voice_sessions);
deps.services.selection_voice =
Arc::new(crate::selection_voice_service::SelectionVoiceService::new(
@@ -2133,8 +2135,8 @@ impl OpenLessBackend {
Arc::clone(&deps.credential_store),
));
}
if let Some(marketplace_config) = deps.marketplace_config.take() {
deps.services.marketplace = Arc::new(crate::marketplace::MarketplaceService::new(
let cloud_sync = if let Some(marketplace_config) = deps.marketplace_config.take() {
let marketplace = Arc::new(crate::marketplace::MarketplaceService::new(
marketplace_config,
Arc::clone(&deps.credential_store),
Arc::clone(&repositories.preferences),
@@ -2142,7 +2144,15 @@ impl OpenLessBackend {
BackendEventPublisher::new(Arc::clone(&events)),
Arc::clone(&style_pack_revision),
)?);
}
deps.services.marketplace = marketplace.clone();
Some(crate::cloud_sync::CloudSyncService::new(
marketplace,
repositories.clone(),
Arc::clone(&settings_write_gate),
))
} else {
None
};
match (
deps.selection_runtime.take(),
deps.selection_polisher.take(),
@@ -2241,7 +2251,8 @@ impl OpenLessBackend {
style_pack_revision,
preferences: repositories.preferences,
preferences_revision,
settings_write_gate: Mutex::new(()),
settings_write_gate,
cloud_sync,
pending_corrections: Arc::new(Mutex::new(Vec::new())),
edit_observation_generation: Arc::new(AtomicU64::new(0)),
text_insertions: Arc::new(Mutex::new(HashMap::new())),
@@ -2274,6 +2285,43 @@ impl OpenLessBackend {
&self.deps.services
}
fn cloud_sync_service(&self) -> Result<&crate::cloud_sync::CloudSyncService, BackendError> {
self.cloud_sync.as_ref().ok_or_else(|| {
BackendError::new(BackendErrorCode::Unsupported, "官方云同步服务尚未配置。")
})
}
/// Read cloud metadata without returning private snapshot contents or GitHub credentials to a UI.
pub async fn cloud_sync_status(&self) -> Result<crate::CloudSyncStatus, BackendError> {
self.cloud_sync_service()?.status().await
}
pub async fn cloud_sync_upload(
&self,
base_revision: u64,
ui_preferences: crate::CloudSyncUiPreferences,
) -> Result<crate::CloudSyncStatus, BackendError> {
self.cloud_sync_service()?
.upload(base_revision, ui_preferences)
.await
}
/// The caller must obtain the user's confirmation before replacing local portable data.
pub async fn cloud_sync_restore(&self) -> Result<crate::CloudSyncRestoreResult, BackendError> {
let restored = self.cloud_sync_service()?.restore().await?;
self.publish_preferences_changed();
self.publish_vocabulary_changed();
self.publish_style_packs_changed();
Ok(restored)
}
pub async fn cloud_sync_delete(
&self,
base_revision: u64,
) -> Result<crate::CloudSyncStatus, BackendError> {
self.cloud_sync_service()?.delete(base_revision).await
}
/// Reserve one Less Computer voice-capture session before a host starts
/// its recorder/native ASR resources.
///
@@ -10207,20 +10255,25 @@ mod tests {
);
}
#[tokio::test]
async fn streaming_polish_deltas_flush_before_the_final_insert() {
async fn assert_streaming_polish_deltas_flush_before_final_insert(translation: bool) {
use crate::testing::{FixtureDictationEngine, FixtureInsertionAction, FixtureTextInserter};
let data_dir = TestDataDir::new("streaming-insert");
let engine = FixtureDictationEngine::successful("raw", "你好").with_polish_deltas(vec![
let (first, second) = if translation {
("Hello", " 🌍")
} else {
("你", "好")
};
let output = format!("{first}{second}");
let engine = FixtureDictationEngine::successful("raw", &output).with_polish_deltas(vec![
crate::types::PolishDelta {
text: "你".into(),
text: first.into(),
offset: 0,
is_final: false,
},
crate::types::PolishDelta {
text: "好".into(),
offset: 1,
text: second.into(),
offset: first.chars().count() as u64,
is_final: false,
},
]);
@@ -10249,10 +10302,36 @@ mod tests {
preferences.streaming_insert = true;
preferences.streaming_insert_save_clipboard = false;
preferences.windows_insertion_mode = crate::shared_types::WindowsInsertionMode::SendInput;
preferences.translation_target_language = "English".into();
preferences.working_languages = vec!["简体中文".into()];
backend.set_preferences(preferences).unwrap();
backend.start().await.unwrap();
let session_id = backend.start_dictation().await.unwrap();
let mut events = backend.subscribe();
let session_id = backend
.start_dictation_with_options(DictationStartOptions {
translation_requested: translation,
..DictationStartOptions::default()
})
.await
.unwrap();
assert_eq!(backend.snapshot().dictation.translation_active, translation);
assert!(!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event.kind, BackendEventKind::PolishDelta(_))));
backend.stop_dictation().await.unwrap();
let deltas: Vec<_> = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event.kind {
BackendEventKind::PolishDelta(delta) => Some(delta),
_ => None,
})
.collect();
assert_eq!(deltas.len(), 3);
assert!(!deltas[0].is_final && !deltas[1].is_final);
assert_eq!(format!("{}{}", deltas[0].text, deltas[1].text), output);
assert!(deltas[2].is_final);
assert_eq!(deltas[2].text, output);
let history = backend.list_history().unwrap();
assert_eq!(history[0].final_text, output);
assert_eq!(history[0].translation_active, translation);
assert_eq!(
inserter.actions(),
@@ -10260,7 +10339,7 @@ mod tests {
FixtureInsertionAction::Prepare(session_id),
FixtureInsertionAction::Write {
session_id,
text: "你好".into(),
text: output.clone(),
},
FixtureInsertionAction::Insert {
session_id,
@@ -10270,7 +10349,21 @@ mod tests {
);
}
async fn assert_cancel_drains_native_insertion(streaming: bool, drop_stop: bool) {
#[tokio::test]
async fn streaming_polish_deltas_flush_before_the_final_insert() {
assert_streaming_polish_deltas_flush_before_final_insert(false).await;
}
#[tokio::test]
async fn streaming_translation_deltas_flush_without_duplicate_final_text() {
assert_streaming_polish_deltas_flush_before_final_insert(true).await;
}
async fn assert_cancel_drains_native_insertion(
streaming: bool,
drop_stop: bool,
translation: bool,
) {
struct BlockingInsertion {
actions: Arc<Mutex<Vec<&'static str>>>,
started: Arc<tokio::sync::Semaphore>,
@@ -10364,9 +10457,17 @@ mod tests {
let mut preferences = backend.get_preferences();
preferences.streaming_insert = streaming;
preferences.windows_insertion_mode = crate::shared_types::WindowsInsertionMode::SendInput;
preferences.translation_target_language = "English".into();
preferences.working_languages = vec!["简体中文".into()];
backend.set_preferences(preferences).unwrap();
backend.start().await.unwrap();
let session_id = backend.start_dictation().await.unwrap();
let session_id = backend
.start_dictation_with_options(DictationStartOptions {
translation_requested: translation,
..DictationStartOptions::default()
})
.await
.unwrap();
let stopping_backend = Arc::clone(&backend);
let stop = tokio::spawn(async move { stopping_backend.stop_dictation().await });
started.acquire().await.unwrap().forget();
@@ -10408,17 +10509,22 @@ mod tests {
#[tokio::test]
async fn streaming_cancel_drains_native_write_before_restoring_and_releasing_voice() {
assert_cancel_drains_native_insertion(true, false).await;
assert_cancel_drains_native_insertion(true, false, false).await;
}
#[tokio::test]
async fn final_insert_cancel_waits_for_the_committed_native_effect() {
assert_cancel_drains_native_insertion(false, false).await;
assert_cancel_drains_native_insertion(false, false, false).await;
}
#[tokio::test]
async fn final_insert_cancellation_survives_a_dropped_stop_caller() {
assert_cancel_drains_native_insertion(false, true).await;
assert_cancel_drains_native_insertion(false, true, false).await;
}
#[tokio::test]
async fn streaming_translation_cancel_drains_native_write() {
assert_cancel_drains_native_insertion(true, false, true).await;
}
#[tokio::test]
@@ -798,6 +798,63 @@ impl TextPolisher for SharedCloudTextPolisher {
}
}
struct PolishStreamPublisher {
sink: Arc<dyn TextStreamSink>,
cancellation: Arc<ProviderCancellation>,
state: Mutex<PolishStreamState>,
}
#[derive(Default)]
struct PolishStreamState {
translation: Option<crate::prompt_compose::PolishTranslationStream>,
offset: u64,
error: Option<BackendError>,
}
impl PolishStreamPublisher {
fn new(
sink: Arc<dyn TextStreamSink>,
cancellation: Arc<ProviderCancellation>,
translation: Option<crate::prompt_compose::PolishTranslationStream>,
) -> Self {
Self {
sink,
cancellation,
state: Mutex::new(PolishStreamState {
translation,
..PolishStreamState::default()
}),
}
}
fn publish(&self, delta: &str) {
let mut state = self.state.lock();
if self.cancellation.is_cancelled() || state.error.is_some() {
return;
}
let text = match state.translation.as_mut() {
Some(translation) => translation.push(delta),
None => delta.to_owned(),
};
if text.is_empty() {
return;
}
let offset = state.offset;
state.offset += text.chars().count() as u64;
if let Err(error) = self.sink.publish(TextStreamChunk { text, offset }) {
state.error = Some(error);
}
}
fn should_cancel(&self) -> bool {
self.cancellation.is_cancelled() || self.state.lock().error.is_some()
}
fn take_error(&self) -> Option<BackendError> {
self.state.lock().error.take()
}
}
async fn run_cloud_polish(
provider: CloudPolisherProvider,
context: Arc<DictationContext>,
@@ -835,34 +892,37 @@ async fn run_cloud_polish(
} else {
context.polish.style_system_prompt.clone()
};
let polished = match &provider {
CloudPolisherProvider::OpenAi(provider)
if !context.polish.translation_active && provider.supports_streaming_polish() =>
let stream = PolishStreamPublisher::new(
Arc::clone(&partials),
Arc::clone(&cancellation),
context
.polish
.translation_active
.then(crate::prompt_compose::PolishTranslationStream::default),
);
let on_delta = |delta: &str| stream.publish(delta);
let should_cancel = || stream.should_cancel();
let result = match &provider {
CloudPolisherProvider::OpenAi(provider @ crate::polish::ActiveLLMProvider::Codex(_))
if !context.polish.translation_active =>
{
let offset = Arc::new(AtomicU64::new(0));
let publish_error: Arc<Mutex<Option<BackendError>>> = Arc::new(Mutex::new(None));
let sink = Arc::clone(&partials);
let stream_offset = Arc::clone(&offset);
let stream_error = Arc::clone(&publish_error);
let on_delta = move |delta: &str| {
if stream_error.lock().is_some() {
return;
}
let offset =
stream_offset.fetch_add(delta.chars().count() as u64, Ordering::AcqRel);
if let Err(error) = sink.publish(TextStreamChunk {
text: delta.to_string(),
offset,
}) {
*stream_error.lock() = Some(error);
}
};
let stream_error_for_cancel = Arc::clone(&publish_error);
let cancellation_for_stream = Arc::clone(&cancellation);
let should_cancel = move || {
cancellation_for_stream.is_cancelled() || stream_error_for_cancel.lock().is_some()
};
let result = provider
provider
.polish(
&raw_text,
context.polish.mode,
&context.polish.hotwords,
&style_system_prompt,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
)
.await
}
CloudPolisherProvider::OpenAi(provider) => {
provider
.polish_streaming(
&raw_text,
context.polish.mode,
@@ -877,43 +937,47 @@ async fn run_cloud_polish(
on_delta,
should_cancel,
)
.await;
if let Some(error) = publish_error.lock().take() {
return Err(error);
}
result.map_err(map_llm_error)?
.await
}
CloudPolisherProvider::Gemini(provider) if !context.polish.translation_active => {
provider
.polish(
&raw_text,
context.polish.mode,
&context.polish.hotwords,
&style_system_prompt,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
)
.await
}
CloudPolisherProvider::Gemini(provider) => {
provider
.polish_streaming(
&raw_text,
context.polish.mode,
&context.polish.hotwords,
&style_system_prompt,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
on_delta,
should_cancel,
)
.await
}
CloudPolisherProvider::OpenAi(provider) => provider
.polish(
&raw_text,
context.polish.mode,
&context.polish.hotwords,
&style_system_prompt,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
)
.await
.map_err(map_llm_error)?,
CloudPolisherProvider::Gemini(provider) => provider
.polish(
&raw_text,
context.polish.mode,
&context.polish.hotwords,
&style_system_prompt,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
)
.await
.map_err(map_llm_error)?,
};
if let Some(error) = stream.take_error() {
return Err(error);
}
let polished = result.map_err(map_llm_error)?;
if cancellation.is_cancelled() {
return Err(cancelled_provider_error());
}
@@ -928,36 +992,57 @@ async fn run_cloud_polish(
log::warn!(
"[cloud-provider] polish+translate response missing markers; using plain translation"
);
let text = match &provider {
CloudPolisherProvider::OpenAi(provider) => provider
.translate_to(
&raw_text,
&context.polish.translation_target_language,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
)
.await
.map_err(map_llm_error)?,
CloudPolisherProvider::Gemini(provider) => provider
.translate_to(
&raw_text,
&context.polish.translation_target_language,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
)
.await
.map_err(map_llm_error)?,
// No target section was published, so fallback starts at offset zero.
let stream = PolishStreamPublisher::new(
Arc::clone(&partials),
Arc::clone(&cancellation),
Some(crate::prompt_compose::PolishTranslationStream::plain()),
);
let on_delta = |delta: &str| stream.publish(delta);
let should_cancel = || stream.should_cancel();
let result = match &provider {
CloudPolisherProvider::OpenAi(provider) => {
provider
.translate_to_streaming(
&raw_text,
&context.polish.translation_target_language,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
on_delta,
should_cancel,
)
.await
}
CloudPolisherProvider::Gemini(provider) => {
provider
.translate_to_streaming(
&raw_text,
&context.polish.translation_target_language,
&context.polish.working_languages,
context.polish.chinese_script_preference,
context.polish.output_language_preference,
context.polish.front_app.as_deref(),
on_delta,
should_cancel,
)
.await
}
};
PolishOutput::text(text)
if let Some(error) = stream.take_error() {
return Err(error);
}
let text = result.map_err(map_llm_error)?;
PolishOutput::text(text.trim().to_owned())
}
}
} else {
PolishOutput::text(polished)
};
if cancellation.is_cancelled() {
return Err(cancelled_provider_error());
}
if output.text.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::Provider,
@@ -1863,6 +1948,255 @@ mod tests {
use super::*;
use crate::{InMemoryCredentialStore, ProviderInvocation, SecretValue};
struct ObservedTranslationStream {
chunks: Mutex<Vec<TextStreamChunk>>,
arrived: tokio::sync::Semaphore,
}
impl ObservedTranslationStream {
fn new() -> Self {
Self {
chunks: Mutex::new(Vec::new()),
arrived: tokio::sync::Semaphore::new(0),
}
}
}
impl TextStreamSink for ObservedTranslationStream {
fn publish(&self, chunk: TextStreamChunk) -> Result<(), BackendError> {
self.chunks.lock().push(chunk);
self.arrived.add_permits(1);
Ok(())
}
}
async fn assert_translation_streams_before_completion(
gemini: bool,
cancel: bool,
fallback: bool,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn check_request(socket: &mut tokio::net::TcpStream, gemini: bool) {
let mut request = Vec::new();
let mut buf = [0_u8; 4096];
loop {
let read = socket.read(&mut buf).await.unwrap();
assert_ne!(read, 0);
request.extend_from_slice(&buf[..read]);
if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..end]);
let length: usize = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|length| length.trim().parse().ok())
})
.unwrap();
if request.len() >= end + 4 + length {
let body: serde_json::Value =
serde_json::from_slice(&request[end + 4..]).unwrap();
if gemini {
assert!(headers.contains(":streamGenerateContent?alt=sse"));
} else {
assert_eq!(body["stream"], true);
}
break;
}
}
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let (source_sent, source_ready) = tokio::sync::oneshot::channel();
let (advance, next) = tokio::sync::oneshot::channel();
let (finish, finishing) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
check_request(&mut socket, gemini).await;
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
let event = |text: &str| {
let value = if gemini {
serde_json::json!({"candidates":[{"content":{"parts":[{"text":text}]}}]})
} else {
serde_json::json!({"choices":[{"delta":{"content":text}}]})
};
let data = format!("data: {value}\n\n");
format!("{:X}\r\n{data}\r\n", data.len())
};
if fallback {
socket
.write_all(event("malformed combined output").as_bytes())
.await
.unwrap();
socket.write_all(b"0\r\n\r\n").await.unwrap();
socket.shutdown().await.unwrap();
(socket, _) = listener.accept().await.unwrap();
check_request(&mut socket, gemini).await;
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
} else {
socket
.write_all(
event("[[OPENLESS_POLISHED_SOURCE]]\n整理后的源文。\n[[OPENLESS_TRANS")
.as_bytes(),
)
.await
.unwrap();
}
socket.flush().await.unwrap();
source_sent.send(()).unwrap();
if next.await.is_err() {
return;
}
socket
.write_all(event(if fallback { "Hello" } else { "LATION]]\nHello" }).as_bytes())
.await
.unwrap();
socket.flush().await.unwrap();
if finishing.await.is_err() {
return;
}
let _ = socket.write_all(event(" 🌍\n").as_bytes()).await;
let _ = socket.write_all(b"0\r\n\r\n").await;
});
let credentials = Arc::new(InMemoryCredentialStore::default());
write_channel_secret(
credentials.as_ref(),
CredentialNamespace::Llm,
"translation",
LLM_ENDPOINT_ACCOUNT,
&endpoint,
)
.await;
write_channel_secret(
credentials.as_ref(),
CredentialNamespace::Llm,
"translation",
LLM_API_KEY_ACCOUNT,
"fixture-key",
)
.await;
let polisher = Arc::new(SharedCloudTextPolisher::new(credentials));
let mut context = DictationContext {
llm: ProviderInvocation::new(
"translation",
if gemini {
"gemini"
} else {
"openai-compatible"
},
),
..DictationContext::default()
};
context.llm.model = Some("fixture-model".into());
context.polish.translation_active = true;
context.polish.translation_target_language = "English".into();
let sink = Arc::new(ObservedTranslationStream::new());
let session_id = SessionId::new();
let run = polisher.polish(
session_id,
Arc::new(context),
"原始语音".into(),
sink.clone(),
);
let run = tokio::spawn(run);
tokio::time::timeout(Duration::from_secs(3), source_ready)
.await
.unwrap()
.unwrap();
assert!(
sink.chunks.lock().is_empty(),
"source and partial markers must stay hidden"
);
assert!(!run.is_finished());
advance.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(3), sink.arrived.acquire())
.await
.unwrap()
.unwrap()
.forget();
assert_eq!(
*sink.chunks.lock(),
[TextStreamChunk {
text: "Hello".into(),
offset: 0
}]
);
assert!(
!run.is_finished(),
"the first translation delta must arrive before completion"
);
if cancel {
polisher.cancel(session_id).await.unwrap();
let error = tokio::time::timeout(Duration::from_secs(3), run)
.await
.unwrap()
.unwrap()
.unwrap_err();
assert_eq!(error.code, BackendErrorCode::Cancelled);
let _ = finish.send(());
server.await.unwrap();
assert_eq!(
sink.chunks.lock().len(),
1,
"cancellation must suppress later translation deltas"
);
} else {
finish.send(()).unwrap();
let output = tokio::time::timeout(Duration::from_secs(3), run)
.await
.unwrap()
.unwrap()
.unwrap();
server.await.unwrap();
assert_eq!(output.text, "Hello 🌍");
assert_eq!(
output.source_text.as_deref(),
if fallback {
None
} else {
Some("整理后的源文。")
}
);
assert_eq!(
*sink.chunks.lock(),
[
TextStreamChunk {
text: "Hello".into(),
offset: 0
},
TextStreamChunk {
text: " 🌍".into(),
offset: 5
},
]
);
}
}
#[tokio::test]
async fn openai_translation_streams_target_before_completion() {
assert_translation_streams_before_completion(false, false, false).await;
}
#[tokio::test]
async fn gemini_translation_streams_target_before_completion() {
assert_translation_streams_before_completion(true, false, false).await;
}
#[tokio::test]
async fn cancelling_translation_stops_the_provider_before_completion() {
assert_translation_streams_before_completion(false, true, false).await;
}
#[tokio::test]
async fn translation_without_markers_streams_the_plain_translation_fallback() {
assert_translation_streams_before_completion(false, false, true).await;
}
struct IgnoreTextStreamSink;
impl TextStreamSink for IgnoreTextStreamSink {
@@ -0,0 +1,625 @@
//! Official-service cloud snapshots. Credentials and device-specific settings never enter the DTO.
use crate::cloud_sync_transaction::RestoreTransaction;
use crate::cloud_sync_types::*;
use crate::cloud_sync_validation::validate_payload;
use crate::credentials::SecretValue;
use crate::marketplace::MarketplaceService;
use crate::persistence::read_or_default;
use crate::{
BackendError, BackendErrorCode, BackendRepositories, CorrectionRule, DictionaryEntry,
StylePack, StylePackKind, UserPreferences,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
pub use crate::cloud_sync_types::{SyncFontScale, SyncLocale};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CloudSyncUiPreferences {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<SyncLocale>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_scale: Option<SyncFontScale>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CloudSyncCounts {
pub dictionary: usize,
pub corrections: usize,
pub style_packs: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CloudSyncStatus {
pub schema_version: u32,
pub revision: u64,
pub updated_at: Option<String>,
pub has_snapshot: bool,
pub counts: CloudSyncCounts,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CloudSyncRestoreResult {
pub status: CloudSyncStatus,
pub ui_preferences: CloudSyncUiPreferences,
}
pub(crate) struct CloudSyncService {
marketplace: Arc<MarketplaceService>,
repositories: BackendRepositories,
settings_gate: Arc<Mutex<()>>,
operation: tokio::sync::Mutex<()>,
}
impl CloudSyncService {
pub(crate) fn new(
marketplace: Arc<MarketplaceService>,
repositories: BackendRepositories,
settings_gate: Arc<Mutex<()>>,
) -> Self {
Self {
marketplace,
repositories,
settings_gate,
operation: tokio::sync::Mutex::new(()),
}
}
pub(crate) async fn status(&self) -> Result<CloudSyncStatus, BackendError> {
let _operation = self.operation.try_lock().map_err(|_| busy())?;
let (snapshot, _) = self.fetch().await?;
Ok(status(&snapshot))
}
pub(crate) async fn upload(
&self,
base_revision: u64,
ui: CloudSyncUiPreferences,
) -> Result<CloudSyncStatus, BackendError> {
let _operation = self.operation.try_lock().map_err(|_| busy())?;
validate_base_revision(base_revision)?;
let token = self.marketplace.read_access_token().await?;
let payload = self.capture(ui)?;
validate_payload(&payload).map_err(invalid)?;
let body = serde_json::to_vec(&CloudSyncPutRequest {
schema_version: SYNC_SCHEMA_VERSION,
base_revision,
payload,
})
.map_err(|_| invalid("无法编码本机同步数据。"))?;
if body.len() > SYNC_MAX_BODY_BYTES {
return Err(invalid("同步数据超过 2 MiB,请减少词条或风格包后重试。"));
}
let snapshot = self
.request(reqwest::Method::PUT, Some(body), &token)
.await?;
Ok(status(&snapshot))
}
pub(crate) async fn delete(&self, base_revision: u64) -> Result<CloudSyncStatus, BackendError> {
let _operation = self.operation.try_lock().map_err(|_| busy())?;
validate_base_revision(base_revision)?;
let token = self.marketplace.read_access_token().await?;
let body = serde_json::to_vec(&CloudSyncDeleteRequest { base_revision })
.map_err(|_| invalid("无法编码云端删除请求。"))?;
let snapshot = self
.request(reqwest::Method::DELETE, Some(body), &token)
.await?;
Ok(status(&snapshot))
}
pub(crate) async fn restore(&self) -> Result<CloudSyncRestoreResult, BackendError> {
let _operation = self.operation.try_lock().map_err(|_| busy())?;
let (snapshot, token) = self.fetch().await?;
let payload = snapshot.payload.as_ref().ok_or_else(|| {
BackendError::new(BackendErrorCode::InvalidState, "云端还没有可恢复的备份。")
})?;
// A sign-out or account switch while downloading must not apply the previous account's data.
if self.marketplace.read_access_token().await? != token {
return Err(BackendError::new(
BackendErrorCode::Cancelled,
"GitHub 账号已切换,请重新读取云端备份。",
));
}
self.restore_local(payload)?;
Ok(CloudSyncRestoreResult {
status: status(&snapshot),
ui_preferences: CloudSyncUiPreferences {
locale: payload.preferences.locale,
font_scale: payload.preferences.font_scale,
},
})
}
async fn fetch(&self) -> Result<(CloudSyncSnapshot, SecretValue), BackendError> {
let token = self.marketplace.read_access_token().await?;
let snapshot = self.request(reqwest::Method::GET, None, &token).await?;
Ok((snapshot, token))
}
async fn request(
&self,
method: reqwest::Method,
body: Option<Vec<u8>>,
token: &SecretValue,
) -> Result<CloudSyncSnapshot, BackendError> {
let url = self.marketplace.public_url("me/sync")?;
let loopback = url.host_str().is_some_and(|host| {
host == "localhost"
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
});
if (!matches!(url.scheme(), "https") && !(url.scheme() == "http" && loopback))
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(invalid("云同步服务必须使用 HTTPS。"));
}
let is_read = method == reqwest::Method::GET;
let mut request = crate::net::credential_http_for_url(url.as_str())
.request(method, url)
.bearer_auth(token.expose_secret())
.timeout(Duration::from_secs(30));
if let Some(body) = body {
request = request
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
// Mutations are never retried automatically: a dropped response can follow a committed write.
let response = request.send().await.map_err(|_| {
BackendError::new(
if is_read {
BackendErrorCode::Provider
} else {
BackendErrorCode::OutcomeUnknown
},
if is_read {
"无法连接云同步服务,请稍后重试。"
} else {
"云端操作结果未确认,请刷新云端状态后再试。"
},
)
})?;
let http_status = response.status();
if http_status.is_redirection() {
return Err(BackendError::new(
BackendErrorCode::Provider,
"云同步服务返回了不受支持的重定向。",
));
}
if matches!(http_status.as_u16(), 401 | 403) {
return Err(BackendError::new(
BackendErrorCode::PermissionDenied,
"GitHub 登录已失效,请重新登录后同步。",
));
}
if matches!(http_status.as_u16(), 404 | 405 | 501) {
return Err(BackendError::new(
BackendErrorCode::Unsupported,
"官方服务暂未启用云同步,请稍后重试。",
));
}
if http_status == reqwest::StatusCode::CONFLICT {
let bytes = read_response(response, 8192).await?;
let conflict: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
return Err(BackendError {
code: BackendErrorCode::Busy,
message: "云端备份已被其他设备更新,请刷新后再选择上传或恢复。".into(),
retryable: false,
details: Some(
serde_json::json!({"reason":"revision_conflict","currentRevision":conflict.get("revision").and_then(serde_json::Value::as_u64)}),
),
});
}
if !http_status.is_success() {
return Err(BackendError::new(
BackendErrorCode::Provider,
format!(
"云同步请求失败(HTTP {}),本机数据保持不变。",
http_status.as_u16()
),
));
}
let bytes = read_response(response, SYNC_MAX_BODY_BYTES + 1024).await?;
let snapshot: CloudSyncSnapshot =
serde_json::from_slice(&bytes).map_err(|_| invalid_remote())?;
validate_snapshot(&snapshot)?;
Ok(snapshot)
}
fn capture(&self, ui: CloudSyncUiPreferences) -> Result<CloudSyncPayload, BackendError> {
let _settings = self
.settings_gate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// This order is also used by restore; no store lock is held across a network await.
let (_dictionary_guard, dictionary_path) =
self.repositories.vocabulary.cloud_sync_access()?;
let (_corrections_guard, corrections_path) =
self.repositories.correction_rules.cloud_sync_access()?;
let (packs, _, _) = self.repositories.style_packs.cloud_sync_access()?;
let (preferences, _) = self.repositories.preferences.cloud_sync_access();
let dictionary: Vec<DictionaryEntry> = read_or_default(dictionary_path)?;
let corrections: Vec<CorrectionRule> = read_or_default(corrections_path)?;
let style_packs = packs
.iter()
.map(|pack| {
let icon = self.repositories.style_packs.icon_data_url_for_pack(pack)?;
Ok(to_wire_pack(
pack,
icon.and_then(|icon| {
icon.strip_prefix("data:image/png;base64,")
.map(str::to_owned)
}),
))
})
.collect::<Result<Vec<_>, BackendError>>()?;
Ok(CloudSyncPayload {
dictionary: dictionary
.into_iter()
.map(|entry| SyncDictionaryEntry {
id: entry.id,
phrase: entry.phrase,
note: entry.note,
enabled: entry.enabled,
hits: entry.hits,
created_at: entry.created_at,
})
.collect(),
corrections: corrections
.into_iter()
.map(|rule| SyncCorrectionRule {
id: rule.id,
pattern: rule.pattern,
replacement: rule.replacement,
enabled: rule.enabled,
created_at: rule.created_at,
source: rule.source,
})
.collect(),
style_packs,
preferences: capture_preferences(&preferences, ui),
})
}
fn restore_local(&self, payload: &CloudSyncPayload) -> Result<(), BackendError> {
validate_payload(payload).map_err(|_| invalid_remote())?;
let mut next_packs = validated_native_packs(payload)?;
let dictionary: Vec<DictionaryEntry> = payload
.dictionary
.iter()
.map(|entry| DictionaryEntry {
id: entry.id.clone(),
phrase: entry.phrase.clone(),
note: entry.note.clone(),
enabled: entry.enabled,
hits: entry.hits,
created_at: entry.created_at.clone(),
})
.collect();
let corrections: Vec<CorrectionRule> = payload
.corrections
.iter()
.map(|rule| CorrectionRule {
id: rule.id.clone(),
pattern: rule.pattern.clone(),
replacement: rule.replacement.clone(),
enabled: rule.enabled,
created_at: rule.created_at.clone(),
source: rule.source,
})
.collect();
for rule in &corrections {
crate::correction::validate_correction_rule_syntax(&rule.pattern, &rule.replacement)
.map_err(|_| invalid_remote())?;
}
let _settings = self
.settings_gate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (_dictionary_guard, dictionary_path) =
self.repositories.vocabulary.cloud_sync_access()?;
let (_corrections_guard, corrections_path) =
self.repositories.correction_rules.cloud_sync_access()?;
let (mut packs, packs_path, assets) = self.repositories.style_packs.cloud_sync_access()?;
let (mut preferences, preferences_path) = self.repositories.preferences.cloud_sync_access();
let mut next_preferences = preferences.clone();
apply_preferences(&mut next_preferences, &payload.preferences);
crate::sync_style_pack_preferences(&mut next_preferences, &next_packs);
if !crate::SettingsEffectPlan::between(&preferences, &next_preferences).is_empty() {
return Err(invalid("云端偏好包含不能跨设备还原的运行设置。"));
}
let mut writes = Vec::new();
for (wire, pack) in payload.style_packs.iter().zip(next_packs.iter_mut()) {
// `validated_native_packs` retains source order; normalisation happens after icon assignment.
if let Some(encoded) = &wire.icon_png_base64 {
let png = STANDARD.decode(encoded).map_err(|_| invalid_remote())?;
crate::style_pack_archive::validate_icon_content("png", &png)
.map_err(|_| invalid_remote())?;
let target = owned_icon_target(assets, &pack.id)?;
pack.icon_path = Some(target.to_string_lossy().into_owned());
writes.push((target, png));
}
}
crate::style_pack_store::normalize_cloud_style_packs(&mut next_packs);
crate::sync_style_pack_preferences(&mut next_preferences, &next_packs);
writes.extend([
(dictionary_path.to_path_buf(), encode(&dictionary)?),
(corrections_path.to_path_buf(), encode(&corrections)?),
(packs_path.to_path_buf(), encode(&next_packs)?),
(preferences_path.to_path_buf(), encode(&next_preferences)?),
]);
RestoreTransaction::prepare(writes)?.commit()?;
*packs = next_packs;
*preferences = next_preferences;
Ok(())
}
}
fn status(snapshot: &CloudSyncSnapshot) -> CloudSyncStatus {
CloudSyncStatus {
schema_version: snapshot.schema_version,
revision: snapshot.revision,
updated_at: snapshot.updated_at.clone(),
has_snapshot: snapshot.payload.is_some(),
counts: snapshot
.payload
.as_ref()
.map(|payload| CloudSyncCounts {
dictionary: payload.dictionary.len(),
corrections: payload.corrections.len(),
style_packs: payload.style_packs.len(),
})
.unwrap_or_default(),
}
}
async fn read_response(response: reqwest::Response, limit: usize) -> Result<Vec<u8>, BackendError> {
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(invalid_remote());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| {
BackendError::new(
BackendErrorCode::Provider,
"读取云端备份失败,本机数据保持不变。",
)
})?;
if bytes.len().saturating_add(chunk.len()) > limit {
return Err(invalid_remote());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
fn validate_snapshot(snapshot: &CloudSyncSnapshot) -> Result<(), BackendError> {
if snapshot.schema_version != SYNC_SCHEMA_VERSION
|| snapshot.revision > SYNC_MAX_REVISION
|| (snapshot.revision == 0 && (snapshot.payload.is_some() || snapshot.updated_at.is_some()))
|| (snapshot.revision > 0
&& snapshot
.updated_at
.as_deref()
.is_none_or(|at| chrono::DateTime::parse_from_rfc3339(at).is_err()))
{
return Err(invalid_remote());
}
if let Some(payload) = &snapshot.payload {
validate_payload(payload).map_err(|_| invalid_remote())?;
}
Ok(())
}
fn validated_native_packs(payload: &CloudSyncPayload) -> Result<Vec<StylePack>, BackendError> {
payload
.style_packs
.iter()
.map(|pack| {
if pack.id == "."
|| pack.id == ".."
|| !pack
.id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
|| (pack.kind == StylePackKind::Builtin
&& pack.id != crate::builtin_style_pack_id(pack.base_mode))
|| (pack.kind == StylePackKind::Imported && pack.id.starts_with("builtin."))
{
return Err(invalid_remote());
}
Ok(StylePack {
id: pack.id.clone(),
name: pack.name.clone(),
description: pack.description.clone(),
author: pack.author.clone(),
version: pack.version.clone(),
kind: pack.kind,
base_mode: pack.base_mode,
selection_prompt: pack.selection_prompt.clone(),
prompt: pack.prompt.clone(),
examples: pack
.examples
.iter()
.map(|example| crate::StylePackExample {
title: example.title.clone(),
input: example.input.clone(),
output: example.output.clone(),
})
.collect(),
tags: pack.tags.clone(),
icon_path: None,
created_at: pack.created_at.clone(),
updated_at: pack.updated_at.clone(),
enabled: pack.enabled,
active: false,
recommended_model: pack.recommended_model.clone(),
compatible_app_version: pack.compatible_app_version.clone(),
origin_pack_id: pack.origin_pack_id.clone(),
origin_author_login: pack.origin_author_login.clone(),
})
})
.collect()
}
fn to_wire_pack(pack: &StylePack, icon_png_base64: Option<String>) -> SyncStylePack {
SyncStylePack {
id: pack.id.clone(),
name: pack.name.clone(),
description: pack.description.clone(),
author: pack.author.clone(),
version: pack.version.clone(),
kind: pack.kind,
base_mode: pack.base_mode,
selection_prompt: pack.selection_prompt.clone(),
prompt: pack.prompt.clone(),
examples: pack
.examples
.iter()
.map(|example| SyncStylePackExample {
title: example.title.clone(),
input: example.input.clone(),
output: example.output.clone(),
})
.collect(),
tags: pack.tags.clone(),
icon_png_base64,
created_at: pack.created_at.clone(),
updated_at: pack.updated_at.clone(),
enabled: pack.enabled,
recommended_model: pack.recommended_model.clone(),
compatible_app_version: pack.compatible_app_version.clone(),
origin_pack_id: pack.origin_pack_id.clone(),
origin_author_login: pack.origin_author_login.clone(),
}
}
fn capture_preferences(
preferences: &UserPreferences,
ui: CloudSyncUiPreferences,
) -> SyncPreferences {
SyncPreferences {
theme_mode: Some(preferences.theme_mode),
capsule_style: Some(preferences.capsule_style),
default_mode: Some(preferences.default_mode),
enabled_modes: Some(preferences.enabled_modes.clone()),
active_style_pack_id: Some(preferences.active_style_pack_id.clone()),
selection_polish_style_pack_id: Some(preferences.selection_polish_style_pack_id.clone()),
working_languages: Some(preferences.working_languages.clone()),
translation_target_language: Some(preferences.translation_target_language.clone()),
chinese_script_preference: Some(preferences.chinese_script_preference),
output_language_preference: Some(preferences.output_language_preference),
qa_save_history: Some(preferences.qa_save_history),
show_capsule: Some(preferences.show_capsule),
audio_cue_on_record: Some(preferences.audio_cue_on_record),
mute_during_recording: Some(preferences.mute_during_recording),
silence_auto_stop_enabled: Some(preferences.silence_auto_stop_enabled),
silence_auto_stop_seconds: Some(preferences.silence_auto_stop_seconds),
show_overview_activity_heatmap: Some(preferences.show_overview_activity_heatmap),
llm_thinking_enabled: Some(preferences.llm_thinking_enabled),
locale: ui.locale,
font_scale: ui.font_scale,
}
}
fn apply_preferences(preferences: &mut UserPreferences, incoming: &SyncPreferences) {
macro_rules! apply { ($($field:ident),+ $(,)?) => { $(if let Some(value) = &incoming.$field { preferences.$field = value.clone(); })+ }; }
apply!(
theme_mode,
capsule_style,
default_mode,
enabled_modes,
active_style_pack_id,
selection_polish_style_pack_id,
working_languages,
translation_target_language,
chinese_script_preference,
output_language_preference,
qa_save_history,
show_capsule,
audio_cue_on_record,
mute_during_recording,
silence_auto_stop_enabled,
silence_auto_stop_seconds,
show_overview_activity_heatmap,
llm_thinking_enabled
);
}
fn owned_icon_target(assets: &Path, id: &str) -> Result<PathBuf, BackendError> {
std::fs::create_dir_all(assets).map_err(|_| {
BackendError::new(
BackendErrorCode::Persistence,
"create style pack asset root",
)
})?;
let root = assets.canonicalize().map_err(|_| {
BackendError::new(
BackendErrorCode::Persistence,
"resolve style pack asset root",
)
})?;
let directory = root.join(id);
std::fs::create_dir_all(&directory).map_err(|_| {
BackendError::new(
BackendErrorCode::Persistence,
"create style pack icon directory",
)
})?;
let directory = directory.canonicalize().map_err(|_| {
BackendError::new(
BackendErrorCode::Persistence,
"resolve style pack icon directory",
)
})?;
if !directory.starts_with(&root) {
return Err(invalid_remote());
}
Ok(directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())))
}
fn encode(value: &impl Serialize) -> Result<Vec<u8>, BackendError> {
serde_json::to_vec_pretty(value).map_err(|_| {
BackendError::new(
BackendErrorCode::Persistence,
"encode local cloud restore document",
)
})
}
fn invalid(message: &str) -> BackendError {
BackendError::new(BackendErrorCode::InvalidArgument, message)
}
fn invalid_remote() -> BackendError {
BackendError::new(
BackendErrorCode::Provider,
"云端备份格式不兼容或超出限制,本机数据保持不变。",
)
}
fn busy() -> BackendError {
BackendError::new(BackendErrorCode::Busy, "已有云同步操作正在进行。")
}
fn validate_base_revision(revision: u64) -> Result<(), BackendError> {
if revision >= SYNC_MAX_REVISION {
Err(invalid("云端版本号无效,请先刷新云端状态。"))
} else {
Ok(())
}
}
@@ -0,0 +1,185 @@
//! File staging and compensation for a cloud restore while all participating stores are locked.
use crate::{BackendError, BackendErrorCode};
use std::{fs, io::Write, path::PathBuf};
struct StagedFile {
target: PathBuf,
replacement: PathBuf,
backup: Option<PathBuf>,
replacement_created: bool,
backup_created: bool,
}
pub(crate) struct RestoreTransaction {
files: Vec<StagedFile>,
preserve_backups: bool,
}
impl RestoreTransaction {
pub(crate) fn prepare(writes: Vec<(PathBuf, Vec<u8>)>) -> Result<Self, BackendError> {
let mut transaction = Self {
files: Vec::new(),
preserve_backups: false,
};
for (target, bytes) in writes {
let parent = target
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| failure("cloud restore store path is unavailable"))?;
fs::create_dir_all(parent).map_err(|_| failure("prepare cloud restore directory"))?;
let suffix = uuid::Uuid::new_v4().simple().to_string();
let replacement = parent.join(format!(".cloud-sync-{suffix}.next"));
let previous = match fs::read(&target) {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => return Err(failure("read local file before cloud restore")),
};
let backup = previous
.as_ref()
.map(|_| parent.join(format!(".cloud-sync-{suffix}.backup")));
transaction.files.push(StagedFile {
target,
replacement: replacement.clone(),
backup: backup.clone(),
replacement_created: false,
backup_created: false,
});
let staged = transaction
.files
.last_mut()
.expect("just added a staged file");
if let (Some(backup), Some(previous)) = (backup, previous) {
write_private_file(&backup, &previous)?;
staged.backup_created = true;
}
write_private_file(&replacement, &bytes)?;
staged.replacement_created = true;
}
Ok(transaction)
}
pub(crate) fn commit(mut self) -> Result<(), BackendError> {
for index in 0..self.files.len() {
if fs::rename(&self.files[index].replacement, &self.files[index].target).is_err() {
let mut restored = true;
for file in self.files[..index].iter_mut().rev() {
let result = match &file.backup {
Some(backup) => fs::rename(backup, &file.target),
None => fs::remove_file(&file.target),
};
if result.is_err() {
restored = false;
}
}
if !restored {
// Keep recovery copies if compensation fails. Never report a partial restore as success.
self.preserve_backups = true;
return Err(BackendError {
code: BackendErrorCode::OutcomeUnknown,
message: "云端恢复失败,部分本地文件未能回滚;请保留数据目录中的 .cloud-sync-*.backup 备份并检查本地数据。".into(),
retryable: false,
details: Some(serde_json::json!({"recoveryRequired": true})),
});
}
return Err(failure("云端恢复失败,本地数据已回滚。"));
}
}
Ok(())
}
}
impl Drop for RestoreTransaction {
fn drop(&mut self) {
for file in &self.files {
if file.replacement_created {
let _ = fs::remove_file(&file.replacement);
}
if file.backup_created && !self.preserve_backups {
if let Some(backup) = &file.backup {
let _ = fs::remove_file(backup);
}
}
}
}
}
fn write_private_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), BackendError> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.map_err(|_| failure("stage cloud restore file"))?;
if file.write_all(bytes).and_then(|_| file.sync_all()).is_err() {
drop(file);
let _ = fs::remove_file(path);
return Err(failure("flush cloud restore file"));
}
Ok(())
}
fn failure(message: &str) -> BackendError {
BackendError::new(BackendErrorCode::Persistence, message)
}
#[cfg(test)]
mod tests {
use super::*;
fn directory() -> PathBuf {
let root =
std::env::temp_dir().join(format!("openless-sync-files-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn failure_after_first_replacement_restores_previous_files() {
let root = directory();
let first = root.join("first.json");
let second = root.join("second.json");
fs::write(&first, b"old first").unwrap();
fs::write(&second, b"old second").unwrap();
let transaction = RestoreTransaction::prepare(vec![
(first.clone(), b"new first".to_vec()),
(second.clone(), b"new second".to_vec()),
])
.unwrap();
fs::remove_file(&transaction.files[1].replacement).unwrap();
assert_eq!(
transaction.commit().unwrap_err().code,
BackendErrorCode::Persistence
);
assert_eq!(fs::read(first).unwrap(), b"old first");
assert_eq!(fs::read(second).unwrap(), b"old second");
assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn failed_compensation_preserves_recovery_files_and_reports_unknown_outcome() {
let root = directory();
let first = root.join("first.json");
let second = root.join("second.json");
fs::write(&first, b"old first").unwrap();
fs::write(&second, b"old second").unwrap();
let transaction = RestoreTransaction::prepare(vec![
(first, b"new first".to_vec()),
(second, b"new second".to_vec()),
])
.unwrap();
fs::remove_file(transaction.files[0].backup.as_ref().unwrap()).unwrap();
fs::remove_file(&transaction.files[1].replacement).unwrap();
let surviving_backup = transaction.files[1].backup.clone().unwrap();
let error = transaction.commit().unwrap_err();
assert_eq!(error.code, BackendErrorCode::OutcomeUnknown);
assert_eq!(error.details.unwrap()["recoveryRequired"], true);
assert_eq!(fs::read(surviving_backup).unwrap(), b"old second");
fs::remove_dir_all(root).unwrap();
}
}
@@ -0,0 +1,185 @@
//! Versioned, portable user data for the official cloud service.
//!
//! These types deliberately do not embed the app's complete preferences or style-pack structs:
//! credentials, provider connections, device permissions and local paths stay on the device.
use crate::shared_types::{
ChineseScriptPreference as SyncChineseScriptPreference,
OutputLanguagePreference as SyncOutputLanguagePreference, ThemeMode as SyncThemeMode,
};
use crate::PolishMode;
use crate::{
CapsuleStyle as SyncCapsuleStyle, RuleSource as SyncRuleSource,
StylePackKind as SyncStylePackKind,
};
use serde::{Deserialize, Serialize};
pub const SYNC_SCHEMA_VERSION: u32 = 1;
pub const SYNC_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
/// Revisions stay exact in JavaScript clients as well as in SQLite.
pub const SYNC_MAX_REVISION: u64 = 9_007_199_254_740_991;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CloudSyncSnapshot {
pub schema_version: u32,
pub revision: u64,
pub updated_at: Option<String>,
/// `None` means no snapshot or a deleted snapshot. A deletion still has a nonzero revision.
pub payload: Option<CloudSyncPayload>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CloudSyncPutRequest {
pub schema_version: u32,
/// The last revision read by this device; zero creates its first cloud snapshot.
pub base_revision: u64,
pub payload: CloudSyncPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CloudSyncDeleteRequest {
pub base_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CloudSyncPayload {
pub dictionary: Vec<SyncDictionaryEntry>,
pub corrections: Vec<SyncCorrectionRule>,
pub style_packs: Vec<SyncStylePack>,
pub preferences: SyncPreferences,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncDictionaryEntry {
pub id: String,
pub phrase: String,
pub note: Option<String>,
pub enabled: bool,
pub hits: u64,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncCorrectionRule {
pub id: String,
pub pattern: String,
pub replacement: String,
pub enabled: bool,
pub created_at: String,
pub source: SyncRuleSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncStylePack {
pub id: String,
pub name: String,
pub description: String,
pub author: Option<String>,
pub version: String,
pub kind: SyncStylePackKind,
pub base_mode: PolishMode,
pub selection_prompt: String,
pub prompt: String,
pub examples: Vec<SyncStylePackExample>,
pub tags: Vec<String>,
/// Raw standard-base64 PNG, at most 64 KiB decoded. This is never a filesystem path or URL.
pub icon_png_base64: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub enabled: bool,
pub recommended_model: Option<String>,
pub compatible_app_version: Option<String>,
pub origin_pack_id: Option<String>,
pub origin_author_login: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncStylePackExample {
pub title: Option<String>,
pub input: String,
pub output: String,
}
/// An allowlist of user-facing preferences. Missing keys leave the receiving device unchanged.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncPreferences {
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_mode: Option<SyncThemeMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capsule_style: Option<SyncCapsuleStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_mode: Option<PolishMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_modes: Option<Vec<PolishMode>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_style_pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selection_polish_style_pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub translation_target_language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chinese_script_preference: Option<SyncChineseScriptPreference>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_language_preference: Option<SyncOutputLanguagePreference>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qa_save_history: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_capsule: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_cue_on_record: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mute_during_recording: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub silence_auto_stop_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub silence_auto_stop_seconds: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_overview_activity_heatmap: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub llm_thinking_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<SyncLocale>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font_scale: Option<SyncFontScale>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SyncLocale {
#[serde(rename = "system")]
System,
#[serde(rename = "zh-CN")]
ZhCn,
#[serde(rename = "zh-TW")]
ZhTw,
#[serde(rename = "en")]
En,
#[serde(rename = "ja")]
Ja,
#[serde(rename = "ko")]
Ko,
#[serde(rename = "es")]
Es,
#[serde(rename = "fr")]
Fr,
#[serde(rename = "de")]
De,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SyncFontScale {
Small,
Medium,
Large,
}
@@ -0,0 +1,203 @@
//! Input limits for portable cloud snapshots. Validation never opens client-supplied paths or URLs.
use crate::cloud_sync_types::{CloudSyncPayload, SyncPreferences, SYNC_MAX_REVISION};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::collections::HashSet;
const MAX_DICTIONARY_ENTRIES: usize = 10_000;
const MAX_CORRECTION_RULES: usize = 2_000;
const MAX_STYLE_PACKS: usize = 200;
const MAX_ICON_BYTES: usize = 64 * 1024;
pub(crate) fn validate_payload(payload: &CloudSyncPayload) -> Result<(), &'static str> {
if payload.dictionary.len() > MAX_DICTIONARY_ENTRIES
|| payload.corrections.len() > MAX_CORRECTION_RULES
|| payload.style_packs.len() > MAX_STYLE_PACKS
{
return Err("snapshot contains too many entries");
}
unique_ids(payload.dictionary.iter().map(|entry| entry.id.as_str()))?;
unique_ids(payload.corrections.iter().map(|rule| rule.id.as_str()))?;
unique_ids(payload.style_packs.iter().map(|pack| pack.id.as_str()))?;
for entry in &payload.dictionary {
text(&entry.phrase, 1024, true)?;
optional_text(entry.note.as_deref(), 4096)?;
text(&entry.created_at, 64, false)?;
if entry.hits > SYNC_MAX_REVISION {
return Err("dictionary hit count exceeds the supported range");
}
}
for rule in &payload.corrections {
text(&rule.pattern, 4096, true)?;
text(&rule.replacement, 16 * 1024, false)?;
text(&rule.created_at, 64, false)?;
}
for pack in &payload.style_packs {
text(&pack.name, 256, true)?;
text(&pack.description, 8192, false)?;
optional_text(pack.author.as_deref(), 256)?;
text(&pack.version, 64, false)?;
text(&pack.prompt, 200_000, false)?;
text(&pack.selection_prompt, 200_000, false)?;
optional_text(pack.created_at.as_deref(), 64)?;
optional_text(pack.updated_at.as_deref(), 64)?;
optional_text(pack.recommended_model.as_deref(), 256)?;
optional_text(pack.compatible_app_version.as_deref(), 64)?;
optional_text(pack.origin_author_login.as_deref(), 64)?;
if let Some(origin_id) = &pack.origin_pack_id {
identifier(origin_id)?;
}
if pack.tags.len() > 32 || pack.examples.len() > 50 {
return Err("style pack contains too many tags or examples");
}
for tag in &pack.tags {
text(tag, 64, true)?;
}
for example in &pack.examples {
optional_text(example.title.as_deref(), 256)?;
text(&example.input, 8192, false)?;
text(&example.output, 16 * 1024, false)?;
}
if let Some(icon) = &pack.icon_png_base64 {
validate_icon(icon)?;
}
}
validate_preferences(&payload.preferences)
}
fn validate_preferences(preferences: &SyncPreferences) -> Result<(), &'static str> {
for id in [
&preferences.active_style_pack_id,
&preferences.selection_polish_style_pack_id,
]
.into_iter()
.flatten()
{
identifier(id)?;
}
if let Some(modes) = &preferences.enabled_modes {
if modes.is_empty()
|| modes.len() > 4
|| modes
.iter()
.enumerate()
.any(|(index, mode)| modes[..index].contains(mode))
{
return Err("enabled modes must contain one to four distinct modes");
}
}
if let Some(languages) = &preferences.working_languages {
if languages.len() > 64 {
return Err("too many working languages");
}
for language in languages {
text(language, 64, true)?;
}
}
if let Some(language) = &preferences.translation_target_language {
// An empty target uses the app's output-language default.
text(language, 64, false)?;
}
if preferences
.silence_auto_stop_seconds
.is_some_and(|seconds| !seconds.is_finite() || !(0.5..=60.0).contains(&seconds))
{
return Err("silence auto-stop must be between 0.5 and 60 seconds");
}
Ok(())
}
fn unique_ids<'a>(ids: impl Iterator<Item = &'a str>) -> Result<(), &'static str> {
let mut seen = HashSet::new();
for id in ids {
identifier(id)?;
if !seen.insert(id) {
return Err("snapshot contains duplicate entry identifiers");
}
}
Ok(())
}
fn identifier(value: &str) -> Result<(), &'static str> {
if value.is_empty()
|| value.len() > 128
|| value == "."
|| value == ".."
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-:".contains(&byte))
{
return Err("invalid entry identifier");
}
Ok(())
}
fn text(value: &str, max_bytes: usize, required: bool) -> Result<(), &'static str> {
if value.len() > max_bytes || value.contains('\0') || (required && value.trim().is_empty()) {
return Err("text is empty, contains NUL, or exceeds its size limit");
}
Ok(())
}
fn optional_text(value: Option<&str>, max_bytes: usize) -> Result<(), &'static str> {
value.map_or(Ok(()), |value| text(value, max_bytes, false))
}
fn validate_icon(encoded: &str) -> Result<(), &'static str> {
const INVALID: &str =
"icon must be a complete static PNG of at most 64 KiB and 1024 by 1024 pixels";
if encoded.len() > MAX_ICON_BYTES.div_ceil(3) * 4 {
return Err(INVALID);
}
let bytes = STANDARD.decode(encoded).map_err(|_| INVALID)?;
if bytes.len() > MAX_ICON_BYTES || !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(INVALID);
}
// Inspect the bounded PNG container without decoding image pixels on the API worker.
let mut offset = 8usize;
let mut has_header = false;
let mut has_image = false;
while offset.checked_add(12).is_some_and(|end| end <= bytes.len()) {
let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().map_err(|_| INVALID)?);
let length = usize::try_from(length).map_err(|_| INVALID)?;
let end = offset
.checked_add(12)
.and_then(|start| start.checked_add(length))
.filter(|end| *end <= bytes.len())
.ok_or(INVALID)?;
let kind = &bytes[offset + 4..offset + 8];
if !has_header {
if kind != b"IHDR" || length != 13 {
return Err(INVALID);
}
let width = u32::from_be_bytes(
bytes[offset + 8..offset + 12]
.try_into()
.map_err(|_| INVALID)?,
);
let height = u32::from_be_bytes(
bytes[offset + 12..offset + 16]
.try_into()
.map_err(|_| INVALID)?,
);
if !(1..=1024).contains(&width) || !(1..=1024).contains(&height) {
return Err(INVALID);
}
has_header = true;
} else if kind == b"IHDR" || kind == b"acTL" {
return Err(INVALID);
} else if kind == b"IDAT" {
has_image = true;
} else if kind == b"IEND" {
return if length == 0 && end == bytes.len() && has_image {
Ok(())
} else {
Err(INVALID)
};
}
offset = end;
}
Err(INVALID)
}
@@ -38,6 +38,12 @@ impl CorrectionRuleStore {
self.read_locked()
}
pub(crate) fn cloud_sync_access(
&self,
) -> Result<(std::sync::MutexGuard<'_, ()>, &Path), BackendError> {
Ok((self.lock_store()?, &self.path))
}
pub fn add(
&self,
pattern: String,
@@ -0,0 +1,80 @@
//! Shared display names and provider language identifiers.
//!
//! A locale identifier is a routing hint, not a promise that the current provider, model or OS
//! supports recognition in that language. Provider availability is still checked at runtime.
use once_cell::sync::Lazy;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Language {
code: String,
native_name: String,
asr_code: String,
apple_locale: String,
#[serde(default)]
aliases: Vec<String>,
}
static LANGUAGES: Lazy<Vec<Language>> = Lazy::new(|| {
serde_json::from_str(include_str!("../../../contract/language-catalog.json"))
.expect("bundled language catalog is valid")
});
fn language(name: &str) -> Option<&'static Language> {
let name = name.trim();
LANGUAGES.iter().find(|language| {
language.native_name == name
|| language.code.eq_ignore_ascii_case(name)
|| language
.aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(name))
})
}
pub fn asr_language_code(native_name: &str) -> Option<String> {
language(native_name).map(|language| language.asr_code.clone())
}
pub fn apple_speech_locale(native_name: &str) -> Option<String> {
language(native_name).map(|language| language.apple_locale.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extended_names_route_to_iso_and_apple_identifiers() {
for (name, code, locale) in [
("Nederlands", "nl", "nl-NL"),
("Polski", "pl", "pl-PL"),
("Türkçe", "tr", "tr-TR"),
("Bahasa Indonesia", "id", "id-ID"),
("Filipino", "tl", "fil-PH"),
("Norsk", "no", "nb-NO"),
("Українська", "uk", "uk-UA"),
("বাংলা", "bn", "bn-IN"),
("עברית", "he", "he-IL"),
] {
assert_eq!(asr_language_code(name).as_deref(), Some(code));
assert_eq!(
crate::provider_rules::zenmux_language_code(name).as_deref(),
Some(code)
);
assert_eq!(apple_speech_locale(name).as_deref(), Some(locale));
}
}
#[test]
fn legacy_chinese_names_and_unknown_values_keep_distinct_semantics() {
assert_eq!(asr_language_code("繁體中文").as_deref(), Some("zh"));
assert_eq!(apple_speech_locale("繁體中文").as_deref(), Some("zh-TW"));
assert_eq!(apple_speech_locale("简体中文").as_deref(), Some("zh-CN"));
assert_eq!(apple_speech_locale("zh-TW").as_deref(), Some("zh-TW"));
assert_eq!(asr_language_code("unknown language"), None);
assert_eq!(apple_speech_locale(""), None);
}
}
@@ -12,6 +12,10 @@ pub mod audio;
pub mod auxiliary;
pub mod cli;
mod cloud_providers;
pub mod cloud_sync;
mod cloud_sync_transaction;
mod cloud_sync_types;
mod cloud_sync_validation;
pub mod coding_agent;
pub mod coding_agent_guard;
pub mod config;
@@ -29,6 +33,7 @@ pub mod external_audio;
pub mod history;
pub mod host_document;
mod hotkey_interpreter;
pub mod language_catalog;
mod less_computer;
pub mod llm_gemini;
mod marketplace;
@@ -76,6 +81,11 @@ mod shortcut_types;
/// migration in `docs/linux-egui-backend-contract.md`.
pub const BACKEND_CONTRACT_VERSION: &str = "2.0.0";
pub use cloud_sync::{
CloudSyncCounts, CloudSyncRestoreResult, CloudSyncStatus, CloudSyncUiPreferences,
SyncFontScale, SyncLocale,
};
pub fn require_backend_contract_version(version: &str) -> Result<(), errors::BackendError> {
if version == BACKEND_CONTRACT_VERSION {
Ok(())
@@ -164,6 +164,74 @@ impl GeminiProvider {
Ok(clean_polish_output(&raw))
}
pub async fn polish_streaming<F, C>(
&self,
raw_text: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
cursor_context: Option<&str>,
prior_turns: &[(String, String)],
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_polish_prompts(
raw_text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
cursor_context,
!prior_turns.is_empty(),
);
let body = self.build_generate_body(
&system_prompt,
build_polish_history_contents(prior_turns, &user_prompt),
);
let url = stream_generate_content_url(&self.config.base_url, &self.config.model);
self.send_streaming(&url, &body, on_delta, should_cancel)
.await
}
pub async fn translate_to_streaming<F, C>(
&self,
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
_output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_translate_prompts(
raw_text,
target_language,
working_languages,
chinese_script_preference,
front_app,
);
let body = self.build_generate_body(&system_prompt, vec![user_content(&user_prompt)]);
let url = stream_generate_content_url(&self.config.base_url, &self.config.model);
self.send_streaming(&url, &body, on_delta, should_cancel)
.await
}
/// 多模态(Omni)识别管线(issue #902)的 Gemini 通道:音频 + 提示词一次调用。
/// `wav_bytes` 为 `Some` 时以 `inlineData(audio/wav)` 追加到 user parts(已是
/// 编码好的 WAV 文件字节,PCM→WAV 的转换由 omni 层统一完成);
@@ -255,7 +255,7 @@ impl MarketplaceService {
})
}
fn public_url(&self, path: &str) -> Result<reqwest::Url, BackendError> {
pub(crate) fn public_url(&self, path: &str) -> Result<reqwest::Url, BackendError> {
self.config.base_url.join(path).map_err(|_| {
BackendError::new(
BackendErrorCode::InvalidArgument,
@@ -411,7 +411,7 @@ impl MarketplaceService {
)
}
async fn read_access_token(&self) -> Result<SecretValue, BackendError> {
pub(crate) async fn read_access_token(&self) -> Result<SecretValue, BackendError> {
if self.auth_tombstoned.load(Ordering::Acquire) {
return Err(Self::authentication_required());
}
@@ -424,9 +424,9 @@ impl MarketplaceService {
async fn clear_authentication(&self) -> Result<(), BackendError> {
self.auth_tombstoned.store(true, Ordering::Release);
let remove_result = self.credential_store.remove(Self::token_key()?).await;
let mut preferences = self.preferences.get();
preferences.marketplace_dev_login.clear();
let preferences_result = self.preferences.set(preferences);
let preferences_result = self.preferences.update(|preferences| {
preferences.marketplace_dev_login.clear();
});
remove_result.and(preferences_result)
}
@@ -781,9 +781,9 @@ impl MarketplaceService {
});
}
self.auth_tombstoned.store(false, Ordering::Release);
let mut preferences = self.preferences.get();
preferences.marketplace_dev_login = login.clone();
let _ = self.preferences.set(preferences);
let _ = self.preferences.update(|preferences| {
preferences.marketplace_dev_login = login.clone();
});
flows.consume(&lease);
return Ok(OAuthPollResult::Authorized { login });
}
@@ -234,13 +234,6 @@ impl ActiveLLMProvider {
}
}
/// 流式润色仅支持 OpenAI-compatible;Codex 使用 Responses API,保留 1.x 的
/// 非流式润色能力。调用方须先检查此能力,不能把不同的 SSE 协议混用。
/// Gemini 由共享 cloud_providers 单独分流,不进入 ActiveLLMProvider 枚举。
pub fn supports_streaming_polish(&self) -> bool {
matches!(self, Self::OpenAI(_))
}
pub async fn polish_streaming<F, C>(
&self,
raw_text: &str,
@@ -279,9 +272,24 @@ impl ActiveLLMProvider {
)
.await
}
Self::Codex(_) => Err(LLMError::Network(
"streaming polish not implemented for codex provider (v1)".into(),
)),
Self::Codex(provider) => {
provider
.polish_streaming(
raw_text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
cursor_context,
prior_turns,
on_delta,
should_cancel,
)
.await
}
}
}
@@ -371,6 +379,53 @@ impl ActiveLLMProvider {
}
}
pub async fn translate_to_streaming<F, C>(
&self,
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
match self {
Self::OpenAI(provider) => {
provider
.translate_to_streaming(
raw_text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await
}
Self::Codex(provider) => {
provider
.translate_to_streaming(
raw_text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await
}
}
}
pub async fn answer_chat_streaming<F, C>(
&self,
messages: &[QaChatMessage],
@@ -610,7 +665,7 @@ impl OpenAICompatibleLLMProvider {
chinese_script_preference,
front_app,
);
// 翻译不在本次改动范围,沿用配置里的固定预算,行为与改动前一致。
// Non-streaming callers retain the configured total request budget.
self.chat_completion(
&system_prompt,
&user_prompt,
@@ -619,6 +674,37 @@ impl OpenAICompatibleLLMProvider {
.await
}
pub async fn translate_to_streaming<F, C>(
&self,
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
_output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_translate_prompts(
raw_text,
target_language,
working_languages,
chinese_script_preference,
front_app,
);
self.chat_completion_messages_streaming(
build_polish_history_messages(&system_prompt, &[], &user_prompt),
StreamingTimeouts::for_input(raw_text.chars().count()),
on_delta,
should_cancel,
)
.await
}
/// 多轮对话感知的 polish 路径。`prior_turns` 是按时间倒序(最新在前)的
/// `(raw_transcript, polished_text)` 序列;这里反转成时间正序、然后展开
/// 成 OpenAI chat completions 的多轮 `user` / `assistant` messages,最后一条
@@ -1182,7 +1268,7 @@ impl CodexOAuthLLMProvider {
cursor_context: Option<&str>,
prior_turns: &[(String, String)],
) -> Result<String, LLMError> {
let (system_prompt, user_prompt) = compose_polish_prompts(
self.polish_streaming(
raw_text,
mode,
hotwords,
@@ -1192,20 +1278,11 @@ impl CodexOAuthLLMProvider {
output_language_preference,
front_app,
cursor_context,
!prior_turns.is_empty(),
);
log::info!(
"[style-pack] llm polish assembled provider=codex-oauth model={} mode={:?} base_prompt_chars={} effective_prompt_chars={} hotwords={} front_app={} prior_turns={}",
self.config.model,
mode,
style_system_prompt.chars().count(),
system_prompt.chars().count(),
hotwords.len(),
front_app.is_some(),
prior_turns.len()
);
let messages = build_polish_history_messages(&system_prompt, prior_turns, &user_prompt);
self.codex_responses(messages, |_| {}, || false).await
prior_turns,
|_| {},
|| false,
)
.await
}
pub async fn translate_to(
@@ -1217,20 +1294,17 @@ impl CodexOAuthLLMProvider {
_output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = context_premise(
self.translate_to_streaming(
raw_text,
target_language,
working_languages,
chinese_script_preference,
OutputLanguagePreference::Auto,
_output_language_preference,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let messages = vec![
json!({ "role": "system", "content": system_prompt }),
json!({ "role": "user", "content": prompts::user_prompt(raw_text) }),
];
self.codex_responses(messages, |_| {}, || false).await
|_| {},
|| false,
)
.await
}
pub async fn answer_chat_streaming<F, C>(
@@ -1266,6 +1340,75 @@ impl CodexOAuthLLMProvider {
.await
}
pub async fn polish_streaming<F, C>(
&self,
raw_text: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
cursor_context: Option<&str>,
prior_turns: &[(String, String)],
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_polish_prompts(
raw_text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
cursor_context,
!prior_turns.is_empty(),
);
self.codex_responses(
build_polish_history_messages(&system_prompt, prior_turns, &user_prompt),
on_delta,
should_cancel,
)
.await
}
pub async fn translate_to_streaming<F, C>(
&self,
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
_output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_translate_prompts(
raw_text,
target_language,
working_languages,
chinese_script_preference,
front_app,
);
self.codex_responses(
build_polish_history_messages(&system_prompt, &[], &user_prompt),
on_delta,
should_cancel,
)
.await
}
async fn codex_responses<F, C>(
&self,
messages: Vec<Value>,
@@ -3717,8 +3860,9 @@ mod tests {
.with_base_url(format!("http://{}", addr))
.with_auth_path(auth_path.clone()),
);
let deltas = StdMutex::new(String::new());
let output = provider
.polish(
.polish_streaming(
"原文",
PolishMode::Raw,
&[],
@@ -3729,11 +3873,14 @@ mod tests {
None,
None,
&[],
|delta| deltas.lock().unwrap().push_str(delta),
|| false,
)
.await
.unwrap();
assert_eq!(output, "最终🙂文本。");
assert_eq!(*deltas.lock().unwrap(), output);
server.join().unwrap();
let _ = std::fs::remove_file(auth_path);
}
@@ -141,6 +141,16 @@ impl PreferencesStore {
.clone()
}
/// Keep the live value locked until a coordinated restore has committed all of its files.
pub(crate) fn cloud_sync_access(&self) -> (std::sync::MutexGuard<'_, UserPreferences>, &Path) {
(
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
&self.path,
)
}
pub fn set(&self, preferences: UserPreferences) -> Result<(), BackendError> {
let json = serde_json::to_vec_pretty(&preferences)
.map_err(|_| persistence_error("encode preferences"))?;
@@ -70,6 +70,66 @@ pub fn split_polish_translate_output(raw: &str) -> Option<(Option<String>, Strin
Some((source, translation))
}
/// Publishes only the target section of the combined polish/translate response.
/// Keep incomplete markers and trailing whitespace until the next delta so the
/// concatenated output matches the final target text after whitespace trimming.
#[derive(Default)]
pub(crate) struct PolishTranslationStream {
marker_tail: String,
in_translation: bool,
started: bool,
pending_whitespace: String,
}
impl PolishTranslationStream {
pub(crate) fn plain() -> Self {
Self {
in_translation: true,
..Self::default()
}
}
pub(crate) fn push(&mut self, delta: &str) -> String {
let target = if self.in_translation {
delta.to_owned()
} else {
self.marker_tail.push_str(delta);
let Some(index) = self.marker_tail.find(POLISH_TRANSLATE_TGT_MARKER) else {
// Source text is retained by the provider's final response. This
// parser only needs a possible prefix of the target marker.
let keep = (1..POLISH_TRANSLATE_TGT_MARKER.len())
.rev()
.find(|length| {
self.marker_tail
.ends_with(&POLISH_TRANSLATE_TGT_MARKER[..*length])
})
.unwrap_or(0);
self.marker_tail.drain(..self.marker_tail.len() - keep);
return String::new();
};
self.in_translation = true;
let target = self
.marker_tail
.split_off(index + POLISH_TRANSLATE_TGT_MARKER.len());
self.marker_tail.clear();
target
};
let target = if self.started {
target.as_str()
} else {
target.trim_start()
};
if target.is_empty() {
return String::new();
}
self.started = true;
self.pending_whitespace.push_str(target);
let ready_length = self.pending_whitespace.trim_end().len();
let trailing = self.pending_whitespace.split_off(ready_length);
std::mem::replace(&mut self.pending_whitespace, trailing)
}
}
/// 把 working_languages + front_app 拼成 system prompt 头部前提:
/// # 上下文
/// 用户的工作语言:…
@@ -384,3 +444,41 @@ pub fn compose_hotword_block_preview(hotwords: &[String]) -> String {
// 实际发给 LLM 是另一段」的不一致。空热词时返回纯错别字纠错指南。
build_hotword_block(hotwords)
}
#[cfg(test)]
mod translation_stream_tests {
use super::*;
#[test]
fn translation_stream_filters_source_and_split_markers() {
let mut stream = PolishTranslationStream::default();
assert_eq!(stream.push(POLISH_TRANSLATE_SRC_MARKER), "");
assert_eq!(stream.push("\n风格化源文\n[[OPENLESS_TRANS"), "");
assert_eq!(stream.push("LATION]]\n \t你好"), "你好");
assert_eq!(stream.push(" \n"), "");
assert_eq!(stream.push(" world🙂\n"), " \n world🙂");
assert_eq!(stream.push(" \t"), "");
}
#[test]
fn translation_stream_matches_final_text_at_every_character_boundary() {
let raw = format!("{POLISH_TRANSLATE_SRC_MARKER}\n源文🙂\n{POLISH_TRANSLATE_TGT_MARKER}\n Hello 🌍\n- 第二行\n\t");
let expected = split_polish_translate_output(&raw).unwrap().1;
for boundary in raw.char_indices().map(|(index, _)| index) {
let mut stream = PolishTranslationStream::default();
let mut output = stream.push(&raw[..boundary]);
output.push_str(&stream.push(&raw[boundary..]));
assert_eq!(output, expected, "split at byte {boundary}");
}
let mut stream = PolishTranslationStream::default();
let output: String = raw.chars().map(|ch| stream.push(&ch.to_string())).collect();
assert_eq!(output, expected);
}
#[test]
fn translation_stream_without_target_marker_never_publishes_source() {
let mut stream = PolishTranslationStream::default();
assert!(stream.push("源文正文").is_empty());
assert!(stream.push("[[OPENLESS_TRANSLATIO").is_empty());
}
}
@@ -804,24 +804,7 @@ pub fn whisper_supports_verbose_json(provider_id: &str, advanced: AdvancedAsrCon
}
pub fn zenmux_language_code(native_name: &str) -> Option<String> {
let code = match native_name.trim() {
"简体中文" | "繁体中文" => "zh",
"English" => "en",
"日本語" => "ja",
"한국어" => "ko",
"Français" => "fr",
"Deutsch" => "de",
"Español" => "es",
"Italiano" => "it",
"Português" => "pt",
"Русский" => "ru",
"العربية" => "ar",
"Tiếng Việt" => "vi",
"ไทย" => "th",
"हिन्दी" => "hi",
_ => return None,
};
Some(code.to_string())
crate::language_catalog::asr_language_code(native_name)
}
pub fn volc_resource_history_label(resource_id: &str) -> Option<String> {
@@ -18,7 +18,7 @@ pub const REMOTE_INPUT_MAX_PCM_FRAME_BYTES: usize = 64 * 1024;
pub const REMOTE_INPUT_PAIRING_PIN_LEN: usize = 6;
const REMOTE_AUDIO_FRAME_HEADER_BYTES: usize = 4 + 16 + 8;
const REMOTE_AUDIO_FRAME_MAGIC: &[u8; 4] = b"OL20";
const SUPPORTED_LOCALES: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"];
const SUPPORTED_LOCALES: [&str; 8] = ["zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de"];
const PIN_MAX_FAILS: u32 = 5;
const PIN_LOCK_SECS: u64 = 60;
const PIN_FAILS_MAX_ENTRIES: usize = 256;
@@ -330,8 +330,7 @@ pub struct UserPreferences {
pub custom_style_prompts: CustomStylePrompts,
pub launch_at_login: bool,
pub show_capsule: bool,
/// 录音胶囊样式:'siri' = 流光 Siri 光效版(默认);'classic' = Openless 经典药丸版。
/// 由 capsule:state 事件的 capsuleStyle 字段下发到胶囊 webview,下次录音即生效。
/// 录音胶囊外观。偏好事件同步到各窗口,录音状态同时携带当前样式。
#[serde(default)]
pub capsule_style: CapsuleStyle,
/// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。
@@ -2270,8 +2269,7 @@ pub enum CapsuleState {
Error,
}
/// 录音胶囊样式。由 UserPreferences.capsule_style 透传到 capsule:state payload,
/// 胶囊 webview 据此选择渲染流光 Siri 光效舞台还是经典药丸。
/// 录音胶囊外观;序列化值用于偏好存储与各 Host 的窗口事件。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum CapsuleStyle {
@@ -2280,6 +2278,8 @@ pub enum CapsuleStyle {
Siri,
/// Openless 默认风格:经典毛玻璃药丸(音量条 + 取消/确认按钮)。
Classic,
/// 传统深色胶囊:蓝色波形,处理时收窄成状态提示。
Typeless,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -2868,16 +2868,20 @@ mod tests {
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
assert_eq!(prefs.capsule_style, CapsuleStyle::Siri);
// 设置里切到 Classic 后:set_settings 存盘(camelCase wire 键)→ 重启
// get_settings 读回,必须保持 Classic(配置文件持久化 roundtrip)。
let classic = UserPreferences {
capsule_style: CapsuleStyle::Classic,
..Default::default()
};
let json = serde_json::to_string(&classic).unwrap();
assert!(json.contains(r#""capsuleStyle":"classic""#));
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
assert_eq!(restored.capsule_style, CapsuleStyle::Classic);
for (style, wire_name) in [
(CapsuleStyle::Siri, "siri"),
(CapsuleStyle::Classic, "classic"),
(CapsuleStyle::Typeless, "typeless"),
] {
let preferences = UserPreferences {
capsule_style: style,
..Default::default()
};
let value = serde_json::to_value(&preferences).unwrap();
assert_eq!(value["capsuleStyle"], wire_name);
let restored: UserPreferences = serde_json::from_value(value).unwrap();
assert_eq!(restored.capsule_style, style);
}
}
#[test]
@@ -144,11 +144,13 @@ pub fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize)
pub fn streaming_insert_eligible(
enabled: bool,
translation_active: bool,
_translation_active: bool,
traditional_script: bool,
windows_paste_insertion: bool,
) -> bool {
enabled && !translation_active && !traditional_script && !windows_paste_insertion
// Translation providers now emit only target-language deltas, so they share
// the same native insertion constraints as ordinary polishing.
enabled && !traditional_script && !windows_paste_insertion
}
#[cfg(test)]
@@ -233,7 +235,7 @@ mod tests {
#[test]
fn policy_blocks_only_unsafe_modes() {
assert!(streaming_insert_eligible(true, false, false, false));
assert!(!streaming_insert_eligible(true, true, false, false));
assert!(streaming_insert_eligible(true, true, false, false));
assert!(!streaming_insert_eligible(true, false, true, false));
}
}
@@ -95,6 +95,20 @@ impl StylePackStore {
Ok(self.lock()?.clone())
}
pub(crate) fn cloud_sync_access(
&self,
) -> Result<(std::sync::MutexGuard<'_, Vec<StylePack>>, &Path, &Path), BackendError> {
let path = self
.path
.as_deref()
.ok_or_else(|| persistence_error("style pack store has no persistent path"))?;
let assets = self
.asset_root
.as_deref()
.ok_or_else(|| persistence_error("style pack asset root unavailable"))?;
Ok((self.lock()?, path, assets))
}
pub fn list_with_active(&self, active_id: &str) -> Result<Vec<StylePack>, BackendError> {
let mut packs = self.list()?;
for pack in &mut packs {
@@ -263,7 +277,14 @@ impl StylePackStore {
/// Returns an error for an unknown pack, an invalid image or path, or a read failure.
pub fn icon_data_url(&self, id: &str) -> Result<Option<String>, BackendError> {
let pack = self.get(id)?;
let Some(path) = pack.icon_path else {
self.icon_data_url_for_pack(&pack)
}
pub(crate) fn icon_data_url_for_pack(
&self,
pack: &StylePack,
) -> Result<Option<String>, BackendError> {
let Some(path) = &pack.icon_path else {
return Ok(None);
};
let root = self
@@ -551,6 +572,12 @@ impl StylePackStore {
}
}
pub(crate) fn normalize_cloud_style_packs(packs: &mut Vec<StylePack>) {
reconcile_builtin_packs(packs);
ensure_at_least_one_enabled(packs);
sort_packs(packs);
}
pub fn migrate_style_packs_from_preferences(
packs: &mut Vec<StylePack>,
preferences: &UserPreferences,
@@ -84,6 +84,13 @@ impl DictionaryStore {
read_or_default(&self.path)
}
/// Cloud restore locks every participating store before preparing or replacing any file.
pub(crate) fn cloud_sync_access(
&self,
) -> Result<(std::sync::MutexGuard<'_, ()>, &Path), BackendError> {
Ok((self.lock_store()?, &self.path))
}
/// Manual entries are intentionally inserted at the front.
pub fn add(
&self,
@@ -0,0 +1,458 @@
use openless_core::{BackendConfig, BackendDependencies, BackendErrorCode, OpenLessBackend};
use openless_core::{
CloudSyncUiPreferences, CredentialKey, CredentialNamespace, CredentialStore,
InMemoryCredentialStore, MarketplaceConfig, SecretValue, StylePackKind, SyncFontScale,
SyncLocale, MARKETPLACE_GITHUB_TOKEN_ACCOUNT,
};
use serde_json::{json, Value};
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
const ICON: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
#[derive(Default)]
struct RemoteState {
revision: u64,
payload: Option<Value>,
response_status: Option<u16>,
requests: Vec<Value>,
}
struct Server {
url: String,
state: Arc<Mutex<RemoteState>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
impl Server {
async fn new() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let state = Arc::new(Mutex::new(RemoteState::default()));
let shared = state.clone();
let task = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let (header_end, length) = loop {
let mut block = [0u8; 8192];
let read = socket.read(&mut block).await.unwrap();
if read == 0 {
return;
}
bytes.extend_from_slice(&block[..read]);
if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&bytes[..index]);
let length = headers
.lines()
.find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
break (index + 4, length);
}
};
while bytes.len() < header_end + length {
let mut block = [0u8; 8192];
let read = socket.read(&mut block).await.unwrap();
if read == 0 {
break;
}
bytes.extend_from_slice(&block[..read]);
}
let headers = String::from_utf8_lossy(&bytes[..header_end]);
let method = headers.split_whitespace().next().unwrap();
assert_eq!(headers.split_whitespace().nth(1).unwrap(), "/me/sync");
assert!(headers
.to_ascii_lowercase()
.contains("authorization: bearer cloud-fixture-token"));
let request: Value = if length > 0 {
serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap()
} else {
Value::Null
};
let (status, response) = {
let mut state = shared.lock().unwrap();
state.requests.push(request.clone());
if let Some(status) = state.response_status {
(status, json!({"error":"fixture_error"}))
} else if method != "GET"
&& request["baseRevision"].as_u64() != Some(state.revision)
{
(
409,
json!({"error":"revision_conflict","revision":state.revision,"updatedAt":"2026-09-08T10:00:00Z"}),
)
} else {
if method == "PUT" {
state.revision += 1;
state.payload = Some(request["payload"].clone());
}
if method == "DELETE" {
state.revision += 1;
state.payload = None;
}
(
200,
json!({"schemaVersion":1,"revision":state.revision,"updatedAt":if state.revision == 0 { None } else { Some("2026-09-08T10:00:00Z") },"payload":state.payload}),
)
}
};
let body = response.to_string();
let response = format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = socket.write_all(response.as_bytes()).await;
}
});
Self { url, state, task }
}
}
struct Device {
backend: OpenLessBackend,
root: PathBuf,
credentials: Arc<InMemoryCredentialStore>,
}
impl Drop for Device {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
impl Device {
async fn new(server: &Server) -> Self {
let root =
std::env::temp_dir().join(format!("openless-cloud-device-{}", uuid::Uuid::new_v4()));
let credentials = Arc::new(InMemoryCredentialStore::default());
credentials
.write(
CredentialKey::new(
CredentialNamespace::Marketplace,
None,
MARKETPLACE_GITHUB_TOKEN_ACCOUNT,
)
.unwrap(),
SecretValue::new("cloud-fixture-token"),
)
.await
.unwrap();
credentials
.write(
CredentialKey::new(CredentialNamespace::Asr, Some("fixture".into()), "api_key")
.unwrap(),
SecretValue::new("never-sync-asr-secret"),
)
.await
.unwrap();
let mut dependencies = BackendDependencies::unsupported();
dependencies.credential_store = credentials.clone();
dependencies.marketplace_config = Some(MarketplaceConfig::new(&server.url).unwrap());
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: root.clone(),
..BackendConfig::default()
},
dependencies,
)
.unwrap();
Self {
backend,
root,
credentials,
}
}
fn documents(&self) -> Vec<Option<Vec<u8>>> {
[
"preferences.json",
"dictionary.json",
"correction-rules.json",
"style-packs.json",
]
.iter()
.map(|name| std::fs::read(self.root.join(name)).ok())
.collect()
}
}
#[tokio::test]
async fn cloud_sync_requires_an_official_service_and_never_invents_an_empty_backup() {
let root = std::env::temp_dir().join(format!("openless-cloud-sync-{}", uuid::Uuid::new_v4()));
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: root.clone(),
..BackendConfig::default()
},
BackendDependencies::unsupported(),
)
.unwrap();
let error = backend.cloud_sync_status().await.unwrap_err();
assert_eq!(error.code, BackendErrorCode::Unsupported);
drop(backend);
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn portable_data_round_trips_without_uploading_credentials_or_device_settings() {
use base64::Engine;
let server = Server::new().await;
let source = Device::new(&server).await;
let word = source
.backend
.add_vocabulary("OpenLess".into(), Some("产品名称".into()))
.unwrap();
let rule = source
.backend
.add_correction_rule("Open Less".into(), "OpenLess".into())
.unwrap();
let mut pack = source.backend.get_style_pack("builtin.structured").unwrap();
pack.id = "custom.cloud".into();
pack.kind = StylePackKind::Imported;
pack.name = "Cloud writing".into();
pack.prompt = "Use clear sentences.".into();
source.backend.create_style_pack(pack).unwrap();
source
.backend
.set_style_pack_icon(
"custom.cloud",
Some(
&base64::engine::general_purpose::STANDARD
.decode(ICON)
.unwrap(),
),
)
.unwrap();
source.backend.activate_style_pack("custom.cloud").unwrap();
let mut preferences = source.backend.get_preferences();
preferences.coding_agent_workdir = Some("/private/source-workspace".into());
preferences.coding_agent_permission_mode = "bypassPermissions".into();
preferences.remote_input_pin = "never-sync-device-pin".into();
preferences.active_asr_provider = "source-only-asr".into();
preferences.theme_mode = openless_core::shared_types::ThemeMode::Dark;
source
.backend
.repositories()
.preferences
.set(preferences)
.unwrap();
let empty = source.backend.cloud_sync_status().await.unwrap();
assert!(!empty.has_snapshot);
let saved = source
.backend
.cloud_sync_upload(
0,
CloudSyncUiPreferences {
locale: Some(SyncLocale::De),
font_scale: Some(SyncFontScale::Large),
},
)
.await
.unwrap();
assert_eq!(saved.revision, 1);
assert_eq!(saved.counts.dictionary, 1);
assert_eq!(saved.counts.corrections, 1);
let uploaded = server.state.lock().unwrap().payload.clone().unwrap();
let serialized = uploaded.to_string();
for private in [
"cloud-fixture-token",
"never-sync-asr-secret",
"never-sync-device-pin",
"/private/source-workspace",
"codingAgent",
"activeAsrProvider",
"iconPath",
] {
assert!(
!serialized.contains(private),
"private field or value was uploaded: {private}"
);
}
let target = Device::new(&server).await;
target
.backend
.add_vocabulary("Local replacement".into(), None)
.unwrap();
let mut local = target.backend.get_preferences();
local.coding_agent_workdir = Some("/private/target-workspace".into());
local.coding_agent_permission_mode = "plan".into();
local.active_asr_provider = "target-asr".into();
local.remote_input_pin = "target-pin".into();
target
.backend
.repositories()
.preferences
.set(local)
.unwrap();
let result = target.backend.cloud_sync_restore().await.unwrap();
assert_eq!(result.status, saved);
assert_eq!(result.ui_preferences.locale, Some(SyncLocale::De));
assert_eq!(result.ui_preferences.font_scale, Some(SyncFontScale::Large));
assert_eq!(target.backend.list_vocabulary().unwrap(), vec![word]);
assert_eq!(target.backend.list_correction_rules().unwrap(), vec![rule]);
assert_eq!(
target
.backend
.get_style_pack("custom.cloud")
.unwrap()
.prompt,
"Use clear sentences."
);
assert_eq!(
target.backend.read_style_pack_icon("custom.cloud").unwrap(),
Some(format!("data:image/png;base64,{ICON}"))
);
let preferences = target.backend.get_preferences();
assert_eq!(
preferences.theme_mode,
openless_core::shared_types::ThemeMode::Dark
);
assert_eq!(preferences.active_style_pack_id, "custom.cloud");
assert_eq!(preferences.active_asr_provider, "target-asr");
assert_eq!(preferences.coding_agent_permission_mode, "plan");
assert_eq!(
preferences.coding_agent_workdir.as_deref(),
Some("/private/target-workspace")
);
assert_eq!(preferences.remote_input_pin, "target-pin");
let secret = target
.credentials
.read(
CredentialKey::new(CredentialNamespace::Asr, Some("fixture".into()), "api_key")
.unwrap(),
)
.await
.unwrap()
.unwrap();
assert_eq!(secret.expose_secret(), "never-sync-asr-secret");
let status_json = serde_json::to_value(result).unwrap();
assert!(status_json.get("payload").is_none());
assert!(!status_json.to_string().contains("OpenLess"));
let reopened = openless_core::BackendRepositories::open(&target.root).unwrap();
assert_eq!(
reopened.preferences.get().active_style_pack_id,
"custom.cloud"
);
}
#[tokio::test]
async fn conflicts_and_cloud_deletion_leave_local_documents_unchanged() {
let server = Server::new().await;
let device = Device::new(&server).await;
device
.backend
.add_vocabulary("keep local".into(), None)
.unwrap();
device
.backend
.cloud_sync_upload(0, CloudSyncUiPreferences::default())
.await
.unwrap();
let before = device.documents();
let error = device
.backend
.cloud_sync_upload(0, CloudSyncUiPreferences::default())
.await
.unwrap_err();
assert_eq!(error.code, BackendErrorCode::Busy);
assert_eq!(error.details.unwrap()["currentRevision"], 1);
assert_eq!(device.documents(), before);
let deleted = device.backend.cloud_sync_delete(1).await.unwrap();
assert_eq!(deleted.revision, 2);
assert!(!deleted.has_snapshot);
assert_eq!(device.documents(), before);
assert_eq!(
device.backend.cloud_sync_restore().await.unwrap_err().code,
BackendErrorCode::InvalidState
);
assert_eq!(device.documents(), before);
}
#[tokio::test]
async fn invalid_remote_data_and_write_preparation_failure_preserve_local_state() {
let server = Server::new().await;
let device = Device::new(&server).await;
device
.backend
.add_vocabulary("keep local".into(), None)
.unwrap();
device
.backend
.cloud_sync_upload(0, CloudSyncUiPreferences::default())
.await
.unwrap();
let good = server.state.lock().unwrap().payload.clone();
let before = device.documents();
server.state.lock().unwrap().payload.as_mut().unwrap()["preferences"]["codingAgentExe"] =
json!("/tmp/untrusted-executable");
assert_eq!(
device.backend.cloud_sync_restore().await.unwrap_err().code,
BackendErrorCode::Provider
);
assert_eq!(device.documents(), before);
server.state.lock().unwrap().payload = good;
let path = device.root.join("preferences.json");
if path.exists() {
std::fs::remove_file(&path).unwrap();
}
std::fs::create_dir(&path).unwrap();
let memory_before = serde_json::to_value(device.backend.get_preferences()).unwrap();
let failing_before = device.documents();
assert_eq!(
device.backend.cloud_sync_restore().await.unwrap_err().code,
BackendErrorCode::Persistence
);
assert_eq!(device.documents(), failing_before);
assert_eq!(
serde_json::to_value(device.backend.get_preferences()).unwrap(),
memory_before
);
}
#[tokio::test]
async fn unsupported_server_auth_failures_and_logout_do_not_invent_success() {
let server = Server::new().await;
let device = Device::new(&server).await;
let before = device.documents();
for (http, code) in [
(404, BackendErrorCode::Unsupported),
(401, BackendErrorCode::PermissionDenied),
(503, BackendErrorCode::Provider),
] {
server.state.lock().unwrap().response_status = Some(http);
assert_eq!(
device.backend.cloud_sync_status().await.unwrap_err().code,
code
);
assert_eq!(device.documents(), before);
}
server.state.lock().unwrap().response_status = None;
device
.backend
.services()
.marketplace
.logout()
.await
.unwrap();
let count = server.state.lock().unwrap().requests.len();
assert_eq!(
device.backend.cloud_sync_status().await.unwrap_err().code,
BackendErrorCode::PermissionDenied
);
assert_eq!(server.state.lock().unwrap().requests.len(), count);
}
@@ -648,10 +648,16 @@ async fn locale_and_connection_status_are_core_owned() {
let runtime = Arc::new(FixtureRemoteRuntime::default());
let (backend, data_dir) = backend(runtime);
let remote = &backend.services().remote_input;
remote.set_locale("en".to_string()).await.unwrap();
assert_eq!(remote.status().unwrap().locale, "en");
for locale in ["en", "es", "fr", "de"] {
remote.set_locale(locale.to_string()).await.unwrap();
assert_eq!(remote.status().unwrap().locale, locale);
}
assert_eq!(
remote.set_locale("fr".to_string()).await.unwrap_err().code,
remote
.set_locale("invalid-locale".to_string())
.await
.unwrap_err()
.code,
BackendErrorCode::InvalidArgument
);
assert_eq!(
@@ -0,0 +1,62 @@
import { register } from 'tsx/esm/api';
register();
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
const { formatHistoryTime, formatLocaleDate, formatLocaleDecimal, formatLocaleNumber } =
await import('../src/lib/localeFormat.ts');
const westOfUtc = spawnSync(
process.execPath,
[
'--import',
'tsx',
'--input-type=module',
'-e',
`import { formatLocaleDate } from ${JSON.stringify(new URL('../src/lib/localeFormat.ts', import.meta.url).href)}; console.log(formatLocaleDate('2026-09-08', 'de'));`,
],
{ env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' },
);
assert.equal(westOfUtc.status, 0, westOfUtc.stderr);
assert.equal(westOfUtc.stdout.trim(), '8.9.', 'calendar dates must not shift one day west of UTC');
assert.equal(formatLocaleNumber(12345, 'de'), '12.345');
assert.equal(formatLocaleNumber(12345, 'en'), '12,345');
assert.equal(formatLocaleDecimal(1.5, 'de'), '1,5');
assert.equal(formatLocaleDecimal(1.5, 'en'), '1.5');
const now = new Date(2026, 8, 9, 18, 0);
const today = new Date(2026, 8, 9, 16, 5).toISOString();
const yesterday = new Date(2026, 8, 8, 16, 5).toISOString();
for (const locale of ['zh-CN', 'zh-TW', 'en', 'es', 'fr', 'de', 'ja', 'ko']) {
assert.equal(
formatHistoryTime(today, locale, now),
new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(new Date(today)),
);
assert.equal(
formatHistoryTime(yesterday, locale, now),
new Intl.DateTimeFormat(locale, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(yesterday)),
);
const day = formatLocaleDate('2026-09-08', locale, {
timeZone: 'America/Los_Angeles',
month: 'numeric',
day: 'numeric',
});
// Local calendar keys must be constructed as a local Date before formatting.
const expected = new Intl.DateTimeFormat(locale, {
timeZone: 'America/Los_Angeles',
month: 'numeric',
day: 'numeric',
}).format(new Date(2026, 8, 8));
assert.equal(day, expected);
}
assert.notEqual(formatHistoryTime(yesterday, 'en', now), formatHistoryTime(yesterday, 'de', now));
assert.equal(formatLocaleDate('2026-09-08', 'de'), '8.9.');
assert.equal(formatLocaleDate('not-a-date', 'de'), 'not-a-date');
assert.equal(formatHistoryTime('not-a-date', 'de', now), 'not-a-date');
assert.ok(
formatHistoryTime(new Date(2025, 8, 8, 16, 5).toISOString(), 'de', now).includes('2025'),
'old records retain their year',
);
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
async function exercise(scenario) {
const entries = new Map();
if (scenario.stored) entries.set('ol.locale', scenario.stored);
const storage = {
getItem: (key) => entries.get(key) ?? null,
setItem: (key, value) => entries.set(key, String(value)),
removeItem: (key) => entries.delete(key),
clear: () => entries.clear(),
};
// The provider/probe tree renders no HTML. These DOM surfaces let React run
// its real mount/update effects without adding a browser emulation dependency.
class Element extends EventTarget {
ownerDocument;
tagName;
nodeType = 1;
namespaceURI = 'http://www.w3.org/1999/xhtml';
style = {};
dataset = {};
textContent = '';
constructor(ownerDocument, tagName) {
super();
this.ownerDocument = ownerDocument;
this.tagName = tagName;
}
get nodeName() {
return this.tagName;
}
setAttribute() {}
contains(value) {
return value === this;
}
}
class TestDocument extends EventTarget {
nodeType = 9;
documentElement = new Element(this, 'HTML');
body = new Element(this, 'BODY');
activeElement = this.body;
createElement(tag) {
return new Element(this, tag.toUpperCase());
}
}
const doc = new TestDocument();
const browser = Object.assign(new EventTarget(), {
document: doc,
localStorage: storage,
sessionStorage: storage,
HTMLElement: Element,
HTMLIFrameElement: class extends Element {},
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: browser });
Object.defineProperty(globalThis, 'document', { configurable: true, value: doc });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { language: scenario.system, platform: 'MacIntel', userAgent: 'Macintosh' },
});
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
const locale = await import('../src/i18n/index.ts');
await locale.i18nReady;
const boot = locale.default.resolvedLanguage;
const { getSettings, setSettings } = await import('../src/lib/ipc/index.ts');
const speech = {
chineseScriptPreference: 'traditional',
outputLanguagePreference: 'ko',
translationTargetLanguage: '日本語',
workingLanguages: ['한국어'],
};
await setSettings({
...(await getSettings()),
...speech,
workingLanguages: [...speech.workingLanguages],
});
const { act, createElement } = await import('react');
let root;
let current;
if (scenario.renderPreferences) {
const { createRoot } = await import('react-dom/client');
const { HotkeySettingsProvider, useHotkeySettings } =
await import('../src/state/HotkeySettingsContext.tsx');
const { useTranslation } = await import('react-i18next');
function Probe() {
current = useHotkeySettings();
useTranslation();
return null;
}
const mounted = createRoot(doc.createElement('div'));
root = mounted;
await act(async () => {
mounted.render(createElement(HotkeySettingsProvider, { children: createElement(Probe) }));
});
assert.ok(current?.prefs, 'the real preference provider has finished loading');
}
await act(async () => {
if (scenario.action === 'switch') await locale.setLocalePreference('fr');
if (scenario.action === 'system') await locale.setLocalePreference('system');
if (scenario.action === 'rapid')
await Promise.all([
locale.setLocalePreference('de'),
locale.setLocalePreference('es'),
locale.setLocalePreference('fr'),
]);
if (scenario.action === 'storage') {
const changed = new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('auxiliary window did not follow the storage event')),
3000,
);
locale.default.once('languageChanged', () => {
clearTimeout(timer);
resolve();
});
});
storage.setItem('ol.locale', 'ko');
browser.dispatchEvent(
Object.assign(new Event('storage'), {
key: 'ol.locale',
newValue: 'ko',
storageArea: storage,
}),
);
await changed;
}
});
if (current?.prefs) {
await act(async () => {
await current.updatePrefs((prefs) => ({ ...prefs, startMinimized: !prefs.startMinimized }));
});
await act(async () => {
await current.refresh();
});
}
const settings = await getSettings();
for (const [key, value] of Object.entries(speech)) {
assert.deepEqual(settings[key], value, `UI locale must not overwrite ${key}`);
}
if (root) await act(async () => root.unmount());
console.log(
JSON.stringify({
boot,
active: locale.default.resolvedLanguage,
saved: storage.getItem('ol.locale'),
preference: locale.getLocalePreference(),
title: locale.default.t('settings.title'),
documentLanguage: doc.documentElement.lang,
}),
);
}
const input = process.env.OPENLESS_LOCALE_TEST_SCENARIO;
if (input) {
await exercise(JSON.parse(input));
} else {
const run = (scenario) => {
const result = spawnSync(
process.execPath,
['--import', 'tsx', fileURLToPath(import.meta.url)],
{
env: { ...process.env, OPENLESS_LOCALE_TEST_SCENARIO: JSON.stringify(scenario) },
encoding: 'utf8',
timeout: 10000,
},
);
assert.equal(result.status, 0, result.stderr || result.error?.message);
return JSON.parse(result.stdout.trim());
};
assert.equal(run({ system: 'de-DE' }).boot, 'de');
assert.equal(run({ system: 'zh-HK' }).boot, 'zh-TW');
assert.equal(run({ system: 'pt-BR' }).boot, 'en');
assert.equal(run({ system: 'ko-KR', stored: 'es' }).boot, 'es');
assert.equal(run({ system: 'fr-CA', stored: 'not-supported' }).boot, 'fr');
const switched = run({
system: 'de-DE',
stored: 'es',
action: 'switch',
renderPreferences: true,
});
assert.equal(switched.active, 'fr');
assert.equal(switched.saved, 'fr');
assert.equal(switched.documentLanguage, 'fr');
assert.equal(
run({ system: 'ja-JP', stored: switched.saved }).boot,
'fr',
'restart preserves the explicit language',
);
const system = run({ system: 'de-DE', stored: 'fr', action: 'system', renderPreferences: true });
assert.equal(system.active, 'de');
assert.equal(system.saved, null);
assert.equal(system.preference, 'system');
assert.equal(run({ system: 'es-MX' }).boot, 'es', 'follow-system is resolved again on restart');
assert.equal(
run({ system: 'en-US', stored: 'de', action: 'storage', renderPreferences: true }).active,
'ko',
);
const rapid = run({ system: 'en-US', action: 'rapid', renderPreferences: true });
assert.equal(rapid.active, 'fr');
assert.equal(rapid.saved, 'fr');
}
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(
new URL('../src-tauri/src/remote_server/assets/app.js', import.meta.url),
'utf8',
);
const prefix = source.slice(0, source.indexOf(' // 极简插值:'));
assert(prefix.includes('var L = I18N[LANG]'), 'read the actual locale dictionary and resolver');
function labels(injected, systemLanguage = 'en-US') {
return vm.runInNewContext(
`${prefix} return { dictionaries: I18N, locale: LANG, labels: L }; })();`,
{
window: { __OL_LANG__: injected },
navigator: { language: systemLanguage },
},
);
}
const baseline = labels('en').labels;
for (const locale of ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'es', 'fr', 'de']) {
const resolved = labels(locale);
assert.equal(resolved.locale, locale);
assert.deepEqual(
Object.keys(resolved.labels).sort(),
Object.keys(baseline).sort(),
`${locale} covers every remote input control`,
);
for (const [key, value] of Object.entries(baseline)) {
assert.equal(typeof resolved.labels[key], 'string');
assert(resolved.labels[key].trim(), `${locale}.${key} has visible text`);
assert.deepEqual(
[...resolved.labels[key].matchAll(/\{\w+\}/g)].map(([value]) => value).sort(),
[...value.matchAll(/\{\w+\}/g)].map(([value]) => value).sort(),
`${locale}.${key} keeps its runtime placeholders`,
);
}
}
assert.equal(labels('', 'es-MX').locale, 'es');
assert.equal(labels('', 'fr-CA').locale, 'fr');
assert.equal(labels('', 'de-CH').locale, 'de');
assert.equal(labels('__proto__', 'en-US').locale, 'en');
console.log('remote input locale tests passed');
@@ -0,0 +1,130 @@
import { register } from 'tsx/esm/api';
register();
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createInstance } from 'i18next';
const { zhCN } = await import('../src/i18n/zh-CN.ts');
const { zhTW } = await import('../src/i18n/zh-TW.ts');
const { en } = await import('../src/i18n/en.ts');
const { de } = await import('../src/i18n/de.ts');
const { es } = await import('../src/i18n/es.ts');
const { fr } = await import('../src/i18n/fr.ts');
const { ja } = await import('../src/i18n/ja.ts');
const { ko } = await import('../src/i18n/ko.ts');
const { getStylePackPresentation } = await import('../src/lib/stylePackPresentation.ts');
const core = readFileSync(
new URL('../crates/openless-core/src/style_packs.rs', import.meta.url),
'utf8',
);
const coreDefaults = [
...core.matchAll(
/PolishMode::(Raw|Light|Structured|Formal) => StylePack \{\s*id:[^\n]+\n\s*name: ("(?:[^"\\]|\\.)*")\.into\(\),\s*description: ("(?:[^"\\]|\\.)*")\.into\(\),/g,
),
].map((match) => ({
id: `builtin.${match[1].toLowerCase()}`,
kind: 'builtin',
baseMode: match[1].toLowerCase(),
name: JSON.parse(match[2]),
description: JSON.parse(match[3]),
}));
assert.equal(coreDefaults.length, 4, 'check every shipped Core builtin');
const mock = readFileSync(new URL('../src/lib/ipc/mock-data.ts', import.meta.url), 'utf8');
const mockDefaults = [
...mock.matchAll(
/makeMockStylePack\(\s*'builtin\.(raw|light|structured|formal)',\s*'builtin',\s*'[^']+',\s*'([^']+)',\s*'([^']+)'/g,
),
].map((match) => ({
id: `builtin.${match[1]}`,
kind: 'builtin',
baseMode: match[1],
name: match[2],
description: match[3],
}));
assert.ok(mockDefaults.length >= 4, 'check browser preview and reset defaults');
const locales = { 'zh-CN': zhCN, 'zh-TW': zhTW, en, de, es, fr, ja, ko };
const instance = createInstance();
await instance.init({
lng: 'en',
fallbackLng: false,
resources: Object.fromEntries(
Object.entries(locales).map(([key, translation]) => [key, { translation }]),
),
});
for (const locale of Object.keys(locales)) {
await instance.changeLanguage(locale);
for (const pack of [...coreDefaults, ...mockDefaults]) {
const before = JSON.stringify(pack);
const display = getStylePackPresentation(pack, instance.t);
assert.equal(
display.name,
instance.t(`style.modes.${pack.baseMode}.name`),
`${locale}: builtin name`,
);
assert.equal(
display.description,
instance.t(`style.modes.${pack.baseMode}.desc`),
`${locale}: builtin description`,
);
assert.equal(JSON.stringify(pack), before, 'display must not alter stored metadata');
const renamed = getStylePackPresentation({ ...pack, name: 'My own style' }, instance.t);
assert.equal(renamed.name, 'My own style');
assert.equal(
renamed.description,
display.description,
'renaming does not suppress the default description translation',
);
const edited = getStylePackPresentation(
{ ...pack, description: '用户自己写的说明' },
instance.t,
);
assert.equal(
edited.name,
display.name,
'editing the description does not suppress the default name translation',
);
assert.equal(edited.description, '用户自己写的说明');
const imported = getStylePackPresentation({ ...pack, kind: 'imported' }, instance.t);
assert.deepEqual(
imported,
{ name: pack.name, description: pack.description, tags: [] },
'authored package content stays original',
);
}
for (const [baseMode, tag, key] of [
['raw', '最小改写', 'minimalEdits'],
['light', '强纠错', 'strongCorrection'],
['light', '沟通', 'communication'],
['light', '自然', 'natural'],
['structured', '条理', 'organized'],
['structured', 'AI 编程', 'aiCoding'],
['structured', '技术结构化', 'technicalStructure'],
['formal', '工作沟通', 'workplaceCommunication'],
]) {
const base = coreDefaults.find((pack) => pack.baseMode === baseMode);
const tags = [tag, '我的自定义标签'];
const translated = getStylePackPresentation({ ...base, tags }, instance.t).tags;
assert.deepEqual(translated, [instance.t(`style.pack.builtinTags.${key}`), '我的自定义标签']);
assert.notEqual(
translated[0],
`style.pack.builtinTags.${key}`,
`${locale}: tag resource exists`,
);
assert.deepEqual(tags, [tag, '我的自定义标签'], 'original tags stay untouched');
assert.deepEqual(
getStylePackPresentation({ ...base, kind: 'imported', tags }, instance.t).tags,
tags,
);
}
assert.deepEqual(
getStylePackPresentation({ ...coreDefaults[0], tags: ['自然'] }, instance.t).tags,
['自然'],
'a known label added to another builtin is user content',
);
}
const pack = coreDefaults[1];
await instance.changeLanguage('en');
const english = getStylePackPresentation(pack, instance.t);
await instance.changeLanguage('de');
const german = getStylePackPresentation(pack, instance.t);
assert.notEqual(german.name, english.name, 'existing data responds to a locale switch');
assert.notEqual(german.description, english.description);
@@ -113,9 +113,14 @@ assertMatch(
assertMatch(
windowChromeTsx,
/const MAC_TITLEBAR_HEIGHT = 28;/,
'macOS titlebar spacer should stay visually compact around the native traffic lights',
/const MAC_TITLEBAR_HEIGHT = 44;/,
'macOS drag region should reserve the native traffic-light area',
);
assertEqual(mainWindow.trafficLightPosition.x, 16, 'traffic lights should have a 16px left inset');
assertEqual(mainWindow.trafficLightPosition.y, 16, 'traffic lights should have an equal top inset');
assertEqual(mainWindow.width, 1300, 'main window should use the reviewed default width');
assertEqual(mainWindow.height, 835, 'main window should use the reviewed default height');
assertEqual(mainWindow.resizable, true, 'users should still be able to resize the main window');
assertMatch(
libRs,
/show_main_window[\s\S]*?set_focus\(\)/,
@@ -169,7 +174,7 @@ assertMatch(
);
if (
!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boolean,?\s*\): CapsuleHostMetrics/.test(
!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boolean,\s*style: CapsuleStyle = 'siri',?\s*\): CapsuleHostMetrics/.test(
capsuleLayoutTs,
)
) {
@@ -188,16 +193,20 @@ assertMatch(
assertMatch(
capsuleLayoutTs,
/const stage = getCapsulePillMetrics\(os\);[\s\S]*?width: stage\.width,[\s\S]*?height: stage\.height,[\s\S]*?horizontalInset: 0,[\s\S]*?bottomInset: 0,[\s\S]*?badgeGap: 8,[\s\S]*?boxSizing: 'border-box'/,
'capsule host metrics should mirror the shared voice-orb stage without legacy Windows insets',
/const stage = getCapsulePillMetrics\(os\);[\s\S]*?width: stage\.width,[\s\S]*?height: style === 'siri' \? stage\.height : style === 'classic' \? 100 : 128,[\s\S]*?horizontalInset: 0,[\s\S]*?bottomInset: style === 'siri' \? 0 : 16,[\s\S]*?badgeGap: 8,[\s\S]*?boxSizing: 'border-box'/,
'capsule host metrics should preserve Siri and reserve compact surfaces for Classic and Typeless',
);
if (!/const hostMetrics = getCapsuleHostMetrics\(os,\s*translation\);/.test(capsuleTsx)) {
if (
!/const hostMetrics = getCapsuleHostMetrics\(os,\s*translation,\s*capsuleStyle\);/.test(
capsuleTsx,
)
) {
throw new Error('capsule should derive host metrics from the shared layout contract');
}
if (
!/return\s*\(\s*<div\s*style=\{\{[\s\S]*?width:\s*'100%',[\s\S]*?height:\s*'100%',[\s\S]*?position:\s*'relative',[\s\S]*?display:\s*'flex',[\s\S]*?alignItems:\s*'center',[\s\S]*?justifyContent:\s*'center',[\s\S]*?paddingLeft:\s*hostMetrics\.horizontalInset,[\s\S]*?paddingRight:\s*hostMetrics\.horizontalInset,[\s\S]*?\}\}/.test(
!/return\s*\(\s*<div\s*style=\{\{[\s\S]*?width:\s*'100%',[\s\S]*?height:\s*'100%',[\s\S]*?position:\s*'relative',[\s\S]*?display:\s*'flex',[\s\S]*?alignItems:\s*capsuleStyle === 'siri' \? 'center' : 'flex-end',[\s\S]*?justifyContent:\s*'center',[\s\S]*?paddingLeft:\s*hostMetrics\.horizontalInset,[\s\S]*?paddingRight:\s*hostMetrics\.horizontalInset,[\s\S]*?\}\}/.test(
capsuleTsx,
)
) {
@@ -211,8 +220,8 @@ if (
throw new Error('capsule host should consume the shared horizontal inset contract');
}
if (!/paddingBottom:\s*os === 'win' \? hostMetrics\.bottomInset : 0/.test(capsuleTsx)) {
throw new Error('windows capsule host should respect the shared bottom inset');
if (!/paddingBottom:\s*hostMetrics\.bottomInset/.test(capsuleTsx)) {
throw new Error('all capsule hosts should respect their style-specific bottom inset');
}
if (!/const badgeBottom = Math\.round\(metrics\.height \* 0\.73\);/.test(capsuleTsx)) {
@@ -221,8 +230,8 @@ if (!/const badgeBottom = Math\.round\(metrics\.height \* 0\.73\);/.test(capsule
assertMatch(
libRs,
/fn capsule_window_bounds\(translation_active: bool\)[\s\S]*?width: 460\.0,[\s\S]*?height: 180\.0,[\s\S]*?bottom_inset: 0\.0,/,
'runtime capsule bounds should match the shared 460x180 voice-orb stage',
/fn capsule_window_bounds_for_style\(style: types::CapsuleStyle\)[\s\S]*?width: 460\.0,[\s\S]*?types::CapsuleStyle::Siri => 180\.0,[\s\S]*?types::CapsuleStyle::Classic => 100\.0,[\s\S]*?types::CapsuleStyle::Typeless => 128\.0,[\s\S]*?bottom_inset: 0\.0,/,
'native capsule bounds should match the frontend dimensions for all three styles',
);
assertMatch(
@@ -820,11 +820,15 @@ fn speech_recognizer_class() -> Result<&'static AnyClass> {
})
}
/// 创建 recognizer。有指定 locale 就 `initWithLocale:`(关键 —— 否则落到系统首选语言,
/// 中文语音会被英文引擎误识别);无 locale 或 NSLocale 构造失败时回退 `init`(系统默认)。
/// 指定工作语言时必须使用对应 locale;无法创建时返回错误,避免悄悄识别成系统默认语言。
fn create_recognizer(locale: Option<&str>) -> Result<*mut AnyObject> {
let cls = speech_recognizer_class()?;
let recognizer: *mut AnyObject = match locale.and_then(ns_locale) {
let requested_locale = locale
.map(|locale| {
ns_locale(locale).ok_or_else(|| anyhow!("Apple Speech 无法使用所选语言 {locale}"))
})
.transpose()?;
let recognizer: *mut AnyObject = match requested_locale {
Some(ns_loc) => {
log::info!(
"[apple-speech] recognizer locale = {}",
@@ -851,7 +855,7 @@ fn create_recognizer(locale: Option<&str>) -> Result<*mut AnyObject> {
Ok(recognizer)
}
/// `[NSLocale localeWithLocaleIdentifier:<id>]`。构造失败返回 None(调用方回退系统默认)。
/// `[NSLocale localeWithLocaleIdentifier:<id>]`。构造失败返回 None,由调用方报告所选语言不可用。
fn ns_locale(identifier: &str) -> Option<*mut AnyObject> {
let ns_id = ns_string_from_str(identifier).ok()?;
let cls = AnyClass::get("NSLocale")?;
@@ -865,30 +869,10 @@ fn ns_locale(identifier: &str) -> Option<*mut AnyObject> {
}
}
/// 用户工作语言(原生名,见前端 `SUPPORTED_LANGUAGES`)→ SFSpeechRecognizer 的 locale
/// 标识符。取 `working_languages` 主语言映射;未收录的语言返回 None(回退系统默认 locale)。
/// SFSpeechRecognizer 一个实例只认一种语言,中英混说时以主语言为准 —— 这是 Apple 的固有
/// 限制,云端 ASR 才能自由多语言混识。
/// 将首选工作语言映射为 Apple locale;识别器仍须检查当前系统是否可用。
/// 未收录的语言返回 None,由调用方报告不支持,而不是改用系统默认语言。
pub fn native_name_to_apple_locale(native_name: &str) -> Option<String> {
let locale = match native_name.trim() {
"简体中文" => "zh-CN",
"繁体中文" | "繁體中文" => "zh-TW",
"English" => "en-US",
"日本語" => "ja-JP",
"한국어" => "ko-KR",
"Français" => "fr-FR",
"Deutsch" => "de-DE",
"Español" => "es-ES",
"Italiano" => "it-IT",
"Português" => "pt-BR",
"Русский" => "ru-RU",
"العربية" => "ar-SA",
"Tiếng Việt" => "vi-VN",
"ไทย" => "th-TH",
"हिन्दी" => "hi-IN",
_ => return None,
};
Some(locale.to_string())
openless_core::language_catalog::apple_speech_locale(native_name)
}
/// `[NSURL fileURLWithPath:<path>]`。
@@ -0,0 +1,35 @@
//! Cloud operations delegate to Core so browser inputs never contain provider or GitHub credentials.
use super::CoreState;
use openless_core::{
BackendError, CloudSyncRestoreResult, CloudSyncStatus, CloudSyncUiPreferences,
};
#[tauri::command]
pub async fn cloud_sync_status(core: CoreState<'_>) -> Result<CloudSyncStatus, BackendError> {
core.cloud_sync_status().await
}
#[tauri::command]
pub async fn cloud_sync_upload(
core: CoreState<'_>,
base_revision: u64,
ui_preferences: CloudSyncUiPreferences,
) -> Result<CloudSyncStatus, BackendError> {
core.cloud_sync_upload(base_revision, ui_preferences).await
}
#[tauri::command]
pub async fn cloud_sync_restore(
core: CoreState<'_>,
) -> Result<CloudSyncRestoreResult, BackendError> {
core.cloud_sync_restore().await
}
#[tauri::command]
pub async fn cloud_sync_delete(
core: CoreState<'_>,
base_revision: u64,
) -> Result<CloudSyncStatus, BackendError> {
core.cloud_sync_delete(base_revision).await
}
@@ -37,7 +37,9 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet
'static,
Result<openless_core::CredentialMetadata, openless_core::BackendError>,
> {
run_credential_task(|| Ok(CredentialsVault::load_metadata()))
run_credential_task(|| {
CredentialsVault::load_metadata().map_err(credential_persistence_error)
})
}
fn save_metadata(
@@ -54,7 +56,10 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet
kind: openless_core::ChannelKind,
channel_id: String,
) -> futures_util::future::BoxFuture<'static, Result<bool, openless_core::BackendError>> {
run_credential_task(move || Ok(CredentialsVault::channel_has_secrets(kind, &channel_id)))
run_credential_task(move || {
CredentialsVault::channel_has_secrets(kind, &channel_id)
.map_err(credential_persistence_error)
})
}
}
@@ -110,7 +115,7 @@ impl openless_core::CredentialStore for SystemCredentialStore {
Result<CredentialsStatus, openless_core::BackendError>,
> {
let model_store = self.model_store.clone();
run_credential_task(move || Ok(credentials_status(preferences, model_store.as_deref())))
run_credential_task(move || credentials_status(preferences, model_store.as_deref()))
}
fn read(
@@ -198,16 +203,18 @@ fn run_credential_task<T: Send + 'static>(
fn credentials_status(
preferences: UserPreferences,
model_store: Option<&openless_core::ModelStore>,
) -> CredentialsStatus {
) -> Result<CredentialsStatus, openless_core::BackendError> {
let pipeline_mode = openless_core::shared_types::effective_pipeline_mode(
preferences.multimodal_pipeline_enabled,
preferences.pipeline_mode,
);
let snap = CredentialsVault::snapshot_for_pipeline(
let snapshot = CredentialsVault::configuration_snapshot(
pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal,
);
let active_asr_provider = CredentialsVault::get_active_asr();
let active_llm_provider = CredentialsVault::get_active_llm();
)
.map_err(credential_persistence_error)?;
let snap = snapshot.credentials;
let active_asr_provider = snapshot.active_asr_provider;
let active_llm_provider = snapshot.active_llm_provider;
let configuration = credential_configuration(
&snap,
&active_llm_provider,
@@ -227,7 +234,7 @@ fn credentials_status(
&snap.active_omni_provider,
&configuration,
);
CredentialsStatus {
Ok(CredentialsStatus {
active_asr_provider,
active_llm_provider,
pipeline_mode,
@@ -236,7 +243,7 @@ fn credentials_status(
omni_configured,
volcengine_configured,
ark_configured: llm_configured,
}
})
}
fn read_vault_credential(
@@ -48,6 +48,7 @@ pub(crate) use crate::types::{
};
mod channels;
mod cloud_sync;
mod credentials;
mod dictation;
mod dictionary;
@@ -77,6 +78,7 @@ mod sherpa_asr;
mod style_packs;
pub use channels::*;
pub use cloud_sync::*;
pub use credentials::*;
pub use dictation::*;
pub use dictionary::*;
@@ -344,8 +344,7 @@ fn hide_insert_fallback_card(inner: &Arc<Inner>) {
// 卡片释放后把最新状态一次性应用回来;若最新是 Idle,该 helper 会正常隐藏。
let preferences = backend.get_preferences();
let show_capsule = payload.selection_polish || preferences.show_capsule;
let classic_style = matches!(preferences.capsule_style, CapsuleStyle::Classic);
capsule.apply_capsule_payload(&payload, show_capsule, classic_style, true);
capsule.apply_capsule_payload(&payload, show_capsule, preferences.capsule_style, true);
}
});
}
@@ -1310,11 +1309,7 @@ impl Coordinator {
.unwrap_or_default()
}
/// 设置保存后立即把胶囊样式同步进 Tauri Host 缓存。
/// emit_capsule 的 ~30Hz 主线程闭包本来也会同步,但入场帧的 payload 是在闭包
/// 同步之前克隆的(会带一帧旧样式),且 Windows 上主线程拥塞时闭包可能延迟
/// 执行——用户反馈「切换成默认风格后仍显示流光 Siri」。在保存路径直接同步后,
/// 任何平台的下一次录音从入场帧起就携带最新样式,不再依赖 emit 闭包的时序。
/// 保存后同步样式、窗口尺寸和点击区域,不等待下一次录音状态事件。
pub fn sync_capsule_style_from_preferences(&self) {
self.inner
.host
@@ -401,9 +401,6 @@ fn emit_capsule_payload_locked(inner: &Arc<Inner>, payload: CapsulePayload) -> u
let backend_for_main = Arc::clone(&inner.backend);
// 入场帧要在 window.show 之后、闭包内部把 state 回发给前端,需要 payload 的独立副本
// move 进闭包;非入场帧走闭包外的即时同步 emit(下方),这里就是 None。
// 注意:入场帧的 payload 在闭包同步 capsule_style 原子之前克隆,最多带一帧旧样式
//(设置里刚切换后的首次录音,第 2 帧 ~33ms 即纠正)。这是刻意取舍——不要在音频
// 线程改回直接读 prefs。前端第 1 帧处于 capsule-in 动画期间(380ms),无感知。
let payload_for_deferred_emit = if defer_capsule_emit {
Some(payload.clone())
} else {
@@ -416,17 +413,17 @@ fn emit_capsule_payload_locked(inner: &Arc<Inner>, payload: CapsulePayload) -> u
}
let preferences = backend_for_main.get_preferences();
let show_capsule = payload_for_window.selection_polish || preferences.show_capsule;
let classic_style = matches!(preferences.capsule_style, CapsuleStyle::Classic);
capsule.apply_capsule_payload(
&payload_for_window,
show_capsule,
classic_style,
preferences.capsule_style,
payload_for_deferred_emit.is_some(),
);
// 入场帧:窗口刚 show(或本次用户关了胶囊显示走了 hide 分支),此刻再把 state 发给
// capsule 前端 —— 前端起播 capsule-in 时窗口已可见,入场动画从头完整播放。
if let Some(payload) = payload_for_deferred_emit.as_ref() {
host_for_main.emit_capsule_state_to_capsule(payload);
if let Some(mut payload) = payload_for_deferred_emit {
payload.capsule_style = preferences.capsule_style;
host_for_main.emit_capsule_state_to_capsule(&payload);
}
});
@@ -1823,10 +1823,11 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine {
Some(model_id),
)
} else if crate::asr::local::is_apple_speech(provider_type) {
let locale =
context.polish.working_languages.first().and_then(|name| {
crate::asr::local::native_name_to_apple_locale(name)
});
let locale = context.polish.working_languages.first().map(|name| {
crate::asr::local::native_name_to_apple_locale(name).ok_or_else(|| {
BackendError::new(BackendErrorCode::Unsupported, "Apple Speech 无法识别所选工作语言,请选择其他语言或语音识别服务。")
})
}).transpose()?;
(
TauriNativeTranscriptionSessionKind::AppleSpeech(Arc::new(
crate::asr::local::AppleSpeechAsr::new(locale),
+155 -65
View File
@@ -231,6 +231,10 @@ macro_rules! app_invoke_handler_desktop {
commands::github_device_flow_poll,
commands::github_device_flow_cancel,
commands::marketplace_auth_status,
commands::cloud_sync_status,
commands::cloud_sync_upload,
commands::cloud_sync_restore,
commands::cloud_sync_delete,
commands::marketplace_logout,
commands::list_vocab,
commands::add_vocab,
@@ -472,6 +476,10 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::github_device_flow_poll,
$crate::commands::github_device_flow_cancel,
$crate::commands::marketplace_auth_status,
$crate::commands::cloud_sync_status,
$crate::commands::cloud_sync_upload,
$crate::commands::cloud_sync_restore,
$crate::commands::cloud_sync_delete,
$crate::commands::marketplace_logout,
$crate::commands::list_vocab,
$crate::commands::add_vocab,
@@ -835,6 +843,7 @@ fn run_desktop() {
// Spin up hotkey listener; coordinator owns the lifecycle.
let app_handle = app.handle().clone();
coordinator.tauri_host().bind(app_handle);
coordinator.sync_capsule_style_from_preferences();
crate::tauri_events::start(app.handle().clone(), Arc::clone(&core_backend));
coordinator.start_hotkey_listener();
// QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS).
@@ -953,6 +962,45 @@ impl TrayLabels {
structured: "Structured",
formal: "Formal",
},
"es" => Self {
toggle: "Mostrar ventana principal",
style: "Estilo de salida",
microphone: "Seleccionar micrófono",
default_microphone: "Micrófono del sistema",
no_microphones: "No se encontraron micrófonos",
default_device_suffix: " (Predeterminado del sistema)",
quit: "Salir de OpenLess",
raw: "Texto original",
light: "Pulido ligero",
structured: "Estructurado",
formal: "Formal",
},
"fr" => Self {
toggle: "Afficher la fenêtre principale",
style: "Style de sortie",
microphone: "Choisir le microphone",
default_microphone: "Microphone du système",
no_microphones: "Aucun microphone détecté",
default_device_suffix: " (Par défaut du système)",
quit: "Quitter OpenLess",
raw: "Texte original",
light: "Retouche légère",
structured: "Structuré",
formal: "Formel",
},
"de" => Self {
toggle: "Hauptfenster anzeigen",
style: "Ausgabestil",
microphone: "Mikrofon auswählen",
default_microphone: "Systemmikrofon",
no_microphones: "Keine Mikrofone gefunden",
default_device_suffix: " (Systemstandard)",
quit: "OpenLess beenden",
raw: "Originaltext",
light: "Leichte Überarbeitung",
structured: "Strukturiert",
formal: "Formell",
},
"zh-TW" => Self {
toggle: "顯示主視窗",
style: "輸出風格",
@@ -1930,18 +1978,6 @@ fn bottom_center_position(
(x, y)
}
fn bottom_visual_position(
frame: LogicalMonitorFrame,
window_width: f64,
visual_height: f64,
bottom_padding: f64,
bottom_inset: f64,
) -> (f64, f64) {
let x = frame.x + ((frame.width - window_width) / 2.0).max(0.0);
let y = frame.y + (frame.height - visual_height - bottom_padding - bottom_inset).max(0.0);
(x, y)
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn frame_contains_point(frame: LogicalMonitorFrame, x: f64, y: f64) -> bool {
x >= frame.x && x < frame.x + frame.width && y >= frame.y && y < frame.y + frame.height
@@ -1968,6 +2004,10 @@ pub(crate) struct CapsuleTargetMonitor {
pub(crate) physical_y: i32,
pub(crate) physical_width: u32,
pub(crate) physical_height: u32,
pub(crate) work_x: i32,
pub(crate) work_y: i32,
pub(crate) work_width: u32,
pub(crate) work_height: u32,
pub(crate) scale: f64,
}
@@ -1982,6 +2022,16 @@ impl CapsuleTargetMonitor {
self.scale,
)
}
fn logical_work_area(self) -> LogicalMonitorFrame {
logical_monitor_frame(
self.work_x,
self.work_y,
self.work_width,
self.work_height,
self.scale,
)
}
}
/// macOS:决定胶囊应该摆到哪块显示器。
@@ -2019,6 +2069,10 @@ fn monitor_for_anchor_point<R: tauri::Runtime>(
physical_y: monitor.position().y,
physical_width: monitor.size().width,
physical_height: monitor.size().height,
work_x: monitor.work_area().position.x,
work_y: monitor.work_area().position.y,
work_width: monitor.work_area().size.width,
work_height: monitor.work_area().size.height,
scale: monitor.scale_factor(),
}),
x,
@@ -3162,13 +3216,22 @@ pub(crate) fn foreground_window_monitor() -> Option<ForegroundMonitor> {
}
}
/// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。
/// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。
/// 初始化和卡片归还路径使用默认舞台;录音显示路径显式传入当前样式。
pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) -> tauri::Result<()> {
let bounds = capsule_window_bounds(translation_active);
position_capsule_bottom_center_with_style(window, translation_active, types::CapsuleStyle::Siri)
}
/// 使用系统报告的可用工作区,自动避开 Dock / taskbar,并随自动隐藏设置更新。
pub(crate) fn position_capsule_bottom_center_with_style<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
_translation_active: bool,
style: types::CapsuleStyle,
) -> tauri::Result<()> {
let bounds = capsule_window_bounds_for_style(style);
const EDGE_GAP: f64 = 12.0;
// Windows:跟随「正在输入的 App」所在显示器摆放,避免多显示器下胶囊
// 总是固定出现在主屏 / 胶囊自己那块屏。
@@ -3183,21 +3246,14 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
phys_h.max(1) as u32,
))?;
let mon_w = mon.right - mon.left;
let x = mon.left + ((mon_w - phys_w) / 2).max(0);
// 与既有行为一致:「距底部 visual高度 + 80 + inset」,按 physical px 计算。
let offset_from_bottom =
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
// #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW
// 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。
let (work_l, work_t, work_r, work_b) =
if mon.work_right > mon.work_left && mon.work_bottom > mon.work_top {
(mon.work_left, mon.work_top, mon.work_right, mon.work_bottom)
} else {
(mon.left, mon.top, mon.right, mon.bottom)
};
let x = work_l + ((work_r - work_l - phys_w) / 2).max(0);
let y = work_b - phys_h - (EDGE_GAP * scale).round() as i32;
let (clamped_x, clamped_y) =
clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b);
log::debug!(
@@ -3218,13 +3274,11 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
{
if let Some(mon) = capsule_target_monitor(window) {
window.set_size(LogicalSize::new(bounds.width, bounds.height))?;
let frame = mon.logical_frame();
let (x, y) = bottom_visual_position(
frame,
let (x, y) = bottom_center_position(
mon.logical_work_area(),
bounds.width,
capsule_visual_height(translation_active),
80.0,
bounds.bottom_inset,
bounds.height,
EDGE_GAP,
);
log::debug!(
"[capsule] mac position: mon=({},{}) size=({}x{}) scale={:.2} -> logical=({:.1},{:.1})",
@@ -3248,16 +3302,10 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
window.set_size(LogicalSize::new(bounds.width, bounds.height))?;
let scale = monitor.scale_factor();
let size = monitor.size();
let pos = monitor.position();
let size = &monitor.work_area().size;
let pos = &monitor.work_area().position;
let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale);
let (x, y) = bottom_visual_position(
frame,
bounds.width,
capsule_visual_height(translation_active),
80.0,
bounds.bottom_inset,
);
let (x, y) = bottom_center_position(frame, bounds.width, bounds.height, EDGE_GAP);
window.set_position(LogicalPosition::new(x, y))?;
Ok(())
}
@@ -3273,17 +3321,23 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {
// 纯光效语音舞台(siri-glsl 原始比例):光条横贯 ~420px + 发光扩散余量。
// 与前端 src/lib/capsuleLayout.ts 的 VOICE_ORB_STAGE_* 保持一致。
let _ = translation_active;
capsule_window_bounds_for_style(types::CapsuleStyle::Siri)
}
fn capsule_window_bounds_for_style(style: types::CapsuleStyle) -> CapsuleWindowBounds {
CapsuleWindowBounds {
width: 460.0,
height: 180.0,
height: match style {
types::CapsuleStyle::Siri => 180.0,
types::CapsuleStyle::Classic => 100.0,
types::CapsuleStyle::Typeless => 128.0,
},
bottom_inset: 0.0,
}
}
fn capsule_visual_height(_translation_active: bool) -> f64 {
// 故意小于窗口高(180):窗口整体下沉 40px,让光条视觉中心落回原胶囊的高度
// 附近(bottom_visual_position 以此值算窗口顶 y = 屏底 - 80 - visual_height)。
// 底部 40px 只是发光余量,窗口透明 + 鼠标穿透,压到 Dock 上方无碍。
// QA 面板沿用此视觉高度计算垂直间距;胶囊本身按样式尺寸和系统工作区定位。
140.0
}
@@ -3294,16 +3348,18 @@ fn capsule_height_for_qa() -> f64 {
#[cfg(test)]
mod tests {
use super::{
bottom_center_position, bottom_visual_position, capsule_height_for_qa,
capsule_visual_height, capsule_window_bounds, clamp_to_monitor, frame_contains_point,
frame_distance_to_point_squared, logical_monitor_frame, parse_tray_style_pack_menu_id,
resolve_tray_style_pack_id, rotate_log_if_too_large, tray_style_menu_enabled,
tray_style_pack_menu_entries, tray_style_pack_menu_id, LogicalMonitorFrame, TrayLabels,
LOG_ROTATE_LIMIT_BYTES,
bottom_center_position, capsule_height_for_qa, capsule_visual_height,
capsule_window_bounds, capsule_window_bounds_for_style, clamp_to_monitor,
frame_contains_point, frame_distance_to_point_squared, logical_monitor_frame,
parse_tray_style_pack_menu_id, resolve_tray_style_pack_id, rotate_log_if_too_large,
tray_style_menu_enabled, tray_style_pack_menu_entries, tray_style_pack_menu_id,
LogicalMonitorFrame, TrayLabels, LOG_ROTATE_LIMIT_BYTES,
};
#[cfg(target_os = "macos")]
use super::{pick_monitor_for_anchor_point, CapsuleTargetMonitor};
use crate::types::{builtin_style_pack_for_mode, PolishMode, StylePack, StylePackKind};
use crate::types::{
builtin_style_pack_for_mode, CapsuleStyle, PolishMode, StylePack, StylePackKind,
};
use std::io::Write;
#[test]
@@ -3396,6 +3452,19 @@ mod tests {
let chinese = TrayLabels::for_locale("zh-CN");
assert_eq!(chinese.style_pack_name(PolishMode::Structured), "清晰结构");
assert_eq!(TrayLabels::for_locale("es").quit, "Salir de OpenLess");
assert_eq!(
TrayLabels::for_locale("fr").toggle,
"Afficher la fenêtre principale"
);
assert_eq!(
TrayLabels::for_locale("de").microphone,
"Mikrofon auswählen"
);
assert_eq!(
TrayLabels::for_locale("fr").style_pack_name(PolishMode::Structured),
"Structuré"
);
assert_eq!(TrayLabels::for_locale("unknown").toggle, "显示主窗口");
}
@@ -3482,6 +3551,33 @@ mod tests {
);
}
#[test]
fn capsule_tracks_reserved_and_auto_hidden_system_bars() {
let monitor = LogicalMonitorFrame {
x: -1440.0,
y: 0.0,
width: 1440.0,
height: 900.0,
};
let work_area = LogicalMonitorFrame {
height: 830.0,
..monitor
};
for style in [
CapsuleStyle::Siri,
CapsuleStyle::Classic,
CapsuleStyle::Typeless,
] {
let bounds = capsule_window_bounds_for_style(style);
let (x, reserved_y) =
bottom_center_position(work_area, bounds.width, bounds.height, 12.0);
let (_, hidden_y) = bottom_center_position(monitor, bounds.width, bounds.height, 12.0);
assert_eq!(x, -950.0);
assert_eq!(reserved_y + bounds.height, 818.0);
assert_eq!(hidden_y - reserved_y, 70.0);
}
}
#[test]
fn capsule_window_bounds_stay_fixed_for_translation_badge() {
let bounds = capsule_window_bounds(true);
@@ -3492,7 +3588,7 @@ mod tests {
}
#[test]
fn capsule_visual_height_sinks_stage_toward_dock() {
fn capsule_visual_height_keeps_the_qa_anchor_reference() {
assert_eq!(capsule_visual_height(true), 140.0);
}
@@ -3554,6 +3650,10 @@ mod tests {
physical_y: 0,
physical_width: 3024,
physical_height: 1964,
work_x: 0,
work_y: 48,
work_width: 3024,
work_height: 1760,
scale: 2.0,
},
CapsuleTargetMonitor {
@@ -3561,6 +3661,10 @@ mod tests {
physical_y: 0,
physical_width: 2560,
physical_height: 1440,
work_x: 1512,
work_y: 24,
work_width: 2560,
work_height: 1320,
scale: 1.0,
},
]
@@ -3615,20 +3719,6 @@ mod tests {
assert_eq!(pos, (-910.0, 276.0));
}
#[test]
fn bottom_visual_position_keeps_capsule_on_upper_monitor() {
let frame = LogicalMonitorFrame {
x: 0.0,
y: -900.0,
width: 1440.0,
height: 900.0,
};
let pos = bottom_visual_position(frame, 220.0, 96.0, 80.0, 0.0);
assert_eq!(pos, (610.0, -176.0));
}
// ---- #470: capsule 四边 clamp(纯函数,合成多显示器 / 负原点 / 1.5x DPI 输入)----
#[test]
@@ -2,8 +2,8 @@
//! Credentials vault.
//!
//! 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider
//! schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台
//! 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。
//! schema 与 active provider 状态,凭据库里保存一个 JSON payload。macOS 使用一个稳定
//! 条目,避免每个分片分别要求授权;Windows 按单条凭据 2560 bytes 的限制拆分。
//!
//! v1 schema:
//! {
@@ -44,6 +44,7 @@ const LEGACY_CREDS_FILE: &str = "credentials.json";
const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1";
const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk.";
const KEYRING_SINGLE_CREDENTIALS_ACCOUNT: &str = "credentials.v2";
#[cfg(target_os = "android")]
const ANDROID_CREDENTIALS_FILE: &str = "credentials.enc.json";
const RESERVED_EXTRA_HEADER_NAMES: &[&str] = &[
@@ -55,6 +56,7 @@ const RESERVED_EXTRA_HEADER_NAMES: &[&str] = &[
];
// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores
// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that.
#[cfg(any(not(any(target_os = "macos", target_os = "android")), test))]
const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000;
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -85,26 +87,9 @@ fn android_marketplace_legacy_scrubbed() -> &'static Mutex<bool> {
ANDROID_MARKETPLACE_LEGACY_SCRUBBED.get_or_init(|| Mutex::new(false))
}
/// Process-wide credentials cache.
///
/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits
/// `load_credentials()` → `load_keyring_credentials()` which reads the
/// manifest entry plus every chunk entry from the OS keyring. On macOS each
/// distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or
/// any binary whose ACL grants haven't been set up yet) prompts on every read
/// of every entry. A single dictation cycle reads credentials 5–10 times,
/// times (1 manifest + N chunks) entries → tens of "OpenLess wants to use
/// the keychain" prompts per recording.
///
/// With this cache the first read populates `Some(CredsRoot)` and every
/// subsequent read in the same process is silent. `save_credentials` keeps
/// the cache in sync after writes so Settings → Recording credential edits
/// take effect immediately.
///
/// Cross-process changes (e.g. user edits via `security` CLI, or another
/// instance of the app — single-instance is enforced but defense in depth)
/// will be invisible until the next process launch. Acceptable trade-off
/// per the credential vault contract: the keyring is owned by this app.
/// Cache successful reads for this process and refresh only after durable writes.
/// Failed reads remain retryable and must not become a cached empty configuration.
/// External Keychain edits take effect on the next app launch.
static CREDENTIALS_CACHE: OnceLock<Mutex<Option<CredsRoot>>> = OnceLock::new();
fn credentials_cache() -> &'static Mutex<Option<CredsRoot>> {
@@ -825,7 +810,7 @@ fn credentials_path() -> Result<PathBuf> {
}
}
#[cfg(not(target_os = "android"))]
#[cfg(not(any(target_os = "android", target_os = "macos")))]
fn keyring_entry() -> Result<keyring::Entry> {
keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT)
}
@@ -1135,16 +1120,14 @@ fn remove_legacy_credentials_file_best_effort() {
struct CredsChunkManifest {
openless_credentials_storage: String,
version: u32,
/// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀,
/// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。
/// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。
/// Earlier vaults used UUID-prefixed chunks. Keep reading them during migration;
/// current chunked writes use stable names so item authorizations can persist.
#[serde(default, skip_serializing_if = "Option::is_none")]
generation: Option<String>,
chunks: usize,
}
/// 旧版(generation=Some):`credentials.v1.chunk.<UUID>.{index}`
/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效
/// Legacy UUID-prefixed and current stable chunk names share one reader.
fn chunk_account(generation: Option<&str>, index: usize) -> String {
match generation {
Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"),
@@ -1152,6 +1135,7 @@ fn chunk_account(generation: Option<&str>, index: usize) -> String {
}
}
#[cfg(any(not(any(target_os = "macos", target_os = "android")), test))]
fn chunk_json_payload(json: &str) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
@@ -1253,34 +1237,80 @@ fn delete_keyring_password(account: &str) {
#[cfg(not(target_os = "android"))]
fn load_keyring_credentials() -> Result<Option<CredsRoot>> {
let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else {
return Ok(None);
};
let manifest = read_chunk_manifest(&json_or_manifest)
.ok_or_else(|| anyhow!("invalid system credential vault manifest"))?;
let mut json = String::new();
for index in 0..manifest.chunks {
let account = chunk_account(manifest.generation.as_deref(), index);
let chunk = get_keyring_password(&account)?
.ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?;
json.push_str(&chunk);
}
serde_json::from_str::<CredsRoot>(&json)
.map(Some)
.context("decode system credential vault payload")
load_keyring_credentials_with(
get_keyring_password,
set_keyring_password,
cfg!(target_os = "macos"),
)
}
#[cfg(not(target_os = "android"))]
fn load_legacy_keyring_credentials() -> CredsRoot {
match load_legacy_keyring_credentials_for_update() {
Ok(root) => root,
Err(e) => {
log::warn!("[vault] read legacy vault credentials failed: {e}");
CredsRoot::default()
fn set_keyring_password(account: &str, value: &str) -> Result<()> {
keyring_entry_for(account)?
.set_password(value)
.with_context(|| format!("write system credential vault {account}"))
}
#[cfg(any(not(target_os = "android"), test))]
fn load_keyring_credentials_with(
mut read: impl FnMut(&str) -> Result<Option<String>>,
mut write: impl FnMut(&str, &str) -> Result<()>,
consolidate: bool,
) -> Result<Option<CredsRoot>> {
if consolidate {
if let Some(json) = read(KEYRING_SINGLE_CREDENTIALS_ACCOUNT)? {
return decode_single_credentials(&json).map(Some);
}
}
let Some(json_or_manifest) = read(KEYRING_CREDENTIALS_ACCOUNT)? else {
return Ok(None);
};
let json = if let Some(manifest) = read_chunk_manifest(&json_or_manifest) {
let mut json = String::new();
for index in 0..manifest.chunks {
let account = chunk_account(manifest.generation.as_deref(), index);
let chunk = read(&account)?
.ok_or_else(|| anyhow::anyhow!("missing system credential vault chunk {index}"))?;
json.push_str(&chunk);
}
json
} else {
json_or_manifest
};
let root = decode_single_credentials(&json)?;
if consolidate {
// macOS has no Windows blob limit. Create one app-owned item after the
// complete old payload was read with the user's authorization. Retain the
// old manifest/chunks without changing their ACLs or asking to delete them.
// A denied migration does not invalidate the successfully read data.
if let Err(error) = write(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json) {
log::warn!("[vault] single-item credential migration deferred: {error}");
}
}
Ok(Some(root))
}
#[cfg(any(not(target_os = "android"), test))]
fn decode_single_credentials(json: &str) -> Result<CredsRoot> {
// Serde defaults alone would accept unrelated JSON as an empty configuration.
let payload: serde_json::Value =
serde_json::from_str(json).context("decode system credential vault payload")?;
anyhow::ensure!(
payload
.get("version")
.and_then(serde_json::Value::as_u64)
.is_some()
&& payload
.get("active")
.is_some_and(serde_json::Value::is_object)
&& payload
.get("providers")
.is_some_and(serde_json::Value::is_object),
"invalid system credential vault payload"
);
serde_json::from_value(payload).context("decode system credential vault payload")
}
#[cfg(not(target_os = "android"))]
@@ -1304,44 +1334,15 @@ fn remove_legacy_keyring_credentials() {
}
}
fn load_legacy_credentials() -> Option<CredsRoot> {
credentials_path()
.ok()
.and_then(|p| read_legacy_credentials_file(&p))
}
fn legacy_vault_has_credentials(root: &CredsRoot) -> bool {
!root.providers.asr.is_empty() || !root.providers.llm.is_empty()
}
fn load_legacy_sources_without_migration() -> CredsRoot {
if let Some(legacy) = load_legacy_credentials() {
return legacy;
}
#[cfg(not(target_os = "android"))]
{
let legacy_vault = load_legacy_keyring_credentials();
if legacy_vault_has_credentials(&legacy_vault) {
return legacy_vault;
}
}
CredsRoot::default()
}
fn migrate_legacy_sources() -> CredsRoot {
match migrate_legacy_sources_for_update() {
Ok(root) => root,
Err(e) => {
log::warn!("[vault] legacy credential migration failed: {e}");
load_legacy_sources_without_migration()
}
}
}
fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
if let Some(legacy) = load_legacy_credentials() {
if let Some(legacy) = credentials_path()
.ok()
.and_then(|path| read_legacy_credentials_file(&path))
{
save_credentials(&legacy)?;
let persisted = credentials_cache()
.lock()
@@ -1371,8 +1372,7 @@ fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
Ok(CredsRoot::default())
}
#[cfg(any(target_os = "android", test))]
fn load_android_credentials_into_cache_with(
fn load_credentials_into_cache_with(
loader: impl FnOnce() -> Result<Option<CredsRoot>>,
) -> CredsRoot {
match loader() {
@@ -1385,17 +1385,14 @@ fn load_android_credentials_into_cache_with(
// Do not cache the fallback. In particular, a failed legacy-token
// scrub must be retried by the next startup/getter call rather than
// hidden for the rest of the process.
log::warn!("[vault] android credential read failed: {e}");
log::warn!("[vault] credential read failed: {e}");
CredsRoot::default()
}
}
}
/// 读凭据并就地补成渠道卡片。
///
/// 迁移**只在内存里做,不主动落盘**:`migrate_channels` 是幂等的(id 沿用原 preset
/// id,不生成 uuid),所以每次读的结果都一致;而启动时写 keyring 会在 macOS 上触发
/// 「OpenLess 想使用钥匙串」的 ACL 弹窗。留给下一次真实写入(用户改配置)顺带固化。
/// 补齐渠道元数据只改内存;下次保存时一并持久化,不在每次启动重写凭据。
/// macOS 的旧存储分片由底层读取器在成功授权后单独合并一次。
fn load_credentials() -> CredsRoot {
let mut root = load_credentials_raw();
migrate_channels(&mut root);
@@ -1415,40 +1412,21 @@ fn load_credentials_raw() -> CredsRoot {
#[cfg(target_os = "android")]
{
return load_android_credentials_into_cache_with(load_android_credentials);
return load_credentials_into_cache_with(load_android_credentials);
}
#[cfg(not(target_os = "android"))]
match load_keyring_credentials() {
Ok(Some(root)) => {
// 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个
// 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain
// 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经
// 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑,
// 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext
// JSON)不需要 ACL,可继续 best-effort 删除。
remove_legacy_credentials_file_best_effort();
store_credentials_cache(&root);
root
load_credentials_into_cache_with(|| {
// Legacy accounts are probed only after a definitive NoEntry. Retrying
// them after an authorization error creates more prompts, not a fallback.
match load_keyring_credentials()? {
Some(root) => {
remove_legacy_credentials_file_best_effort();
Ok(Some(root))
}
None => migrate_legacy_sources_for_update().map(Some),
}
Ok(None) => {
// 没有现成 chunked manifest —— 走 migrate(如果有 legacy 则写入并返回写后的 root)。
// migrate_legacy_sources 内部 save_credentials 已经会刷 cache,这里再补一次
// 是为了「无 legacy 也无 manifest」走默认 root 的路径也能进 cache。
let root = migrate_legacy_sources();
store_credentials_cache(&root);
root
}
Err(e) => {
// **不缓存 keyring 错误路径下的 fallback**。Keychain 可能只是临时不可读
// (用户尚未在第一次弹窗里点同意 / DataProtection 错误 / login keychain
// 还没 unlock);如果在这里把 legacy fallback 写进 cache,等用户授权后
// 我们就再也不会重读 keyring,整个进程生命周期里都拿 stale 数据。下次
// 调用让它再尝试一次 keyring。pr_agent feedback on PR #394。
log::warn!("[vault] system credential read failed: {e}");
load_legacy_sources_without_migration()
}
}
})
}
fn load_credentials_for_update_raw() -> Result<CredsRoot> {
@@ -1507,7 +1485,18 @@ fn save_credentials(root: &CredsRoot) -> Result<()> {
return Ok(());
}
#[cfg(not(target_os = "android"))]
#[cfg(target_os = "macos")]
{
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
// Updating this stable item keeps the user's authorization attached to it.
// Do not recreate entries or rewrite/delete legacy chunks on each save.
set_keyring_password(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json)?;
store_credentials_cache(&cleaned);
remove_legacy_credentials_file_best_effort();
return Ok(());
}
#[cfg(not(any(target_os = "android", target_os = "macos")))]
{
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)
@@ -1516,10 +1505,8 @@ fn save_credentials(root: &CredsRoot) -> Result<()> {
.and_then(|value| read_chunk_manifest(&value));
let chunks = chunk_json_payload(&json);
// 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让
// manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后
// 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation
// 设计的回退)。
// Publish the chunk count only after all writes succeed. Existing chunk
// names remain stable; this format is retained for bounded platform stores.
for (index, chunk) in chunks.iter().enumerate() {
let account = chunk_account(None, index);
keyring_entry_for(&account)?
@@ -1818,6 +1805,39 @@ pub struct CredentialsSnapshot {
pub omni_model: Option<String>,
}
/// Provider identities and their fields from the same successful vault read.
pub(crate) struct CredentialConfigurationSnapshot {
pub active_asr_provider: String,
pub active_llm_provider: String,
pub credentials: CredentialsSnapshot,
}
fn configuration_snapshot_with(
include_omni: bool,
load: impl FnOnce() -> Result<CredsRoot>,
) -> Result<CredentialConfigurationSnapshot> {
let root = load()?;
let asr_id = &root.active.asr;
let llm_id = &root.active.llm;
Ok(CredentialConfigurationSnapshot {
active_asr_provider: root
.providers
.asr
.get(asr_id)
.map(|entry| channel_provider_type(asr_id, entry))
.unwrap_or(asr_id)
.to_owned(),
active_llm_provider: root
.providers
.llm
.get(llm_id)
.map(|entry| channel_provider_type(llm_id, entry))
.unwrap_or(llm_id)
.to_owned(),
credentials: credentials_snapshot(&root, include_omni),
})
}
fn credentials_snapshot(root: &CredsRoot, include_omni: bool) -> CredentialsSnapshot {
CredentialsSnapshot {
volcengine_app_key: lookup_account(root, CredentialAccount::VolcengineAppKey),
@@ -1979,9 +1999,9 @@ impl CredentialsVault {
/// 系统凭据库 service name;macOS 下对应 Keychain service。
pub const SERVICE_NAME: &'static str = "com.openless.app";
pub fn load_metadata() -> openless_core::CredentialMetadata {
pub fn load_metadata() -> Result<openless_core::CredentialMetadata> {
let _guard = credentials_lock().lock();
credential_metadata(&load_credentials())
Ok(credential_metadata(&load_credentials_for_update()?))
}
pub fn save_metadata(metadata: openless_core::CredentialMetadata) -> Result<()> {
@@ -1991,14 +2011,18 @@ impl CredentialsVault {
save_credentials(&root)
}
pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> bool {
pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> Result<bool> {
let _guard = credentials_lock().lock();
channel_has_secrets(&load_credentials(), kind, id)
Ok(channel_has_secrets(
&load_credentials_for_update()?,
kind,
id,
))
}
pub fn get(account: CredentialAccount) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
Ok(lookup_account(&load_credentials(), account))
Ok(lookup_account(&load_credentials_for_update()?, account))
}
pub fn set(account: CredentialAccount, value: &str) -> Result<()> {
@@ -2015,7 +2039,7 @@ impl CredentialsVault {
pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
root.active.asr = id.to_string();
Ok(lookup_account(&root, account))
}
@@ -2057,7 +2081,9 @@ impl CredentialsVault {
);
}
#[cfg(not(target_os = "android"))]
Ok(lookup_marketplace_github_token(&load_credentials()))
Ok(lookup_marketplace_github_token(
&load_credentials_for_update()?,
))
}
pub fn set_marketplace_github_token(value: &str) -> Result<()> {
@@ -2186,7 +2212,7 @@ impl CredentialsVault {
/// 渠道化后必须能读任意一张卡片。
pub fn get_for_llm_provider(id: &str, account: CredentialAccount) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
root.active.llm = id.to_string();
Ok(lookup_account(&root, account))
}
@@ -2404,6 +2430,13 @@ impl CredentialsVault {
let root = load_credentials();
credentials_snapshot(&root, include_omni)
}
pub(crate) fn configuration_snapshot(
include_omni: bool,
) -> Result<CredentialConfigurationSnapshot> {
let _guard = credentials_lock().lock();
configuration_snapshot_with(include_omni, load_credentials_for_update)
}
}
#[cfg(test)]
@@ -2413,7 +2446,7 @@ mod tests {
use super::{
android_persistable_credentials, chunk_json_payload, credentials_cache,
get_android_marketplace_token_at, load_android_credentials_from_path,
load_android_credentials_from_path_with_crypto, load_android_credentials_into_cache_with,
load_android_credentials_from_path_with_crypto, load_credentials_into_cache_with,
lookup_account, lookup_marketplace_github_token, lookup_omni_account,
omni_extra_headers_json, omni_temperature_string, parse_extra_headers_json,
parse_llm_temperature, reset_credentials_cache_for_tests,
@@ -2426,6 +2459,226 @@ mod tests {
use parking_lot::Mutex;
use std::collections::HashMap;
#[test]
fn macos_credentials_read_one_item_after_legacy_chunk_migration() {
use std::cell::RefCell;
let mut root = CredsRoot::default();
root.version = 2;
write_account(
&mut root,
CredentialAccount::AsrApiKey,
Some("fixture-key".repeat(200)),
);
let json = serde_json::to_string(&root).unwrap();
let chunks = chunk_json_payload(&json);
assert!(chunks.len() > 1);
let manifest = super::CredsChunkManifest {
openless_credentials_storage: "chunked".into(),
version: 1,
generation: Some("legacy-generation".into()),
chunks: chunks.len(),
};
let mut entries = HashMap::new();
entries.insert(
super::KEYRING_CREDENTIALS_ACCOUNT.to_owned(),
serde_json::to_string(&manifest).unwrap(),
);
for (index, chunk) in chunks.iter().enumerate() {
entries.insert(
super::chunk_account(Some("legacy-generation"), index),
chunk.clone(),
);
}
let entries = RefCell::new(entries);
let reads = RefCell::new(Vec::new());
let writes = RefCell::new(Vec::new());
let read = |account: &str| {
reads.borrow_mut().push(account.to_owned());
Ok(entries.borrow().get(account).cloned())
};
let write = |account: &str, value: &str| {
writes.borrow_mut().push(account.to_owned());
entries
.borrow_mut()
.insert(account.to_owned(), value.to_owned());
Ok(())
};
let loaded = super::load_keyring_credentials_with(read, write, true)
.unwrap()
.unwrap();
assert_eq!(
lookup_account(&loaded, CredentialAccount::AsrApiKey),
lookup_account(&root, CredentialAccount::AsrApiKey)
);
assert_eq!(
*writes.borrow(),
[super::KEYRING_SINGLE_CREDENTIALS_ACCOUNT]
);
assert!(
super::read_chunk_manifest(
entries
.borrow()
.get(super::KEYRING_CREDENTIALS_ACCOUNT)
.unwrap()
)
.is_some(),
"the old manifest stays readable by a previous app version"
);
assert!(
entries
.borrow()
.contains_key(&super::chunk_account(Some("legacy-generation"), 0)),
"migration must leave legacy Keychain entries untouched"
);
reads.borrow_mut().clear();
writes.borrow_mut().clear();
let loaded = super::load_keyring_credentials_with(read, write, true)
.unwrap()
.unwrap();
assert_eq!(loaded.version, 2);
assert_eq!(*reads.borrow(), [super::KEYRING_SINGLE_CREDENTIALS_ACCOUNT]);
assert!(
writes.borrow().is_empty(),
"a migrated vault must not write at startup"
);
}
#[test]
fn macos_credentials_keep_readable_data_when_consolidation_is_denied() {
let root = CredsRoot::default();
let json = serde_json::to_string(&root).unwrap();
let manifest = serde_json::to_string(&super::CredsChunkManifest {
openless_credentials_storage: "chunked".into(),
version: 1,
generation: None,
chunks: 1,
})
.unwrap();
let loaded = super::load_keyring_credentials_with(
|account| {
if account == super::KEYRING_SINGLE_CREDENTIALS_ACCOUNT {
return Ok(None);
}
Ok(Some(if account == super::KEYRING_CREDENTIALS_ACCOUNT {
manifest.clone()
} else {
json.clone()
}))
},
|_, _| Err(anyhow!("fixture write denied")),
true,
)
.unwrap();
assert!(
loaded.is_some(),
"an optional format migration must not turn readable credentials into an error"
);
}
#[test]
fn macos_credentials_propagate_read_failures_without_migrating() {
let missing = super::load_keyring_credentials_with(
|_| Ok(None),
|_, _| panic!("missing credentials must not be written"),
true,
)
.unwrap();
assert!(missing.is_none());
let mut denied_reads = Vec::new();
let denied = super::load_keyring_credentials_with(
|account| {
denied_reads.push(account.to_owned());
Err(anyhow!("fixture access denied"))
},
|_, _| panic!("unreadable credentials must not be overwritten"),
true,
);
assert!(denied.is_err());
assert_eq!(
denied_reads,
[super::KEYRING_SINGLE_CREDENTIALS_ACCOUNT],
"an unreadable v2 item must not fall back to stale v1 credentials"
);
let malformed = super::load_keyring_credentials_with(
|_| Ok(Some("{}".into())),
|_, _| panic!("malformed credentials must not be overwritten"),
true,
);
assert!(malformed.is_err());
}
#[test]
fn macos_credentials_never_migrate_an_incomplete_legacy_payload() {
let manifest = serde_json::to_string(&super::CredsChunkManifest {
openless_credentials_storage: "chunked".into(),
version: 1,
generation: None,
chunks: 2,
})
.unwrap();
let result = super::load_keyring_credentials_with(
|account| match account {
super::KEYRING_SINGLE_CREDENTIALS_ACCOUNT => Ok(None),
super::KEYRING_CREDENTIALS_ACCOUNT => Ok(Some(manifest.clone())),
"credentials.v1.chunk.0" => Ok(Some("{\"version\":1,".into())),
_ => Err(anyhow!("fixture chunk access denied")),
},
|_, _| panic!("incomplete legacy data must not replace a vault"),
true,
);
assert!(result.is_err());
}
#[test]
fn credential_configuration_snapshot_is_atomic_and_propagates_load_errors() {
let denied =
super::configuration_snapshot_with(false, || Err(anyhow!("fixture keychain locked")));
assert!(
denied.is_err(),
"an unreadable vault is not an unconfigured ASR provider"
);
let mut root = CredsRoot::default();
root.active.asr = "my-asr-channel".into();
write_account(
&mut root,
CredentialAccount::AsrApiKey,
Some("fixture-asr-key".into()),
);
root.providers
.asr
.get_mut("my-asr-channel")
.unwrap()
.channel
.providerType = Some("bailian".into());
root.active.llm = "my-llm-channel".into();
write_account(
&mut root,
CredentialAccount::ArkApiKey,
Some("fixture-llm-key".into()),
);
root.providers
.llm
.get_mut("my-llm-channel")
.unwrap()
.channel
.providerType = Some("openai".into());
let loaded = super::configuration_snapshot_with(false, || Ok(root)).unwrap();
assert_eq!(loaded.active_asr_provider, "bailian");
assert_eq!(loaded.active_llm_provider, "openai");
assert_eq!(
loaded.credentials.asr_api_key.as_deref(),
Some("fixture-asr-key")
);
assert_eq!(
loaded.credentials.ark_api_key.as_deref(),
Some("fixture-llm-key")
);
}
#[test]
fn credential_payload_chunks_stay_under_windows_blob_limit() {
let payload = format!(
@@ -2861,9 +3114,8 @@ mod tests {
#[test]
fn android_startup_failure_does_not_cache_default_or_suppress_retry() {
reset_credentials_cache_for_tests();
let first = load_android_credentials_into_cache_with(|| {
Err(anyhow!("injected startup scrub failure"))
});
let first =
load_credentials_into_cache_with(|| Err(anyhow!("injected startup scrub failure")));
assert!(lookup_marketplace_github_token(&first).is_none());
assert!(credentials_cache().lock().is_none());
@@ -2871,8 +3123,7 @@ mod tests {
std::env::temp_dir().join(format!("openless-android-startup-{}", uuid::Uuid::new_v4()));
let path = dir.join("credentials.enc.json");
write_legacy_android_envelope(&path, "gho_legacy_startup_secret");
let second =
load_android_credentials_into_cache_with(|| load_android_credentials_from_path(&path));
let second = load_credentials_into_cache_with(|| load_android_credentials_from_path(&path));
assert!(lookup_marketplace_github_token(&second).is_none());
assert!(credentials_cache().lock().is_some());
@@ -10,7 +10,7 @@
'use strict';
// ============================================================
// i18n —— 文案字典(与 PC 端 src/i18n 对齐的 5 种语言)
// i18n —— 文案字典(与 PC 端 src/i18n 对齐的 8 种语言)
// ============================================================
var I18N = {
'zh-CN': {
@@ -183,6 +183,184 @@
copy: 'Copy',
copied: 'Copied ✓',
},
es: {
title: 'Entrada remota de OpenLess',
brandTitle: 'Entrada remota de OpenLess',
brandSub: 'Graba desde el móvil y escribe en el ordenador en tiempo real',
pinFieldLabel: 'Código de emparejamiento (6 dígitos mostrados en el ordenador)',
btnConnect: 'Conectar',
btnConnecting: 'Conectando…',
modeToggle: 'Tocar',
insertLabel: 'Escribir en el ordenador',
modeHold: 'Mantener',
offlineTitle: 'Desconectado',
offlineSub: 'Se ha perdido la conexión con el ordenador.',
btnReconnect: 'Volver a conectar',
certTip:
'En la primera visita, el navegador mostrará «No es seguro» por el certificado local autofirmado. En Chrome para Android: «Avanzado» → «Continuar». En Safari para iOS: «Mostrar detalles» → «Visitar este sitio web».',
tipToggle: 'Toca el botón grande para grabar y vuelve a tocarlo para terminar y transcribir.',
tipHold: 'Mantén pulsado el botón para hablar y suéltalo para terminar y transcribir.',
labelToggleIdle: 'Toca para empezar',
labelToggleRec: 'Toca para terminar',
labelHoldIdle: 'Mantén para hablar',
labelHoldRec: 'Suelta para terminar',
ready: 'Listo',
preparingMic: 'Preparando el micrófono…',
preparingBackend: 'Preparando el servicio…',
statusRecording: '🎤 Grabando',
statusTranscribing: '🔄 Transcribiendo',
statusPolishing: '✨ Puliendo el texto',
statusDone: '✅ Se han insertado {n} caracteres',
cancelled: 'Cancelado',
connLost: 'Conexión perdida',
errPinFormat: 'Introduce el código de emparejamiento de 6 dígitos.',
errPinWrong: 'El código no es correcto. Inténtalo de nuevo.',
errPinLocked: 'El emparejamiento está bloqueado. Genera otro código en el ordenador.',
errConnFail:
'No se pudo conectar. Comprueba que el móvil confía en el certificado del ordenador e inténtalo de nuevo.',
errConnCreate: 'No se pudo establecer la conexión. Comprueba la red.',
errConnTimeout:
'Se agotó el tiempo de conexión. Sigue los pasos de abajo para confiar en el certificado e inténtalo de nuevo.',
busy: 'Ordenador ocupado: {reason}',
busyDefault: 'espera un momento',
micDenied: '❌ Permiso de micrófono denegado. Actívalo en los ajustes del navegador.',
micNotFound: '❌ No hay ningún micrófono disponible.',
micBusy: '❌ Otra aplicación está usando el micrófono.',
micTimeout: '❌ Se agotó el tiempo de preparación del micrófono. Inténtalo de nuevo.',
pcmQueueOverflow: '❌ El búfer de audio está lleno. Inténtalo de nuevo.',
micUnknown: '❌ No se pudo iniciar la grabación{name}.',
errGeneric: 'Se ha producido un error',
helpTitle: '¿No puedes conectar? Comprueba el certificado en el móvil',
helpAndroid:
'① En Android u otros navegadores: abre esta página en una pestaña de incógnito, elige «Continuar» ante el aviso «No es seguro» e introduce el código.',
helpIos:
'② En Safari para iOS: abre una pestaña privada, toca «Mostrar detalles → Visitar este sitio web» ante el aviso de privacidad e introduce el código. No hace falta instalar el certificado.',
helpDownloadCert: '⬇ Descargar e instalar el certificado',
helpCopyLink: '⧉ Copiar enlace',
helpCopied: 'Copiado ✓',
copy: 'Copiar',
copied: 'Copiado ✓',
},
fr: {
title: 'Saisie à distance OpenLess',
brandTitle: 'Saisie à distance OpenLess',
brandSub: 'Enregistrez sur votre téléphone et écrivez sur votre ordinateur en temps réel',
pinFieldLabel: 'Code de jumelage (6 chiffres affichés sur votre ordinateur)',
btnConnect: 'Connecter',
btnConnecting: 'Connexion…',
modeToggle: 'Toucher',
insertLabel: 'Saisir sur le PC',
modeHold: 'Maintenir',
offlineTitle: 'Déconnecté',
offlineSub: 'La connexion à votre ordinateur a été perdue.',
btnReconnect: 'Reconnecter',
certTip:
'À la première visite, le navigateur affiche « Non sécurisé » à cause du certificat local autosigné. Chrome sur Android : « Paramètres avancés » → « Continuer ». Safari sur iOS : « Afficher les détails » → « Visiter ce site web ».',
tipToggle:
'Touchez le grand bouton pour enregistrer, puis à nouveau pour terminer et transcrire.',
tipHold:
'Maintenez le grand bouton pour parler, puis relâchez-le pour terminer et transcrire.',
labelToggleIdle: 'Toucher pour démarrer',
labelToggleRec: 'Toucher pour arrêter',
labelHoldIdle: 'Maintenir pour parler',
labelHoldRec: 'Relâcher pour arrêter',
ready: 'Prêt',
preparingMic: 'Préparation du microphone…',
preparingBackend: 'Préparation du service…',
statusRecording: '🎤 Enregistrement',
statusTranscribing: '🔄 Transcription',
statusPolishing: '✨ Retouche du texte',
statusDone: '✅ {n} caractères insérés',
cancelled: 'Annulé',
connLost: 'Connexion perdue',
errPinFormat: 'Saisissez le code de jumelage à 6 chiffres.',
errPinWrong: 'Code incorrect. Réessayez.',
errPinLocked: 'Jumelage verrouillé. Générez un nouveau code sur votre ordinateur.',
errConnFail:
'Connexion impossible. Vérifiez que le téléphone accepte le certificat de votre ordinateur, puis réessayez.',
errConnCreate: 'Impossible de se connecter. Vérifiez votre réseau.',
errConnTimeout:
'Délai de connexion dépassé. Suivez les instructions ci-dessous pour accepter le certificat, puis réessayez.',
busy: 'Ordinateur occupé : {reason}',
busyDefault: 'veuillez patienter',
micDenied: '❌ Accès au microphone refusé. Autorisez-le dans les réglages du navigateur.',
micNotFound: '❌ Aucun microphone disponible.',
micBusy: '❌ Le microphone est utilisé par une autre application.',
micTimeout: '❌ La préparation du microphone a expiré. Réessayez.',
pcmQueueOverflow: '❌ Le tampon audio est plein. Réessayez.',
micUnknown: '❌ Impossible de démarrer l’enregistrement{name}.',
errGeneric: 'Une erreur est survenue',
helpTitle: 'Connexion impossible ? Vérifiez le certificat sur votre téléphone',
helpAndroid:
'① Sur Android ou un autre navigateur : ouvrez cette page en navigation privée, choisissez « Continuer » après l’avertissement de sécurité, puis saisissez le code.',
helpIos:
'② Sur Safari pour iOS : ouvrez un onglet privé, touchez « Afficher les détails → Visiter ce site web » après l’avertissement, puis saisissez le code. Aucune installation de certificat n’est nécessaire.',
helpDownloadCert: '⬇ Télécharger et installer le certificat',
helpCopyLink: '⧉ Copier le lien',
helpCopied: 'Copié ✓',
copy: 'Copier',
copied: 'Copié ✓',
},
de: {
title: 'OpenLess Ferneingabe',
brandTitle: 'OpenLess Ferneingabe',
brandSub: 'Auf dem Smartphone aufnehmen und in Echtzeit am Computer schreiben',
pinFieldLabel: 'Kopplungscode (6 Ziffern auf dem Computer)',
btnConnect: 'Verbinden',
btnConnecting: 'Verbindung wird hergestellt…',
modeToggle: 'Tippen',
insertLabel: 'Am Computer einfügen',
modeHold: 'Gedrückt halten',
offlineTitle: 'Getrennt',
offlineSub: 'Die Verbindung zum Computer wurde getrennt.',
btnReconnect: 'Erneut verbinden',
certTip:
'Beim ersten Besuch meldet der Browser „Nicht sicher“, da das lokale Zertifikat selbst signiert ist. In Chrome für Android: „Erweitert“ → „Weiter“. In Safari für iOS: „Details einblenden“ → „Diese Website besuchen“.',
tipToggle:
'Tippe auf die große Schaltfläche, um aufzunehmen. Tippe erneut, um die Aufnahme zu beenden und zu transkribieren.',
tipHold:
'Halte die große Schaltfläche zum Sprechen gedrückt. Lass sie los, um die Aufnahme zu beenden und zu transkribieren.',
labelToggleIdle: 'Zum Starten tippen',
labelToggleRec: 'Zum Beenden tippen',
labelHoldIdle: 'Zum Sprechen halten',
labelHoldRec: 'Zum Beenden loslassen',
ready: 'Bereit',
preparingMic: 'Mikrofon wird vorbereitet…',
preparingBackend: 'Dienst wird vorbereitet…',
statusRecording: '🎤 Aufnahme läuft',
statusTranscribing: '🔄 Transkription läuft',
statusPolishing: '✨ Text wird überarbeitet',
statusDone: '✅ {n} Zeichen eingefügt',
cancelled: 'Abgebrochen',
connLost: 'Verbindung getrennt',
errPinFormat: 'Gib den 6-stelligen Kopplungscode ein.',
errPinWrong: 'Der Code ist falsch. Versuche es erneut.',
errPinLocked: 'Die Kopplung ist gesperrt. Erstelle am Computer einen neuen Code.',
errConnFail:
'Die Verbindung ist fehlgeschlagen. Prüfe, ob das Smartphone dem Zertifikat des Computers vertraut, und versuche es erneut.',
errConnCreate: 'Die Verbindung konnte nicht hergestellt werden. Prüfe dein Netzwerk.',
errConnTimeout:
'Zeitüberschreitung bei der Verbindung. Befolge die Schritte unten, um dem Zertifikat zu vertrauen, und versuche es erneut.',
busy: 'Computer beschäftigt: {reason}',
busyDefault: 'bitte warten',
micDenied: '❌ Mikrofonzugriff verweigert. Erlaube ihn in den Browsereinstellungen.',
micNotFound: '❌ Kein Mikrofon verfügbar.',
micBusy: '❌ Eine andere App verwendet das Mikrofon.',
micTimeout: '❌ Zeitüberschreitung beim Vorbereiten des Mikrofons. Versuche es erneut.',
pcmQueueOverflow: '❌ Der Audiopuffer ist voll. Versuche es erneut.',
micUnknown: '❌ Die Aufnahme konnte nicht gestartet werden{name}.',
errGeneric: 'Ein Fehler ist aufgetreten',
helpTitle: 'Keine Verbindung? Prüfe das Zertifikat auf deinem Smartphone',
helpAndroid:
'① Android oder andere Browser: Öffne diese Seite in einem privaten Tab, wähle bei der Sicherheitswarnung „Weiter“ und gib den Kopplungscode ein.',
helpIos:
'② Safari für iOS: Öffne einen privaten Tab und tippe bei der Warnung auf „Details einblenden → Diese Website besuchen“. Gib dann den Code ein. Eine Zertifikatsinstallation ist nicht erforderlich.',
helpDownloadCert: '⬇ Zertifikat herunterladen und installieren',
helpCopyLink: '⧉ Link kopieren',
helpCopied: 'Kopiert ✓',
copy: 'Kopieren',
copied: 'Kopiert ✓',
},
ja: {
title: 'OpenLess リモート入力',
brandTitle: 'OpenLess リモート入力',
@@ -303,9 +481,8 @@
// 解析显示语言:优先 PC 注入的 window.__OL_LANG__,回退手机系统语言。
var LANG = (function () {
var supported = { 'zh-CN': 1, 'zh-TW': 1, en: 1, ja: 1, ko: 1 };
var injected = (window.__OL_LANG__ || '').trim();
if (supported[injected]) return injected;
if (Object.prototype.hasOwnProperty.call(I18N, injected)) return injected;
var nav = (navigator.language || '').toLowerCase();
if (nav.indexOf('zh') === 0) {
if (
@@ -317,9 +494,8 @@
return 'zh-TW';
return 'zh-CN';
}
if (nav.indexOf('ja') === 0) return 'ja';
if (nav.indexOf('ko') === 0) return 'ko';
if (nav.indexOf('en') === 0) return 'en';
var base = nav.split('-')[0];
if (Object.prototype.hasOwnProperty.call(I18N, base)) return base;
return 'zh-CN';
})();
var L = I18N[LANG] || I18N['zh-CN'];
@@ -244,10 +244,15 @@ fn capsule_window_action(
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CapsuleLayoutState {
translation_active: bool,
style: CapsuleStyle,
monitor_x: i32,
monitor_y: i32,
monitor_width: u32,
monitor_height: u32,
work_x: i32,
work_y: i32,
work_width: u32,
work_height: u32,
scale_bits: u64,
}
@@ -258,6 +263,9 @@ struct CapsuleWindowState {
fallback_card_visible: AtomicBool,
fallback_presentation_id: AtomicU64,
deferred_payload: Mutex<Option<CapsulePayload>>,
last_payload: Mutex<Option<CapsulePayload>>,
layout_watch_active: AtomicBool,
layout_watch_epoch: AtomicU64,
}
impl Default for CapsuleWindowState {
@@ -269,6 +277,9 @@ impl Default for CapsuleWindowState {
fallback_card_visible: AtomicBool::new(false),
fallback_presentation_id: AtomicU64::new(0),
deferred_payload: Mutex::new(None),
last_payload: Mutex::new(None),
layout_watch_active: AtomicBool::new(false),
layout_watch_epoch: AtomicU64::new(0),
}
}
}
@@ -276,7 +287,11 @@ impl Default for CapsuleWindowState {
impl CapsuleWindowState {
fn cache_style(&self, style: CapsuleStyle) {
self.style.store(
u8::from(matches!(style, CapsuleStyle::Classic)),
match style {
CapsuleStyle::Siri => 0,
CapsuleStyle::Classic => 1,
CapsuleStyle::Typeless => 2,
},
Ordering::Relaxed,
);
}
@@ -284,6 +299,7 @@ impl CapsuleWindowState {
fn cached_style(&self) -> CapsuleStyle {
match self.style.load(Ordering::Relaxed) {
1 => CapsuleStyle::Classic,
2 => CapsuleStyle::Typeless,
_ => CapsuleStyle::Siri,
}
}
@@ -361,6 +377,8 @@ impl TauriCapsuleWindow {
}
pub(crate) fn set_size(&self, width: f64, height: f64) -> tauri::Result<()> {
// Cards temporarily borrow this window; their geometry belongs to the card until released.
self.stop_layout_watch();
if let Some(window) = self.window() {
window.set_size(tauri::LogicalSize::new(width, height))?;
}
@@ -383,6 +401,7 @@ impl TauriCapsuleWindow {
}
pub(crate) fn hide(&self) -> tauri::Result<()> {
self.stop_layout_watch();
if let Some(window) = self.window() {
window.hide()?;
}
@@ -436,7 +455,11 @@ impl TauriCapsuleWindow {
pub(crate) fn position_capsule_bottom_center(&self, translation: bool) -> tauri::Result<()> {
if let Some(window) = self.window() {
crate::position_capsule_bottom_center(&window, translation)?;
crate::position_capsule_bottom_center_with_style(
&window,
translation,
self.state.cached_style(),
)?;
}
Ok(())
}
@@ -445,16 +468,22 @@ impl TauriCapsuleWindow {
&self,
window: &tauri::WebviewWindow,
translation_active: bool,
style: CapsuleStyle,
) -> Option<CapsuleLayoutState> {
#[cfg(target_os = "windows")]
{
if let Some(mon) = crate::foreground_window_monitor() {
return Some(CapsuleLayoutState {
translation_active,
style,
monitor_x: mon.left,
monitor_y: mon.top,
monitor_width: (mon.right - mon.left).max(0) as u32,
monitor_height: (mon.bottom - mon.top).max(0) as u32,
work_x: mon.work_left,
work_y: mon.work_top,
work_width: (mon.work_right - mon.work_left).max(0) as u32,
work_height: (mon.work_bottom - mon.work_top).max(0) as u32,
scale_bits: mon.scale.to_bits(),
});
}
@@ -464,10 +493,15 @@ impl TauriCapsuleWindow {
if let Some(mon) = crate::capsule_target_monitor(window) {
return Some(CapsuleLayoutState {
translation_active,
style,
monitor_x: mon.physical_x,
monitor_y: mon.physical_y,
monitor_width: mon.physical_width,
monitor_height: mon.physical_height,
work_x: mon.work_x,
work_y: mon.work_y,
work_width: mon.work_width,
work_height: mon.work_height,
scale_bits: mon.scale.to_bits(),
});
}
@@ -475,10 +509,15 @@ impl TauriCapsuleWindow {
let monitor = window.current_monitor().ok().flatten()?;
Some(CapsuleLayoutState {
translation_active,
style,
monitor_x: monitor.position().x,
monitor_y: monitor.position().y,
monitor_width: monitor.size().width,
monitor_height: monitor.size().height,
work_x: monitor.work_area().position.x,
work_y: monitor.work_area().position.y,
work_width: monitor.work_area().size.width,
work_height: monitor.work_area().size.height,
scale_bits: monitor.scale_factor().to_bits(),
})
}
@@ -487,14 +526,17 @@ impl TauriCapsuleWindow {
&self,
window: &tauri::WebviewWindow,
translation_active: bool,
style: CapsuleStyle,
) {
let Some(next) = self.layout_snapshot(window, translation_active) else {
let Some(next) = self.layout_snapshot(window, translation_active, style) else {
return;
};
if self.state.layout.lock().as_ref() == Some(&next) {
return;
}
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
if crate::position_capsule_bottom_center_with_style(window, translation_active, style)
.is_ok()
{
*self.state.layout.lock() = Some(next);
}
}
@@ -505,18 +547,100 @@ impl TauriCapsuleWindow {
}
}
fn stop_layout_watch(&self) {
self.state
.layout_watch_active
.store(false, Ordering::SeqCst);
self.state.layout_watch_epoch.fetch_add(1, Ordering::SeqCst);
}
/// Audio callbacks stop during remote processing, but Dock and monitor changes must still apply.
fn start_layout_watch(&self) {
if self.state.layout_watch_active.swap(true, Ordering::SeqCst) {
return;
}
let epoch = self
.state
.layout_watch_epoch
.fetch_add(1, Ordering::SeqCst)
.wrapping_add(1);
let capsule = self.clone();
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if capsule.state.layout_watch_epoch.load(Ordering::SeqCst) != epoch {
break;
}
let _ = capsule.run_on_main_thread(move |capsule| {
if capsule.state.layout_watch_epoch.load(Ordering::SeqCst) != epoch {
return;
}
let Some(window) = capsule.window() else {
capsule.stop_layout_watch();
return;
};
if !window.is_visible().unwrap_or(false) {
capsule.stop_layout_watch();
return;
}
let payload = capsule.state.last_payload.lock().clone();
if let Some(payload) = payload {
capsule.maybe_position_capsule_bottom_center(
&window,
payload.translation,
capsule.state.cached_style(),
);
}
});
}
});
}
fn update_cursor_for_payload(&self, payload: &CapsulePayload, style: CapsuleStyle) {
#[cfg(not(mobile))]
{
let interactive = style != CapsuleStyle::Siri
&& !payload.selection_polish
&& matches!(
payload.state,
CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing
);
let want_passthrough = !interactive;
if self.state.cursor_passthrough.load(Ordering::SeqCst) != want_passthrough {
if let Err(error) = self.set_cursor_passthrough(want_passthrough) {
log::warn!("[capsule] set_ignore_cursor_events failed: {error}");
}
}
}
}
fn refresh_style(&self) {
let Some(window) = self.window() else {
return;
};
if !window.is_visible().unwrap_or(false)
|| self.state.fallback_card_visible.load(Ordering::SeqCst)
|| !self.state.layout_watch_active.load(Ordering::SeqCst)
{
return;
}
let payload = self.state.last_payload.lock().clone();
if let Some(payload) = payload {
let style = self.state.cached_style();
self.maybe_position_capsule_bottom_center(&window, payload.translation, style);
self.update_cursor_for_payload(&payload, style);
}
}
pub(crate) fn apply_capsule_payload(
&self,
payload: &CapsulePayload,
show_capsule: bool,
classic_style: bool,
style: CapsuleStyle,
reassert_spaces: bool,
) {
self.state.cache_style(if classic_style {
CapsuleStyle::Classic
} else {
CapsuleStyle::Siri
});
self.state.cache_style(style);
*self.state.last_payload.lock() = Some(payload.clone());
let Some(window) = self.window() else {
return;
};
@@ -528,7 +652,7 @@ impl TauriCapsuleWindow {
window,
payload,
show_capsule,
classic_style,
style,
fallback_card_active,
reassert_spaces,
);
@@ -545,35 +669,13 @@ impl TauriCapsuleWindow {
return;
}
self.maybe_position_capsule_bottom_center(&window, payload.translation);
#[cfg(not(mobile))]
{
let interactive = classic_style
&& action == CapsuleWindowAction::ShowCapsule
&& !payload.selection_polish
&& matches!(
payload.state,
CapsuleState::Recording
| CapsuleState::Transcribing
| CapsuleState::Polishing
);
let want_passthrough = !interactive;
if self
.state
.cursor_passthrough
.swap(want_passthrough, Ordering::SeqCst)
!= want_passthrough
{
if let Err(error) = window.set_ignore_cursor_events(want_passthrough) {
log::warn!("[capsule] set_ignore_cursor_events failed: {error}");
}
}
}
self.maybe_position_capsule_bottom_center(&window, payload.translation, style);
self.update_cursor_for_payload(payload, style);
match action {
CapsuleWindowAction::PreserveFallbackCard => unreachable!(),
CapsuleWindowAction::ShowCapsule => {
self.start_layout_watch();
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
log::info!(
"[capsule] first show this session: show_capsule=true visible=true state={}",
@@ -585,6 +687,7 @@ impl TauriCapsuleWindow {
crate::restore_main_window_key_if_active(&self.app);
}
CapsuleWindowAction::HideCapsule => {
self.stop_layout_watch();
if !show_capsule
&& !matches!(payload.state, CapsuleState::Idle)
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
@@ -646,6 +749,9 @@ impl TauriCoordinatorHost {
pub(crate) fn cache_capsule_style(&self, style: CapsuleStyle) {
self.capsule.cache_style(style);
if let Some(capsule) = self.capsule_window() {
let _ = capsule.run_on_main_thread(|capsule| capsule.refresh_style());
}
}
pub(crate) fn begin_insert_fallback_card(&self) -> u64 {
@@ -862,6 +968,51 @@ impl TauriCoordinatorHost {
mod tests {
use super::*;
#[test]
fn capsule_style_cache_preserves_every_wire_variant() {
let state = CapsuleWindowState::default();
assert_eq!(state.cached_style(), CapsuleStyle::Siri);
for style in [
CapsuleStyle::Classic,
CapsuleStyle::Typeless,
CapsuleStyle::Siri,
] {
state.cache_style(style);
assert_eq!(state.cached_style(), style);
}
}
#[test]
fn capsule_layout_cache_notices_work_area_and_style_changes() {
let initial = CapsuleLayoutState {
translation_active: false,
style: CapsuleStyle::Classic,
monitor_x: 0,
monitor_y: 0,
monitor_width: 1920,
monitor_height: 1080,
work_x: 0,
work_y: 0,
work_width: 1920,
work_height: 1040,
scale_bits: 1.0_f64.to_bits(),
};
assert_ne!(
initial,
CapsuleLayoutState {
work_height: 1080,
..initial
}
);
assert_ne!(
initial,
CapsuleLayoutState {
style: CapsuleStyle::Typeless,
..initial
}
);
}
#[test]
fn capsule_show_strategy_matches_platform_activation_contract() {
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -464,6 +464,11 @@ fn map_dictation_state(
fn emit_preferences(app: &AppHandle, backend: &OpenLessBackend) {
let preferences = backend.get_preferences();
// Cloud restore also changes preferences directly through Core. Keep native
// capsule geometry and hit testing current for every source of a preference change.
if let Some(coordinator) = app.try_state::<Arc<crate::coordinator::Coordinator>>() {
coordinator.sync_capsule_style_from_preferences();
}
let _ = app.emit("prefs:changed", &preferences);
}
+3 -3
View File
@@ -16,8 +16,8 @@
{
"label": "main",
"title": "OpenLess",
"width": 1240,
"height": 800,
"width": 1300,
"height": 835,
"minWidth": 980,
"minHeight": 640,
"resizable": true,
@@ -26,7 +26,7 @@
"shadow": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 20, "y": 20 },
"trafficLightPosition": { "x": 16, "y": 16 },
"visible": false,
"acceptFirstMouse": true
},
+57 -50
View File
@@ -12,11 +12,14 @@ import {
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import { SiriGL, warmUpSiriShaders } from './SiriGL';
import { TypelessCapsule } from './TypelessCapsule';
import { getSettings } from '../lib/ipc/settings';
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
import {
getCapsuleHostMetrics,
getCapsuleMessageLayout,
getCapsulePillMetrics,
parseCapsuleStyle,
} from '../lib/capsuleLayout';
import { isTauri } from '../lib/ipc';
import type {
@@ -183,8 +186,11 @@ const errorGlowTextStyle: CSSProperties = {
fontWeight: 600,
lineHeight: 1.4,
textAlign: 'center',
color: '#ff7a70',
textShadow: '0 0 14px rgba(255,70,60,0.5), 0 1px 6px rgba(0,0,0,0.6)',
color: 'var(--ol-err)',
padding: '6px 12px',
background: 'var(--ol-capsule-pill-bg)',
border: '1px solid var(--ol-capsule-pill-border)',
borderRadius: 12,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -638,7 +644,7 @@ function ClassicCapsule({
}: ClassicCapsuleProps) {
const { t } = useTranslation();
const metrics = classicPillMetrics(os);
const hostMetrics = getCapsuleHostMetrics(os, false);
const hostMetrics = getCapsuleHostMetrics(os, false, 'classic');
const onCancel = useCallback(() => {
void cancelDictation();
}, []);
@@ -656,12 +662,7 @@ function ClassicCapsule({
style={{
position: 'absolute',
left: '50%',
// macOS / Linux:pill 居中在 460×180 host,badge 锚到 pill 中线上方 21+8。
// Windows:pill 更高(52),badge 锚到 pill 上沿(bottomInset + height + gap)。
bottom:
os === 'win'
? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`
: 'calc(50% + 21px + 8px)',
bottom: hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap,
transform: 'translateX(-50%)',
pointerEvents: 'none',
}}
@@ -763,9 +764,7 @@ function getPreviewCapsulePayload() {
translation: params.get('translation') === '1',
warming: params.get('warming') === '1',
selectionPolish: params.get('selectionPolish') === '1',
// 浏览器预览:?style=classic 直接看经典药丸。
style:
params.get('style') === 'classic' ? ('classic' as CapsuleStyle) : ('siri' as CapsuleStyle),
style: parseCapsuleStyle(params.get('style')) ?? 'siri',
};
}
@@ -789,10 +788,11 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
});
const [translation, setTranslation] = useState<boolean>(preview.translation);
const [selectionPolish, setSelectionPolish] = useState<boolean>(preview.selectionPolish);
// 胶囊样式(siri / classic):随 capsule:state payload 下发;设置里切换后还会经
// prefs:changed 广播即时到达(见下方监听),无需等下一次录音。
// 偏好事件即时换肤;录音状态携带同一个样式,保证首次显示也能正确呈现。
const [capsuleStyle, setCapsuleStyle] = useState<CapsuleStyle>(preview.style);
const stylePreferenceReadyRef = useRef(false);
const isClassic = capsuleStyle === 'classic';
const isTypeless = capsuleStyle === 'typeless';
// 预备态:麦克风尚未吐第一帧 PCM。true 时录音光条走「待命」呼吸形态(见 SiriGL warming)。
const [warming, setWarming] = useState<boolean>(preview.warming);
// 预备→就绪耗时的移动平均,驱动光条展开动画的预测节奏(见 SiriGL warmProgress)。
@@ -814,19 +814,15 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
);
const [lastVisibleMessage, setLastVisibleMessage] = useState<string | undefined>(preview.message);
// 退出动画时长跟随样式;leaving effect 故意只依赖 state,所以经 ref 读取最新值。
const exitMsRef = useRef(isClassic ? EXIT_ANIM_MS_CLASSIC : EXIT_ANIM_MS_SIRI);
exitMsRef.current = isClassic ? EXIT_ANIM_MS_CLASSIC : EXIT_ANIM_MS_SIRI;
const exitMsRef = useRef(capsuleStyle === 'siri' ? EXIT_ANIM_MS_SIRI : EXIT_ANIM_MS_CLASSIC);
exitMsRef.current = capsuleStyle === 'siri' ? EXIT_ANIM_MS_SIRI : EXIT_ANIM_MS_CLASSIC;
const exitMs = exitMsRef.current;
// 词条建议卡片。走独立事件通道,不进会话状态机 —— 那套状态机身上挂着 Esc 独占、
// Space 贴附、多屏定位一整串逻辑,加一个非会话状态进去只会污染它。
const [suggestions, setSuggestions] = useState<PendingCorrection[]>([]);
// 落字失败兜底卡片。与词条卡片同一套路:独立事件通道,不进会话状态机。
const [insertFallback, setInsertFallback] = useState<InsertFallbackCardPayload | null>(null);
// 前端 host 与原生窗口保持同一份透明语音 orb 舞台尺寸。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// Windows 端 host 用「host 高 − pill 高」把 pill 垂直居中;Siri 舞台 460×180 与 host
// 等高(padding 0),经典药丸则在 180 高的窗口里垂直居中。
const pillMetrics = isClassic ? classicPillMetrics(os) : metrics;
const hostMetrics = getCapsuleHostMetrics(os, translation, capsuleStyle);
const badgeBottom = Math.round(metrics.height * 0.73);
// 空闲预热 shader 编译缓存:首次按下热键时光条不用现场编译(响应延迟反馈)。
@@ -862,7 +858,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
setTranslation(p.translation === true);
setWarming(p.warming === true);
setSelectionPolish(p.selectionPolish === true);
if (p.capsuleStyle != null) setCapsuleStyle(p.capsuleStyle);
const style = parseCapsuleStyle(p.capsuleStyle);
if (style && !stylePreferenceReadyRef.current) setCapsuleStyle(style);
if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars;
operatingRef.current = p.operating === true;
});
@@ -900,39 +897,41 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
};
}, []);
// 设置里切换胶囊样式「立即生效」:prefs:changed 由 Rust 广播到所有 webview(含胶囊
// 窗口,见 set_settings 的 app.emit),不用等下一次录音的 capsule:state payload。
// idle 时组件常驻(只渲染 0 尺寸 div),监听不丢;录音中切换则当帧换肤。
// capsule:state 仍作为兜底(payload.capsuleStyle),两路幂等。
// 先订阅再读取偏好,兼顾隐藏窗口首次加载和已经播放中的即时换肤。
useEffect(() => {
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
let preferenceRevision = 0;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<{ capsuleStyle?: CapsuleStyle }>('prefs:changed', (event) => {
const next = event.payload?.capsuleStyle;
if (next === 'siri' || next === 'classic') setCapsuleStyle(next);
preferenceRevision += 1;
const next = parseCapsuleStyle(event.payload?.capsuleStyle);
if (next) {
stylePreferenceReadyRef.current = true;
setCapsuleStyle(next);
}
});
if (cancelled) handle();
else unlisten = handle;
})();
if (cancelled) {
handle();
return;
}
unlisten = handle;
const revisionAtRead = preferenceRevision;
const preferences = await getSettings();
const next = parseCapsuleStyle(preferences.capsuleStyle);
if (!cancelled && revisionAtRead === preferenceRevision && next) {
stylePreferenceReadyRef.current = true;
setCapsuleStyle(next);
}
})().catch((error) => console.warn('[capsule] preferences subscription failed', error));
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
// 切换样式时重置胶囊瞬态(「切换完成后重置并重新初始化相关配置」):
// 中止进行中的退出动画(避免用旧时长收尾)、清掉上一会话残留的插入字数/操作态
// 与预备态计时,让换肤后的首帧从干净状态重新初始化。
useEffect(() => {
setLeaving(false);
insertedCharsRef.current = 0;
operatingRef.current = false;
warmStartRef.current = null;
}, [capsuleStyle]);
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放
// EXIT_ANIM_MS_SIRI / EXIT_ANIM_MS_CLASSIC(按当前样式,经 exitMsRef 读取),再卸载。
// 设计要点:
@@ -1025,16 +1024,12 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
height: '100%',
position: 'relative',
display: 'flex',
alignItems: 'center',
alignItems: capsuleStyle === 'siri' ? 'center' : 'flex-end',
justifyContent: 'center',
paddingLeft: hostMetrics.horizontalInset,
paddingRight: hostMetrics.horizontalInset,
paddingBottom: hostMetrics.bottomInset,
boxSizing: hostMetrics.boxSizing,
paddingTop:
os === 'win'
? Math.max(0, hostMetrics.height - pillMetrics.height - hostMetrics.bottomInset)
: 0,
paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0,
background: 'transparent',
animation: leaving
? `capsule-out ${exitMs}ms cubic-bezier(.55,.06,.68,.19) forwards`
@@ -1056,6 +1051,16 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
operating={operatingRef.current}
translation={translation}
/>
) : isTypeless ? (
<TypelessCapsule
state={renderedState}
level={leaving ? 0 : level}
insertedChars={insertedCharsRef.current}
message={renderedMessage}
operating={operatingRef.current}
translation={translation}
warming={!leaving && warming}
/>
) : (
<>
{/* "正在翻译" 徽章 — 嵌套两层:
@@ -1078,9 +1083,11 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
gap: 5,
fontSize: 10.5,
fontWeight: 600,
// 纯光效语言:无壳发光小字,与波形同一气质(浮空 + 冷蓝光晕)。
color: 'rgba(190, 212, 255, 0.95)',
textShadow: '0 0 14px rgba(90, 140, 255, 0.85), 0 1px 6px rgba(0, 0, 0, 0.45)',
color: 'var(--ol-capsule-center-ink)',
background: 'var(--ol-capsule-badge-bg)',
border: '1px solid var(--ol-capsule-pill-border)',
borderRadius: 999,
padding: '4px 10px',
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
// 隐藏:从光条附近偏下出发;显示:归位到光条上方。
@@ -0,0 +1,253 @@
.ol-typeless-capsule-wrap {
--ol-typeless-bg: #fafafa;
--ol-typeless-border: #d4d4d8;
--ol-typeless-ink: #18181b;
--ol-typeless-button-bg: #e4e4e7;
--ol-typeless-button-hover: #d4d4d8;
--ol-typeless-confirm-bg: #27272a;
--ol-typeless-confirm-ink: #ffffff;
position: relative;
font-family: var(--ol-font-sans);
}
.ol-typeless-capsule {
position: relative;
width: 176px;
height: 64px;
overflow: hidden;
border: 1px solid var(--ol-typeless-border);
border-radius: 999px;
box-sizing: border-box;
background: var(--ol-typeless-bg);
color: var(--ol-typeless-ink);
box-shadow:
0 5px 14px rgb(0 0 0 / 16%),
inset 0 1px 0 rgb(255 255 255 / 5%);
transition: width 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ol-typeless-capsule[data-recording='true'] {
width: 232px;
}
.ol-typeless-capsule[data-error='true'] {
width: 300px;
}
.ol-typeless-recording,
.ol-typeless-status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 8px;
box-sizing: border-box;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.ol-typeless-recording {
width: 230px;
left: 50%;
transform: translateX(-50%);
}
.ol-typeless-capsule[data-recording='true'] .ol-typeless-recording,
.ol-typeless-capsule:not([data-recording='true']) .ol-typeless-status {
opacity: 1;
pointer-events: auto;
}
.ol-typeless-action {
display: inline-flex;
flex: 0 0 46px;
width: 46px;
height: 46px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--ol-typeless-button-bg);
color: var(--ol-typeless-ink);
cursor: pointer;
transition:
background 120ms ease,
transform 120ms ease;
}
.ol-typeless-confirm {
background: var(--ol-typeless-confirm-bg);
color: var(--ol-typeless-confirm-ink);
}
.ol-typeless-action:hover {
background: var(--ol-typeless-button-hover);
}
.ol-typeless-confirm:hover {
background: var(--ol-typeless-confirm-bg);
opacity: 0.85;
}
.ol-typeless-action:active {
transform: scale(0.94);
}
.ol-typeless-action:focus-visible,
.ol-typeless-stop:focus-visible {
outline: 2px solid #79b0ff;
outline-offset: 2px;
}
.ol-capsule-waveform {
display: inline-flex;
flex: 0 0 auto;
width: 80px;
height: 28px;
align-items: center;
justify-content: center;
gap: 3px;
}
.ol-capsule-waveform > span {
width: 3px;
height: 28px;
border-radius: 999px;
background: #3478f6;
transform-origin: center;
transition: transform 100ms linear;
}
.ol-capsule-waveform[data-warming='true'] {
opacity: 0.5;
}
.ol-typeless-status {
gap: 6px;
padding-inline: 18px;
}
.ol-typeless-status > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 500;
line-height: 1.4;
}
.ol-typeless-capsule[data-error='true'] .ol-typeless-status {
color: var(--ol-err);
}
.ol-typeless-capsule[data-processing='true'] .ol-typeless-status > span {
animation: ol-typeless-thinking 1.8s ease-in-out infinite;
}
.ol-typeless-stop {
display: inline-flex;
flex: 0 0 24px;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 50%;
background: var(--ol-typeless-button-bg);
color: var(--ol-typeless-ink);
padding: 0;
cursor: pointer;
}
.ol-typeless-translation {
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
transform: translateX(-50%);
padding: 3px 10px;
white-space: nowrap;
font-size: 10px;
font-weight: 500;
color: var(--ol-typeless-ink);
background: var(--ol-typeless-bg);
border: 1px solid var(--ol-typeless-border);
border-radius: 999px;
}
.ol-capsule-style-preview {
display: flex;
align-items: center;
justify-content: center;
width: 256px;
max-width: 100%;
height: 96px;
margin-top: 8px;
border-radius: 12px;
background: var(--ol-surface-2);
pointer-events: none;
}
.ol-typeless-capsule-wrap[data-preview='true'] {
transform: scale(0.86);
}
.ol-classic-capsule-preview {
display: flex;
align-items: center;
justify-content: space-between;
width: 176px;
height: 42px;
padding: 0 7px;
box-sizing: border-box;
border: 1px solid var(--ol-capsule-pill-border);
border-radius: 999px;
background: var(--ol-capsule-pill-bg);
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
}
.ol-classic-capsule-preview > span:not(.ol-capsule-waveform) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: var(--ol-capsule-btn-ink);
background: var(--ol-capsule-btn-bg);
}
.ol-classic-capsule-preview .ol-capsule-waveform {
width: 60px;
transform: scale(0.7);
}
.ol-siri-capsule-preview .ol-capsule-waveform {
width: 160px;
gap: 5px;
filter: drop-shadow(0 0 6px #8b5cf680);
}
.ol-siri-capsule-preview .ol-capsule-waveform > span {
width: 8px;
background: #a580ff;
}
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(-n + 4) {
background: #65a5ff;
}
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(n + 8) {
background: #fa84b3;
}
[data-ol-theme='dark'] .ol-typeless-capsule-wrap {
--ol-typeless-bg: #202024;
--ol-typeless-border: #505056;
--ol-typeless-ink: #fafafa;
--ol-typeless-button-bg: #414148;
--ol-typeless-button-hover: #55555e;
--ol-typeless-confirm-bg: #fafafa;
--ol-typeless-confirm-ink: #18181b;
}
@keyframes ol-typeless-thinking {
50% {
opacity: 0.55;
}
}
@media (prefers-reduced-motion: reduce) {
.ol-typeless-capsule,
.ol-typeless-recording,
.ol-typeless-status,
.ol-capsule-waveform > span {
transition: none;
}
.ol-typeless-capsule[data-processing='true'] .ol-typeless-status > span {
animation: none;
}
}
@@ -44,8 +44,8 @@ const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace'];
/** macOS 上侧栏顶部需让开原生红绿灯的高度。红绿灯在 (20,20),按钮组高约 14px,
* 上下留等距 20px → 避让带总高 54px,红绿灯恰好竖直居中、左缘与导航图标列对齐。 */
const MAC_TRAFFIC_LIGHT_CLEARANCE = 54;
const SIDEBAR_WIDTH = 282;
const MAC_TRAFFIC_LIGHT_CLEARANCE = 44;
const SIDEBAR_WIDTH = 226;
/** tab → 页面组件映射(渲染主内容用;与侧栏树解耦,含未直接列在树上的页)。 */
const PAGE_CMP: Record<Exclude<AppTab, 'localAsr'>, ComponentType> = {
@@ -78,7 +78,7 @@ const NAV_TREE: NavNode[] = [
kind: 'group',
key: 'tools',
icon: 'selectionAsk',
children: [{ id: 'selectionAsk' }, { id: 'translation' }, { id: 'corrections' }],
children: [{ id: 'translation' }, { id: 'selectionAsk' }, { id: 'corrections' }],
},
];
@@ -183,10 +183,17 @@ function FloatingShellBody({
useEffect(() => {
let cancelled = false;
(async () => {
const credentials = await getCredentials();
const promptDeferredValue = window.sessionStorage.getItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY);
if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptDeferredValue)) {
setProviderPromptOpen(true);
try {
const credentials = await getCredentials();
const promptDeferredValue = window.sessionStorage.getItem(
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
);
if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptDeferredValue)) {
setProviderPromptOpen(true);
}
} catch (error) {
// A locked/unavailable credential store is not an unconfigured provider.
console.warn('[startup] credential status unavailable', error);
}
})();
return () => {
@@ -517,8 +524,8 @@ function FloatingShellBody({
padding: mobile
? '16px 16px calc(16px + env(safe-area-inset-bottom, 0px) + 56px)'
: displayTab === 'overview'
? '24px 28px 24px'
: '24px 28px 32px',
? `${os === 'mac' ? 80 : 56}px 28px 24px`
: `${os === 'mac' ? 80 : 56}px 28px 32px`,
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
position: 'relative',
+3 -3
View File
@@ -41,7 +41,7 @@ import {
Plus,
RefreshCw,
Search,
Settings2,
Settings,
ShieldCheck,
SlidersHorizontal,
Sparkles,
@@ -63,7 +63,7 @@ export const ICONS: Record<string, LucideIcon> = {
style: SlidersHorizontal,
translate: Languages,
selectionAsk: MessageSquareText,
settings: Settings2,
settings: Settings,
help: CircleHelp,
mic: Mic,
search: Search,
@@ -142,7 +142,7 @@ export function Icon({
stroke={stroke}
strokeWidth={strokeWidth}
fill={fill}
style={style}
style={{ flexShrink: 0, ...style }}
className={className}
aria-hidden="true"
focusable="false"
@@ -49,7 +49,7 @@ export function SavedToast({
padding: '4px 11px',
borderRadius: 999,
border: failed ? '0.5px solid rgba(239,68,68,0.22)' : '0.5px solid rgba(37,99,235,0.16)',
background: failed ? 'rgba(254,242,242,0.92)' : 'rgba(239,244,255,0.92)',
background: failed ? '#fef2f2' : '#eff4ff',
color: failed ? '#dc2626' : '#2563eb',
fontSize: 11.5,
fontWeight: 600,
@@ -55,7 +55,7 @@ export function SettingsModal({
initialSettingsSection,
closing = false,
}: SettingsModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const mobile = useMobileLayout();
const conservative = useConservativeLayout();
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
@@ -117,8 +117,19 @@ export function SettingsModal({
setRailThumb(null);
return;
}
setRailThumb({ top: btn.offsetTop, height: btn.offsetHeight });
}, [section, searching, mobile, sections.length]);
const measure = () => {
const next = { top: btn.offsetTop, height: btn.offsetHeight };
setRailThumb((current) =>
current?.top === next.top && current.height === next.height ? current : next,
);
};
measure();
// Translated labels may wrap when the language, font scale or window width changes.
const observer = new ResizeObserver(measure);
observer.observe(nav);
observer.observe(btn);
return () => observer.disconnect();
}, [section, searching, mobile, sections.length, i18n.resolvedLanguage]);
useEffect(() => {
let cancelled = false;
@@ -283,7 +294,7 @@ export function SettingsModal({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: mobile ? 0 : 20,
padding: mobile ? 0 : '64px 28px 24px',
zIndex: mobile ? 70 : 50,
animation: mobile
? undefined
@@ -304,9 +315,9 @@ export function SettingsModal({
onKeyDown={handleKeyDown}
style={{
width: '100%',
maxWidth: mobile ? undefined : 1020,
maxWidth: mobile ? undefined : 960,
height: '100%',
maxHeight: mobile ? undefined : 760,
maxHeight: mobile ? undefined : 680,
minHeight: 0,
background: 'var(--ol-settings-content-bg)',
borderRadius: mobile ? 0 : 14,
@@ -364,7 +375,7 @@ export function SettingsModal({
>
<aside
style={{
width: mobile ? undefined : 232,
width: mobile ? undefined : 214,
flexShrink: 0,
minHeight: 0,
overflow: 'auto',
@@ -444,12 +455,14 @@ export function SettingsModal({
zIndex: 1,
flexShrink: 0,
padding: '10px',
whiteSpace: 'nowrap',
whiteSpace: 'normal',
}
}
>
{!mobile && <Icon name={item.icon} size={16} />}
{item.title}
<span style={{ minWidth: 0, overflowWrap: mobile ? undefined : 'anywhere' }}>
{item.title}
</span>
</button>
);
})}
@@ -329,7 +329,9 @@ export function ShortcutRecorder({
}}
>
{t('settings.recording.comboRecordHint')}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>Esc 取消</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
Esc · {t('common.cancel')}
</div>
</motion.div>
) : (
<motion.div
@@ -4,6 +4,7 @@ import { Icon } from './Icon';
import { readStylePackIcon, setStylePackIcon } from '../lib/ipc';
import { isStyleIconDataUrl, rasterizeStyleSvg } from '../lib/stylePackIcon';
import type { StylePack } from '../lib/types';
import { getStylePackPresentation } from '../lib/stylePackPresentation';
const DEFAULT_ICONS = { raw: 'mic', light: 'feather', structured: 'layout', formal: 'doc' };
@@ -17,6 +18,7 @@ export function StylePackIconPicker({
onStatus: (failed: boolean, message: string) => void;
}) {
const { t } = useTranslation();
const { name } = getStylePackPresentation(pack, t);
const [src, setSrc] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,8 +67,8 @@ export function StylePackIconPicker({
data-custom={src ? 'true' : undefined}
onClick={() => inputRef.current?.click()}
disabled={busy}
aria-label={t('style.pack.uploadIcon', { name: pack.name })}
title={t('style.pack.uploadIcon', { name: pack.name })}
aria-label={t('style.pack.uploadIcon', { name })}
title={t('style.pack.uploadIcon', { name })}
>
{src ? (
<img src={src} width={24} height={24} alt="" onError={() => setSrc(null)} />
+2 -1
View File
@@ -268,7 +268,8 @@ const bubbleStyle: CSSProperties = {
WebkitBackdropFilter: 'blur(12px) saturate(150%)',
border: '0.5px solid var(--ol-glass-border)',
boxShadow: 'var(--ol-shadow-md)',
whiteSpace: 'nowrap',
whiteSpace: 'normal',
overflowWrap: 'anywhere',
pointerEvents: 'none',
animation: 'ol-tooltip-in 0.12s ease-out both',
};
@@ -0,0 +1,130 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
import type { CapsuleState, CapsuleStyle } from '../lib/types';
import { Icon } from './Icon';
import './CapsuleStyles.css';
interface TypelessCapsuleProps {
state: CapsuleState;
level: number;
message?: string;
insertedChars?: number;
operating?: boolean;
translation?: boolean;
warming?: boolean;
preview?: boolean;
}
const WAVE_ENVELOPE = [0.28, 0.44, 0.63, 0.82, 0.96, 1, 0.96, 0.82, 0.63, 0.44, 0.28];
export function CapsuleWaveform({ level, warming = false }: { level: number; warming?: boolean }) {
const voice = Math.min(1, Math.max(0, (level - 0.012) / 0.328));
const amplitude = warming ? 0 : Math.pow(voice, 0.55);
return (
<span className="ol-capsule-waveform" data-warming={warming} aria-hidden="true">
{WAVE_ENVELOPE.map((envelope, index) => (
<span key={index} style={{ transform: `scaleY(${0.12 + amplitude * envelope * 0.88})` }} />
))}
</span>
);
}
/** A single shell changes width between recording and processing, so its outline stays stable. */
export function TypelessCapsule({
state,
level,
message,
insertedChars = 0,
operating = false,
translation = false,
warming = false,
preview = false,
}: TypelessCapsuleProps) {
const { t } = useTranslation();
const recording = state === 'recording';
const processing = state === 'transcribing' || state === 'polishing';
const label = processing
? t(operating ? 'capsule.using' : 'capsule.thinking')
: state === 'done'
? message || t('capsule.inserted', { count: insertedChars })
: state === 'cancelled'
? t('capsule.cancelled')
: message || t('capsule.error');
const cancel = useCallback(() => void cancelDictation(), []);
const confirm = useCallback(() => void stopDictation(), []);
return (
<div className="ol-typeless-capsule-wrap" data-preview={preview}>
{translation && <span className="ol-typeless-translation">{t('capsule.translating')}</span>}
<div
className="ol-typeless-capsule"
data-recording={recording}
data-error={state === 'error'}
data-processing={processing}
>
<div className="ol-typeless-recording" aria-hidden={!recording}>
<button
className="ol-typeless-action"
aria-label={t('common.cancel')}
disabled={!recording || preview}
onMouseDown={(event) => event.preventDefault()}
onClick={cancel}
>
<Icon name="close" size={24} strokeWidth={2} />
</button>
<CapsuleWaveform level={level} warming={warming} />
<button
className="ol-typeless-action ol-typeless-confirm"
aria-label={t('settings.shortcuts.confirm')}
disabled={!recording || preview}
onMouseDown={(event) => event.preventDefault()}
onClick={confirm}
>
<Icon name="check" size={25} strokeWidth={2.1} />
</button>
</div>
<div className="ol-typeless-status" aria-hidden={recording}>
<span role="status" title={label}>
{label}
</span>
{processing && !preview && (
<button
className="ol-typeless-stop"
aria-label={t('common.cancel')}
onMouseDown={(event) => event.preventDefault()}
onClick={cancel}
>
<Icon name="close" size={13} />
</button>
)}
</div>
</div>
</div>
);
}
/** This local preview makes a saved choice visible without starting the microphone. */
export function CapsuleStylePreview({ style }: { style: CapsuleStyle }) {
return (
<div className="ol-capsule-style-preview" data-style={style} aria-hidden="true">
{style === 'typeless' ? (
<TypelessCapsule state="recording" level={0.2} preview />
) : style === 'classic' ? (
<div className="ol-classic-capsule-preview">
<span>
<Icon name="close" size={13} />
</span>
<CapsuleWaveform level={0.2} />
<span>
<Icon name="check" size={13} />
</span>
</div>
) : (
<div className="ol-siri-capsule-preview">
<CapsuleWaveform level={0.2} />
</div>
)}
</div>
);
}
@@ -1,4 +1,5 @@
import { Icon } from './Icon';
import { useTranslation } from 'react-i18next';
import {
type CSSProperties,
type ReactNode,
@@ -23,8 +24,8 @@ export function detectOS(): OS {
return 'mac';
}
const MAC_TITLEBAR_HEIGHT = 28;
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 76;
const MAC_TITLEBAR_HEIGHT = 44;
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 88;
const LINUX_TITLEBAR_HEIGHT = 36;
const WIN_CONSOLE_RADIUS = 10;
@@ -46,12 +47,6 @@ export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChrom
const useSolidSurface = os === 'linux' || os === 'android';
// 主窗口底色是不透明渐变(--ol-window-bg),backdrop-filter 模糊不到任何
// 内容(非透明窗口拿不到窗口背后的像素,见 global.css .ol-frost 注释)——
// 之前 blur(36px) 是纯合成开销死代码,macOS WKWebView 在切换模型/高频
// 重渲染时合成层故障,整窗「消失一下又恢复」。全平台统一 none。
const useBackdropFilter = false;
return (
<div
className="ol-winchrome"
@@ -71,12 +66,9 @@ export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChrom
border:
os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid var(--ol-window-border)',
background: useSolidSurface ? 'var(--ol-surface)' : 'var(--ol-window-bg)',
backdropFilter: useBackdropFilter
? 'blur(var(--ol-glass-blur-strong)) saturate(190%)'
: 'none',
WebkitBackdropFilter: useBackdropFilter
? 'blur(var(--ol-glass-blur-strong)) saturate(190%)'
: 'none',
// The main window is opaque; backdrop blur would only add a compositing layer.
backdropFilter: 'none',
WebkitBackdropFilter: 'none',
animation:
os === 'win' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
transition:
@@ -112,6 +104,7 @@ export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChrom
type TauriWindow = import('@tauri-apps/api/window').Window;
function LinuxTitlebar() {
const { t } = useTranslation();
const [maximized, setMaximized] = useState(false);
const winRef = useRef<TauriWindow | null>(null);
@@ -198,17 +191,22 @@ function LinuxTitlebar() {
style={{ display: 'flex', gap: 4, pointerEvents: 'auto' }}
onMouseDown={(e) => e.stopPropagation()}
>
<button onClick={onMinimize} aria-label="Minimize" style={ctrlBtn}>
<button onClick={onMinimize} aria-label={t('windowChrome.minimize')} style={ctrlBtn}>
<MinimizeSvg />
</button>
<button
onClick={onToggleMaximize}
aria-label={maximized ? 'Restore' : 'Maximize'}
aria-label={t(maximized ? 'windowChrome.restore' : 'windowChrome.maximize')}
style={ctrlBtn}
>
{maximized ? <RestoreSvg /> : <MaximizeSvg />}
</button>
<button onClick={onClose} aria-label="Close" className="ol-linux-close-btn" style={ctrlBtn}>
<button
onClick={onClose}
aria-label={t('windowChrome.close')}
className="ol-linux-close-btn"
style={ctrlBtn}
>
<CloseSvg />
</button>
</div>
@@ -339,3 +339,13 @@
border-top: 1px solid var(--color-border);
margin: 8px 0;
}
/* Opaque backing prevents desktop text from bleeding through the floating panel. */
.olchat-shell {
background-color: #fbfbfc;
background-image:
linear-gradient(rgba(251, 251, 252, 0.985), rgba(248, 248, 250, 0.985)), var(--ol-frost-grain);
color: #18181b;
isolation: isolate;
border-radius: 18px;
}
@@ -1,14 +1,16 @@
import * as React from 'react';
import { Loader2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../lib/utils';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
const { t } = useTranslation();
return (
<Loader2Icon
data-slot="spinner"
role="status"
aria-label="Loading"
aria-label={t('common.loading')}
className={cn('size-4 animate-spin', className)}
{...props}
/>
File diff suppressed because it is too large. Load diff
+67 -2
View File
@@ -4,6 +4,41 @@ import type { zhCN } from './zh-CN';
// Type-level guarantee that en mirrors the zh-CN shape.
export const en: typeof zhCN = {
cloudSync: {
title: 'Cloud sync',
description:
'Use your GitHub account to sync your dictionary, styles, and preferences across devices.',
signIn: 'Sign in with GitHub',
account: 'Sync account',
refresh: 'Refresh status',
loading: 'Checking cloud status…',
noBackup: 'No cloud backup yet',
available: 'Cloud backup available',
summary: '{{dictionary}} words · {{corrections}} corrections · {{stylePacks}} styles',
updated: 'Updated {{time}}',
upload: 'Back up to cloud',
restore: 'Restore from cloud',
delete: 'Delete cloud backup',
working: 'Syncing…',
uploadSuccess: 'Cloud backup saved',
restoreSuccess: 'Cloud settings restored',
deleteSuccess: 'Cloud backup deleted',
failed: 'Sync failed: {{error}}',
conflict:
'The cloud copy has changed. Refresh its status before choosing to back up or restore.',
unavailable: 'The official sync service is currently unavailable. Try again later.',
signInRequired: 'Sign in with GitHub first.',
restoreTitle: 'Restore cloud backup?',
restoreDescription:
'Cloud dictionary entries, corrections, styles, and synced preferences will replace their local equivalents. API keys, device paths, and permissions stay on this device.',
deleteTitle: 'Delete cloud backup?',
deleteDescription:
'This removes only the cloud backup for this GitHub account. Local data is kept.',
confirmRestore: 'Restore and replace',
confirmDelete: 'Delete backup',
scope:
'Sync dictionary entries, corrections, style icons, and common preferences. API keys, credentials, and device settings stay on this device.',
},
app: {
name: 'OpenLess',
tagline: 'Speak naturally, write perfectly',
@@ -244,7 +279,7 @@ export const en: typeof zhCN = {
shortcutLabel: 'Recording shortcut',
shortcutHint: 'Start / Stop',
betaTag: 'BETA',
betaNote: 'All data stays on this device.',
betaNote: 'Local storage, optional cloud backup',
navHint: {
overview: 'Status overview: usage stats, provider & permission health',
history: 'Dictation history: search, replay and copy past transcripts',
@@ -508,6 +543,12 @@ export const en: typeof zhCN = {
dismiss: 'Dismiss',
},
vocab: {
selectAllVisible: 'Select current results',
selectedCount: '{{count}} words selected',
selectWord: 'Select “{{phrase}}”',
deleteSelected: 'Delete selected ({{count}})',
batchDeleteFailed: 'Could not delete {{count}} words. They remain selected so you can retry.',
kicker: 'DICTIONARY',
title: 'Dictionary',
desc: 'Add terms or jargon to improve recognition accuracy.',
@@ -598,7 +639,7 @@ export const en: typeof zhCN = {
},
structured: {
name: 'Structured',
desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.',
desc: 'Organize coding discussions, troubleshooting and product feedback with precise terminology.',
sample: '1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point',
},
formal: {
@@ -608,6 +649,18 @@ export const en: typeof zhCN = {
},
},
pack: {
builtinTags: {
minimalEdits: 'Minimal edits',
strongCorrection: 'Strong corrections',
communication: 'Communication',
natural: 'Natural',
organized: 'Organized',
workplaceCommunication: 'Work communication',
aiCoding: 'AI coding',
technicalStructure: 'Technical structure',
},
newName: 'Untitled style',
newDescription: 'Briefly describe when to use this style.',
uploadIcon: 'Upload an SVG icon for {{name}}',
resetIcon: 'Restore default icon',
iconSaved: 'Icon saved',
@@ -734,6 +787,12 @@ export const en: typeof zhCN = {
},
},
translation: {
searchLanguages: 'Search languages…',
noMatchingLanguages: 'No matching languages',
selectedLanguages: '{{count}} languages selected',
languageSupportHint:
'Available speech languages depend on your provider. Translation targets are independent of the app language.',
kicker: 'TRANSLATION',
title: 'Translation',
desc: 'Auto-translate recordings into a target language before insertion.',
@@ -951,6 +1010,7 @@ export const en: typeof zhCN = {
openPanelAction: 'Open Less Computer',
},
debug: {
cursorLabel: 'Cursor',
title: 'Debug tools',
desc: 'For troubleshooting recognition issues; off by default.',
cursorProbeLabel: 'Cursor context probe',
@@ -1006,6 +1066,7 @@ export const en: typeof zhCN = {
microphoneMonitorError: 'Failed to monitor input level: {{message}}',
capsuleLabel: 'Recording capsule',
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording.',
capsuleStyleTypeless: 'Typeless compact style',
capsuleStyleLabel: 'Capsule style',
capsuleStyleSiri: 'Shimmer Siri style',
capsuleStyleClassic: 'OpenLess default style',
@@ -1588,6 +1649,9 @@ export const en: typeof zhCN = {
confirm: 'Enable',
},
language: {
es: 'Español',
fr: 'Français',
de: 'Deutsch',
title: 'Interface language',
desc: 'Switch the UI language. Applies to the current session immediately and persists across launches.',
label: 'Language',
@@ -1822,6 +1886,7 @@ export const en: typeof zhCN = {
},
},
windowChrome: {
restore: 'Restore',
minimize: 'Minimize',
maximize: 'Maximize',
close: 'Close',
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+46 -74
View File
@@ -1,20 +1,13 @@
// i18n 入口 — 必须在任意 UI 组件 import 之前完成 init。
//
// 设计说明:
// - 资源在打包时静态注入(zh-CN.ts / en.ts)。无需后端推送,无网络请求。
// - LocalStorage key `ol.locale` 持久化用户选择;首次启动按 navigator.language 推断。
// - fallback 永远是 zh-CN:已知的产品权威文案,且 zh-CN.ts 是 source of truth。
// - 不用 LanguageDetector 插件:它的异步 init 在 Tauri WebView 里会让首次渲染拿到的
// `t()` 返回 key(react-i18next useSuspense 默认 false 时返回 key 而非阻塞)。
// 手写检测 + initImmediate: false 让 init 同步完成,渲染前 t 就能用。
// Each UI locale is a bundled local chunk. main.tsx waits for i18nReady so the
// first frame already uses the saved language, without briefly showing fallback text.
// The UI preference is independent from recognition and translation languages.
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { zhCN } from './zh-CN';
import type { UserPreferences } from '../lib/types';
import { setRemoteLocale } from '../lib/ipc';
export const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'] as const;
export const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'es', 'fr', 'de'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const LOCALE_STORAGE_KEY = 'ol.locale';
@@ -28,8 +21,8 @@ function detectSystemLocale(): SupportedLocale {
return 'zh-TW';
return 'zh-CN';
}
if (nav.startsWith('ja')) return 'ja';
if (nav.startsWith('ko')) return 'ko';
const primary = nav.split('-')[0];
if (SUPPORTED_LOCALES.some((locale) => locale === primary)) return primary as SupportedLocale;
return 'en';
}
@@ -43,22 +36,20 @@ function resolveLocalePreference(
function getStoredLocale(): SupportedLocale | null {
if (typeof window === 'undefined') return null;
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return raw === 'zh-CN' || raw === 'zh-TW' || raw === 'en' || raw === 'ja' || raw === 'ko'
? raw
: null;
return SUPPORTED_LOCALES.some((locale) => locale === raw) ? (raw as SupportedLocale) : null;
}
const initialLng: SupportedLocale = getStoredLocale() ?? detectSystemLocale();
// 只把 fallback / source-of-truth 的 zh-CN 静态打进 index —— 5 个 webview 都加载 index,
// 把 en / ja / ko / zh-TW 四份语言包拆成按需 chunk,减小每个 webview 的常驻体积。
// 代价:非 zh-CN 用户首屏先显示 zh-CN 一瞬,语言包异步到位后切换;最坏情况(加载失败)
// 也只停在 zh-CN(可读),不会出现 key 字面量。
// Language files remain separate local chunks; Node tests use the same explicit loaders.
const localeLoaders: Record<Exclude<SupportedLocale, 'zh-CN'>, () => Promise<typeof zhCN>> = {
'zh-TW': () => import('./zh-TW').then((m) => m.zhTW),
en: () => import('./en').then((m) => m.en),
ja: () => import('./ja').then((m) => m.ja),
ko: () => import('./ko').then((m) => m.ko),
'zh-TW': () => import('./zh-TW').then((module) => module.zhTW),
en: () => import('./en').then((module) => module.en),
ja: () => import('./ja').then((module) => module.ja),
ko: () => import('./ko').then((module) => module.ko),
es: () => import('./es').then((module) => module.es),
fr: () => import('./fr').then((module) => module.fr),
de: () => import('./de').then((module) => module.de),
};
async function ensureLocaleLoaded(lng: SupportedLocale): Promise<void> {
@@ -70,20 +61,44 @@ async function ensureLocaleLoaded(lng: SupportedLocale): Promise<void> {
}
}
void i18n.use(initReactI18next).init({
// 仅内联 zh-CN(fallback)。init 仍同步完成,首屏 t() 立刻可用(非 zh-CN 暂回退 zh-CN)。
const initialReady = i18n.use(initReactI18next).init({
// Simplified Chinese is the canonical fallback; the selected chunk loads before rendering.
resources: { 'zh-CN': { translation: zhCN } },
lng: initialLng,
fallbackLng: 'zh-CN',
supportedLngs: SUPPORTED_LOCALES as unknown as string[],
partialBundledLanguages: true, // 告诉 i18next 内联资源已完整,无需 backend 拉取
interpolation: { escapeValue: false },
react: { useSuspense: false }, // 不悬挂;首屏必须能拿到译文(zh-CN 已内联,同步完成)
react: { useSuspense: false },
});
// 初始语言非 zh-CN:异步补加载该语言包后切换(首屏先 zh-CN,到位后无缝切过去)。
if (initialLng !== 'zh-CN') {
void ensureLocaleLoaded(initialLng).then(() => i18n.changeLanguage(initialLng));
function applyDocumentLocale(locale: string) {
if (typeof document === 'undefined') return;
document.documentElement.lang = locale;
document.documentElement.dir = 'ltr';
}
i18n.on('languageChanged', applyDocumentLocale);
let localeRequest = 0;
async function applyLocale(resolved: SupportedLocale): Promise<void> {
const request = ++localeRequest;
await ensureLocaleLoaded(resolved);
if (request !== localeRequest) return;
await i18n.changeLanguage(resolved);
syncRemoteLocale(resolved);
}
export const i18nReady = initialReady.then(async () => {
await applyLocale(getStoredLocale() ?? detectSystemLocale());
});
// Auxiliary WebViews stay in the same language when the main window changes it.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === null || event.key === LOCALE_STORAGE_KEY) {
void applyLocale(resolveLocalePreference(getLocalePreference()));
}
});
}
export default i18n;
@@ -109,9 +124,7 @@ export async function setLocalePreference(
} else {
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
}
await ensureLocaleLoaded(resolved);
await i18n.changeLanguage(resolved);
syncRemoteLocale(resolved);
await applyLocale(resolved);
return resolved;
}
@@ -120,46 +133,5 @@ export async function setLocalePreference(
function syncRemoteLocale(resolved: SupportedLocale): void {
void setRemoteLocale(resolved).catch(() => {});
}
// 启动时同步一次当前语言,覆盖“开机即自动开启远程服务”的场景。
syncRemoteLocale(i18n.language as SupportedLocale);
export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE;
export function outputPrefsForLocale(
resolved: SupportedLocale,
): Pick<UserPreferences, 'chineseScriptPreference' | 'outputLanguagePreference'> {
if (resolved === 'zh-CN') {
return {
chineseScriptPreference: 'simplified',
outputLanguagePreference: 'zhCn',
};
}
if (resolved === 'zh-TW') {
return {
chineseScriptPreference: 'traditional',
outputLanguagePreference: 'zhTw',
};
}
if (resolved === 'en') {
return {
chineseScriptPreference: 'auto',
outputLanguagePreference: 'en',
};
}
if (resolved === 'ja') {
return {
chineseScriptPreference: 'auto',
outputLanguagePreference: 'ja',
};
}
if (resolved === 'ko') {
return {
chineseScriptPreference: 'auto',
outputLanguagePreference: 'ko',
};
}
return {
chineseScriptPreference: 'auto',
outputLanguagePreference: 'auto',
};
}
+75 -10
View File
@@ -6,6 +6,40 @@ import { en } from './en';
// 更新してください(更新されない key は ...en で英語にフォールバックします)。
export const ja: typeof zhCN = {
...en,
cloudSync: {
title: 'クラウド同期',
description: 'GitHub アカウントで辞書、スタイル、個人設定をデバイス間で同期します。',
signIn: 'GitHub でログイン',
account: '同期アカウント',
refresh: '状態を更新',
loading: 'クラウドの状態を確認中…',
noBackup: 'クラウドバックアップはありません',
available: 'クラウドバックアップがあります',
summary: '単語 {{dictionary}} 件 · 修正规則 {{corrections}} 件 · スタイル {{stylePacks}} 件',
updated: '更新日時:{{time}}',
upload: 'クラウドにバックアップ',
restore: 'クラウドから復元',
delete: 'クラウドバックアップを削除',
working: '同期中…',
uploadSuccess: 'クラウドに保存しました',
restoreSuccess: 'クラウドの設定を復元しました',
deleteSuccess: 'クラウドバックアップを削除しました',
failed: '同期に失敗しました:{{error}}',
conflict:
'クラウドの内容が更新されています。状態を更新してから、バックアップまたは復元を選んでください。',
unavailable: '公式の同期サービスを利用できません。後でもう一度お試しください。',
signInRequired: '先に GitHub でログインしてください。',
restoreTitle: 'クラウドバックアップを復元しますか?',
restoreDescription:
'クラウドの辞書、修正规則、スタイル、同期設定で、このデバイスの対応する内容を置き換えます。API キー、デバイスのパス、権限は保持されます。',
deleteTitle: 'クラウドバックアップを削除しますか?',
deleteDescription:
'この GitHub アカウントのクラウドバックアップだけを削除します。ローカルデータは保持されます。',
confirmRestore: '復元して置き換える',
confirmDelete: 'バックアップを削除',
scope:
'辞書、修正规則、スタイルのアイコン、共通設定を同期します。API キー、ログイン情報、デバイス固有の設定は本機に保持されます。',
},
app: {
name: 'OpenLess',
tagline: '自然に話し、きれいに書く',
@@ -137,7 +171,7 @@ export const ja: typeof zhCN = {
more: 'その他',
},
marketplace: {
kicker: 'MARKETPLACE',
kicker: 'マーケット',
title: 'スタイルパック マーケット',
desc: 'コミュニティのスタイルパックを閲覧・インストール・共有。',
searchPlaceholder: '名前 / 説明 / タグを検索…',
@@ -246,7 +280,7 @@ export const ja: typeof zhCN = {
shortcutLabel: '録音ショートカット',
shortcutHint: '開始 / 停止',
betaTag: 'BETA',
betaNote: 'すべてのデータはローカルにのみ保存されます。',
betaNote: 'ローカル保存、任意でクラウドバックアップ',
navHint: {
overview: 'ステータス概要:使用状況・プロバイダー・権限の状態',
history: '入力履歴:過去の書き起こしを検索・再生・コピー',
@@ -372,7 +406,7 @@ export const ja: typeof zhCN = {
permissionsHint:
'録音やショートカットが反応しない場合は、「権限とデータ」で権限、マイク、ショートカットの状態を確認してください。',
},
kicker: 'DASHBOARD',
kicker: '概要',
title: '本日の概要',
desc: '本日のディクテーション統計とシステム状態。',
pressPrefix: '押す',
@@ -437,7 +471,7 @@ export const ja: typeof zhCN = {
},
history: {
exportError: '録音のエクスポートに失敗しました。もう一度お試しください。',
kicker: 'HISTORY',
kicker: '履歴',
title: '履歴',
desc: 'ローカルに保存された認識記録。',
filterAll: 'すべて',
@@ -507,7 +541,14 @@ export const ja: typeof zhCN = {
dismiss: '閉じる',
},
vocab: {
kicker: 'DICTIONARY',
selectAllVisible: '現在の結果を選択',
selectedCount: '{{count}} 語を選択中',
selectWord: '「{{phrase}}」を選択',
deleteSelected: '選択項目を削除({{count}})',
batchDeleteFailed:
'{{count}} 語を削除できませんでした。選択状態を保持しています。再試行できます。',
kicker: '辞書',
title: '辞書',
desc: '新語や専門用語を追加して認識精度を向上。',
sectionTitle: '項目',
@@ -567,7 +608,7 @@ export const ja: typeof zhCN = {
},
},
style: {
kicker: 'STYLE',
kicker: 'スタイル',
title: '出力スタイル',
desc: '録音のデフォルト出力スタイルを選択。',
masterToggle: '全体有効化',
@@ -597,7 +638,7 @@ export const ja: typeof zhCN = {
},
structured: {
name: '明確な構造',
desc: '複数のトピックや手順がある場合は、自動的に箇条書きに整理します。',
desc: '開発の相談、技術的な問題解決、製品への意見を、用語を正確に保って整理します。',
sample: '1. トピック 1\na. ポイント\nb. ポイント\n2. トピック 2\na. ポイント\nb. ポイント',
},
formal: {
@@ -607,6 +648,18 @@ export const ja: typeof zhCN = {
},
},
pack: {
builtinTags: {
minimalEdits: '最小限の修正',
strongCorrection: '誤認識を補正',
communication: 'コミュニケーション',
natural: '自然な文章',
organized: '整理',
workplaceCommunication: '仕事のやり取り',
aiCoding: 'AIコーディング',
technicalStructure: '技術内容の構造化',
},
newName: '名称未設定のスタイル',
newDescription: 'このスタイルを使う場面を簡潔に説明してください。',
uploadIcon: '{{name}} の SVG アイコンをアップロード',
resetIcon: '既定のアイコンに戻す',
iconSaved: 'アイコンを保存しました',
@@ -635,7 +688,7 @@ export const ja: typeof zhCN = {
selectionActivated: '「{{name}}」を選択範囲の推敲に設定しました',
selectionActivateFailed: '選択範囲の推敲スタイル切替に失敗:{{err}}',
selectionChars: '{{count}} 文字',
kicker: 'STYLE PACKS',
kicker: 'スタイルパック',
title: 'スタイルパック',
desc: 'ローカルスタイルパックを管理。',
marketplaceBtn: 'マーケット',
@@ -733,7 +786,13 @@ export const ja: typeof zhCN = {
},
},
translation: {
kicker: 'TRANSLATION',
searchLanguages: '言語を検索…',
noMatchingLanguages: '一致する言語がありません',
selectedLanguages: '{{count}} 言語を選択中',
languageSupportHint:
'音声認識で使える言語はサービスによって異なります。翻訳先はアプリの表示言語とは独立しています。',
kicker: '翻訳',
title: '翻訳',
desc: '録音後に自動翻訳してから入力。',
statusEnabled: '有効',
@@ -834,7 +893,7 @@ export const ja: typeof zhCN = {
previewConfirm: 'プレビューして確認',
previewConfirmHint: '編集可能なウィンドウで結果を確認してから、元の選択範囲を置き換えます。',
},
kicker: 'SETTINGS',
kicker: '設定',
title: '設定',
desc: '録音、プロバイダー、ショートカット、権限の設定。',
network: {
@@ -947,6 +1006,7 @@ export const ja: typeof zhCN = {
openPanelAction: 'Less Computer を開く',
},
debug: {
cursorLabel: 'カーソル',
title: 'デバッグツール',
desc: '認識の問題を調査するときに使用。通常はオフのままで構いません。',
cursorProbeLabel: 'カーソル文脈プローブ',
@@ -1004,6 +1064,7 @@ export const ja: typeof zhCN = {
microphoneMonitorError: '入力レベルの監視に失敗:{{message}}',
capsuleLabel: '録音カプセル',
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
capsuleStyleTypeless: 'Typeless コンパクトスタイル',
capsuleStyleLabel: 'カプセルスタイル',
capsuleStyleSiri: '光条 Siri スタイル',
capsuleStyleClassic: 'Openless デフォルトスタイル',
@@ -1569,6 +1630,9 @@ export const ja: typeof zhCN = {
confirm: '有効化する',
},
language: {
es: 'Español',
fr: 'Français',
de: 'Deutsch',
title: '表示言語',
desc: 'UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。',
label: '言語',
@@ -1802,6 +1866,7 @@ export const ja: typeof zhCN = {
},
},
windowChrome: {
restore: '元のサイズに戻す',
minimize: '最小化',
maximize: '最大化',
close: '閉じる',
+73 -10
View File
@@ -6,6 +6,38 @@ import { en } from './en';
// 갱신해 주세요(갱신되지 않은 key 는 ...en 으로 영어로 fallback 됩니다).
export const ko: typeof zhCN = {
...en,
cloudSync: {
title: '클라우드 동기화',
description: 'GitHub 계정으로 사전, 스타일, 개인 설정을 기기 간에 동기화합니다.',
signIn: 'GitHub로 로그인',
account: '동기화 계정',
refresh: '상태 새로고침',
loading: '클라우드 상태 확인 중…',
noBackup: '클라우드 백업 없음',
available: '클라우드 백업 있음',
summary: '단어 {{dictionary}}개 · 교정 규칙 {{corrections}}개 · 스타일 {{stylePacks}}개',
updated: '업데이트: {{time}}',
upload: '클라우드에 백업',
restore: '클라우드에서 복원',
delete: '클라우드 백업 삭제',
working: '동기화 중…',
uploadSuccess: '클라우드에 백업했습니다',
restoreSuccess: '클라우드 설정을 복원했습니다',
deleteSuccess: '클라우드 백업을 삭제했습니다',
failed: '동기화 실패: {{error}}',
conflict: '클라우드 내용이 변경되었습니다. 상태를 새로고친 후 백업 또는 복원을 선택하세요.',
unavailable: '공식 동기화 서비스를 이용할 수 없습니다. 나중에 다시 시도하세요.',
signInRequired: '먼저 GitHub로 로그인하세요.',
restoreTitle: '클라우드 백업을 복원할까요?',
restoreDescription:
'클라우드의 사전, 교정 규칙, 스타일, 동기화 설정으로 해당 로컬 내용을 덮어씁니다. API 키, 기기 경로, 권한은 유지됩니다.',
deleteTitle: '클라우드 백업을 삭제할까요?',
deleteDescription: '이 GitHub 계정의 클라우드 백업만 삭제합니다. 로컬 데이터는 유지됩니다.',
confirmRestore: '복원 및 덮어쓰기',
confirmDelete: '백업 삭제',
scope:
'사전, 교정 규칙, 스타일 아이콘, 공통 설정을 동기화합니다. API 키, 로그인 정보, 기기별 설정은 이 기기에 유지됩니다.',
},
app: {
name: 'OpenLess',
tagline: '자연스럽게 말하고, 정확하게 작성하세요',
@@ -137,7 +169,7 @@ export const ko: typeof zhCN = {
more: '더보기',
},
marketplace: {
kicker: 'MARKETPLACE',
kicker: '마켓',
title: '스타일 팩 마켓',
desc: '커뮤니티 스타일 팩 둘러보기, 설치, 공유.',
searchPlaceholder: '이름 / 설명 / 태그 검색…',
@@ -246,7 +278,7 @@ export const ko: typeof zhCN = {
shortcutLabel: '녹음 단축키',
shortcutHint: '시작 / 정지',
betaTag: 'BETA',
betaNote: '모든 데이터는 로컬에만 저장됩니다.',
betaNote: '로컬 저장, 선택적 클라우드 백업',
navHint: {
overview: '상태 개요: 사용량 통계, 제공자 및 권한 상태',
history: '받아쓰기 기록: 과거 전사 검색·재생·복사',
@@ -372,7 +404,7 @@ export const ko: typeof zhCN = {
permissionsHint:
'녹음이나 단축키가 반응하지 않나요? 권한 및 데이터에서 권한, 마이크, 단축키 상태를 확인하세요.',
},
kicker: 'DASHBOARD',
kicker: '개요',
title: '오늘 개요',
desc: '오늘의 받아쓰기 통계와 시스템 상태.',
pressPrefix: '누르기',
@@ -437,7 +469,7 @@ export const ko: typeof zhCN = {
},
history: {
exportError: '녹음을 내보내지 못했습니다. 다시 시도해 주세요.',
kicker: 'HISTORY',
kicker: '기록',
title: '기록',
desc: '로컬에 저장된 인식 기록.',
filterAll: '전체',
@@ -507,7 +539,14 @@ export const ko: typeof zhCN = {
dismiss: '닫기',
},
vocab: {
kicker: 'DICTIONARY',
selectAllVisible: '현재 결과 선택',
selectedCount: '단어 {{count}}개 선택됨',
selectWord: '“{{phrase}}” 선택',
deleteSelected: '선택 항목 삭제({{count}})',
batchDeleteFailed:
'단어 {{count}}개를 삭제하지 못했습니다. 다시 시도할 수 있도록 선택을 유지합니다.',
kicker: '사전',
title: '사전',
desc: '새 단어나 전문 용어를 추가하여 인식 정확도 향상.',
sectionTitle: '항목',
@@ -566,7 +605,7 @@ export const ko: typeof zhCN = {
},
},
style: {
kicker: 'STYLE',
kicker: '스타일',
title: '출력 스타일',
desc: '녹음의 기본 출력 스타일 선택.',
masterToggle: '전체 활성화',
@@ -596,7 +635,7 @@ export const ko: typeof zhCN = {
},
structured: {
name: '명확한 구조',
desc: '여러 주제나 단계가 있을 때 자동으로 항목별 목록으로 정리합니다.',
desc: '개발 협업, 기술 문제 해결, 제품 피드백을 정확한 용어와 명확한 구조로 정리합니다.',
sample: '1. 주제 1\na. 포인트\nb. 포인트\n2. 주제 2\na. 포인트\nb. 포인트',
},
formal: {
@@ -606,6 +645,18 @@ export const ko: typeof zhCN = {
},
},
pack: {
builtinTags: {
minimalEdits: '최소 수정',
strongCorrection: '정확한 교정',
communication: '의사소통',
natural: '자연스러움',
organized: '체계적 정리',
workplaceCommunication: '업무 소통',
aiCoding: 'AI 코딩',
technicalStructure: '기술 내용 구조화',
},
newName: '이름 없는 스타일',
newDescription: '이 스타일을 언제 사용하는지 간단히 설명하세요.',
uploadIcon: '{{name}}의 SVG 아이콘 업로드',
resetIcon: '기본 아이콘 복원',
iconSaved: '아이콘이 저장되었습니다',
@@ -635,7 +686,7 @@ export const ko: typeof zhCN = {
selectionActivated: '선택 영역 다듬기에 "{{name}}"을(를) 설정했습니다',
selectionActivateFailed: '선택 영역 다듬기 스타일 전환 실패: {{err}}',
selectionChars: '{{count}}자',
kicker: 'STYLE PACKS',
kicker: '스타일 팩',
title: '스타일 팩',
desc: '로컬 스타일 팩 관리.',
marketplaceBtn: '마켓',
@@ -733,7 +784,13 @@ export const ko: typeof zhCN = {
},
},
translation: {
kicker: 'TRANSLATION',
searchLanguages: '언어 검색…',
noMatchingLanguages: '일치하는 언어가 없습니다',
selectedLanguages: '언어 {{count}}개 선택됨',
languageSupportHint:
'음성 서비스에 따라 지원 언어가 다릅니다. 번역 언어는 앱 표시 언어와 별개입니다.',
kicker: '번역',
title: '번역',
desc: '녹음 후 대상 언어로 자동 번역하여 삽입.',
statusEnabled: '활성화됨',
@@ -832,7 +889,7 @@ export const ko: typeof zhCN = {
previewConfirm: '미리보기 후 확인',
previewConfirmHint: '편집 가능한 창에서 결과를 확인한 뒤 원래 선택 영역을 교체합니다.',
},
kicker: 'SETTINGS',
kicker: '설정',
title: '설정',
desc: '녹음, 공급자, 단축키, 권한 설정.',
network: {
@@ -941,6 +998,7 @@ export const ko: typeof zhCN = {
openPanelAction: 'Less Computer 열기',
},
debug: {
cursorLabel: '커서',
title: '디버그 도구',
desc: '인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.',
cursorProbeLabel: '커서 문맥 프로브',
@@ -998,6 +1056,7 @@ export const ko: typeof zhCN = {
microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}',
capsuleLabel: '녹음 캡슐',
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
capsuleStyleTypeless: 'Typeless 컴팩트 스타일',
capsuleStyleLabel: '캡슐 스타일',
capsuleStyleSiri: '시리 광선 스타일',
capsuleStyleClassic: 'Openless 기본 스타일',
@@ -1558,6 +1617,9 @@ export const ko: typeof zhCN = {
confirm: '활성화',
},
language: {
es: 'Español',
fr: 'Français',
de: 'Deutsch',
title: '인터페이스 언어',
desc: 'UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.',
label: '언어',
@@ -1790,6 +1852,7 @@ export const ko: typeof zhCN = {
},
},
windowChrome: {
restore: '이전 크기로 복원',
minimize: '최소화',
maximize: '최대화',
close: '닫기',
+71 -11
View File
@@ -1,7 +1,38 @@
// 简体中文资源 — 与产品当前文案保持一致。
// 五个语言文件共享同一结构;新增或删除 key 时同步更新全部语言。
// 八个语言文件共享同一结构;新增或删除 key 时同步更新全部语言。
export const zhCN = {
cloudSync: {
title: '云同步',
description: '使用 GitHub 账号,在设备之间同步词典、风格与个人偏好。',
signIn: '使用 GitHub 登录',
account: '同步账号',
refresh: '刷新状态',
loading: '正在读取云端状态…',
noBackup: '云端尚无备份',
available: '云端备份已就绪',
summary: '{{dictionary}} 个词条 · {{corrections}} 条纠正规则 · {{stylePacks}} 个风格',
updated: '更新于 {{time}}',
upload: '备份到云端',
restore: '从云端恢复',
delete: '删除云端备份',
working: '正在同步…',
uploadSuccess: '已备份到云端',
restoreSuccess: '已恢复云端配置',
deleteSuccess: '云端备份已删除',
failed: '同步失败:{{error}}',
conflict: '云端已有更新。请刷新状态后,再决定备份或恢复。',
unavailable: '官方同步服务暂不可用,请稍后重试。',
signInRequired: '请先登录 GitHub。',
restoreTitle: '恢复云端备份?',
restoreDescription:
'云端的词典、纠正规则、风格和同步偏好将覆盖本机对应内容。API 密钥、设备目录与权限保持本机设置。',
deleteTitle: '删除云端备份?',
deleteDescription: '仅删除这个 GitHub 账号的云端备份,本机数据会保留。',
confirmRestore: '恢复并替换',
confirmDelete: '删除备份',
scope: '同步词典、纠正规则、风格图标与常用偏好。API 密钥、登录凭据及设备专属设置保留在本机。',
},
app: {
name: 'OpenLess',
tagline: '自然说话,完美书写',
@@ -132,7 +163,7 @@ export const zhCN = {
more: '更多',
},
marketplace: {
kicker: 'MARKETPLACE',
kicker: '风格市场',
title: '风格包市场',
desc: '浏览、安装和分享社区风格包。',
searchPlaceholder: '搜索名称 / 描述 / 标签…',
@@ -239,7 +270,7 @@ export const zhCN = {
shortcutLabel: '录音快捷键',
shortcutHint: '开始 / 停止',
betaTag: 'BETA',
betaNote: '所有数据都只保存在本机。',
betaNote: '本地存储,可选云端备份',
navHint: {
overview: '状态总览:用量统计、提供商与权限健康检查',
history: '听写历史:搜索、回放与复制过往转写',
@@ -359,7 +390,7 @@ export const zhCN = {
tryDictationDesc: '把光标放到要输入的地方。{{shortcut}}',
permissionsHint: '录音或快捷键没有反应?在「权限与数据」中查看权限、麦克风和快捷键状态。',
},
kicker: 'DASHBOARD',
kicker: '概览',
title: '今日概览',
desc: '今日口述统计与系统状态。',
pressPrefix: '按',
@@ -424,7 +455,7 @@ export const zhCN = {
},
history: {
exportError: '导出录音失败,请重试。',
kicker: 'HISTORY',
kicker: '历史记录',
title: '历史记录',
desc: '本机保存的识别记录。',
filterAll: '全部',
@@ -493,7 +524,13 @@ export const zhCN = {
dismiss: '关闭',
},
vocab: {
kicker: 'DICTIONARY',
selectAllVisible: '选择当前结果',
selectedCount: '已选择 {{count}} 个词',
selectWord: '选择「{{phrase}}」',
deleteSelected: '删除已选({{count}})',
batchDeleteFailed: '{{count}} 个词条删除失败,已保留选中,可重试。',
kicker: '词典',
title: '词典',
desc: '添加生词或专业术语,提高识别准确率。',
sectionTitle: '词条',
@@ -551,7 +588,7 @@ export const zhCN = {
},
},
style: {
kicker: 'STYLE',
kicker: '风格',
title: '输出风格',
desc: '选择录音的默认输出风格。',
masterToggle: '整体启用',
@@ -579,7 +616,7 @@ export const zhCN = {
},
structured: {
name: '清晰结构',
desc: '多个主题或步骤时,自动组织为分点列表。',
desc: '面向编程协作、技术排障和产品反馈,准确保留术语并梳理结构。',
sample: '1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点',
},
formal: {
@@ -589,6 +626,18 @@ export const zhCN = {
},
},
pack: {
builtinTags: {
minimalEdits: '最小改写',
strongCorrection: '强纠错',
communication: '沟通',
natural: '自然',
organized: '条理',
workplaceCommunication: '工作沟通',
aiCoding: 'AI 编程',
technicalStructure: '技术结构化',
},
newName: '未命名风格',
newDescription: '简短描述这个风格的使用场景。',
uploadIcon: '为「{{name}}」上传 SVG 图标',
resetIcon: '恢复默认图标',
iconSaved: '图标已保存',
@@ -616,7 +665,7 @@ export const zhCN = {
selectionActivated: '已将「{{name}}」用于选区润色',
selectionActivateFailed: '选区润色风格切换失败:{{err}}',
selectionChars: '{{count}} 字符',
kicker: 'STYLE PACKS',
kicker: '风格包',
title: '风格包',
desc: '管理本地风格包。',
marketplaceBtn: '风格市场',
@@ -713,7 +762,12 @@ export const zhCN = {
},
},
translation: {
kicker: 'TRANSLATION',
searchLanguages: '搜索语言…',
noMatchingLanguages: '没有匹配的语言',
selectedLanguages: '已选择 {{count}} 种语言',
languageSupportHint: '语音服务支持的语种可能不同;翻译目标不受界面语言限制。',
kicker: '翻译',
title: '翻译',
desc: '录音后自动翻译为目标语言再插入。',
statusEnabled: '已启用',
@@ -811,7 +865,7 @@ export const zhCN = {
previewConfirm: '预览确认',
previewConfirmHint: '在可编辑弹窗中核对结果,再确认覆盖原选区。',
},
kicker: 'SETTINGS',
kicker: '设置',
title: '设置',
desc: '录音、提供商、快捷键与权限配置。',
network: {
@@ -913,6 +967,7 @@ export const zhCN = {
openPanelAction: '打开 Less Computer',
},
debug: {
cursorLabel: '光标',
title: '调试工具',
desc: '排查识别问题时使用,平时无需开启。',
cursorProbeLabel: '光标上下文探针',
@@ -966,6 +1021,7 @@ export const zhCN = {
microphoneMonitorError: '输入电平监听失败:{{message}}',
capsuleLabel: '录音胶囊',
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
capsuleStyleTypeless: 'Typeless 传统风格',
capsuleStyleLabel: '胶囊样式',
capsuleStyleSiri: '流光 Siri 风格',
capsuleStyleClassic: 'Openless 默认风格',
@@ -1501,6 +1557,9 @@ export const zhCN = {
confirm: '确认启用',
},
language: {
es: 'Español',
fr: 'Français',
de: 'Deutsch',
title: '界面语言',
desc: '切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。',
label: '语言',
@@ -1727,6 +1786,7 @@ export const zhCN = {
},
},
windowChrome: {
restore: '还原',
minimize: '最小化',
maximize: '最大化',
close: '关闭',
+70 -10
View File
@@ -4,6 +4,37 @@ import type { zhCN } from './zh-CN';
// 新增 key 時,必須同步更新 en.ts,避免切換到 English 後出現中文殘留。
export const zhTW: typeof zhCN = {
cloudSync: {
title: '雲端同步',
description: '使用 GitHub 帳號,在裝置之間同步詞典、風格與個人偏好。',
signIn: '使用 GitHub 登入',
account: '同步帳號',
refresh: '重新整理狀態',
loading: '正在讀取雲端狀態…',
noBackup: '雲端尚無備份',
available: '雲端備份已就緒',
summary: '{{dictionary}} 個詞條 · {{corrections}} 條修正规則 · {{stylePacks}} 個風格',
updated: '更新於 {{time}}',
upload: '備份至雲端',
restore: '從雲端還原',
delete: '刪除雲端備份',
working: '正在同步…',
uploadSuccess: '已備份至雲端',
restoreSuccess: '已還原雲端設定',
deleteSuccess: '已刪除雲端備份',
failed: '同步失敗:{{error}}',
conflict: '雲端已有更新。請重新整理狀態後,再決定備份或還原。',
unavailable: '官方同步服務暫時無法使用,請稍後重試。',
signInRequired: '請先登入 GitHub。',
restoreTitle: '還原雲端備份?',
restoreDescription:
'雲端的詞典、修正规則、風格和同步偏好將覆蓋本機對應內容。API 金鑰、裝置目錄與權限維持本機設定。',
deleteTitle: '刪除雲端備份?',
deleteDescription: '僅刪除此 GitHub 帳號的雲端備份,本機資料會保留。',
confirmRestore: '還原並取代',
confirmDelete: '刪除備份',
scope: '同步詞典、修正规則、風格圖示與常用偏好。API 金鑰、登入憑據與裝置專屬設定保留在本機。',
},
app: {
name: 'OpenLess',
tagline: '自然說話,完美書寫',
@@ -134,7 +165,7 @@ export const zhTW: typeof zhCN = {
more: '更多',
},
marketplace: {
kicker: 'MARKETPLACE',
kicker: '風格市場',
title: '風格包市場',
desc: '瀏覽、安裝和分享社區風格包。',
searchPlaceholder: '搜尋名稱 / 描述 / 標籤…',
@@ -241,7 +272,7 @@ export const zhTW: typeof zhCN = {
shortcutLabel: '錄音快捷鍵',
shortcutHint: '開始 / 停止',
betaTag: 'BETA',
betaNote: '所有數據都只保存在本機。',
betaNote: '本機儲存,可選雲端備份',
navHint: {
overview: '狀態總覽:用量統計、提供商與權限健康檢查',
history: '聽寫歷史:搜尋、回放與複製過往轉寫',
@@ -361,7 +392,7 @@ export const zhTW: typeof zhCN = {
tryDictationDesc: '將游標放到要輸入的位置。{{shortcut}}',
permissionsHint: '錄音或快捷鍵沒有反應?在「權限與資料」中查看權限、麥克風和快捷鍵狀態。',
},
kicker: 'DASHBOARD',
kicker: '概覽',
title: '今日概覽',
desc: '今日口述統計與系統狀態。',
pressPrefix: '按',
@@ -426,7 +457,7 @@ export const zhTW: typeof zhCN = {
},
history: {
exportError: '匯出錄音失敗,請重試。',
kicker: 'HISTORY',
kicker: '歷史記錄',
title: '歷史記錄',
desc: '本機保存的識別記錄。',
filterAll: '全部',
@@ -495,7 +526,13 @@ export const zhTW: typeof zhCN = {
dismiss: '關閉',
},
vocab: {
kicker: 'DICTIONARY',
selectAllVisible: '選取目前結果',
selectedCount: '已選取 {{count}} 個詞',
selectWord: '選取「{{phrase}}」',
deleteSelected: '刪除已選({{count}})',
batchDeleteFailed: '{{count}} 個詞條刪除失敗,已保留選取,可重試。',
kicker: '詞典',
title: '詞典',
desc: '添加生詞或專業術語,提高識別準確率。',
sectionTitle: '詞條',
@@ -553,7 +590,7 @@ export const zhTW: typeof zhCN = {
},
},
style: {
kicker: 'STYLE',
kicker: '風格',
title: '輸出風格',
desc: '選擇錄音的預設輸出風格。',
masterToggle: '整體啓用',
@@ -581,7 +618,7 @@ export const zhTW: typeof zhCN = {
},
structured: {
name: '清晰結構',
desc: '多個主題或步驟時,自動組織爲分點列表。',
desc: '面向程式協作、技術排障和產品回饋,準確保留術語並整理結構。',
sample: '1. 主題一\na. 要點\nb. 要點\n2. 主題二\na. 要點\nb. 要點',
},
formal: {
@@ -591,6 +628,18 @@ export const zhTW: typeof zhCN = {
},
},
pack: {
builtinTags: {
minimalEdits: '最小改寫',
strongCorrection: '強糾錯',
communication: '溝通',
natural: '自然',
organized: '條理',
workplaceCommunication: '工作溝通',
aiCoding: 'AI 程式開發',
technicalStructure: '技術結構化',
},
newName: '未命名風格',
newDescription: '簡短描述這個風格的使用情境。',
uploadIcon: '為「{{name}}」上傳 SVG 圖示',
resetIcon: '還原預設圖示',
iconSaved: '圖示已儲存',
@@ -618,7 +667,7 @@ export const zhTW: typeof zhCN = {
selectionActivated: '已將「{{name}}」用於選區潤色',
selectionActivateFailed: '選區潤色風格切換失敗:{{err}}',
selectionChars: '{{count}} 字元',
kicker: 'STYLE PACKS',
kicker: '風格包',
title: '風格包',
desc: '管理本機風格包。',
marketplaceBtn: '風格市場',
@@ -715,7 +764,12 @@ export const zhTW: typeof zhCN = {
},
},
translation: {
kicker: 'TRANSLATION',
searchLanguages: '搜尋語言…',
noMatchingLanguages: '沒有符合的語言',
selectedLanguages: '已選擇 {{count}} 種語言',
languageSupportHint: '語音服務支援的語種可能不同;翻譯目標不受介面語言限制。',
kicker: '翻譯',
title: '翻譯',
desc: '錄音後自動翻譯為目標語言再插入。',
statusEnabled: '已啓用',
@@ -812,7 +866,7 @@ export const zhTW: typeof zhCN = {
previewConfirm: '預覽確認',
previewConfirmHint: '在可編輯彈窗中核對結果,再確認覆蓋原選區。',
},
kicker: 'SETTINGS',
kicker: '設定',
title: '設置',
desc: '錄音、提供商、快捷鍵與權限配置。',
network: {
@@ -913,6 +967,7 @@ export const zhTW: typeof zhCN = {
openPanelAction: '開啟 Less Computer',
},
debug: {
cursorLabel: '游標',
title: '除錯工具',
desc: '排查辨識問題時使用,平時無需開啟。',
cursorProbeLabel: '游標上下文探針',
@@ -979,6 +1034,7 @@ export const zhTW: typeof zhCN = {
microphoneMonitorError: '輸入電平監聽失敗:{{message}}',
capsuleLabel: '錄音膠囊',
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
capsuleStyleTypeless: 'Typeless 傳統風格',
capsuleStyleLabel: '膠囊樣式',
capsuleStyleSiri: '流光 Siri 風格',
capsuleStyleClassic: 'Openless 預設風格',
@@ -1487,6 +1543,9 @@ export const zhTW: typeof zhCN = {
confirm: '確認啓用',
},
language: {
es: 'Español',
fr: 'Français',
de: 'Deutsch',
title: '界面語言',
desc: '切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。',
label: '語言',
@@ -1713,6 +1772,7 @@ export const zhTW: typeof zhCN = {
},
},
windowChrome: {
restore: '還原',
minimize: '最小化',
maximize: '最大化',
close: '關閉',
@@ -2,6 +2,7 @@ import {
getCapsuleHostMetrics,
getCapsuleMessageLayout,
getCapsulePillMetrics,
parseCapsuleStyle,
} from './capsuleLayout.ts';
function assertEqual<T>(actual: T, expected: T, name: string) {
@@ -80,3 +81,20 @@ assertEqual(winProcessingLayout.allowWrap, true, 'windows processing label wraps
const macErrorLayout = getCapsuleMessageLayout('mac', 'error');
assertEqual(macErrorLayout.lineClamp, 1, 'mac error message stays single-line');
assertEqual(macErrorLayout.allowWrap, false, 'mac error message stays nowrap');
for (const os of ['mac', 'win', 'linux'] as const) {
const classic = getCapsuleHostMetrics(os, false, 'classic');
const typeless = getCapsuleHostMetrics(os, true, 'typeless');
assertEqual(classic.height, 100, `${os}: classic uses the compact native window`);
assertEqual(typeless.height, 128, `${os}: typeless reserves room for the translation badge`);
assertEqual(typeless.bottomInset, 16, `${os}: visible capsule sits close to the work-area edge`);
assertEqual(typeless.width, 460, `${os}: error text remains inside the shared window`);
}
for (const style of ['siri', 'classic', 'typeless'] as const) {
assertEqual(parseCapsuleStyle(style), style, `${style} is accepted from preferences and events`);
}
assertEqual(
parseCapsuleStyle('unknown'),
undefined,
'unknown styles do not replace the active choice',
);
+12 -3
View File
@@ -1,4 +1,5 @@
import type { OS } from '../components/WindowChrome';
import type { CapsuleStyle } from './types';
export type CapsuleMessageKind = 'default' | 'processing' | 'error';
@@ -30,6 +31,10 @@ const VOICE_ORB_STAGE_WIDTH = 460;
const VOICE_ORB_STAGE_HEIGHT = 180;
const VOICE_ORB_TEXT_WIDTH = 400;
export function parseCapsuleStyle(value: unknown): CapsuleStyle | undefined {
return value === 'siri' || value === 'classic' || value === 'typeless' ? value : undefined;
}
export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
void os;
return {
@@ -40,14 +45,18 @@ export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
};
}
export function getCapsuleHostMetrics(os: OS, translationActive: boolean): CapsuleHostMetrics {
export function getCapsuleHostMetrics(
os: OS,
translationActive: boolean,
style: CapsuleStyle = 'siri',
): CapsuleHostMetrics {
const stage = getCapsulePillMetrics(os);
void translationActive;
return {
width: stage.width,
height: stage.height,
height: style === 'siri' ? stage.height : style === 'classic' ? 100 : 128,
horizontalInset: 0,
bottomInset: 0,
bottomInset: style === 'siri' ? 0 : 16,
badgeGap: 8,
boxSizing: 'border-box',
};
+3 -13
View File
@@ -1,14 +1,4 @@
// 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。
// 通过 html[data-ol-conservative-layout] + global.css 工具类生效。
export function applyConservativeLayout(enabled: boolean): void {
if (enabled) {
document.documentElement.dataset.olConservativeLayout = 'true';
} else {
delete document.documentElement.dataset.olConservativeLayout;
}
}
export function isConservativeLayoutActive(): boolean {
return document.documentElement.dataset.olConservativeLayout === 'true';
// Remove the retired layout attribute even when an older saved value is still enabled.
export function applyConservativeLayout(_enabled: boolean): void {
delete document.documentElement.dataset.olConservativeLayout;
}
@@ -84,9 +84,17 @@ assert(
// 内置包显示名走 i18n mode 名,自定义包用原名。
assert(
packDisplayName(pack('builtin.light', true, 'builtin', 'light'), modeLabel) === 'Light polish',
packDisplayName(
{ ...pack('builtin.light', true, 'builtin', 'light'), name: '轻度润色' },
modeLabel,
) === 'Light polish',
'builtin packs should display the i18n mode label',
);
assert(
packDisplayName(pack('builtin.light', true, 'builtin', 'light'), modeLabel) ===
'包 builtin.light',
'user-renamed builtin packs retain their chosen name',
);
assert(
packDisplayName(pack('custom-alive', true), modeLabel) === '包 custom-alive',
'custom packs should display their own name',
+3 -11
View File
@@ -1,4 +1,4 @@
import type { DictationSession, PolishMode, StylePack } from './types';
import type { DictationSession, StylePack } from './types';
/**
* 「用原风格重试」要用的风格包 id。
@@ -18,16 +18,8 @@ export function resolveRepolishRetryPackId(
return allPacks.some((pack) => pack.id === session.stylePackId) ? session.stylePackId : undefined;
}
/**
* 风格包在界面上的显示名。
*
* 内置包例外:后端内置包名是硬编码中文("轻度润色"…),直接显示会在英/日/韩界面
* 串语言,所以内置包一律走 i18n 的 mode 名(与历史条目 Pill 同一原则)。自定义包
* 显示用户起的原名。
*/
export function packDisplayName(pack: StylePack, modeLabel: Record<PolishMode, string>): string {
return pack.kind === 'builtin' ? modeLabel[pack.baseMode] : pack.name.trim();
}
// History shares the card label policy, including user-renamed builtins.
export { stylePackDisplayName as packDisplayName } from './stylePackPresentation';
/** 「换风格」下拉的默认选中项:当前激活包优先,其次第一个可用包,空列表返回 ''。 */
export function defaultPackId(packs: StylePack[]): string {
@@ -0,0 +1,43 @@
import { invokeOrMock } from './shared';
export interface CloudSyncStatus {
schemaVersion: number;
revision: number;
updatedAt: string | null;
hasSnapshot: boolean;
counts: { dictionary: number; corrections: number; stylePacks: number };
}
export interface CloudSyncUiPreferences {
locale?: string;
fontScale?: string;
}
export interface CloudSyncRestoreResult {
status: CloudSyncStatus;
uiPreferences: CloudSyncUiPreferences;
}
// A browser preview has no native stores or authenticated cloud account.
function unavailable(): never {
throw new Error('cloud_sync_unavailable');
}
export function cloudSyncStatus(): Promise<CloudSyncStatus> {
return invokeOrMock('cloud_sync_status', undefined, unavailable);
}
export function cloudSyncUpload(
baseRevision: number,
uiPreferences: CloudSyncUiPreferences,
): Promise<CloudSyncStatus> {
return invokeOrMock('cloud_sync_upload', { baseRevision, uiPreferences }, unavailable);
}
export function cloudSyncRestore(): Promise<CloudSyncRestoreResult> {
return invokeOrMock('cloud_sync_restore', undefined, unavailable);
}
export function cloudSyncDelete(baseRevision: number): Promise<CloudSyncStatus> {
return invokeOrMock('cloud_sync_delete', { baseRevision }, unavailable);
}
+4
View File
@@ -273,3 +273,7 @@ export { takeSplashPlayback, SPLASH_MAJOR } from './splash';
// utils
export { openExternal, exportErrorLog, logClientError, debugReadCursorContext } from './utils';
// Personal cloud backups use the native credential store and Core-owned data.
export { cloudSyncStatus, cloudSyncUpload, cloudSyncRestore, cloudSyncDelete } from './cloud-sync';
export type { CloudSyncStatus, CloudSyncUiPreferences, CloudSyncRestoreResult } from './cloud-sync';
+25 -12
View File
@@ -3,31 +3,44 @@ import { invokeOrMock } from './shared';
import { mockVocab, mockCorrectionRules } from './mock-data';
export function listVocab(): Promise<DictionaryEntry[]> {
return invokeOrMock('list_vocab', undefined, () => mockVocab);
return invokeOrMock('list_vocab', undefined, () => mockVocab.map((entry) => ({ ...entry })));
}
export function addVocab(phrase: string, note?: string): Promise<DictionaryEntry> {
return invokeOrMock('add_vocab', { phrase, note }, () => ({
id: `vocab-new-${Date.now()}`,
phrase,
note: note ?? null,
enabled: true,
hits: 0,
createdAt: new Date().toISOString(),
}));
return invokeOrMock('add_vocab', { phrase, note }, () => {
const entry = {
id: crypto.randomUUID(),
phrase,
note: note ?? null,
enabled: true,
hits: 0,
createdAt: new Date().toISOString(),
};
mockVocab.unshift(entry);
return { ...entry };
});
}
export function removeVocab(id: string): Promise<void> {
return invokeOrMock('remove_vocab', { id }, () => undefined);
return invokeOrMock('remove_vocab', { id }, () => {
const index = mockVocab.findIndex((entry) => entry.id === id);
if (index >= 0) mockVocab.splice(index, 1);
});
}
export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => {
const entry = mockVocab.find((entry) => entry.id === id);
if (entry) entry.enabled = enabled;
});
}
/** 编辑词条文本:id / hits / enabled 保持不变(后端 update_vocab)。 */
export function updateVocab(id: string, phrase: string): Promise<void> {
return invokeOrMock('update_vocab', { id, phrase }, () => undefined);
return invokeOrMock('update_vocab', { id, phrase }, () => {
const entry = mockVocab.find((entry) => entry.id === id);
if (entry) entry.phrase = phrase;
});
}
export function listCorrectionRules(): Promise<CorrectionRule[]> {
@@ -0,0 +1,48 @@
import {
filterLanguages,
localizedLanguages,
nativeLanguageName,
SUPPORTED_LANGUAGES,
} from './languageCatalog.ts';
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const english = localizedLanguages('en', ['My custom language']);
const french = english.find((language) => language.nativeName === 'Français');
assert(french?.displayName === 'French', 'display language names follow the UI locale');
assert(french.label.includes('Français'), 'native language names remain visible');
assert(
SUPPORTED_LANGUAGES.includes('简体中文'),
'existing native-name preference values stay compatible',
);
assert(
nativeLanguageName('繁體中文') === '繁体中文',
'traditional Chinese aliases resolve without changing scripts',
);
assert(
nativeLanguageName('zh-CN') === '简体中文',
'language-code preferences resolve to their existing native value',
);
assert(
filterLanguages(english, 'francais').some((language) => language.nativeName === 'Français'),
'search accepts native names without accents',
);
assert(
filterLanguages(english, 'nl').some((language) => language.nativeName === 'Nederlands'),
'search also accepts language codes',
);
assert(
filterLanguages(localizedLanguages('de'), 'Französisch')[0]?.nativeName === 'Français',
'search accepts localized names',
);
assert(
english.some((language) => language.nativeName === 'My custom language'),
'previously saved custom values remain visible',
);
assert(
filterLanguages(english, 'language-that-is-not-present').length === 0,
'no matching language yields an empty result',
);
console.log('languageCatalog tests passed');
+100
View File
@@ -0,0 +1,100 @@
/// <reference lib="es2021.intl" />
import catalog from '../../contract/language-catalog.json';
export interface LanguageDefinition {
code: string;
nativeName: string;
asrCode: string;
appleLocale: string;
aliases?: readonly string[];
}
export interface LocalizedLanguage {
code: string;
nativeName: string;
displayName: string;
label: string;
searchText: string;
}
/** Core and the Apple Speech adapter read the same catalog; stored values remain native names. */
export const LANGUAGE_CATALOG: readonly LanguageDefinition[] = catalog;
export const SUPPORTED_LANGUAGES: readonly string[] = catalog.map(
(language) => language.nativeName,
);
export function nativeLanguageName(value: string): string {
const key = value.trim();
return (
LANGUAGE_CATALOG.find(
(language) =>
language.nativeName === key ||
language.code.toLowerCase() === key.toLowerCase() ||
language.aliases?.some((alias) => alias.toLowerCase() === key.toLowerCase()),
)?.nativeName ?? key
);
}
function searchText(value: string): string {
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
}
export function localizedLanguages(
locale: string,
existingValues: readonly string[] = [],
): LocalizedLanguage[] {
let names: Intl.DisplayNames | undefined;
try {
if (typeof Intl.DisplayNames === 'function') {
names = new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' });
}
} catch {
// Older webviews can still show and save the native names without a locale-name implementation.
}
const languages = LANGUAGE_CATALOG.map((language) => {
const displayName = names?.of(language.code) ?? language.nativeName;
return {
code: language.code,
nativeName: language.nativeName,
displayName,
label:
displayName === language.nativeName
? displayName
: `${displayName} · ${language.nativeName}`,
searchText: searchText(
[
displayName,
language.nativeName,
language.code,
language.asrCode,
...(language.aliases ?? []),
].join(' '),
),
};
});
const known = new Set(languages.map((language) => language.nativeName));
for (const value of existingValues) {
const nativeName = nativeLanguageName(value);
if (!nativeName || known.has(nativeName)) continue;
known.add(nativeName);
// Keep a previously saved custom name visible and removable instead of silently dropping it.
languages.push({
code: '',
nativeName,
displayName: nativeName,
label: nativeName,
searchText: searchText(nativeName),
});
}
const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true });
return languages.sort((left, right) => collator.compare(left.displayName, right.displayName));
}
export function filterLanguages(
languages: readonly LocalizedLanguage[],
query: string,
): LocalizedLanguage[] {
const needle = searchText(query.trim());
return languages.filter((language) => language.searchText.includes(needle));
}
+47
View File
@@ -0,0 +1,47 @@
/** Display formatters always receive the selected UI locale, not the host default. */
export function formatLocaleNumber(
value: number,
locale: string,
options: Intl.NumberFormatOptions = {},
): string {
return new Intl.NumberFormat(locale, options).format(value);
}
export function formatLocaleDecimal(value: number, locale: string, digits = 1): string {
return formatLocaleNumber(value, locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function displayDate(value: string): Date {
const calendar = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
// Activity keys are local calendar dates, not UTC timestamps. Parsing them as
// UTC shifts the displayed day in time zones west of Greenwich.
return calendar
? new Date(Number(calendar[1]), Number(calendar[2]) - 1, Number(calendar[3]))
: new Date(value);
}
export function formatLocaleDate(
value: string,
locale: string,
options: Intl.DateTimeFormatOptions = { month: 'numeric', day: 'numeric' },
): string {
const date = displayDate(value);
return Number.isNaN(date.getTime())
? value
: new Intl.DateTimeFormat(locale, options).format(date);
}
export function formatHistoryTime(value: string, locale: string, now = new Date()): string {
const date = displayDate(value);
if (Number.isNaN(date.getTime())) return value;
const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
if (date.toDateString() !== now.toDateString()) {
options.month = 'numeric';
options.day = 'numeric';
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
}
return new Intl.DateTimeFormat(locale, options).format(date);
}
+9 -11
View File
@@ -1,8 +1,5 @@
import {
applyStackedLayout,
applyStackedLayoutFromPrefs,
isStackedLayoutActive,
} from './stackedLayout';
import { applyStackedLayoutFromPrefs } from './stackedLayout';
import { applyConservativeLayout } from './conservativeLayout';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
@@ -16,23 +13,24 @@ Object.defineProperty(globalThis, 'document', {
});
try {
dataset.olStackedLayout = 'true';
applyStackedLayoutFromPrefs(true);
assert(
dataset.olStackedLayout === 'true',
'enabled preference should set the root layout attribute',
!('olStackedLayout' in dataset),
'retired enabled preference must clear the old root layout attribute',
);
assert(isStackedLayoutActive(), 'active query should reflect the root layout attribute');
applyStackedLayoutFromPrefs(false);
assert(
!('olStackedLayout' in dataset),
'disabled preference should remove the root layout attribute',
);
assert(!isStackedLayoutActive(), 'active query should become false after removal');
applyStackedLayout(true);
dataset.olStackedLayout = 'true';
applyStackedLayoutFromPrefs(undefined);
assert(!('olStackedLayout' in dataset), 'missing preference should restore the base layout');
dataset.olConservativeLayout = 'true';
applyConservativeLayout(true);
assert(!('olConservativeLayout' in dataset), 'retired single-column layout must not reactivate');
} finally {
if (previousDocument) {
Object.defineProperty(globalThis, 'document', previousDocument);
+3 -17
View File
@@ -1,18 +1,4 @@
// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。
// 通过 html[data-ol-stacked-layout] + global.css 工具类生效。
export function applyStackedLayout(enabled: boolean): void {
if (enabled) {
document.documentElement.dataset.olStackedLayout = 'true';
} else {
delete document.documentElement.dataset.olStackedLayout;
}
}
export function applyStackedLayoutFromPrefs(stackedRowLayout?: boolean): void {
applyStackedLayout(stackedRowLayout === true);
}
export function isStackedLayoutActive(): boolean {
return document.documentElement.dataset.olStackedLayout === 'true';
// Older preferences may still contain this field. The responsive layout no longer uses it.
export function applyStackedLayoutFromPrefs(_stackedRowLayout?: boolean): void {
delete document.documentElement.dataset.olStackedLayout;
}
@@ -0,0 +1,102 @@
import type { TFunction } from 'i18next';
import type { PolishMode, StylePack } from './types';
type DisplayPack = Pick<StylePack, 'id' | 'kind' | 'baseMode' | 'name' | 'description'> &
Partial<Pick<StylePack, 'tags'>>;
const DEFAULT_NAMES: Record<PolishMode, string> = {
raw: '原文',
light: '轻度润色',
structured: '清晰结构',
formal: '正式表达',
};
// Match shipped Core metadata and browser-preview defaults exactly. A translated
// card must never overwrite a user's renamed pack or edited description.
const DEFAULT_DESCRIPTIONS: Record<PolishMode, readonly string[]> = {
raw: [
'尽量保留原话的顺序、语气和信息密度,只做必要断句与标点整理。',
'尽量保留原话顺序和语气,只做必要的断句与标点整理。',
],
light: [
'在保留原意 / 语气 / 表达习惯前提下,把口语转写整理成自然顺畅、可直接发送或继续编辑的文字。v2.0 中文序号七节骨架(角色 → 核心原则 → 润色强度 → 风格判断 → ASR 纠错 → 原样保留 → 禁止事项 → 输出),把「± 20% 字数」「工程化直陈 vs 自然润色」两个判断点抽到独立章节作为最显眼的两个开关。',
'把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。',
],
structured: [
'面向 AI 编程协作、技术排障、模型资讯和产品 UI 反馈,优先保证术语与结构准确。v3.0 Beta:人格化「语修」角色 + 场景优先级分型 + ASR 术语纠错词表 + 反 AI 自述式表达约束,双层格式与锚示例保持不变。',
'适合多事项和多主题口述,自动整理为层次清楚的结构化输出。',
'面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。',
],
formal: [
'把口语转写整理成适合工作沟通、邮件、跨团队同步的正式书面表达。v2.0 中文序号七节骨架(角色 → 核心原则 → 正式化强度 → 风格判断 → ASR 纠错 → 原样保留 → 禁止事项 → 输出),把「± 30% 字数」「通用商务正式 vs 邮件场景识别问候落款」两个判断点抽到独立章节;含邮件场景示例覆盖问候/落款识别规则。',
'适合邮件、同步和工作沟通场景,语气更完整、专业、克制。',
],
};
const DEFAULT_TAG_KEYS: Record<PolishMode, Readonly<Record<string, string>>> = {
raw: {
原文: 'style.modes.raw.name',
最小改写: 'style.pack.builtinTags.minimalEdits',
},
light: {
轻度润色: 'style.modes.light.name',
强纠错: 'style.pack.builtinTags.strongCorrection',
沟通: 'style.pack.builtinTags.communication',
自然: 'style.pack.builtinTags.natural',
},
structured: {
'AI 编程': 'style.pack.builtinTags.aiCoding',
技术结构化: 'style.pack.builtinTags.technicalStructure',
结构化: 'style.modes.structured.name',
条理: 'style.pack.builtinTags.organized',
},
formal: {
正式表达: 'style.modes.formal.name',
正式: 'style.modes.formal.name',
强纠错: 'style.pack.builtinTags.strongCorrection',
工作沟通: 'style.pack.builtinTags.workplaceCommunication',
},
};
function isBuiltin(pack: Pick<DisplayPack, 'id' | 'kind' | 'baseMode'>): boolean {
return pack.kind === 'builtin' && pack.id === `builtin.${pack.baseMode}`;
}
export function stylePackDisplayName(
pack: Pick<DisplayPack, 'id' | 'kind' | 'baseMode' | 'name'>,
modeLabels: Record<PolishMode, string>,
): string {
const name = pack.name.trim();
return isBuiltin(pack) && name === DEFAULT_NAMES[pack.baseMode]
? modeLabels[pack.baseMode]
: name;
}
/** Localized labels for display only. Persistence and export keep original data. */
export function getStylePackPresentation(
pack: DisplayPack,
t: TFunction,
): {
name: string;
description: string;
tags: string[];
} {
const name = stylePackDisplayName(pack, {
raw: t('style.modes.raw.name'),
light: t('style.modes.light.name'),
structured: t('style.modes.structured.name'),
formal: t('style.modes.formal.name'),
});
const description = pack.description.trim();
return {
name,
description:
isBuiltin(pack) && DEFAULT_DESCRIPTIONS[pack.baseMode].includes(description)
? t(`style.modes.${pack.baseMode}.desc`)
: description,
tags: (pack.tags ?? []).map((tag) => {
const key = isBuiltin(pack) ? DEFAULT_TAG_KEYS[pack.baseMode][tag] : undefined;
return key ? t(key) : tag;
}),
};
}
+3 -21
View File
@@ -338,7 +338,7 @@ export interface UserPreferences {
customStylePrompts: CustomStylePrompts;
launchAtLogin: boolean;
showCapsule: boolean;
/** 录音胶囊样式('siri' | 'classic')。见 CapsulePayload.capsuleStyle 的运行时下发。 */
/** 录音胶囊外观;保存后同步到胶囊窗口。 */
capsuleStyle: CapsuleStyle;
/** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */
muteDuringRecording: boolean;
@@ -662,31 +662,13 @@ export interface LessComputerSyncResult {
voiceState?: LessComputerVoiceEvent;
}
/** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。
* 添加新语言时直接在这里加一项(原生名),无需修改后端。 */
export const SUPPORTED_LANGUAGES: readonly string[] = [
'简体中文',
'繁体中文',
'English',
'日本語',
'한국어',
'Français',
'Deutsch',
'Español',
'Italiano',
'Português',
'Русский',
'العربية',
'Tiếng Việt',
'ไทย',
'हिन्दी',
] as const;
export { SUPPORTED_LANGUAGES } from './languageCatalog';
export type CapsuleState =
'idle' | 'recording' | 'transcribing' | 'polishing' | 'done' | 'cancelled' | 'error';
/** 录音胶囊样式:'siri' = 流光 Siri 光效版(默认);'classic' = Openless 经典药丸版。 */
export type CapsuleStyle = 'siri' | 'classic';
export type CapsuleStyle = 'siri' | 'classic' | 'typeless';
export interface CapsulePayload {
state: CapsuleState;
+5 -11
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { detectOS } from '../components/WindowChrome';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
export function shouldUseMobileLayout(breakpoint = 720): boolean {
if (typeof window === 'undefined') return false;
@@ -25,21 +24,16 @@ export function useMobileLayout(breakpoint = 720): boolean {
return mobile;
}
/** 易读布局:只表示用户偏好,不混入移动端基础布局。 */
/** Legacy row consumers remain responsive, but retired layout preferences are ignored. */
export function useReadableLayout(): boolean {
const { prefs } = useHotkeySettings();
return prefs?.stackedRowLayout === true;
return false;
}
/** 排版堆叠:移动端壳层 或 用户开启「易读布局」。用于 grid/flex 换行,不含底栏等壳层逻辑。 */
/** Narrow screens stack controls automatically. */
export function useLayoutStack(breakpoint = 720): boolean {
const mobile = useMobileLayout(breakpoint);
const readable = useReadableLayout();
return mobile || readable;
return useMobileLayout(breakpoint);
}
/** 保守排版:仅读用户偏好,不与 mobile 联动。 */
export function useConservativeLayout(): boolean {
const { prefs } = useHotkeySettings();
return prefs?.conservativeLayout === true;
return false;
}
+3 -8
View File
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client';
import { App } from './App';
import { SplashVideo } from './components/SplashVideo';
import { detectOS } from './components/WindowChrome';
import i18n from './i18n'; // 副作用:触发 i18next init
import { i18nReady } from './i18n';
import { initThemeMode } from './lib/themeMode';
import './styles/tokens.css';
import './styles/global.css';
@@ -45,10 +45,5 @@ const renderApp = () => {
);
};
// i18n 必须就绪后才能渲染:否则首次渲染拿到的 t() 返回 key 字面量。
// react-i18next useSuspense=false 时不会自动等,只有事件触发后重渲染才能拿到译文。
if (i18n.isInitialized) {
renderApp();
} else {
i18n.on('initialized', renderApp);
}
// Mount only after the selected local language chunk is ready; avoid mixed-language startup.
void i18nReady.then(renderApp);
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
addCorrectionRule,
isTauri,
listCorrectionRules,
removeCorrectionRule,
setCorrectionRuleEnabled,
@@ -49,6 +50,21 @@ export function Corrections() {
useEffect(() => {
void refresh();
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
// A cloud restore updates vocabulary and correction stores together.
void import('@tauri-apps/api/event')
.then(async ({ listen }) => {
const stop = await listen('vocab:updated', () => void refresh());
if (cancelled) stop();
else unlisten = stop;
})
.catch((error) => console.warn('[corrections] update listener failed', error));
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const onAddCorrectionRule = async () => {
+26 -23
View File
@@ -25,6 +25,7 @@ import {
import { useMobileLayout } from '../lib/useMobileLayout';
import type { DictationSession, PolishMode, StylePack } from '../lib/types';
import { countCodePoints } from '../lib/unicode';
import { formatHistoryTime, formatLocaleDecimal, formatLocaleNumber } from '../lib/localeFormat';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { Btn, Card, PageHeader, Pill } from './_atoms';
import { SelectLite } from '../components/ui/SelectLite';
@@ -67,7 +68,8 @@ function styleLabelFor(
}
export function History() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language;
const os = detectOS();
const MODE_LABEL = useModeLabel();
const [query, setQuery] = useState('');
@@ -494,7 +496,7 @@ export function History() {
color: 'var(--ol-ink-3)',
}}
>
{formatTime(s.createdAt)}
{formatHistoryTime(s.createdAt, locale)}
</span>
<span
style={{
@@ -503,7 +505,7 @@ export function History() {
fontFamily: 'var(--ol-font-mono)',
}}
>
{formatDuration(s.durationMs, t)}
{formatDuration(s.durationMs, t, locale)}
</span>
</div>
<div
@@ -571,7 +573,7 @@ export function History() {
flexShrink: 0,
}}
>
{formatTime(item.createdAt)}
{formatHistoryTime(item.createdAt, locale)}
</span>
<span style={{ display: 'flex', minWidth: 0 }} title={styleLabel(item)}>
<Pill size="sm" tone="default" style={TRUNCATED_PILL_STYLE}>
@@ -586,7 +588,9 @@ export function History() {
{/* 「录音」前缀:与下方识别/润色耗时区分——录音时长发生在松键前,
不该与流水线各步耗时加总(用户反馈"时间对不上")。 */}
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('history.recorded', { duration: formatDuration(item.durationMs, t) })}
{t('history.recorded', {
duration: formatDuration(item.durationMs, t, locale),
})}
</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
@@ -685,7 +689,7 @@ export function History() {
whiteSpace: 'nowrap',
}}
>
{item.asrMs != null ? formatStepDuration(item.asrMs, t) : ''}
{item.asrMs != null ? formatStepDuration(item.asrMs, t, locale) : ''}
</span>
</>
)}
@@ -708,7 +712,7 @@ export function History() {
whiteSpace: 'nowrap',
}}
>
{item.polishMs != null ? formatStepDuration(item.polishMs, t) : ''}
{item.polishMs != null ? formatStepDuration(item.polishMs, t, locale) : ''}
</span>
</>
)}
@@ -1297,26 +1301,25 @@ function AudioRecordingPlayer({
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, '0');
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 流水线单步耗时:<1s 显示整数毫秒(流式收尾常在几十 ms,0.1s 精度会把不同结果
* 拍成同一个值,模型对比就失真了——PR #826 review);≥1s 沿用 0.1s 精度。 */
function formatStepDuration(ms: number, t: ReturnType<typeof useTranslation>['t']): string {
if (ms < 1000) return t('common.durationMillis', { value: Math.round(ms) });
return formatDuration(ms, t);
function formatStepDuration(
ms: number,
t: ReturnType<typeof useTranslation>['t'],
locale: string,
): string {
if (ms < 1000)
return t('common.durationMillis', { value: formatLocaleNumber(Math.round(ms), locale) });
return formatDuration(ms, t, locale);
}
function formatDuration(ms: number | null, t: ReturnType<typeof useTranslation>['t']): string {
function formatDuration(
ms: number | null,
t: ReturnType<typeof useTranslation>['t'],
locale: string,
): string {
if (ms == null || ms <= 0) return '—';
const sec = ms / 1000;
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
return t('common.durationMinutes', { value: (sec / 60).toFixed(1) });
if (sec < 60) return t('common.durationSeconds', { value: formatLocaleDecimal(sec, locale) });
return t('common.durationMinutes', { value: formatLocaleDecimal(sec / 60, locale) });
}
+49 -42
View File
@@ -7,6 +7,12 @@ import { getActivityStats, getCredentials, listHistory } from '../lib/ipc';
import { Heatmap } from '../components/Heatmap';
import { useMobileLayout } from '../lib/useMobileLayout';
import { countCodePoints } from '../lib/unicode';
import {
formatHistoryTime,
formatLocaleDate,
formatLocaleDecimal,
formatLocaleNumber,
} from '../lib/localeFormat';
import { isDesktop } from '../lib/platform';
import { getOverviewSetup, type OverviewSettingsSection } from '../lib/overviewSetup';
import {
@@ -61,7 +67,8 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
};
export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language;
const mobile = useMobileLayout();
const modeLabel = useModeLabels();
const [history, setHistory] = useState<DictationSession[]>([]);
@@ -341,7 +348,7 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
<Metric
icon="hash"
label={t('overview.metricChars')}
value={historyError ? '—' : metrics.charsToday.toLocaleString()}
value={historyError ? '—' : formatLocaleNumber(metrics.charsToday, locale)}
trend={
historyError
? t('overview.historyLoadError')
@@ -351,13 +358,13 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
<Metric
icon="mic"
label={t('overview.metricDuration')}
value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t)}
value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t, locale)}
trend={historyError ? t('overview.historyLoadError') : ''}
/>
<Metric
icon="clock"
label={t('overview.metricAvg')}
value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t)}
value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t, locale)}
trend={
historyError
? t('overview.historyLoadError')
@@ -369,7 +376,7 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
<Metric
icon="bolt"
label={t('overview.metricTotal')}
value={historyError ? '—' : String(history.length)}
value={historyError ? '—' : formatLocaleNumber(history.length, locale)}
trend={historyError ? t('overview.historyLoadError') : t('overview.metricTotalTrend')}
/>
</div>
@@ -564,7 +571,7 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
const year = now.getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
const lang = i18n.language || 'en';
const lang = i18n.resolvedLanguage || i18n.language || 'en';
const monthFormat = new Intl.DateTimeFormat(lang, { month: 'short' });
const dayFormat = new Intl.DateTimeFormat(lang, { weekday: 'short' });
const dateFormat = new Intl.DateTimeFormat(lang, { dateStyle: 'medium' });
@@ -583,7 +590,7 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
date: (date: Date) => dateFormat.format(date),
},
};
}, [activity, i18n.language]);
}, [activity, i18n.resolvedLanguage, i18n.language]);
return (
<Card padding={12} style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink-2)', flexShrink: 0 }}>
@@ -724,7 +731,8 @@ function PeriodMetricsCard({
loadError: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language;
const periodOptions = ACTIVITY_PERIODS.map((days) => ({
value: days,
label: t(`overview.period.last${days}Days`),
@@ -796,11 +804,11 @@ function PeriodMetricsCard({
lineHeight: 1.1,
}}
>
{formatMetricValue(series.total, metric, t)}
{formatMetricValue(series.total, metric, t, locale)}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', marginTop: 5 }}>
{t('overview.period.dailyAverage', {
value: formatMetricValue(series.dailyAverage, metric, t),
value: formatMetricValue(series.dailyAverage, metric, t, locale),
})}
</div>
</div>
@@ -820,7 +828,8 @@ function PeriodChart({
series: ReturnType<typeof buildPeriodSeries>;
metric: ActivityMetric;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language;
const { buckets } = series;
const max = Math.max(...buckets.map((b) => b.value), 1);
const dense = buckets.length > 7;
@@ -844,7 +853,7 @@ function PeriodChart({
return (
<div
key={bucket.date}
title={`${bucket.date} · ${formatMetricValue(bucket.value, metric, t)}`}
title={`${formatLocaleDate(bucket.date, locale, { dateStyle: 'medium' })} · ${formatMetricValue(bucket.value, metric, t, locale)}`}
style={{
flex: 1,
minWidth: 0,
@@ -865,7 +874,7 @@ function PeriodChart({
flexShrink: 0,
}}
>
{formatMetricValue(bucket.value, metric, t)}
{formatMetricValue(bucket.value, metric, t, locale)}
</div>
)}
<div
@@ -896,7 +905,7 @@ function PeriodChart({
>
{dense
? [0, midIndex, lastIndex].map((i) => (
<span key={i}>{shortDateLabel(buckets[i].date)}</span>
<span key={i}>{formatLocaleDate(buckets[i].date, locale)}</span>
))
: buckets.map((bucket) => (
<span key={bucket.date}>
@@ -911,13 +920,6 @@ function PeriodChart({
);
}
/** `YYYY-MM-DD` → `M/D`。日期键是后端按本地日历写的,直接切字符串即可,
* 不要 new Date(key) —— 那会按 UTC 解析再转回本地,跨时区会差一天。 */
function shortDateLabel(dateKey: string): string {
const [, month, day] = dateKey.split('-');
return `${Number(month)}/${Number(day)}`;
}
function weekDayLabel(dateKey: string, names: string[]): string {
const [year, month, day] = dateKey.split('-').map(Number);
return names[new Date(year, month - 1, day).getDay()];
@@ -928,21 +930,31 @@ function formatMetricValue(
value: number,
metric: ActivityMetric,
t: ReturnType<typeof useTranslation>['t'],
locale: string,
): string {
if (metric === 'duration') return formatLongDuration(value, t);
return Math.round(value).toLocaleString();
if (metric === 'duration') return formatLongDuration(value, t, locale);
return formatLocaleNumber(Math.round(value), locale);
}
/** 周期总时长可能是几十小时,不能沿用只处理秒/分的 formatDuration。 */
function formatLongDuration(ms: number, t: ReturnType<typeof useTranslation>['t']): string {
function formatLongDuration(
ms: number,
t: ReturnType<typeof useTranslation>['t'],
locale: string,
): string {
if (ms <= 0) return '0';
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) return t('common.durationSeconds', { value: totalSeconds });
if (totalSeconds < 60)
return t('common.durationSeconds', { value: formatLocaleNumber(totalSeconds, locale) });
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return t('overview.period.minutes', { value: totalMinutes });
if (totalMinutes < 60)
return t('overview.period.minutes', { value: formatLocaleNumber(totalMinutes, locale) });
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return t('overview.period.hoursMinutes', { hours, minutes });
return t('overview.period.hoursMinutes', {
hours: formatLocaleNumber(hours, locale),
minutes: formatLocaleNumber(minutes, locale),
});
}
function RecentRow({
@@ -952,7 +964,8 @@ function RecentRow({
session: DictationSession;
modeLabel: Record<PolishMode, string>;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language;
const [copied, setCopied] = useState(false);
const onCopy = async () => {
@@ -992,7 +1005,7 @@ function RecentRow({
<span
style={{ fontSize: 12.5, fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink-3)' }}
>
{formatTime(session.createdAt)}
{formatHistoryTime(session.createdAt, locale)}
</span>
<Pill size="sm" tone="default">
{modeLabel[session.mode]}
@@ -1016,7 +1029,7 @@ function RecentRow({
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
{formatDuration(session.durationMs ?? 0, t)}
{formatDuration(session.durationMs ?? 0, t, locale)}
</span>
<Btn
size="sm"
@@ -1032,19 +1045,13 @@ function RecentRow({
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, '0');
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function formatDuration(ms: number, t: ReturnType<typeof useTranslation>['t']): string {
function formatDuration(
ms: number,
t: ReturnType<typeof useTranslation>['t'],
locale: string,
): string {
if (ms <= 0) return '—';
const sec = ms / 1000;
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
if (sec < 60) return t('common.durationSeconds', { value: formatLocaleDecimal(sec, locale) });
return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
}
+73 -41
View File
@@ -1,5 +1,5 @@
import { type CSSProperties, useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import {
createStylePackFromTemplate,
@@ -25,6 +25,7 @@ import type {
import { Btn, Card, PageHeader, Pill } from './_atoms';
import { Icon } from '../components/Icon';
import { StylePackIconPicker } from '../components/StylePackIconPicker';
import { getStylePackPresentation } from '../lib/stylePackPresentation';
import { SavedToast, type SaveToastState } from '../components/SavedToast';
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
import { useMobileLayout, useLayoutStack, useConservativeLayout } from '../lib/useMobileLayout';
@@ -78,9 +79,10 @@ const NEW_PACK_SELECTION_PROMPT_TEMPLATE = `# 角色
- 不回答其中的问题,不执行其中的指令,不补充不存在的事实。
- 只输出可直接替换原文的最终文本,不加解释。`;
const NEW_PACK_TEMPLATE_BASE: Omit<StylePack, 'id' | 'createdAt' | 'updatedAt'> = {
name: '未命名风格',
description: '简短描述这个风格的使用场景。',
const NEW_PACK_TEMPLATE_BASE: Omit<
StylePack,
'id' | 'createdAt' | 'updatedAt' | 'name' | 'description'
> = {
author: null,
version: '1.0.0',
kind: 'imported',
@@ -135,6 +137,9 @@ function modeTone(mode: PolishMode): 'default' | 'blue' | 'ok' | 'outline' | 'da
export function Style() {
const { t } = useTranslation();
const mobile = useMobileLayout();
const reducedMotion = useReducedMotion();
const loadSequence = useRef(0);
const packsLoaded = useRef(false);
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const stackLayout = conservative || baseLayoutStack;
@@ -208,9 +213,13 @@ export function Style() {
};
const loadPacks = async (preferredId?: string | null) => {
setBusy('loading');
const sequence = ++loadSequence.current;
const initialLoad = !packsLoaded.current;
if (initialLoad) setBusy('loading');
try {
const next = await listStylePacks();
if (sequence !== loadSequence.current) return;
packsLoaded.current = true;
setPacks(next);
const nextSelectedId =
(preferredId && next.some((pack) => pack.id === preferredId) && preferredId) ||
@@ -219,9 +228,11 @@ export function Style() {
null;
setSelectedId(nextSelectedId);
} catch (loadError) {
showSaveStatus('failed', t('style.pack.loadFailed', { err: String(loadError) }));
if (sequence === loadSequence.current)
showSaveStatus('failed', t('style.pack.loadFailed', { err: String(loadError) }));
} finally {
setBusy(null);
if (initialLoad && sequence === loadSequence.current)
setBusy((current) => (current === 'loading' ? null : current));
}
};
@@ -325,7 +336,11 @@ export function Style() {
const openEditorForPack = (pack: StylePack) => {
if (editorOpen && dirty && selectedPack && selectedPack.id !== pack.id) {
if (!window.confirm(t('style.pack.discardSwitchConfirm', { name: pack.name }))) {
if (
!window.confirm(
t('style.pack.discardSwitchConfirm', { name: getStylePackPresentation(pack, t).name }),
)
) {
return;
}
}
@@ -404,7 +419,11 @@ export function Style() {
setBusy('activating');
try {
await setActiveStylePack(pack.id);
showSaveStatus('saved', t('style.pack.activateSuccess', { name: pack.name }), true);
showSaveStatus(
'saved',
t('style.pack.activateSuccess', { name: getStylePackPresentation(pack, t).name }),
true,
);
await loadPacks(pack.id);
} catch (activateError) {
showSaveStatus('failed', t('style.pack.activateFailed', { err: String(activateError) }));
@@ -419,7 +438,11 @@ export function Style() {
...current,
selectionPolishStylePackId: pack.id,
}));
showSaveStatus('saved', t('style.pack.selectionActivated', { name: pack.name }), true);
showSaveStatus(
'saved',
t('style.pack.selectionActivated', { name: getStylePackPresentation(pack, t).name }),
true,
);
} catch (activateError) {
showSaveStatus(
'failed',
@@ -433,7 +456,11 @@ export function Style() {
setBusy('resetting');
try {
await resetBuiltinStylePack(selectedPack.id);
showSaveStatus('saved', t('style.pack.resetSuccess', { name: selectedPack.name }), true);
showSaveStatus(
'saved',
t('style.pack.resetSuccess', { name: getStylePackPresentation(selectedPack, t).name }),
true,
);
await loadPacks(selectedPack.id);
} catch (resetError) {
showSaveStatus('failed', t('style.pack.resetFailed', { err: String(resetError) }));
@@ -444,13 +471,21 @@ export function Style() {
const handleDeleteImportedPack = async (pack: StylePack) => {
if (pack.kind !== 'imported') return;
if (!window.confirm(t('style.pack.deleteConfirm', { name: pack.name }))) {
if (
!window.confirm(
t('style.pack.deleteConfirm', { name: getStylePackPresentation(pack, t).name }),
)
) {
return;
}
setBusy('deleting');
try {
await deleteStylePack(pack.id);
showSaveStatus('saved', t('style.pack.deleteSuccess', { name: pack.name }), true);
showSaveStatus(
'saved',
t('style.pack.deleteSuccess', { name: getStylePackPresentation(pack, t).name }),
true,
);
if (editorOpen && selectedId === pack.id) {
startEditorClose();
}
@@ -473,6 +508,8 @@ export function Style() {
const template: StylePack = {
...NEW_PACK_TEMPLATE_BASE,
id: '',
name: t('style.pack.newName'),
description: t('style.pack.newDescription'),
};
const created = await createStylePackFromTemplate(template);
showSaveStatus('saved', t('style.pack.createSuccess'), true);
@@ -736,7 +773,7 @@ export function Style() {
type="button"
onClick={() => void handleActivate(rawPack)}
disabled={rawPack.active || busy === 'activating'}
title={rawPack.name}
title={getStylePackPresentation(rawPack, t).name}
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -755,7 +792,7 @@ export function Style() {
'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
>
<span>{rawPack.name}</span>
<span>{getStylePackPresentation(rawPack, t).name}</span>
{rawPack.active && (
<span style={{ fontSize: 11, opacity: 0.85 }}>·{t('style.pack.active')}</span>
)}
@@ -815,7 +852,8 @@ export function Style() {
</div>
</div>
</div>
<div
<motion.div
layoutScroll
className="ol-thinscroll"
style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}
>
@@ -823,13 +861,15 @@ export function Style() {
className="ol-grid-auto-cards"
style={{
display: 'grid',
gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(260px, 1fr))',
gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fill, minmax(250px, 1fr))',
position: 'relative',
gap: 12,
}}
>
<AnimatePresence mode="sync">
<AnimatePresence mode="popLayout" initial={false}>
{bodyPacks.map((pack) => {
const isBuiltin = pack.kind === 'builtin';
const presentation = getStylePackPresentation(pack, t);
const isCurrentForView =
workflowView === 'selection' ? pack.id === selectionPolishPackId : pack.active;
return (
@@ -837,16 +877,13 @@ export function Style() {
key={pack.id}
data-ol-style-pack={pack.id}
data-active={isCurrentForView ? 'true' : undefined}
{...(!stackLayout ? { layout: true, layoutDependency: bodyPacks.length } : {})}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
layout={reducedMotion ? false : 'position'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
...(stackLayout
? {}
: { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
layout: { duration: reducedMotion ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: reducedMotion ? 0 : 0.12 },
}}
style={{
display: 'flex',
@@ -860,7 +897,7 @@ export function Style() {
background: isCurrentForView
? 'var(--ol-style-card-bg-active)'
: 'var(--ol-style-card-bg)',
borderRadius: 18,
borderRadius: 14,
padding: 16,
boxShadow: 'none',
cursor: 'default',
@@ -892,7 +929,7 @@ export function Style() {
color: 'var(--ol-style-card-ink)',
}}
>
{pack.name}
{presentation.name}
</div>
<Pill tone="outline" size="sm">
{isBuiltin ? t('style.pack.builtin') : t('style.pack.imported')}
@@ -931,7 +968,7 @@ export function Style() {
minHeight: 60,
}}
>
{pack.description.trim() || pack.name}
{presentation.description || presentation.name}
</div>
</div>
<StylePackIconPicker
@@ -966,7 +1003,7 @@ export function Style() {
? t('style.pack.writtenPolish')
: t(`style.modes.${pack.baseMode}.name`)}
</Pill>
{pack.tags.slice(0, 1).map((tag) => (
{presentation.tags.slice(0, 1).map((tag) => (
<Pill key={`${pack.id}-${tag}`} tone="default" size="sm">
{tag}
</Pill>
@@ -1026,15 +1063,10 @@ export function Style() {
})}
<motion.button
key="add-new-pack-btn"
{...(!stackLayout ? { layout: true } : {})}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
layout={reducedMotion ? false : 'position'}
initial={false}
transition={{
...(stackLayout
? {}
: { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
layout: { duration: reducedMotion ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] },
}}
type="button"
disabled={busy === 'creating'}
@@ -1048,7 +1080,7 @@ export function Style() {
gap: 8,
textAlign: 'center',
border: '0.5px dashed var(--ol-line-strong)',
borderRadius: 18,
borderRadius: 14,
padding: 16,
background: 'transparent',
color: 'var(--ol-ink-3)',
@@ -1089,7 +1121,7 @@ export function Style() {
</motion.button>
</AnimatePresence>
</div>
</div>
</motion.div>
</Card>
<AnimatePresence>
+133
View File
@@ -0,0 +1,133 @@
.ol-translation-toolbar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.ol-language-search {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 240px;
max-width: 440px;
min-height: 36px;
padding: 0 11px;
border: 1px solid var(--ol-line);
border-radius: 9px;
background: var(--ol-control-muted);
color: var(--ol-ink-4);
}
.ol-language-search:focus-within {
border-color: var(--ol-ink-4);
}
.ol-language-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--ol-ink);
font: inherit;
font-size: 12px;
}
.ol-translation-count,
.ol-translation-description,
.ol-translation-language-hint {
color: var(--ol-ink-4);
font-size: 12px;
line-height: 1.55;
}
.ol-translation-description {
margin: 0 0 12px;
}
.ol-language-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
align-content: start;
gap: 6px;
max-height: 316px;
overflow: auto;
padding: 1px 3px 1px 1px;
}
.ol-language-choice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 9px;
min-width: 0;
min-height: 54px;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--ol-control-muted);
color: var(--ol-ink-2);
text-align: start;
font: inherit;
cursor: pointer;
}
.ol-language-choice:hover,
.ol-language-choice[data-selected='true'] {
border-color: var(--ol-line-strong);
background: var(--ol-control-elevated);
color: var(--ol-ink);
}
.ol-language-choice:focus-visible {
outline: 2px solid var(--ol-ink-4);
outline-offset: 1px;
}
.ol-language-name,
.ol-language-native {
display: block;
overflow-wrap: anywhere;
}
.ol-language-name {
font-size: 12px;
font-weight: 550;
line-height: 1.4;
}
.ol-language-native {
margin-top: 2px;
color: var(--ol-ink-4);
font-size: 11px;
line-height: 1.3;
}
.ol-language-check {
display: grid;
flex: 0 0 17px;
width: 17px;
height: 17px;
place-items: center;
border: 1px solid var(--ol-line-strong);
border-radius: 5px;
}
.ol-language-choice[data-selected='true'] .ol-language-check {
border-color: var(--ol-ink-3);
background: var(--ol-ink-2);
color: var(--ol-surface);
}
.ol-language-empty {
grid-column: 1 / -1;
padding: 28px 10px;
text-align: center;
color: var(--ol-ink-4);
font-size: 12px;
}
.ol-translation-language-hint {
margin: 12px 0 0;
}
+128 -50
View File
@@ -9,23 +9,57 @@ import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SavedToast } from '../components/SavedToast';
import { SelectLite } from '../components/ui/SelectLite';
import { listStylePacks } from '../lib/ipc';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { isTauri, listStylePacks } from '../lib/ipc';
import { filterLanguages, localizedLanguages, nativeLanguageName } from '../lib/languageCatalog';
import { Icon } from '../components/Icon';
import { getStylePackPresentation } from '../lib/stylePackPresentation';
import { isTranslationEnabled, isTranslationTargetRedundant } from '../lib/translationTarget';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { formatComboLabel } from '../lib/hotkey';
import type { UserPreferences } from '../lib/types';
import type { StylePack, UserPreferences } from '../lib/types';
import './Translation.css';
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
export function Translation() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { prefs, loading, error, refresh, updatePrefs: savePrefs } = useHotkeySettings();
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const [activeStylePackName, setActiveStylePackName] = useState<string | null>(null);
const [activeStylePack, setActiveStylePack] = useState<StylePack | null>(null);
const activeStylePackName = activeStylePack
? getStylePackPresentation(activeStylePack, t).name
: null;
const [stylePackLoadFailed, setStylePackLoadFailed] = useState(false);
const statusTimer = useRef<number | null>(null);
const [languageQuery, setLanguageQuery] = useState('');
const locale = i18n.resolvedLanguage ?? i18n.language;
const languages = useMemo(
() =>
localizedLanguages(locale, [
...(prefs?.workingLanguages ?? []),
prefs?.translationTargetLanguage ?? '',
]),
[locale, prefs?.workingLanguages, prefs?.translationTargetLanguage],
);
const filteredLanguages = useMemo(
() => filterLanguages(languages, languageQuery),
[languages, languageQuery],
);
const targetOptions = useMemo(() => {
const options = filteredLanguages.map((language) => ({
value: language.nativeName,
label: language.label,
}));
const selected = prefs?.translationTargetLanguage;
if (selected && !options.some((option) => option.value === selected)) {
const language = languages.find(
(language) => language.nativeName === nativeLanguageName(selected),
);
options.unshift({ value: selected, label: language?.label ?? selected });
}
return [{ value: '', label: t('translation.target.disabled') }, ...options];
}, [filteredLanguages, languages, prefs?.translationTargetLanguage, t]);
useEffect(
() => () => {
@@ -37,24 +71,41 @@ export function Translation() {
useEffect(() => {
const activeStylePackId = prefs?.activeStylePackId;
let cancelled = false;
setActiveStylePackName(null);
let unlisten: (() => void) | undefined;
let request = 0;
setActiveStylePack(null);
setStylePackLoadFailed(false);
void listStylePacks()
.then((packs) => {
if (cancelled) return;
const activePack =
packs.find((pack) => pack.active && pack.enabled) ??
packs.find((pack) => pack.id === activeStylePackId && pack.enabled);
setActiveStylePackName(activePack?.name ?? null);
setStylePackLoadFailed(!activePack);
})
.catch((loadError) => {
console.warn('[translation] failed to load active style pack', loadError);
if (!cancelled) setStylePackLoadFailed(true);
});
const loadStyle = () => {
const current = ++request;
return listStylePacks()
.then((packs) => {
if (cancelled || current !== request) return;
const activePack =
packs.find((pack) => pack.active && pack.enabled) ??
packs.find((pack) => pack.id === activeStylePackId && pack.enabled);
setActiveStylePack(activePack ?? null);
setStylePackLoadFailed(!activePack);
})
.catch((loadError) => {
console.warn('[translation] failed to load active style pack', loadError);
if (!cancelled && current === request) setStylePackLoadFailed(true);
});
};
void loadStyle();
if (isTauri) {
// Restore can replace an active pack's contents without changing its ID.
void import('@tauri-apps/api/event')
.then(async ({ listen }) => {
const stop = await listen('prefs:changed', () => void loadStyle());
if (cancelled) stop();
else unlisten = stop;
})
.catch((error) => console.warn('[translation] preference listener failed', error));
}
return () => {
cancelled = true;
unlisten?.();
};
}, [prefs?.activeStylePackId]);
@@ -131,17 +182,16 @@ export function Translation() {
);
}
const onWorkingLanguagesChange = (workingLanguages: string[]) => {
void persistPrefs(
(current) => ({ ...current, workingLanguages }),
t('translation.save.workingFailed'),
);
};
const toggleWorkingLanguage = (lang: string) => {
const next = prefs.workingLanguages.includes(lang)
? prefs.workingLanguages.filter((l) => l !== lang)
: [...prefs.workingLanguages, lang];
onWorkingLanguagesChange(next);
void persistPrefs((current) => {
const selected = current.workingLanguages.some((value) => nativeLanguageName(value) === lang);
return {
...current,
workingLanguages: selected
? current.workingLanguages.filter((value) => nativeLanguageName(value) !== lang)
: [...current.workingLanguages, lang],
};
}, t('translation.save.workingFailed'));
};
const onTargetChange = (translationTargetLanguage: string) => {
void persistPrefs(
@@ -160,14 +210,6 @@ export function Translation() {
);
const enabled = isTranslationEnabled(prefs.translationTargetLanguage) && !redundantTarget;
const targetOptions = useMemo(
() => [
{ value: '', label: t('translation.target.disabled') },
...SUPPORTED_LANGUAGES.map((lang) => ({ value: lang, label: lang })),
],
[t],
);
return (
<>
<PageHeader title={t('translation.title')} desc={t('translation.desc')} />
@@ -219,37 +261,73 @@ export function Translation() {
<SavedToast saveState={saveState} message={saveMessage} />
<div className="ol-translation-toolbar">
<label className="ol-language-search">
<Icon name="search" size={15} />
<input
type="search"
value={languageQuery}
onChange={(event) => setLanguageQuery(event.target.value)}
placeholder={t('translation.searchLanguages')}
aria-label={t('translation.searchLanguages')}
/>
</label>
<span className="ol-translation-count">
{t('translation.selectedLanguages', { count: prefs.workingLanguages.length })}
</span>
</div>
{/* 宽屏下「工作语言 / 目标语言」并排两栏(窄屏自动叠成一栏),
语言 chips 用对齐的均匀网格代替自由换行,避免参差的标签云。 */}
<div className="ol-translation-grid">
{/* 1. 工作语言 */}
<Card style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 12 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 6 }}>
{t('translation.working.title')}
</div>
<p className="ol-translation-description">{t('translation.working.desc')}</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(104px, 1fr))',
gap: 8,
}}
className="ol-language-grid ol-thinscroll"
role="group"
aria-label={t('translation.working.title')}
>
{SUPPORTED_LANGUAGES.map((lang) => {
const checked = prefs.workingLanguages.includes(lang);
{filteredLanguages.map((language) => {
const checked = prefs.workingLanguages.some(
(value) => nativeLanguageName(value) === language.nativeName,
);
return (
<button
key={lang}
key={language.nativeName}
type="button"
className="ol-chip"
className="ol-language-choice"
data-selected={checked}
aria-pressed={checked}
title={lang}
onClick={() => toggleWorkingLanguage(lang)}
title={language.label}
onClick={() => toggleWorkingLanguage(language.nativeName)}
>
{lang}
<span>
<span className="ol-language-name" dir="auto">
{language.displayName}
</span>
{language.displayName !== language.nativeName && (
<span className="ol-language-native" dir="auto">
{language.nativeName}
</span>
)}
</span>
<span className="ol-language-check">
{checked && <Icon name="check" size={13} />}
</span>
</button>
);
})}
{filteredLanguages.length === 0 && (
<p className="ol-language-empty" role="status">
{t('translation.noMatchingLanguages')}
</p>
)}
</div>
<p className="ol-translation-language-hint">{t('translation.languageSupportHint')}</p>
</Card>
{/* 2. 翻译目标语言 */}
+102 -38
View File
@@ -66,12 +66,16 @@ export function Vocab() {
const cardRefs = useRef(new Map<string, HTMLDivElement>());
const prevCardTops = useRef(new Map<string, number>());
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchBusy, setBatchBusy] = useState(false);
const refresh = async () => {
try {
setError(null);
const data = await listVocab();
setEntries(data);
const ids = new Set(data.map((entry) => entry.id));
setSelectedIds((current) => new Set([...current].filter((id) => ids.has(id))));
} catch (e) {
// 之前没 try/catch,后端 decode 失败时 spinner 永久卡死。
setError(e instanceof Error ? e.message : String(e));
@@ -130,13 +134,38 @@ export function Vocab() {
}
};
const onRemove = async (id: string) => {
await fadeOutCard(id);
await removeVocab(id);
setEntries((prev) => prev.filter((e) => e.id !== id));
setRemovingIds((prev) => {
const next = new Set(prev);
next.delete(id);
const removeEntries = async (ids: string[]) => {
if (batchBusy || ids.length === 0) return;
setBatchBusy(true);
setError(null);
const removed = new Set<string>();
let failures = 0;
try {
// Bound IPC concurrency and retain unsuccessful selections for retry.
for (let start = 0; start < ids.length; start += 8) {
const batch = ids.slice(start, start + 8);
const results = await Promise.allSettled(batch.map((id) => removeVocab(id)));
results.forEach((result, index) => {
if (result.status === 'fulfilled') removed.add(batch[index]);
else failures += 1;
});
}
await Promise.all([...removed].map((id) => fadeOutCard(id)));
setEntries((current) => current.filter((entry) => !removed.has(entry.id)));
setSelectedIds((current) => new Set([...current].filter((id) => !removed.has(id))));
if (failures) setError(t('vocab.batchDeleteFailed', { count: failures }));
else flashSaved();
} finally {
setRemovingIds(new Set());
setBatchBusy(false);
}
};
const toggleSelection = (id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
@@ -322,29 +351,14 @@ export function Vocab() {
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.92)' },
],
{ duration: 160, easing: 'cubic-bezier(0.4, 0, 1, 1)' },
{ duration: 140, easing: 'ease-out', fill: 'forwards' },
).finished;
} catch {
/* 动画被打断(筛选切换/卸载)不阻塞删除 */
}
};
const onRemoveAllLearnedEntries = async () => {
// 逐条删而不是加一条批量后端命令:词条是几十条量级,为此多开一条 IPC 不值得,
// 而且逐条删失败一条也不影响其余。
await Promise.all(learnedEntries.map((entry) => fadeOutCard(entry.id)));
const removed: string[] = [];
for (const entry of learnedEntries) {
try {
await removeVocab(entry.id);
removed.push(entry.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
setEntries((prev) => prev.filter((e) => !removed.includes(e.id)));
setRemovingIds(new Set());
};
const onRemoveAllLearnedEntries = () => removeEntries(learnedEntries.map((entry) => entry.id));
const needle = query.trim().toLowerCase();
const visibleEntries = entries.filter(
@@ -379,6 +393,17 @@ export function Vocab() {
desc={t('vocab.desc')}
right={
<div style={{ display: 'flex', gap: 8 }}>
{selectedIds.size > 0 && (
<button
type="button"
className="ol-vocab-delete-selected"
disabled={batchBusy}
onClick={() => void removeEntries([...selectedIds])}
>
<Icon name="trash" size={15} />
{t('vocab.deleteSelected', { count: selectedIds.size })}
</button>
)}
<Btn
variant="primary"
icon="plus"
@@ -428,6 +453,35 @@ export function Vocab() {
</button>
))}
</div>
<label className="ol-vocab-select-all">
<input
type="checkbox"
disabled={batchBusy || visibleEntries.length === 0}
checked={
visibleEntries.length > 0 &&
visibleEntries.every((entry) => selectedIds.has(entry.id))
}
ref={(element) => {
if (element)
element.indeterminate =
visibleEntries.some((entry) => selectedIds.has(entry.id)) &&
!visibleEntries.every((entry) => selectedIds.has(entry.id));
}}
onChange={(event) => {
const checked = event.target.checked;
setSelectedIds((current) => {
const next = new Set(current);
visibleEntries.forEach((entry) =>
checked ? next.add(entry.id) : next.delete(entry.id),
);
return next;
});
}}
/>
{selectedIds.size
? t('vocab.selectedCount', { count: selectedIds.size })
: t('vocab.selectAllVisible')}
</label>
<div style={{ flex: 1 }} />
{/* 圆形控件原地展开成搜索框 —— 放大镜固定在右缘不动,
占位文字「搜索」在框内;收起走同一条 width 过渡(从哪来回到哪去)。 */}
@@ -544,14 +598,15 @@ export function Vocab() {
key={entry.id}
entry={entry}
auto={sourceOf(entry) === 'auto'}
removing={removingIds.has(entry.id)}
removing={removingIds.has(entry.id) || batchBusy}
selected={selectedIds.has(entry.id)}
onSelect={() => toggleSelection(entry.id)}
cardRef={(element) => {
if (element) cardRefs.current.set(entry.id, element);
else cardRefs.current.delete(entry.id);
}}
onToggle={() => void onToggle(entry)}
onEdit={() => openEdit(entry)}
onRemove={() => void onRemove(entry.id)}
/>
))}
</div>
@@ -864,11 +919,21 @@ interface WordCardProps {
cardRef: (element: HTMLDivElement | null) => void;
onToggle: () => void;
onEdit: () => void;
onRemove: () => void;
selected: boolean;
onSelect: () => void;
}
/** 词条卡片:默认只显图标+文字+命中数;hover/focus-within 变灰并浮现编辑/删除。 */
function WordCard({ entry, auto, removing, cardRef, onToggle, onEdit, onRemove }: WordCardProps) {
function WordCard({
entry,
auto,
removing,
selected,
cardRef,
onToggle,
onEdit,
onSelect,
}: WordCardProps) {
const { t } = useTranslation();
const enabled = entry.enabled;
return (
@@ -876,6 +941,7 @@ function WordCard({ entry, auto, removing, cardRef, onToggle, onEdit, onRemove }
ref={cardRef}
className="ol-word-card"
data-disabled={enabled ? undefined : 'true'}
data-selected={selected ? 'true' : undefined}
style={removing ? { pointerEvents: 'none' } : undefined}
>
<span className="ol-word-card-icon" aria-hidden>
@@ -901,17 +967,15 @@ function WordCard({ entry, auto, removing, cardRef, onToggle, onEdit, onRemove }
<Icon name="pencil" size={14} />
</button>
</Tooltip>
<Tooltip content={t('common.delete')} placement="top">
<button
type="button"
className="ol-word-card-action"
aria-label={t('vocab.removeAria')}
onClick={onRemove}
>
<Icon name="trash" size={14} />
</button>
</Tooltip>
</span>
<input
type="checkbox"
className="ol-word-card-select"
checked={selected}
disabled={removing}
aria-label={t('vocab.selectWord', { phrase: entry.phrase })}
onChange={onSelect}
/>
</div>
);
}
@@ -0,0 +1,307 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { GithubLoginModal } from '../../components/GithubLoginModal';
import { Modal } from '../../components/ui/Modal';
import { Btn, Card } from '../_atoms';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { marketplaceAuthStatus } from '../../lib/ipc';
import {
cloudSyncDelete,
cloudSyncRestore,
cloudSyncStatus,
cloudSyncUpload,
type CloudSyncStatus,
} from '../../lib/ipc/cloud-sync';
import {
getLocalePreference,
setLocalePreference,
SUPPORTED_LOCALES,
type SupportedLocale,
} from '../../i18n';
import { readFontScale, setFontScale, type FontScaleId } from '../../lib/fontScale';
type Action = 'upload' | 'restore' | 'delete';
export function CloudSyncSection() {
const { t, i18n } = useTranslation();
const { prefs, updatePrefs, refresh } = useHotkeySettings();
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [status, setStatus] = useState<CloudSyncStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<Action | null>(null);
const [message, setMessage] = useState('');
const [failed, setFailed] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [confirm, setConfirm] = useState<'restore' | 'delete' | null>(null);
const sequence = useRef(0);
const login = prefs?.marketplaceDevLogin?.trim() ?? '';
const showError = (error: unknown) => {
const detail =
error && typeof error === 'object'
? (error as {
message?: string;
code?: string;
details?: { reason?: string; status?: number };
})
: null;
const text = detail?.message ?? String(error);
setFailed(true);
if (detail?.details?.reason === 'revision_conflict') setMessage(t('cloudSync.conflict'));
else if (
detail?.code === 'unsupported' ||
/cloud_sync_unavailable|404|not supported|unavailable/i.test(text)
)
setMessage(t('cloudSync.unavailable'));
else if (detail?.code === 'permission_denied' || detail?.details?.status === 401) {
setSignedIn(false);
setStatus(null);
setMessage(t('cloudSync.signInRequired'));
} else setMessage(t('cloudSync.failed', { error: text.slice(0, 400) }));
};
const load = async () => {
const request = ++sequence.current;
setLoading(true);
setStatus(null);
setMessage('');
try {
const auth = await marketplaceAuthStatus();
if (request !== sequence.current) return;
setSignedIn(auth.signedIn);
if (auth.signedIn) {
const next = await cloudSyncStatus();
if (request === sequence.current) setStatus(next);
}
} catch (error) {
if (request === sequence.current) showError(error);
} finally {
if (request === sequence.current) setLoading(false);
}
};
useEffect(() => {
void load();
return () => {
sequence.current += 1;
};
}, [login]);
const perform = async (action: Action) => {
if (!status || busy) return;
sequence.current += 1;
setConfirm(null);
setBusy(action);
setMessage('');
try {
if (action === 'upload') {
setStatus(
await cloudSyncUpload(status.revision, {
locale: getLocalePreference(),
fontScale: readFontScale(),
}),
);
} else if (action === 'restore') {
const restored = await cloudSyncRestore();
setStatus(restored.status);
await refresh();
const locale = restored.uiPreferences.locale;
if (locale === 'system' || SUPPORTED_LOCALES.some((item) => item === locale)) {
await setLocalePreference(locale as SupportedLocale | 'system');
}
const scale = restored.uiPreferences.fontScale;
if (scale === 'small' || scale === 'medium' || scale === 'large')
setFontScale(scale as FontScaleId);
} else {
setStatus(await cloudSyncDelete(status.revision));
}
setFailed(false);
setMessage(t(`cloudSync.${action}Success`));
} catch (error) {
showError(error);
} finally {
setBusy(null);
}
};
return (
<Card>
<section className="ol-cloud-sync" aria-labelledby="cloud-sync-title">
<header>
<span className="ol-cloud-sync-icon">
<Icon name="cloud" size={21} />
</span>
<div>
<h3 id="cloud-sync-title">{t('cloudSync.title')}</h3>
<p>{t('cloudSync.description')}</p>
</div>
<button
type="button"
className="ol-settings-back"
onClick={() => void load()}
disabled={loading || busy !== null}
aria-label={t('cloudSync.refresh')}
title={t('cloudSync.refresh')}
>
<Icon name="refresh" size={16} />
</button>
</header>
{loading ? (
<p role="status">{t('cloudSync.loading')}</p>
) : signedIn === false ? (
<Btn variant="primary" icon="user" onClick={() => setShowLogin(true)}>
{t('cloudSync.signIn')}
</Btn>
) : signedIn ? (
<>
<div className="ol-cloud-sync-account">
<Icon name="user" size={16} />
<span>{t('cloudSync.account')}</span>
<strong dir="auto">{login ? `@${login}` : 'GitHub'}</strong>
</div>
{status && (
<div className="ol-cloud-sync-backup">
<strong>
{t(status.hasSnapshot ? 'cloudSync.available' : 'cloudSync.noBackup')}
</strong>
{status.hasSnapshot && <p>{t('cloudSync.summary', status.counts)}</p>}
{status.updatedAt && (
<small>
{t('cloudSync.updated', {
time: new Date(status.updatedAt).toLocaleString(
i18n.resolvedLanguage ?? i18n.language,
),
})}
</small>
)}
</div>
)}
<div className="ol-cloud-sync-actions">
<Btn
variant="primary"
icon="upload"
disabled={!status || busy !== null}
onClick={() => void perform('upload')}
>
{t('cloudSync.upload')}
</Btn>
<Btn
icon="download"
disabled={!status?.hasSnapshot || busy !== null}
onClick={() => setConfirm('restore')}
>
{t('cloudSync.restore')}
</Btn>
<button
type="button"
className="ol-vocab-delete-selected"
disabled={!status?.hasSnapshot || busy !== null}
onClick={() => setConfirm('delete')}
>
<Icon name="trash" size={14} />
{t('cloudSync.delete')}
</button>
</div>
</>
) : null}
{busy && <p role="status">{t('cloudSync.working')}</p>}
{message && (
<p
role={failed ? 'alert' : 'status'}
className={failed ? 'ol-cloud-sync-error' : undefined}
>
{message}
</p>
)}
<p className="ol-cloud-sync-scope">{t('cloudSync.scope')}</p>
</section>
{showLogin && (
<GithubLoginModal
onClose={() => setShowLogin(false)}
onSuccess={(nextLogin) => {
setShowLogin(false);
void updatePrefs((current) => ({ ...current, marketplaceDevLogin: nextLogin }))
.then(load)
.catch(showError);
}}
/>
)}
{confirm && (
<CloudSyncConfirmation
action={confirm}
onCancel={() => setConfirm(null)}
onConfirm={() => void perform(confirm)}
/>
)}
</Card>
);
}
function CloudSyncConfirmation({
action,
onCancel,
onConfirm,
}: {
action: 'restore' | 'delete';
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const dialog = useRef<HTMLDivElement>(null);
const opener = useRef(document.activeElement);
useEffect(() => {
const previous = opener.current;
const background =
previous instanceof HTMLElement ? previous.closest<HTMLElement>('[role="dialog"]') : null;
const wasInert = background?.inert ?? false;
dialog.current?.querySelector('button')?.focus();
if (background) background.inert = true;
return () => {
if (background) background.inert = wasInert;
if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
};
}, []);
return (
<Modal zIndex={100} width="min(440px, 100%)" onClose={onCancel}>
<div
ref={dialog}
role="alertdialog"
aria-modal="true"
aria-labelledby="cloud-confirm-title"
aria-describedby="cloud-confirm-desc"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
}
if (event.key === 'Tab') {
const buttons = dialog.current?.querySelectorAll('button');
if (!buttons?.length) return;
if (event.shiftKey && document.activeElement === buttons[0]) {
event.preventDefault();
buttons[buttons.length - 1].focus();
} else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) {
event.preventDefault();
buttons[0].focus();
}
}
}}
>
<h3 id="cloud-confirm-title" style={{ margin: 0 }}>
{t(`cloudSync.${action}Title`)}
</h3>
<p id="cloud-confirm-desc" style={{ color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
{t(`cloudSync.${action}Description`)}
</p>
<div className="ol-cloud-sync-actions">
<Btn onClick={onCancel}>{t('common.cancel')}</Btn>
<Btn variant="primary" onClick={onConfirm}>
{t(action === 'restore' ? 'cloudSync.confirmRestore' : 'cloudSync.confirmDelete')}
</Btn>
</div>
</div>
</Modal>
);
}
@@ -179,7 +179,9 @@ export function DebugToolsSection() {
{probeResult.window && (
<div style={{ marginTop: 4, whiteSpace: 'pre-wrap' }}>
{probeResult.window.text.slice(0, probeResult.window.cursor)}
<span style={{ color: 'var(--ol-blue)', fontWeight: 700 }}>⟦光标⟧</span>
<span style={{ color: 'var(--ol-blue)', fontWeight: 700 }}>
⟦{t('settings.debug.cursorLabel')}⟧
</span>
{probeResult.window.text.slice(probeResult.window.cursor)}
</div>
)}
@@ -1,13 +1,10 @@
// 语言切换面板:跟随系统 / 简中 / 繁中 / 英文 / 日文 (Beta) / 韩文 (Beta)。
// 切换语言同时把对应的 outputPrefs(中文偏好、输出语言)合并进 prefs。
// 界面语言单独持久化;不覆盖工作语言或翻译目标。
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import {
FOLLOW_SYSTEM,
getLocalePreference,
outputPrefsForLocale,
setLocalePreference,
type SupportedLocale,
} from '../../i18n';
@@ -16,10 +13,11 @@ import { Card } from '../_atoms';
import { SettingRow } from './shared';
export function LanguageSection() {
const { t } = useTranslation();
const { updatePrefs } = useHotkeySettings();
const { t, i18n } = useTranslation();
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
useEffect(() => setPref(getLocalePreference()), [i18n.resolvedLanguage]);
const options = useMemo(
() => [
{ value: FOLLOW_SYSTEM, label: t('settings.language.followSystem') },
@@ -28,23 +26,17 @@ export function LanguageSection() {
{ value: 'en', label: t('settings.language.en') },
{ value: 'ja', label: t('settings.language.ja') },
{ value: 'ko', label: t('settings.language.ko') },
{ value: 'es', label: t('settings.language.es') },
{ value: 'fr', label: t('settings.language.fr') },
{ value: 'de', label: t('settings.language.de') },
],
[t],
);
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
setPref(next);
const resolved = await setLocalePreference(next);
const localePrefs = outputPrefsForLocale(resolved);
await updatePrefs((current) => {
if (
current.chineseScriptPreference === localePrefs.chineseScriptPreference &&
current.outputLanguagePreference === localePrefs.outputLanguagePreference
) {
return current;
}
return { ...current, ...localePrefs };
});
// Interface language is independent from speech recognition and translation targets.
await setLocalePreference(next);
};
return (
@@ -1,41 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SettingRow, Toggle } from './shared';
export function LayoutSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
if (!prefs) return null;
return (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
{t('settings.layout.title')}
</div>
<SettingRow
label={t('settings.theme.stackedRowLayoutLabel')}
desc={t('settings.theme.stackedRowLayoutDesc')}
>
<Toggle
on={prefs.stackedRowLayout === true}
onToggle={(next) =>
void updatePrefs((current) => ({ ...current, stackedRowLayout: next }))
}
/>
</SettingRow>
<SettingRow
label={t('settings.theme.conservativeLayoutLabel')}
desc={t('settings.theme.conservativeLayoutDesc')}
>
<Toggle
on={prefs.conservativeLayout === true}
onToggle={(next) =>
void updatePrefs((current) => ({ ...current, conservativeLayout: next }))
}
/>
</SettingRow>
</Card>
);
}
@@ -1,5 +1,5 @@
// 权限/连通性面板:麦克风 / 辅助功能 / 全局热键 / Windows IME / 网络。
// 内含三个状态 Pill + 适配器名称翻译辅助函数。
// Normal states use localized labels; failed native checks retain their diagnostic details.
import { useEffect, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
@@ -231,7 +231,7 @@ export function PermissionsSection() {
{platformCaps?.supportsDesktopHotkey === true && (
<SettingRow label={t('settings.permissions.hotkeyLabel')}>
<div style={permissionActionsStyle}>
{hotkey?.message && (
{hotkey?.state === 'failed' && hotkey.message && (
<span
style={{
fontSize: 11.5,
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CapsuleStylePreview } from '../../components/TypelessCapsule';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { playRecordStartCue } from '../../lib/audioCue';
import { defaultDictationHotkey } from '../../lib/hotkey';
@@ -428,19 +429,20 @@ export function RecordingInputSection() {
)}
{os !== 'linux' && !isAndroid && (
<SettingRow label={t('settings.recording.capsuleStyleLabel')}>
<SelectLite
value={prefs.capsuleStyle ?? 'siri'}
onChange={(next) => savePrefs({ ...prefs, capsuleStyle: next as CapsuleStyle })}
options={[
{ value: 'siri', label: t('settings.recording.capsuleStyleSiri') },
{ value: 'classic', label: t('settings.recording.capsuleStyleClassic') },
]}
ariaLabel={t('settings.recording.capsuleStyleLabel')}
// 与麦克风/语言等下拉同款视觉:走 SelectLite 默认触发器底色
//(--ol-select-trigger-bg),而不是 inputStyle 的 surface-2;宽度
// 220 与同区粘贴快捷键行、语言区保持一致(minWidth 200 防缩窄)。
style={{ maxWidth: 220, minWidth: 200 }}
/>
<div style={{ minWidth: 0 }}>
<SelectLite
value={prefs.capsuleStyle ?? 'siri'}
onChange={(next) => savePrefs({ ...prefs, capsuleStyle: next as CapsuleStyle })}
options={[
{ value: 'siri', label: t('settings.recording.capsuleStyleSiri') },
{ value: 'classic', label: t('settings.recording.capsuleStyleClassic') },
{ value: 'typeless', label: t('settings.recording.capsuleStyleTypeless') },
]}
ariaLabel={t('settings.recording.capsuleStyleLabel')}
style={{ maxWidth: 220, minWidth: 200 }}
/>
<CapsuleStylePreview style={prefs.capsuleStyle ?? 'siri'} />
</div>
</SettingRow>
)}
<SettingRow
@@ -26,6 +26,7 @@ import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SettingRow } from './shared';
import { detectOS } from '../../components/WindowChrome';
import { getStylePackPresentation } from '../../lib/stylePackPresentation';
export function ShortcutsSection() {
const { t } = useTranslation();
@@ -60,14 +61,15 @@ export function ShortcutsSection() {
const stylePackHotkeys: StylePackHotkey[] = prefs.stylePackHotkeys ?? [];
// 下拉列出全部风格包(含停用的,激活时后端自动启用);已被其它行绑定的包置灰防重复。
const stylePackOptions = (currentPackId: string) =>
stylePacks.map((pack) => ({
value: pack.id,
label: pack.enabled
? pack.name
: `${pack.name}${t('settings.shortcuts.stylePackDisabledSuffix')}`,
disabled:
pack.id !== currentPackId && stylePackHotkeys.some((entry) => entry.packId === pack.id),
}));
stylePacks.map((pack) => {
const { name } = getStylePackPresentation(pack, t);
return {
value: pack.id,
label: pack.enabled ? name : `${name}${t('settings.shortcuts.stylePackDisabledSuffix')}`,
disabled:
pack.id !== currentPackId && stylePackHotkeys.some((entry) => entry.packId === pack.id),
};
});
// 整表替换:失败时统一在本区域显示,并继续抛给 ShortcutRecorder 结束录制状态。
const saveStylePackHotkeys = async (next: StylePackHotkey[]) => {
setStylePackError(null);
+2 -2
View File
@@ -10,12 +10,12 @@ import { ShortcutsSection } from './ShortcutsSection';
import { SelectionWorkspaceSection } from './SelectionWorkspaceSection';
import { LanguageSection } from './LanguageSection';
import { ThemeSection } from './ThemeSection';
import { LayoutSection } from './LayoutSection';
import { ProvidersSection } from './ChannelList';
import { NetworkSection } from './NetworkSection';
import { MarketplaceSection } from './MarketplaceSection';
import { PermissionsSection } from './PermissionsSection';
import { DataStorageSection } from './DataStorageSection';
import { CloudSyncSection } from './CloudSyncSection';
import { LocalModelSection } from './LocalModelSection';
import { DebugToolsSection } from './DebugToolsSection';
import { MultimodalPipelineSection } from './MultimodalPipelineSection';
@@ -75,7 +75,6 @@ export function AppearanceTab() {
return (
<>
<ThemeSection />
<LayoutSection />
<LanguageSection />
</>
);
@@ -219,6 +218,7 @@ export function PrivacyTab() {
</div>
<PermissionsSection />
<DataStorageSection />
<CloudSyncSection />
</>
);
}
@@ -10,7 +10,6 @@ import {
} from 'react';
import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc';
import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types';
import i18n, { outputPrefsForLocale, type SupportedLocale } from '../i18n';
import { applyThemeFromPreference } from '../lib/themeMode';
import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout';
import { applyConservativeLayout } from '../lib/conservativeLayout';
@@ -129,35 +128,6 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
latestPrefsRef.current = prefs;
}, [prefs]);
useEffect(() => {
const currentPrefs = latestPrefsRef.current;
if (!currentPrefs) return;
const lang = (i18n.resolvedLanguage || i18n.language || '').toLowerCase();
const resolvedLocale: SupportedLocale =
lang.startsWith('zh-tw') || lang.includes('hant')
? 'zh-TW'
: lang.startsWith('zh-cn') || lang.startsWith('zh')
? 'zh-CN'
: lang.startsWith('ja')
? 'ja'
: lang.startsWith('ko')
? 'ko'
: 'en';
const nextLocalePrefs = outputPrefsForLocale(resolvedLocale);
if (
currentPrefs.chineseScriptPreference === nextLocalePrefs.chineseScriptPreference &&
currentPrefs.outputLanguagePreference === nextLocalePrefs.outputLanguagePreference
) {
return;
}
const merged = { ...currentPrefs, ...nextLocalePrefs };
latestPrefsRef.current = merged;
setPrefs(merged);
void queueSetSettings(merged).catch((error) => {
console.warn('[settings] sync locale output preferences failed', error);
});
}, [prefs, queueSetSettings]);
const updatePrefs = useCallback(
async (next: UserPreferences | ((current: UserPreferences) => UserPreferences)) => {
const current = latestPrefsRef.current;
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more