diff --git a/docs/architecture.md b/docs/architecture.md index 82e14e8c..5e32e5b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/` 执行,按变更范围选择: diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md new file mode 100644 index 00000000..84705b6c --- /dev/null +++ b/docs/cloud-sync.md @@ -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 登录和安装包内的设备交互需要另按平台验收;本机假服务测试不代表生产服务已部署。 diff --git a/docs/index.md b/docs/index.md index 3dbf7cdf..7d0738a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ ## 接口契约 +- [官方云同步](cloud-sync.md):可同步字段、GitHub 身份边界、版本冲突、本地恢复与验证。 - [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。 ## 平台与运营 diff --git a/docs/structure.md b/docs/structure.md index e7a39fc7..c1a81a11 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -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 的事实来源 | diff --git a/openless-all/app/contract/language-catalog.json b/openless-all/app/contract/language-catalog.json new file mode 100644 index 00000000..cb76693e --- /dev/null +++ b/openless-all/app/contract/language-catalog.json @@ -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" } +] diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 68b88c63..dc6e7385 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1830,7 +1830,8 @@ pub struct OpenLessBackend { style_pack_revision: Arc, preferences: Arc, preferences_revision: Arc, - settings_write_gate: Mutex<()>, + settings_write_gate: Arc>, + cloud_sync: Option, pending_corrections: Arc>>, edit_observation_generation: Arc, text_insertions: Arc>>, @@ -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 { + self.cloud_sync_service()?.status().await + } + + pub async fn cloud_sync_upload( + &self, + base_revision: u64, + ui_preferences: crate::CloudSyncUiPreferences, + ) -> Result { + 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 { + 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 { + 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>>, started: Arc, @@ -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] diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 67b4640b..1fa42479 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -798,6 +798,63 @@ impl TextPolisher for SharedCloudTextPolisher { } } +struct PolishStreamPublisher { + sink: Arc, + cancellation: Arc, + state: Mutex, +} + +#[derive(Default)] +struct PolishStreamState { + translation: Option, + offset: u64, + error: Option, +} + +impl PolishStreamPublisher { + fn new( + sink: Arc, + cancellation: Arc, + translation: Option, + ) -> 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 { + self.state.lock().error.take() + } +} + async fn run_cloud_polish( provider: CloudPolisherProvider, context: Arc, @@ -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>> = 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>, + 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 { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs new file mode 100644 index 00000000..9f3c25db --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_scale: Option, +} + +#[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, + 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, + repositories: BackendRepositories, + settings_gate: Arc>, + operation: tokio::sync::Mutex<()>, +} + +impl CloudSyncService { + pub(crate) fn new( + marketplace: Arc, + repositories: BackendRepositories, + settings_gate: Arc>, + ) -> Self { + Self { + marketplace, + repositories, + settings_gate, + operation: tokio::sync::Mutex::new(()), + } + } + + pub(crate) async fn status(&self) -> Result { + 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 { + 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 { + 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 { + 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>, + token: &SecretValue, + ) -> Result { + let url = self.marketplace.public_url("me/sync")?; + let loopback = url.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .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 { + 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 = read_or_default(dictionary_path)?; + let corrections: Vec = 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::, 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 = 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 = 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, 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, 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) -> 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 { + 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, 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(()) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs b/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs new file mode 100644 index 00000000..c369d7e8 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs @@ -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, + replacement_created: bool, + backup_created: bool, +} + +pub(crate) struct RestoreTransaction { + files: Vec, + preserve_backups: bool, +} + +impl RestoreTransaction { + pub(crate) fn prepare(writes: Vec<(PathBuf, Vec)>) -> Result { + 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(); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_types.rs new file mode 100644 index 00000000..bc080dc4 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_types.rs @@ -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, + /// `None` means no snapshot or a deleted snapshot. A deletion still has a nonzero revision. + pub payload: Option, +} + +#[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, + pub corrections: Vec, + pub style_packs: Vec, + 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, + 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, + pub version: String, + pub kind: SyncStylePackKind, + pub base_mode: PolishMode, + pub selection_prompt: String, + pub prompt: String, + pub examples: Vec, + pub tags: Vec, + /// Raw standard-base64 PNG, at most 64 KiB decoded. This is never a filesystem path or URL. + pub icon_png_base64: Option, + pub created_at: Option, + pub updated_at: Option, + pub enabled: bool, + pub recommended_model: Option, + pub compatible_app_version: Option, + pub origin_pack_id: Option, + pub origin_author_login: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncStylePackExample { + pub title: Option, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub capsule_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_modes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_style_pack_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_polish_style_pack_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_languages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub translation_target_language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chinese_script_preference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_language_preference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub qa_save_history: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub show_capsule: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_cue_on_record: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mute_during_recording: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub silence_auto_stop_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub silence_auto_stop_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub show_overview_activity_heatmap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_thinking_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_scale: Option, +} + +#[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, +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_validation.rs b/openless-all/app/crates/openless-core/src/cloud_sync_validation.rs new file mode 100644 index 00000000..cdf3b4f8 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_validation.rs @@ -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) -> 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) +} diff --git a/openless-all/app/crates/openless-core/src/correction.rs b/openless-all/app/crates/openless-core/src/correction.rs index 2596bf05..910528aa 100644 --- a/openless-all/app/crates/openless-core/src/correction.rs +++ b/openless-all/app/crates/openless-core/src/correction.rs @@ -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, diff --git a/openless-all/app/crates/openless-core/src/language_catalog.rs b/openless-all/app/crates/openless-core/src/language_catalog.rs new file mode 100644 index 00000000..6183ff2e --- /dev/null +++ b/openless-all/app/crates/openless-core/src/language_catalog.rs @@ -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, +} + +static LANGUAGES: Lazy> = 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 { + language(native_name).map(|language| language.asr_code.clone()) +} + +pub fn apple_speech_locale(native_name: &str) -> Option { + 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); + } +} diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 61e317df..d4d89dd8 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -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(()) diff --git a/openless-all/app/crates/openless-core/src/llm_gemini.rs b/openless-all/app/crates/openless-core/src/llm_gemini.rs index e1707a3f..76e88af7 100644 --- a/openless-all/app/crates/openless-core/src/llm_gemini.rs +++ b/openless-all/app/crates/openless-core/src/llm_gemini.rs @@ -164,6 +164,74 @@ impl GeminiProvider { Ok(clean_polish_output(&raw)) } + pub async fn polish_streaming( + &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 + 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( + &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 + 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 层统一完成); diff --git a/openless-all/app/crates/openless-core/src/marketplace.rs b/openless-all/app/crates/openless-core/src/marketplace.rs index c5378d44..6763bdde 100644 --- a/openless-all/app/crates/openless-core/src/marketplace.rs +++ b/openless-all/app/crates/openless-core/src/marketplace.rs @@ -255,7 +255,7 @@ impl MarketplaceService { }) } - fn public_url(&self, path: &str) -> Result { + pub(crate) fn public_url(&self, path: &str) -> Result { 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 { + pub(crate) async fn read_access_token(&self) -> Result { 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 }); } diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 0a0a8476..ce823eea 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -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( &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( + &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 + 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( &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( + &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 + 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 { - 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 { - 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( @@ -1266,6 +1340,75 @@ impl CodexOAuthLLMProvider { .await } + pub async fn polish_streaming( + &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 + 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( + &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 + 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( &self, messages: Vec, @@ -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); } diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 9691fb49..35a4b815 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -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"))?; diff --git a/openless-all/app/crates/openless-core/src/prompt_compose.rs b/openless-all/app/crates/openless-core/src/prompt_compose.rs index 2463dcaa..da92de8d 100644 --- a/openless-all/app/crates/openless-core/src/prompt_compose.rs +++ b/openless-all/app/crates/openless-core/src/prompt_compose.rs @@ -70,6 +70,66 @@ pub fn split_polish_translate_output(raw: &str) -> Option<(Option, 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()); + } +} diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index e182797a..ced46716 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -804,24 +804,7 @@ pub fn whisper_supports_verbose_json(provider_id: &str, advanced: AdvancedAsrCon } pub fn zenmux_language_code(native_name: &str) -> Option { - 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 { diff --git a/openless-all/app/crates/openless-core/src/remote_input_service.rs b/openless-all/app/crates/openless-core/src/remote_input_service.rs index b7e7257f..da98b3bf 100644 --- a/openless-all/app/crates/openless-core/src/remote_input_service.rs +++ b/openless-all/app/crates/openless-core/src/remote_input_service.rs @@ -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; diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index c80c57c1..7d541914 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -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] diff --git a/openless-all/app/crates/openless-core/src/streaming_insert.rs b/openless-all/app/crates/openless-core/src/streaming_insert.rs index ceac41bb..c6979b12 100644 --- a/openless-all/app/crates/openless-core/src/streaming_insert.rs +++ b/openless-all/app/crates/openless-core/src/streaming_insert.rs @@ -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)); } } diff --git a/openless-all/app/crates/openless-core/src/style_pack_store.rs b/openless-all/app/crates/openless-core/src/style_pack_store.rs index f1f14b01..5644b344 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_store.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_store.rs @@ -95,6 +95,20 @@ impl StylePackStore { Ok(self.lock()?.clone()) } + pub(crate) fn cloud_sync_access( + &self, + ) -> Result<(std::sync::MutexGuard<'_, Vec>, &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, 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, 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, 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) { + reconcile_builtin_packs(packs); + ensure_at_least_one_enabled(packs); + sort_packs(packs); +} + pub fn migrate_style_packs_from_preferences( packs: &mut Vec, preferences: &UserPreferences, diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index 6c2dcf05..2066964a 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -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, diff --git a/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs new file mode 100644 index 00000000..a5b90f7a --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs @@ -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, + response_status: Option, + requests: Vec, +} + +struct Server { + url: String, + state: Arc>, + 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::().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, +} + +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>> { + [ + "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); +} diff --git a/openless-all/app/crates/openless-core/tests/remote_input_contract.rs b/openless-all/app/crates/openless-core/tests/remote_input_contract.rs index 104ebb5d..86fd2a8f 100644 --- a/openless-all/app/crates/openless-core/tests/remote_input_contract.rs +++ b/openless-all/app/crates/openless-core/tests/remote_input_contract.rs @@ -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!( diff --git a/openless-all/app/scripts/locale-format.test.mjs b/openless-all/app/scripts/locale-format.test.mjs new file mode 100644 index 00000000..48af37b0 --- /dev/null +++ b/openless-all/app/scripts/locale-format.test.mjs @@ -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', +); diff --git a/openless-all/app/scripts/locale-lifecycle.test.mjs b/openless-all/app/scripts/locale-lifecycle.test.mjs new file mode 100644 index 00000000..c93f3533 --- /dev/null +++ b/openless-all/app/scripts/locale-lifecycle.test.mjs @@ -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'); +} diff --git a/openless-all/app/scripts/remote-input-locales.test.mjs b/openless-all/app/scripts/remote-input-locales.test.mjs new file mode 100644 index 00000000..640d29f0 --- /dev/null +++ b/openless-all/app/scripts/remote-input-locales.test.mjs @@ -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'); diff --git a/openless-all/app/scripts/style-pack-localization.test.mjs b/openless-all/app/scripts/style-pack-localization.test.mjs new file mode 100644 index 00000000..38924c6c --- /dev/null +++ b/openless-all/app/scripts/style-pack-localization.test.mjs @@ -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); diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 1c84dd16..9e6bdb3e 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -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* 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( diff --git a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs index 7b557022..5720337d 100644 --- a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs @@ -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:]`。构造失败返回 None(调用方回退系统默认)。 +/// `[NSLocale localeWithLocaleIdentifier:]`。构造失败返回 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 { - 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:]`。 diff --git a/openless-all/app/src-tauri/src/commands/cloud_sync.rs b/openless-all/app/src-tauri/src/commands/cloud_sync.rs new file mode 100644 index 00000000..29053b77 --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/cloud_sync.rs @@ -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 { + core.cloud_sync_status().await +} + +#[tauri::command] +pub async fn cloud_sync_upload( + core: CoreState<'_>, + base_revision: u64, + ui_preferences: CloudSyncUiPreferences, +) -> Result { + core.cloud_sync_upload(base_revision, ui_preferences).await +} + +#[tauri::command] +pub async fn cloud_sync_restore( + core: CoreState<'_>, +) -> Result { + core.cloud_sync_restore().await +} + +#[tauri::command] +pub async fn cloud_sync_delete( + core: CoreState<'_>, + base_revision: u64, +) -> Result { + core.cloud_sync_delete(base_revision).await +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 9d0ce9f5..04f3f2d8 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -37,7 +37,9 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet 'static, Result, > { - 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> { - 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, > { 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( fn credentials_status( preferences: UserPreferences, model_store: Option<&openless_core::ModelStore>, -) -> CredentialsStatus { +) -> Result { 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( diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 7819c23d..fb485e92 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6727f108..c365e34b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -344,8 +344,7 @@ fn hide_insert_fallback_card(inner: &Arc) { // 卡片释放后把最新状态一次性应用回来;若最新是 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 diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index c9d14647..4ccdfc1d 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -401,9 +401,6 @@ fn emit_capsule_payload_locked(inner: &Arc, 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, 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); } }); diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b5e65b34..de649537 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -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), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c5bb0fa2..cbf955ba 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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( 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 { } } -/// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 -/// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。 +/// 初始化和卡片归还路径使用默认舞台;录音显示路径显式传入当前样式。 pub(crate) fn position_capsule_bottom_center( window: &tauri::WebviewWindow, 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( + window: &tauri::WebviewWindow, + _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( 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( { 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( 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] diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 30488148..dca42a50 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -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> = OnceLock::new(); @@ -85,26 +87,9 @@ fn android_marketplace_legacy_scrubbed() -> &'static Mutex { 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>> = OnceLock::new(); fn credentials_cache() -> &'static Mutex> { @@ -825,7 +810,7 @@ fn credentials_path() -> Result { } } -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "macos")))] fn keyring_entry() -> Result { 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, chunks: usize, } -/// 旧版(generation=Some):`credentials.v1.chunk..{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 { 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> { - 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::(&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>, + mut write: impl FnMut(&str, &str) -> Result<()>, + consolidate: bool, +) -> Result> { + 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 { + // 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 { - 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 { - 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 { 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>, ) -> 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 { @@ -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, } +/// 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, +) -> Result { + 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 { 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 { 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> { 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> { 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> { 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 { + 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()); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index 40ee20f1..392a16da 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -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']; diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index b12d279f..05995a70 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -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>, + last_payload: Mutex>, + 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 { #[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"))] diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index b3dd51ce..6c3a7b8d 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -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::>() { + coordinator.sync_capsule_style_from_preferences(); + } let _ = app.emit("prefs:changed", &preferences); } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index e2e639c6..d5e0d362 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -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 }, diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index aa5befcd..36f0ecb3 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -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(preview.translation); const [selectionPolish, setSelectionPolish] = useState(preview.selectionPolish); - // 胶囊样式(siri / classic):随 capsule:state payload 下发;设置里切换后还会经 - // prefs:changed 广播即时到达(见下方监听),无需等下一次录音。 + // 偏好事件即时换肤;录音状态携带同一个样式,保证首次显示也能正确呈现。 const [capsuleStyle, setCapsuleStyle] = useState(preview.style); + const stylePreferenceReadyRef = useRef(false); const isClassic = capsuleStyle === 'classic'; + const isTypeless = capsuleStyle === 'typeless'; // 预备态:麦克风尚未吐第一帧 PCM。true 时录音光条走「待命」呼吸形态(见 SiriGL warming)。 const [warming, setWarming] = useState(preview.warming); // 预备→就绪耗时的移动平均,驱动光条展开动画的预测节奏(见 SiriGL warmProgress)。 @@ -814,19 +814,15 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { ); const [lastVisibleMessage, setLastVisibleMessage] = useState(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([]); // 落字失败兜底卡片。与词条卡片同一套路:独立事件通道,不进会话状态机。 const [insertFallback, setInsertFallback] = useState(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 ? ( + ) : ( <> {/* "正在翻译" 徽章 — 嵌套两层: @@ -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', // 隐藏:从光条附近偏下出发;显示:归位到光条上方。 diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css new file mode 100644 index 00000000..8b4c97eb --- /dev/null +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -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; + } +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 24c978ad..a5e99644 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -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, 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', diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index c4cc3e48..be57cbfe 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -41,7 +41,7 @@ import { Plus, RefreshCw, Search, - Settings2, + Settings, ShieldCheck, SlidersHorizontal, Sparkles, @@ -63,7 +63,7 @@ export const ICONS: Record = { 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" diff --git a/openless-all/app/src/components/SavedToast.tsx b/openless-all/app/src/components/SavedToast.tsx index 4f301c92..33541271 100644 --- a/openless-all/app/src/components/SavedToast.tsx +++ b/openless-all/app/src/components/SavedToast.tsx @@ -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, diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 34621c3d..fb1b1b02 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -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(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({ >