diff --git a/.agents/run-parity-agents.mjs b/.agents/run-parity-agents.mjs new file mode 100644 index 000000000..bf61984dd --- /dev/null +++ b/.agents/run-parity-agents.mjs @@ -0,0 +1,129 @@ +// One-off orchestration script for the Linux egui 2.0 parity rewrites. +// Loaded by the pi-subagents workflow sandbox via workflowScriptPath. + +const rules = [ + '# 项目背景', + '仓库:/opt/openless-src/openless-all/app (Rust + egui 0.33 + eframe)。', + 'Linux 端 UI 正在从旧版 egui 迁到与 Tauri 版一致的设计。**唯一设计来源是 Tauri/React 源码**:src/pages/*.tsx、src/pages/settings/*.tsx(含中文文案与 i18n key)。', + '', + '# egui 前端架构(务必先读这些文件当范式)', + '- linux-egui/src/ui/frontend/ 一个页面一个模块,mod.rs 里 render() 路由。', + '- 页面是**纯渲染**:只读 FrontendViewModel,点击时往 actions: &mut Vec push 意图,由 main.rs 的 apply_frontend_actions 处理。不要在页面里改业务状态(输入框本地缓冲除外)。', + '- 先读 overview.rs 与 history.rs,照它们的风格写:显式 rect + painter + layout:: 共享控件。', + '- 共享控件在 ui/frontend/layout.rs:page_header / card / paint_card / section_title / segmented / segmented_width / action_button(ButtonKind) / toggle / pill_size / paint_pill(PillTone) / text_width / text_galley / fixed_ui / card_salt / soft_separator / unsupported_page。', + '- 颜色与圆角用 ui/theme.rs:SURFACE SURFACE_2 CANVAS LINE INK INK_2 INK_3 INK_4 BLUE BLUE_SOFT OK。卡片圆角 14,内边距 18~20,行高 32~36。', + '', + '# 硬性规则', + '1. **禁止写死任何用户可见的中文或其它语言字符串**。一律 tr_l10n(lang, "key") / fmt_l10n(lang, "key", &[&arg])(来自 openless_linux_egui)。key 用 snake_case,尽量对应 Tauri 的 camelCase key(Tauri vocab.searchPlaceholder 对应 vocab.search_placeholder),这样我能用脚本从 src/i18n/*.ts 自动补齐 5 种语言。', + '2. **不要修改 linux-egui/src/i18n.rs**,不要运行 scripts/sync-egui-i18n.mjs(我统一做)。', + '3. **只改任务里列出的文件**(多个代理并行,改别人的文件会冲突)。', + '4. tests/localization_contract.rs 禁止新增裸中文字面量(注释可以,字符串不行)。', + '5. 编译测试用共享 target 省时间:所有 cargo 命令前加环境变量 CARGO_TARGET_DIR=/data-win/openless-cache/app-target 。', + '6. 完成前必须跑:cargo fmt,然后 CARGO_TARGET_DIR=... cargo test --bin openless-linux-egui(要求全绿)。', + '7. 结论必须包含:① 改了哪些文件 ② 引用到的、Tauri 目录里可能不存在的 i18n key(我手工补译文)③ 需要但 VM 里缺失的字段或动作(不要自己乱加,列出来)④ 测试结果。', + '', +].join('\n'); + +const settingsTask = rules + [ + '# 你的任务:重写设置弹窗的内容', + '改动文件(只能改这三个):', + '- linux-egui/src/ui/frontend/settings.rs', + '- linux-egui/src/ui/frontend/view_model.rs (只加设置相关的字段和动作)', + '- linux-egui/src/main.rs (只加这些动作的处理,以及把 Core 数据塞进 VM)', + '', + '现状:弹窗左栏我已经改成 Tauri 的样子(顶部搜索框 查找设置分类…、7 个分区顺序 录音与输入 / 快捷键与选区 / AI 服务与模型 / 外观与语言 / 权限与数据 / 实验与扩展 / 关于与更新、底部 帮助中心 和 发布日志、内容区右上 修改后自动保存 加 ×)。**左栏保留,别推倒重来。**', + '', + '要做的是**每个分区的内容卡片对齐 Tauri 的 settings 页面**(读 src/pages/settings/tabs.tsx 看每个 tab 由哪些 Section 组成,再读对应 Section.tsx 的卡片标题、描述、行):', + '1. 录音与输入 = RecordingInputSection + RemoteInputSection', + '2. 快捷键与选区 = ShortcutsSection + SelectionWorkspaceSection', + '3. AI 服务与模型 = ServicesTab(语言模型 / 语音识别 / 本地模型 / 连接与扩展 子标签 + 渠道列表 + + 添加渠道;渠道行显示 名称 + 当前使用标记 + model + 上次验证 + 验证 / 启用开关 / 编辑)', + '4. 外观与语言 = ThemeSection + LanguageSection', + '5. 权限与数据 = PermissionsSection + DataStorageSection', + '6. 实验与扩展 = 多模态管线 / 流式输入 / Less Computer / Claude 控制台 / Beta 渠道 / 调试工具', + '7. 关于与更新 = AboutSection + AutoUpdateSection', + '', + '优先级(时间不够按此顺序保):', + '(a) 先把 7 个分区的卡片标题、描述、行标签全部按 Tauri 文案对齐,每行都接真实 prefs 读写;', + '(b) 再做 AI 服务与模型的渠道列表:main.rs 里旧的 shell UI 已有完整的 Core 渠道面板接线(搜 provider_management_ui / ProviderPanel / load_provider_panel / list_channels / ChannelSummary / ProviderDescriptor),把它复用到 VM 和弹窗里(列表 + 启用开关 + 验证 + 打开编辑框可简化为最小可用版本),+ 添加渠道 至少能创建或打开编辑器;', + '(c) 做完再加主题行、自动更新渠道等。', + '', + '可用的 Core Preferences 字段(openless_core::shared_types::Preferences,已在 main.rs 的 self.preferences 里):', + 'launch_at_login, mute_during_recording, audio_cue_on_record, silence_auto_stop_enabled, microphone_device_name, multimodal_pipeline_enabled, use_system_proxy, remote_input_enabled/port/default_mode, history_retention_days, streaming_insert, streaming_insert_save_clipboard, auto_update_check, record_audio_for_debug, translation_target_language, theme_mode, locale。', + '参考已存在的 VM:view_model.rs 里的 SettingsFields / SettingsField / SettingsComboField / SettingsTextField / SettingsActionField。', + '', + '设置弹窗渲染在 settings.rs 的 settings_overlay()(Backdrop + Tooltip 层 + rail + panel)。panel() 已把 section 分发到 general/shortcuts/appearance/services/privacy/advanced/about 七个函数。', +].join('\n'); + +const styleTask = rules + [ + '# 你的任务:重写 润色模式 页,并微调 划词追问 版式', + '改动文件(只能改这四个):', + '- 新建 linux-egui/src/ui/frontend/style.rs (新的风格页)', + '- linux-egui/src/ui/frontend/mod.rs (加 pub mod style; 并把 Page::Style 路由到 style::page)', + '- linux-egui/src/ui/frontend/pages.rs (删掉 style_page / style_pack_card / new_style_pack_card / style_editor_overlay / StyleCardAction;correction_chip 保留别动)', + '- linux-egui/src/ui/frontend/selection_ask.rs (版式对齐)', + '', + '## 1) 风格页(主要工作)', + '照 src/pages/Style.tsx 移植到 style.rs。已知参考画面(我截图看过 Tauri 版):', + '- 头部:kicker 风格 + 标题 输出风格 + 描述 选择录音的默认输出风格。,右上 [刷新] [导入 ZIP](导入是蓝色主按钮)', + '- 主卡片顶部:标题 风格包 + 左侧一个 原文 小 tab(选中态蓝色),右侧一个**分段按钮组**:录音 / ASR 风格 与 选区润色(选中项蓝底白字),最右一个 N 个风格包 的灰色小徽标', + '- 风格包网格(每行 3 张卡):卡内 = 名称 + [内置] + [当前] 徽标 + 描述 + 一个模式 pill(原文 / 轻度润色 等)+ 底部三个按钮 [激活] [导出 ZIP] [编辑]', + '- **已知 BUG 必须修**:现在会有两张卡同时是激活态(淡蓝底)。激活只应由 当前默认风格 决定(VM 的 StylePack.is_active 与 style_selected,注意 style_selected == usize::MAX 表示 原文 那一项),悬停才用 hover 底色;两者不要混。', + '- 末尾一张 新建风格包 虚线卡(含 从模板开始创建自己的风格)', + '- 编辑弹窗(风格包编辑器):标题、风格描述输入、提示词多行、[保存] [恢复默认] [取消]', + '- 已有 i18n key:style.kicker / style.title / style.desc / nav.styles / style.pack.selection_tab / style.pack.dictation_tab / style.pack.current / style.pack.builtin / style.pack_count / style.new_pack_hint / style.pack.dictation_prompt_title / style.pack.new_description / style.custom_prompt_save / btn.new_style / btn.import_zip / btn.export_zip / btn.activate / btn.edit / btn.reset_builtin / head.style_pack_editor / lbl.description / lbl.style_note / overview.mode_raw / overview.mode_light / overview.mode_structured / overview.mode_formal / common.refresh / common.cancel / status.style_switched', + '可用的 VM:style_packs: Vec、style_selected: usize、style_selection_workflow: bool、style_editor_open: bool、style_prompt: String、style_notice: Option、style_unsupported: bool', + '可用动作:StyleActivate(usize)、StyleExport(usize)、StyleEdit(usize)、StyleSaveEditor(String)、StyleCloseEditor、StyleNewPack、StyleImport、Navigate(Page::)', + '', + '## 2) 划词追问版式(小改)', + '照 src/pages/SelectionAsk.tsx 加我截的 Tauri 画面:', + '- 头部右侧:先一个 已保存 小 toast(可只在有 notice 时显示),下面一行右对齐的 [快捷键设置] 按钮', + '- 使用方法 **不是卡片**,是一段普通区块:标题 使用方法 + 三列(01 打开追问浮窗 / 02 选中想了解的内容 / 03 开口说出问题),每列标题加描述,快捷键用内联 kbd 小胶囊显示(VM 的 qa_hotkey / dictation_hotkey)', + '- 下面一行:左侧 [刷新图标] 继续使用录音快捷键,即可多轮追问。,右侧 Esc 胶囊 加 关闭浮窗,结束本次对话', + '- 再下面才是 保存历史 卡片:左侧时钟图标 + 标题 保存历史 + 描述 开启后在本地保存问答记录,默认关闭。,右侧开关', + '现有 key:selection_ask.title/desc/shortcut_settings/howto_title/guide_open_title/guide_open_desc/guide_unset_desc/guide_select_title/howto_step2/guide_ask_title/guide_ask_desc/guide_followup/guide_dismiss/history_title/history_desc', +].join('\n'); + +const pagesTask = rules + [ + '# 你的任务:对齐 词典 与 翻译 页版式', + '改动文件(只能改这两个):', + '- linux-egui/src/ui/frontend/vocab.rs', + '- linux-egui/src/ui/frontend/translation.rs', + '', + '## 1) 词典(照 src/pages/Vocab.tsx 加我截的 Tauri 画面)', + '必须有的元素:', + '- 头部:kicker 词典 + 标题 词典 + 描述 添加生词或专业术语,提高识别准确率。,右上**黑色主按钮** + 新词(点击聚焦到输入框)', + '- 一张大卡片内:', + ' - 一行:左边**带图标的标签页** 所有 / 自动添加(铅笔图标)/ 手动添加(选中项浅灰底加深色文字,不是蓝色分段);右边一个 选择当前结果 勾选框 加 一个**圆形放大镜按钮**(点击展开或聚焦搜索输入框)', + ' - 空态文案(居中):还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。', + ' - 底部一行:输入框 输入词语,按 Enter 或点添加… 加 黑色 + 添加 按钮', + ' - 输入框下方灰色小字:支持中英混合 · 数字开头按字面识别 · 命中次数自动计数', + '- 词条 pills(可换行):词 + 命中数小徽标 + 点击启停 + × 删除;上方有 自动收集(N)分组 加 全部删除', + '- 下方一张 场景预设 卡片(可折叠标题 + 描述 + 预设 pills + 新建预设 + 编辑/保存)', + '现有 VM:vocab_entries: Vec、vocab_filter: usize(0/1/2)、vocab_query、vocab_input、vocab_selected_presets、vocab_editing_preset、vocab_saved_presets、vocab_preset_name/phrases、vocab_error、vocab_unsupported', + '现有动作:VocabAddPhrase(String)、VocabRemovePhrase(usize)、VocabTogglePhrase(usize)、VocabFilter(usize)、VocabSearch(String)、VocabApplyPreset(usize)、VocabCreatePreset{name,phrases}', + '现有 key:vocab.kicker/title/desc/new_word/placeholder/tip/empty/search_placeholder/search_empty/filter_all/filter_auto/filter_manual/learned_section/remove_all_learned/section_title/presets_title/presets_tip/presets_create/presets_apply/presets_save/presets_edit/presets_new_preset/presets_name_placeholder/presets_words_placeholder/presets_dev_tools/presets_products/presets_terms/presets_english、btn.add、common.cancel', + '选择当前结果的 key 可能是 vocab.select_all_visible,新词 是 vocab.new_word。', + '', + '## 2) 翻译(照 src/pages/Translation.tsx 加我截的 Tauri 画面)', + '必须有的元素:', + '- 头部:kicker 翻译 + 标题 翻译 + 描述 录音后自动翻译为目标语言再插入。', + '- 顶部一行:左 搜索语言… 输入框(宽) 加 右 已选择 N 种语言', + '- 两栏(宽屏并排、窄屏堆叠):', + ' - 左卡 工作语言 加 描述 勾选日常使用的语言,影响润色与翻译效果。,里面是**两列网格**的语言行:每行左=粗体语言名 加 下面灰色母语小字(如 阿拉伯语 加 العربية),右=方形勾选框(选中黑色打勾);没有匹配时显示 没有匹配的语言;卡片底部灰色小字 语音服务支持的语种可能不同;翻译目标不受界面语言限制。', + ' - 右卡 翻译目标语言 加 描述 加 右上 未启用/已启用 徽标;下面一个下拉 不启用(Shift 按下不触发翻译);分隔线;翻译风格 加 描述 自动继承「风格」页当前激活的风格包。 加 右侧蓝色小胶囊显示当前风格名;若目标语言与唯一工作语言相同,显示琥珀色告警条。', + '- 下方整宽卡 使用方法:编号 1~5 的步骤**排成 4 列流式**(不是一行一个),然后两条小注(翻译模式指示 / 安全兜底)。', + '现有 VM:translation_working_languages: Vec、translation_target_language、translation_query、style_packs 与 style_selected、dictation_hotkey、translation_hotkey、translation_unsupported', + '现有动作:TranslationToggleLanguage(String)、TranslationSetTarget(String)', + '现有 key:translation.kicker/title/desc/search_languages/selected_languages/no_matching_languages/language_support_hint/working_title/working_desc/target_title/target_desc/target_disabled/target_same_as_working/status_enabled/status_disabled/style_title/style_desc/howto_title/howto_step1 到 howto_step5/howto_indicator_title/howto_indicator_desc/howto_fallback_title/howto_fallback_desc、overview.mode_raw/overview.mode_light', + '语言列表目前是 translation.rs 里的 const SUPPORTED_LANGUAGES(15 个母语名),可以保留。', +].join('\n'); + +const results = await runs.all([ + { key: 'settings', agent: 'worker', task: settingsTask }, + { key: 'style', agent: 'worker', task: styleTask }, + { key: 'pages', agent: 'worker', task: pagesTask }, +]); + +return results.map(function (run) { + return { key: run.key, runId: run.runId, status: run.status, output: run.output }; +}); diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index ff1bf6d6e..b2ee9f3e7 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -1,19 +1,26 @@ name: Android APK (debug) -# Triggers: -# - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release -# - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist -# (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking) +# 统一发布语义(与 release-tauri.yml 一致):任何发布标签 `v*` 都出全平台产物, +# `-tauri` / `-egui` 只是**命名约定**,不再是「只构建哪一半」的开关。 +# - push v* tag → 签名 release APK + minisign + updater manifest → GitHub Release +# (安卓是独立 workflow,所以它与 Tauri/Linux 那次 run 并行,但资产落在**同一个 +# release** 上:release-tauri.yml 里没有 android job,也不必往里塞一套 JDK/Gradle。) +# - workflow_dispatch → 有 ANDROID_KEYSTORE_* 就出签名 release APK(覆盖安装、保数据); +# 否则出未签名 debug APK(annotated,非阻塞)—— 该 fallback 语义保持不变。 +# +# 为什么要 Android 单独一条 workflow 而不是 matrix 里的一腿:它用的是另一套工具链 +# (JDK + Android SDK + Gradle),塞进 Tauri 的 matrix 会让每一步都长满 `if:` 分支。 # # Scope: full overlay/accessibility APK for ADB testing and tag releases. on: push: tags: - - 'v*-tauri' + - 'v*' workflow_dispatch: # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 +# 这条 group 带 workflow 名,所以不会和 release-tauri.yml 的 run 互相取消。 concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} cancel-in-progress: ${{ github.event_name == 'push' }} @@ -27,9 +34,9 @@ jobs: CI: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} + OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || endsWith(github.ref_name, '-beta-egui') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # Android 不使用本地 Qwen3/Whisper C 子模块。 submodules: false @@ -48,8 +55,9 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | set -euo pipefail + # 任意发布标签都算 tag release(统一语义):不再要求 -tauri 后缀。 is_tag=false - if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then is_tag=true fi echo "is_tag_release=$is_tag" >> "$GITHUB_OUTPUT" @@ -144,7 +152,7 @@ jobs: exit "$sdkmanager_exit" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22" cache: npm @@ -377,28 +385,28 @@ jobs: echo "EOF" >> "$GITHUB_OUTPUT" - name: Upload Android APK artifact (arm64-v8a) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-arm64-v8a' || 'openless-android-debug-arm64-v8a' }} path: ${{ steps.apk.outputs.arm64_v8a_path }} if-no-files-found: error - name: Upload Android APK artifact (armeabi-v7a) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-armeabi-v7a' || 'openless-android-debug-armeabi-v7a' }} path: ${{ steps.apk.outputs.armeabi_v7a_path }} if-no-files-found: error - name: Upload Android APK artifact (x86) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86' || 'openless-android-debug-x86' }} path: ${{ steps.apk.outputs.x86_path }} if-no-files-found: error - name: Upload Android APK artifact (x86_64) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86_64' || 'openless-android-debug-x86_64' }} path: ${{ steps.apk.outputs.x86_64_path }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6515e726..9e66c0114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ name: CI # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 在合并前快速验证两件事: # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) -# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只测试共享 core。 +# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 同时验证共享 Core +# 与独立 egui host,且不拉取本地 Qwen ASR 或 Tauri/WebKit 依赖。 # 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 on: @@ -166,8 +167,10 @@ jobs: --no-daemon linux-core-contract: - name: Linux core tests - runs-on: ubuntu-22.04 + name: Linux core and egui host tests + # cpal's PipeWire backend needs the PipeWire/libspa ABI shipped by 24.04. + # 22.04 headers fail to compile libspa 0.10.x (missing spa_meta_first etc.). + runs-on: ubuntu-24.04 defaults: run: working-directory: openless-all/app @@ -182,8 +185,42 @@ jobs: with: workspaces: 'openless-all/app -> target' + - name: Install Linux egui build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libasound2-dev \ + libdbus-1-dev \ + libpipewire-0.3-dev \ + libssl-dev \ + libwayland-dev \ + libx11-dev \ + libxkbcommon-dev \ + pkg-config \ + ripgrep + - name: Test Core - run: cargo test --locked -p openless-core + run: | + cargo test --locked -p openless-core + cargo clippy --locked -p openless-core --all-targets + + - name: Test Linux egui host without local Qwen ASR + run: | + cargo test --locked -p openless-linux-egui --all-targets + cargo check --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs + + - name: Enforce Linux dependency boundaries + shell: pwsh + run: | + ./scripts/check-core-deps.ps1 + ./scripts/check-core-deps.ps1 openless-linux-egui + ./scripts/check-core-secret-surface.ps1 + ./scripts/check-core-test-isolation.ps1 + ./scripts/check-core-runtime-seam.ps1 + ./scripts/check-linux-public-surface.ps1 - name: Test remote input TLS identity and Unix key permissions run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index f0bae627d..fcea886e4 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -1,37 +1,97 @@ name: Release Linux egui -# This workflow deliberately has no automatic tag trigger until real Ubuntu -# audio, focus/input, install, upgrade, and rollback evidence is recorded. It is -# already reusable by a release orchestrator: pass release_tag to upload verified packages. +# 单独手动入口:只想出一份 Linux 包(不跑 macOS/Windows)时 dispatch 这条。 +# +# ⚠ 发版路径**不再**经过这里:release-tauri.yml 把下面这套步骤**内联**成与 build +# 平级的普通 job(linux-egui),因为 GitHub 会把 workflow_call 调用的工作流渲染成 +# 嵌套可折叠的一层,Linux 在 Actions 页面上就不与三个平台平级。 +# ⚠ 两份步骤必须保持一致:scripts/linux-egui-release-contract.test.mjs 会断言 +# 两边都保留同样的关键门禁(deb/rpm 各恰好一个、ldd 门禁、手动安装包门禁等), +# 只改一边会让契约测试失败。 +# +# 这里仍然不挂自动 tag 触发(历史上没有真正的 Ubuntu 音频/焦点/安装/回滚证据), +# 所以它永远不会自己跑起来。版本串只能来自 release_tag 输入,从不自己拼 tag。 +# 产物命名 OpenLess-Linux-egui--.deb/.rpm,校验和由打包脚本自己产出。 on: workflow_dispatch: inputs: release_tag: - description: Existing GitHub release tag to receive Linux assets; blank only uploads Actions artifacts + description: "Existing v* tag whose Linux egui assets to build/upload (e.g. v2.0.0-Beta.1-egui); blank only uploads Actions artifacts" required: false type: string + attach_release: + description: "Upload the built packages to the release named by release_tag (ignored when release_tag is blank)" + required: false + default: true + type: boolean workflow_call: inputs: release_tag: required: false type: string - secrets: - LINUX_EGUI_MINISIGN_SECRET_KEY: + # 编排层(release-tauri.yml)传 false:四个平台并发跑,谁都不能在建 release + # 的同一秒里抢写,所以 Linux 资产的 release 上传统一交给聚合层(bundle job)。 + attach_release: required: false + default: true + type: boolean permissions: contents: write jobs: build-linux-egui: - runs-on: ubuntu-22.04 + # 跟 ci.yml 的 linux-core-contract 用同一个镜像,理由也一样:cpal 的 PipeWire + # 后端需要 24.04 自带的 PipeWire/libspa ABI,22.04 的头文件编译不过 + # libspa 0.10.x(缺 spa_meta_first 等字段—— + # "spa_video_info_raw has no field named flags" 就是这个)。 + # 这条工作流以前从没跑过,所以从没拿到 ci.yml 早就打上的这个修正; + # 一旦冷缓存重编 libspa 就会在编译阶段直接挂掉(实测 2026-09-16 run 35120587777)。 + # + # ⚠ 分发基线(用户已确认,不要改回 22.04):产出的 deb/rpm 需要 glibc ≥ 2.39, + # 支持范围是 Ubuntu 24.04+ 一类的新发行版;Ubuntu 22.04 / Debian 12 不在 + # 支持范围内。要覆盖它们必须在依赖层解决 libspa/PipeWire 版本,而不是换 runner。 + runs-on: ubuntu-24.04 env: RELEASE_TAG: ${{ inputs.release_tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: false + # 这个 gate 必须排在编译之前:它只依赖 checkout 与 gh,几秒就能出结论。 + # 历史教训:它原来在依赖安装 + fcitx5 插件构建 + 全量测试之后(第 8 步), + # 一个不合规的 release_tag 会白烧掉 4.5 分钟 runner 才报错。 + - name: Validate release target and UI gate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -z "${RELEASE_TAG:-}" ]; then + echo "release_tag is empty: only uploading Actions artifacts, no release is touched" + exit 0 + fi + case "$RELEASE_TAG" in + v*) ;; + *) + echo "::error::release_tag '$RELEASE_TAG' must be an existing v* tag (e.g. v2.0.0-Beta.1-egui); leave it blank to only upload Actions artifacts" + exit 1 + ;; + esac + if grep -q 'openless-linux-egui host stub' openless-all/app/linux-egui/src/main.rs; then + echo "::error::The egui UI stub cannot be uploaded as a release asset" + exit 1 + fi + # 只要求 tag 存在(存在才能安全建 release);release 不存在就由本工作流 + # 末尾的 softprops 步骤创建——-egui tag 是全新命名空间,首次推送时还没 + # 有任何 release,旧实现要求的"release 必须先存在"会把这条路堵死。 + if ! gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "::error::tag '$RELEASE_TAG' does not exist in $GITHUB_REPOSITORY; push the tag first, or leave release_tag blank" + exit 1 + fi + echo "release target $RELEASE_TAG is a real tag in $GITHUB_REPOSITORY" + - name: Install native build and packaging dependencies run: | sudo add-apt-repository -y universe || true @@ -41,38 +101,21 @@ jobs: build-essential \ cmake \ desktop-file-utils \ - extra-cmake-modules \ fcitx5-modules-dev \ - file \ - fuse \ libasound2-dev \ libdbus-1-dev \ + libpipewire-0.3-dev \ + libpulse-dev \ libfcitx5config-dev \ libfcitx5core-dev \ libfcitx5utils-dev \ - libopenblas-dev \ libssl-dev \ libwayland-dev \ libx11-dev \ libxkbcommon-dev \ - patchelf \ pkg-config \ ripgrep \ rpm \ - ruby-dev \ - wget - sudo gem install --no-document fpm -v 1.16.0 - - - name: Install appimagetool - env: - APPIMAGETOOL_SHA256: b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b65398f2be7add2 - run: | - wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage \ - -O /tmp/appimagetool - echo "$APPIMAGETOOL_SHA256 /tmp/appimagetool" | sha256sum --check --strict - chmod +x /tmp/appimagetool - sudo install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool - file /usr/local/bin/appimagetool | grep -q ELF - uses: dtolnay/rust-toolchain@stable with: @@ -92,24 +135,15 @@ jobs: test -s build/libopenless.so test -s build/openless.conf - - name: Checkout pinned Qwen ASR runtime source - run: git submodule update --init --depth 1 -- openless-all/app/src-tauri/vendor/qwen-asr - - - name: Build portable Qwen ASR runtime - working-directory: openless-all/app/src-tauri/vendor/qwen-asr - run: | - make blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic" - test -x qwen_asr - ./qwen_asr --help >/dev/null 2>&1 - - name: Verify framework-independent Linux contract working-directory: openless-all/app shell: pwsh run: | cargo test --locked -p openless-core - cargo clippy --locked -p openless-core --all-targets -- -D warnings + cargo clippy --locked -p openless-core --all-targets cargo test --locked -p openless-linux-egui --all-targets - cargo check --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs ./scripts/check-core-deps.ps1 ./scripts/check-core-deps.ps1 openless-linux-egui ./scripts/check-core-secret-surface.ps1 @@ -117,28 +151,6 @@ jobs: ./scripts/check-core-runtime-seam.ps1 ./scripts/check-linux-public-surface.ps1 - - name: Validate release target and UI gate - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [ -z "${RELEASE_TAG:-}" ]; then - exit 0 - fi - case "$RELEASE_TAG" in - v*-tauri) ;; - *) - echo "::error::release_tag must name an existing v*-tauri release" - exit 1 - ;; - esac - if grep -q 'openless-linux-egui host stub' openless-all/app/linux-egui/src/main.rs; then - echo "::error::The egui UI stub cannot be uploaded as a release asset" - exit 1 - fi - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - - name: Validate Linux desktop metadata run: | desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop @@ -155,19 +167,20 @@ jobs: run: | if [ -n "${RELEASE_TAG:-}" ]; then VERSION=${RELEASE_TAG#v} + # egui 产物统一用 -egui tag 后缀;$%-tauri 只保留为历史 tag 的兼容写法。 + VERSION=${VERSION%-egui} VERSION=${VERSION%-tauri} else - VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1) + VERSION=$(node -p "require('./package.json').version") fi test -n "$VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Package deb, rpm, and AppImage without Tauri + - name: Package deb and rpm without Tauri working-directory: openless-all/app env: OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} OPENLESS_LINUX_ARCH: x86_64 - APPIMAGE_EXTRACT_AND_RUN: '1' run: bash scripts/package-linux-egui.sh - name: Verify package contents and ELF dependencies @@ -176,86 +189,51 @@ jobs: OUTPUT=target/linux-egui-packages test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 - test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 ! ldd target/release/openless-linux-egui | grep -q 'not found' ! ldd target/release/openless-linux-egui | grep -Eqi 'webkit|wry|tauri' - ! ldd src-tauri/vendor/qwen-asr/qwen_asr | grep -q 'not found' dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'usr/bin/openless' dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'fcitx5/libopenless.so' - dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'usr/lib/openless/resources/qwen-asr/qwen_asr' rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/bin/openless' rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/lib64/fcitx5/libopenless.so' - rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/lib/openless/resources/qwen-asr/qwen_asr' - "$OUTPUT"/*.AppImage --appimage-extract >/dev/null - test -x squashfs-root/usr/bin/openless - test -s squashfs-root/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so - test -x squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr - ! ldd squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr | grep -q 'not found' - squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr --help >/dev/null 2>&1 - rm -rf squashfs-root - - name: Sign AppImage and write independent updater manifest + - name: Generate release checksums working-directory: openless-all/app - env: - MINISIGN_SECRET: ${{ secrets.LINUX_EGUI_MINISIGN_SECRET_KEY }} - VERSION: ${{ steps.version.outputs.version }} run: | + set -euo pipefail OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - if [ -n "${RELEASE_TAG:-}" ] && [ -z "${MINISIGN_SECRET:-}" ]; then - echo "::error::LINUX_EGUI_MINISIGN_SECRET_KEY is required for release upload" - exit 1 - fi - if [ -n "${MINISIGN_SECRET:-}" ]; then - if ! command -v minisign >/dev/null 2>&1; then - cargo install --locked --version 0.9.1 minisign - fi - printf '%s' "$MINISIGN_SECRET" > "$RUNNER_TEMP/linux-egui.minisign.key" - minisign -S -s "$RUNNER_TEMP/linux-egui.minisign.key" -m "$APPIMAGE" -x "$APPIMAGE.minisig" - fi - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - ASSET=$(basename "$APPIMAGE") - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - SIGNATURE=null - if [ -f "$APPIMAGE.minisig" ]; then - SIGNATURE=$(base64 -w0 "$APPIMAGE.minisig" | jq -R .) - fi - jq -n \ - --arg version "$VERSION" \ - --arg url "https://github.com/$RELEASE_REPOSITORY/releases/download/${RELEASE_TAG:-manual}/$ASSET" \ - --arg sha256 "$SHA256" \ - --argjson signature "$SIGNATURE" \ - '{schemaVersion:1, host:"linux-egui", arch:"x86_64", version:$version, url:$url, sha256:$sha256, minisign:$signature}' \ - > "$OUTPUT/latest-linux-egui-x86_64.json" - - - name: Verify Linux updater manifest matches the artifact + test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 + # 保持打包脚本/本地流程一致的命名(不改名):聚合 zip 只装 Linux 产物, + # 不会与其它平台那份撞名。 + ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + test "$(wc -l < "$OUTPUT/SHA256SUMS")" -eq 2 + cat "$OUTPUT/SHA256SUMS" + + - name: Verify Linux release checksums working-directory: openless-all/app run: | - set -euo pipefail - OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - MANIFEST="$OUTPUT/latest-linux-egui-x86_64.json" - test -s "$APPIMAGE" - test -s "$MANIFEST" - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - jq -e \ - --arg sha256 "$SHA256" \ - --arg repository "$RELEASE_REPOSITORY" \ - '.schemaVersion == 1 and .host == "linux-egui" and .arch == "x86_64" and .sha256 == $sha256 and (.url | startswith("https://github.com/" + $repository + "/releases/download/"))' \ - "$MANIFEST" >/dev/null + test -s target/linux-egui-packages/SHA256SUMS + ( cd target/linux-egui-packages && sha256sum -c SHA256SUMS >/dev/null ) - name: Upload Linux egui workflow artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openless-linux-egui-x86_64 path: openless-all/app/target/linux-egui-packages/* if-no-files-found: error - - name: Upload Linux egui assets to existing release - if: inputs.release_tag != '' + - name: Create / update the release with Linux egui assets + if: inputs.release_tag != '' && inputs.attach_release uses: softprops/action-gh-release@v2 with: tag_name: ${{ inputs.release_tag }} + # 不传 name/body_path:release 不存在时用默认标题(tag 名本身带 -egui + # 后缀,不会误标成 Tauri),已存在时保留 leader 写好的标题与 body。 + draft: false + # -egui tag 是一个独立命名空间,beta 渠道必须自己判:否则首次为 + # v*-Beta.N-egui 建 release 时会默认成正式版,把预发布包推给正式版用户。 + prerelease: ${{ endsWith(inputs.release_tag, '-beta-egui') || contains(inputs.release_tag, '-Beta.') }} files: openless-all/app/target/linux-egui-packages/* fail_on_unmatched_files: true diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index f161811e3..629c98a83 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -1,13 +1,38 @@ -name: Release Tauri (cross-platform) +name: Release cross-platform (Tauri + Linux egui) # meta: ensure Actions indexes this workflow on forks (no behavior change). -# 触发条件: -# - 推 v*.*.*-tauri 形式的 tag(与老 Swift 版的 vX.Y.Z 区分开,不冲突) -# - 手动 dispatch(用于测试构建,不发版) # -# 输出: -# macOS arm64/x64 .dmg + Windows x64 .msi/.exe,自动作为 GitHub Release 资产上传。 -# Linux egui 由 release-linux-egui.yml 独立构建;本工作流不编译 Linux/Tauri。 +# 这是**唯一一条**发版流水线:一次 tag 推送、同一个 run 里四个平台并发构建, +# 所有产物堆在同一个 GitHub Release 下。 # +# tag 约定(统一语义):任何发布标签 `v*` 都构建并发布**全部**平台,例如 +# v2.0.0-Beta.1-egui / v2.0.0-tauri。`-tauri` / `-egui` 只是**命名约定** +# (影响产物名与版本串),不再是「只构建哪一半」的开关。 +# → macOS arm64 .dmg + macOS x64 .dmg + Windows x64 .msi/.exe + Linux deb/rpm +# (Linux 步骤**内联**在本文件里、与三个平台平级并发:不用 workflow_call, +# 否则 Actions 页面会把 Linux 渲染成嵌套折叠的一层) +# → 安卓 APK 由 android-apk.yml **并行**跑(另一套工具链:JDK/SDK/Gradle), +# 同样对 `v*` 触发,资产挂到**同一个 release**上。 +# → linux-egui job 在**同一个 job 内**完成 deb/rpm 打包 + 手动安装 zip 组装: +# zip 装的是散装安装树(usr/...)+ install.sh + 针对散装文件重算的 SHA256SUMS, +# **不含 deb/rpm**(能装包的人直接下 release 里的 deb/rpm 资产,那两份仍保留); +# macOS/Windows 的 dmg/msi/exe 各自作为独立 release 资产,不进这个 zip。 +# → 后缀是「本次发布同时覆盖 Linux egui」的标记,不是平台分发开关; +# Linux 产物名自带 egui 标识(OpenLess-Linux-egui--.deb/.rpm), +# 与桌面包区分。 +# +# 历史 tag 兼容:v-tauri(含 -beta-tauri / -Beta.N-tauri)仍然可用, +# 保持旧行为:只出 Tauri 三平台(不跑 Linux egui job)。已发布的 v*-tauri +# release 不受影响;把发布迁移到 -egui 后需要注意的外部引用列在下面。 +# +# 迁到 -egui tag 后仍需跟进的仓库外部引用(不在本工作流范围内,见报告): +# - Casks/openless.rb 的下载 URL 写死了 v#{version}-tauri +# - README.md / README.zh.md 的维护者发布清单只写了 v-tauri +# - scripts/bump-version.sh 结尾提示的 tag 命令只拼了 -tauri +# - android-apk.yml 只在 v*-tauri 上触发(APK 因此不在 -egui 发布里) +# 保留旧名不删是为了能让历史 -egui/-tauri release 重跑时行为可预测。 +# +# 手动 dispatch(测试构建,不发版)→ 四平台都构建,release 相关步骤全部跳过。 + # macOS 分发: # - 配好 APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID / # APPLE_PASSWORD / APPLE_TEAM_ID 后,Tauri 会做 Developer ID 签名和公证。 @@ -18,8 +43,12 @@ name: Release Tauri (cross-platform) on: push: + # 统一语义:任何发布标签都构建**全部**平台(Tauri 三平台 + Linux egui + 安卓)。 + # `-tauri` / `-egui` 只是命名约定(产物名/版本串沿用),不再决定构建哪一半。 + # 安卓不在本文件里:它用另一套工具链(JDK/Android SDK/Gradle),由 + # android-apk.yml 并行跑,资产落到同一个 release 上。 tags: - - 'v*-tauri' + - 'v*' workflow_dispatch: # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 @@ -29,6 +58,8 @@ concurrency: jobs: build: + # 统一发布语义:任何发布标签(或手动 dispatch)都跑 Tauri 三平台。 + if: ${{ !cancelled() }} permissions: contents: write strategy: @@ -58,9 +89,11 @@ jobs: # v-tauri → stable 渠道(正式版,文件名沿用旧约定,向后兼容) # workflow_dispatch / 非 tag 触发时 github.ref_name 不是 tag 字符串, # endsWith 返回 false,回退为 stable,不改变现有 dispatch 行为。 - OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} + # -egui 是新约定的 tag:四平台全跑。历史 -tauri tag 保持只跑 Tauri 三平台, + # 所以这条 job 的 if 里仍带上 -tauri。 + OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || endsWith(github.ref_name, '-beta-egui') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # MLX 子模块只在 macOS 发布构建需要。 submodules: ${{ startsWith(matrix.platform, 'macos') && 'recursive' || 'false' }} @@ -69,7 +102,7 @@ jobs: if: ${{ !startsWith(matrix.platform, 'macos') }} run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22" cache: npm @@ -95,7 +128,7 @@ jobs: run: npm ci - name: Check updater signing availability - if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + if: startsWith(github.ref, 'refs/tags/v') shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -106,7 +139,7 @@ jobs: fi - name: Check Apple signing availability - if: startsWith(matrix.platform, 'macos') && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + if: startsWith(matrix.platform, 'macos') && startsWith(github.ref, 'refs/tags/v') shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -424,7 +457,7 @@ jobs: - name: Upload macOS artifacts if: startsWith(matrix.platform, 'macos') - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openless-macos-${{ matrix.updater-arch }} path: | @@ -433,7 +466,7 @@ jobs: - name: Upload macOS updater artifacts if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openless-macos-${{ matrix.updater-arch }}-updater path: | @@ -444,7 +477,7 @@ jobs: - name: Upload Windows artifacts if: matrix.platform == 'windows-latest' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openless-windows-x64 path: | @@ -454,7 +487,7 @@ jobs: - name: Upload Windows updater artifacts if: matrix.platform == 'windows-latest' && env.TAURI_SIGNING_PRIVATE_KEY != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openless-windows-x64-updater path: | @@ -469,7 +502,7 @@ jobs: # existing release body 不动,避免每个 matrix job 都 append 一遍同样的 prelude # 导致 release notes 重复 N 次。 - name: Prepare release body prelude - if: matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + if: matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' && startsWith(github.ref, 'refs/tags/v') shell: bash run: | cat > "$RUNNER_TEMP/release-body.md" << 'EOF' @@ -496,7 +529,9 @@ jobs: echo "OPENLESS_RELEASE_BODY_PATH=$RUNNER_TEMP/release-body.md" >> "$GITHUB_ENV" - name: Create / update release - if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + # 任何 v* tag(-egui / -tauri)都要把三平台桌面包挂上同一个 release: + # 四平台共事一个 tag 是当前约定,Linux deb/rpm 由 release-linux-egui job 上传。 + if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} @@ -524,6 +559,293 @@ jobs: openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig openless-all/app/src-tauri/target/release/bundle/latest-*.json + # ── Linux 侧(egui 原生宿主):四平台之一,与 Tauri 三层同 run **并发** ── + # 这里把原 release-linux-egui.yml 的步骤**内联**成与 build 平级的普通 job,而不是 + # `uses:` 调用它:GitHub 会把可复用工作流渲染成嵌套可折叠的一层,Linux 在 Actions + # 页面上就不与三个平台平级(用户明确不接受这种显示)。 + # release-linux-egui.yml 仍然保留,作为「只出 Linux 包」的单独手动入口; + # ⚠ 两份步骤必须保持一致:scripts/linux-egui-release-contract.test.mjs 同时断言 + # 两份里的关键门禁字符串,改这里时必须同步改那边(漂移会让契约测试失败)。 + # + # deb/rpm 打包与「手动安装」zip 组装都在**同一个 job** 里完成(用户要求合并): + # 编译插件 → 编译宿主 → 打 deb/rpm → 校验包内容与 ELF 依赖 → 校验和 → + # 解出散装 payload → 加 install.sh + 重算 SHA256SUMS → 打 manual zip(不含 deb/rpm) + # → 硬门禁 → 上传 artifact → 附到 release。 + # 故意不写 needs: build —— 四个平台要同时开始编,而不是 Linux 等 macOS/Windows 编完。 + # 竞态:Linux 与 Tauri 三层会同时往同一个 release 写资产。softprops/action-gh-release + # 是 create-or-update,但**首次建立** release 时并发的 create 会撞 422 already_exists; + # 所以下面先幂等地确保 release 存在(gh release view || gh release create), + # 再让它只做 attach/update,避免同一秒抢建。 + linux-egui: + name: Linux egui packages (deb + rpm + manual zip) + # 统一发布语义:任何发布标签(或手动 dispatch)都跑 Linux egui,不给它单独开关。 + if: ${{ !cancelled() }} + # 跟 ci.yml 的 linux-core-contract 用同一个镜像,理由也一样:cpal 的 PipeWire + # 后端需要 24.04 自带的 PipeWire/libspa ABI,22.04 的头文件编译不过 libspa 0.10.x。 + # ⚠ 分发基线(用户已确认,不要改回 22.04):产出的 deb/rpm 需要 glibc ≥ 2.39, + # 支持 Ubuntu 24.04+ 一类的新发行版;22.04 / Debian 12 不在范围内。 + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + # dispatch 时 ref_name 是分支名(含 `/`,不能进文件名);tag 推送时传 tag, + # 且 tag 名自带 -egui 后缀,Linux 资产不会和桌面包撞名。 + RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} + # 与 build job 同一套渠道判定:beta release 必须保持 prerelease=true, + # 否则这个 job 会把它「升格」成正式版并推送给所有用户。 + OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || endsWith(github.ref_name, '-beta-egui') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} + MANUAL_ZIP: OpenLess-Linux-egui-${{ github.ref_type == 'tag' && github.ref_name || format('run-{0}', github.run_number) }}-x86_64-manual.zip + steps: + - uses: actions/checkout@v6 + with: + submodules: false + + # 这个 gate 必须排在编译之前:它只依赖 checkout 与 gh,几秒就能出结论。 + # 历史教训:它原来在依赖安装 + 插件构建 + 全量测试之后,一个不合规的 + # release_tag 会白烧掉 4.5 分钟 runner 才报错。 + - name: Validate release target and UI gate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -z "${RELEASE_TAG:-}" ]; then + echo "release_tag is empty: only uploading Actions artifacts, no release is touched" + exit 0 + fi + case "$RELEASE_TAG" in + v*) ;; + *) + echo "::error::release target '$RELEASE_TAG' must be an existing v* tag (e.g. v2.0.0-Beta.1-egui); leave it blank to only upload Actions artifacts" + exit 1 + ;; + esac + if grep -q 'openless-linux-egui host stub' openless-all/app/linux-egui/src/main.rs; then + echo "::error::The egui UI stub cannot be uploaded as a release asset" + exit 1 + fi + if ! gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "::error::tag '$RELEASE_TAG' does not exist in $GITHUB_REPOSITORY; push the tag first, or leave release_tag blank" + exit 1 + fi + echo "release target $RELEASE_TAG is a real tag in $GITHUB_REPOSITORY" + + - name: Install native build and packaging dependencies + run: | + sudo add-apt-repository -y universe || true + sudo apt-get update + sudo apt-get install -y \ + appstream \ + build-essential \ + cmake \ + desktop-file-utils \ + fcitx5-modules-dev \ + libasound2-dev \ + libdbus-1-dev \ + libpipewire-0.3-dev \ + libpulse-dev \ + libfcitx5config-dev \ + libfcitx5core-dev \ + libfcitx5utils-dev \ + libssl-dev \ + libwayland-dev \ + libx11-dev \ + libxkbcommon-dev \ + pkg-config \ + ripgrep \ + rpm \ + unzip \ + zip \ + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache Cargo + uses: swatinem/rust-cache@v2 + with: + workspaces: 'openless-all/app -> target' + + - name: Build fcitx5 plugin + working-directory: openless-all/scripts/linux-fcitx5-plugin + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr + cmake --build build --parallel + ctest --test-dir build --output-on-failure + test -s build/libopenless.so + test -s build/openless.conf + + - name: Verify framework-independent Linux contract + working-directory: openless-all/app + shell: pwsh + run: | + cargo test --locked -p openless-core + cargo clippy --locked -p openless-core --all-targets + cargo test --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs + ./scripts/check-core-deps.ps1 + ./scripts/check-core-deps.ps1 openless-linux-egui + ./scripts/check-core-secret-surface.ps1 + ./scripts/check-core-test-isolation.ps1 + ./scripts/check-core-runtime-seam.ps1 + ./scripts/check-linux-public-surface.ps1 + + - name: Validate Linux desktop metadata + run: | + desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop + appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml + + - name: Build Linux egui host + working-directory: openless-all/app + run: cargo build --locked --release -p openless-linux-egui + + - name: Resolve package version + id: version + shell: bash + working-directory: openless-all/app + run: | + if [ -n "${RELEASE_TAG:-}" ]; then + VERSION=${RELEASE_TAG#v} + # egui 产物统一用 -egui tag 后缀;-tauri 只保留为历史 tag 的兼容写法。 + VERSION=${VERSION%-egui} + VERSION=${VERSION%-tauri} + else + VERSION=$(node -p "require('./package.json').version") + fi + test -n "$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Package deb and rpm without Tauri + working-directory: openless-all/app + env: + OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} + OPENLESS_LINUX_ARCH: x86_64 + run: bash scripts/package-linux-egui.sh + + - name: Verify package contents and ELF dependencies + working-directory: openless-all/app + run: | + OUTPUT=target/linux-egui-packages + test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 + ! ldd target/release/openless-linux-egui | grep -q 'not found' + ! ldd target/release/openless-linux-egui | grep -Eqi 'webkit|wry|tauri' + dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'usr/bin/openless' + dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'fcitx5/libopenless.so' + rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/bin/openless' + rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/lib64/fcitx5/libopenless.so' + + - name: Generate release checksums + working-directory: openless-all/app + run: | + set -euo pipefail + OUTPUT=target/linux-egui-packages + test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 + ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + test "$(wc -l < "$OUTPUT/SHA256SUMS")" -eq 2 + cat "$OUTPUT/SHA256SUMS" + + - name: Verify Linux release checksums + working-directory: openless-all/app + run: | + test -s target/linux-egui-packages/SHA256SUMS + ( cd target/linux-egui-packages && sha256sum -c SHA256SUMS >/dev/null ) + + # ── 手动安装包(散装文件):给用不了 deb/rpm 的发行版 ── + # zip 根 = install.sh + SHA256SUMS + usr/... 安装树;**不含 deb/rpm**(能装包的 + # 人直接下 release 里的 deb/rpm 资产,那两份仍然保留)。 + - name: Build the manual-install payload + shell: bash + run: | + set -euo pipefail + OUT=openless-all/app/target/linux-egui-packages + deb=$(find "$OUT" -maxdepth 1 -name '*.deb' | head -1) + test -n "$deb" + mkdir -p manual + # 用 dpkg-deb -x 解出精确安装树:不自己抄文件清单,payload 就不会和 deb 漂移 + # (下一步会逐项对比)。 + dpkg-deb -x "$deb" manual + cp openless-all/app/scripts/linux-egui-manual-install.sh manual/install.sh + chmod +x manual/install.sh + bash -n manual/install.sh + # 散装 payload 自己的校验和:覆盖 install.sh 与所有安装文件;不复用针对 + # deb/rpm 的那份 SHA256SUMS。 + ( cd manual && find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum > SHA256SUMS ) + ( cd manual && sha256sum -c SHA256SUMS >/dev/null ) + # 逐项对比:散装文件必须与 deb 的内容清单完全一致(漏文件最难发现)。 + dpkg-deb -c "$deb" | awk '$1 !~ /^d/ {print $NF}' | sed 's|^\./||' | LC_ALL=C sort > "$RUNNER_TEMP/deb-files.txt" + ( cd manual && find usr -type f | LC_ALL=C sort ) > "$RUNNER_TEMP/payload-files.txt" + diff -u "$RUNNER_TEMP/deb-files.txt" "$RUNNER_TEMP/payload-files.txt" + find manual -type f | LC_ALL=C sort + + - name: Zip the manual-install payload + shell: bash + run: | + set -euo pipefail + # 从 manual/ 内部打包:zip 里就是 install.sh、SHA256SUMS 与 usr/... 目录树, + # 用户解压后直接 `sudo ./install.sh`。 + ( cd manual && zip -q -r "../$MANUAL_ZIP" . ) + test -s "$MANUAL_ZIP" + unzip -Z1 "$MANUAL_ZIP" + # 硬门禁:绝不能出现 deb/rpm。 + test "$(unzip -Z1 "$MANUAL_ZIP" | grep -cE '\.(deb|rpm)$' || true)" -eq 0 + for f in install.sh SHA256SUMS usr/bin/openless usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so; do + unzip -Z1 "$MANUAL_ZIP" | grep -qx "$f" + done + # 解压后校验和必须过(证明 zip 没有损坏 payload)。 + ( cd "$RUNNER_TEMP" && rm -rf manual-check && mkdir manual-check && cd manual-check \ + && unzip -q "$GITHUB_WORKSPACE/$MANUAL_ZIP" && sha256sum -c SHA256SUMS >/dev/null ) + # 收进打包输出目录:这样下面一个 artifact 就能同时带上 deb/rpm/校验和/zip。 + cp "$MANUAL_ZIP" openless-all/app/target/linux-egui-packages/ + + - name: Upload Linux egui workflow artifacts + uses: actions/upload-artifact@v7 + with: + name: openless-linux-egui-x86_64 + path: openless-all/app/target/linux-egui-packages/* + if-no-files-found: error + # 重跑同一个 run 时 v4 默认拒绝同名 artifact;发版重跑是常规操作。 + overwrite: true + + # 竞态处理:先幂等地保证 release 存在(并发的 create 会撞 422 already_exists), + # 之后 softprops 只做 attach/update。标题与 body 留给 build job 的 leader 写。 + - name: Ensure the release exists before attaching Linux assets + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "release $RELEASE_TAG already exists" + exit 0 + fi + args=(--repo "$GITHUB_REPOSITORY" --verify-tag --title "OpenLess $RELEASE_TAG") + if [ "${OPENLESS_RELEASE_CHANNEL:-stable}" = "beta" ]; then + args+=(--prerelease) + fi + # 与并发的 Tauri job 同时创建时必有一方拿到 422,这里吞掉即可: + # 只要最终 release 存在,后面的 attach 就成立。 + gh release create "$RELEASE_TAG" "${args[@]}" \ + --notes "Linux egui assets: deb/rpm + manual install bundle" \ + || gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + + - name: Attach Linux assets (deb/rpm + manual zip) to the release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + draft: false + # 不传 name/body_path:release 不存在时用 tag 名作标题(tag 自带 -egui 后缀, + # 不会误标成 Tauri);已存在时保留 leader 写好的标题与 body。 + prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} + files: openless-all/app/target/linux-egui-packages/* + fail_on_unmatched_files: true + # ── 正式版发布后,自动更新 Homebrew cask ── # 为什么放进这条流水线,而不是单独的 `release: published` 工作流:softprops 用默认 # GITHUB_TOKEN 创建的 Release 不会触发 `release` 事件的其它工作流(GitHub 防递归), @@ -532,6 +854,12 @@ jobs: # # 仅正式版:v*-tauri 且非 -beta-tauri。beta 不碰 Homebrew,避免把预发布版推给 # `brew install --cask openless` 的用户。cask 文件在默认分支(beta)上,提交回该分支。 + # + # ⚠ 统一发布语义下的影响(任何 v* 标签都出全平台产物):这条 job 的 if 仍只认 + # 「稳定的 -tauri 标签」,所以推 v2.0.0-Beta.1-egui(或任何非 -tauri 后缀)时 + # 全平台产物照常发布,但 Homebrew cask **不会**更新 —— 这是刻意的分发边界 + # (用户决定:cask 只跟 -tauri 正式版)。要让 brew 用户拿到新版,发一个不带 + # Beta、后缀 -tauri 的标签即可,那时本 job 照旧自动更新 cask。 update-homebrew-cask: name: Update Homebrew cask (stable only) needs: build @@ -545,7 +873,7 @@ jobs: && !contains(github.ref_name, '-Beta.') steps: - name: Checkout default branch (cask 住在这里) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.repository.default_branch }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5d2a84e26..b90a03694 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ video-materials/ node_modules/ dist/ target/ +# Explicitly ignore local cache symlinks as well as generated target directories. +/openless-all/app/target +/openless-all/egui-frontend/target # 本地从 CI / Actions 下载的 APK 及解压目录(非源码) ci-artifacts/ *.apk diff --git a/.gitmodules b/.gitmodules index 18862cfed..b9c2c02ae 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,5 +1,5 @@ -[submodule "openless-all/app/src-tauri/vendor/qwen-asr"] - path = openless-all/app/src-tauri/vendor/qwen-asr +[submodule "openless-all/app/vendor/qwen-asr"] + path = openless-all/app/vendor/qwen-asr url = https://github.com/Open-Less/qwen-asr.git [submodule "openless-all/app/src-tauri/vendor/qwen3-asr-rs"] path = openless-all/app/src-tauri/vendor/qwen3-asr-rs diff --git a/docs/linux-egui-tauri2-parity.md b/docs/linux-egui-tauri2-parity.md new file mode 100644 index 000000000..3d489c9de --- /dev/null +++ b/docs/linux-egui-tauri2-parity.md @@ -0,0 +1,82 @@ +# Linux egui / Tauri 2 parity tracker + +This local stack is based on PR #1019 head `5f668b1d`. Linux ships one +`openless-linux-egui` executable and does not link Tauri, Wry, or WebKitGTK. +Windows, macOS, and Android remain on Tauri. + +## Implemented on the native Core 2.0 path + +- eframe/egui 0.33.3 shell, system CJK fallback, single instance, minimized + startup, close-to-tray, safe shutdown, XDG autostart, notifications, external + URLs, file dialogs, file logging, and diagnostic-log export +- fcitx5 dictation, QA, selection polish, translation, style switching, main + window, style-pack direct, and applicable Coding Agent hotkeys; settings use + strict collision checks and survive fcitx5 restart +- CPAL recording with canonical WAV archives, Core retention policy, history + playback/export and failed-session retranscription +- Native `mute_during_recording` (PipeWire `wpctl`/PulseAudio `pactl`) with + guaranteed sink restore by RAII across stop/cancel/error/drop/shutdown +- Native recording start/stop audio cues synthesized to the default sink on a + worker thread (never blocking the egui frame), gated by `audio_cue_on_record` + and muted-aware (`audio_mute`/`audio_cue` modules) +- Core-backed history search/delete/clear/copy/re-polish/retranscribe, vocabulary, + pending corrections, shared vocabulary presets, correction rules, and complete + style-pack CRUD/reset/prompt diagnostics/ZIP import-export/direct hotkeys +- provider channel CRUD/order/enable/activation, credential metadata, endpoint, + model listing, validation, and revision-aware settings conflict merging +- Qwen catalog/download/cancel/delete/activate/prepare/preload/release/test and + progress; Remote Input TLS service, URLs, PIN, locale, connection count, and + error events +- Marketplace list/detail/install/download/upload/update/delete, likes, authored + packs, GitHub device flow, polling, cancel, and logout +- dictation, QA text/voice, selection polish preview/confirm/cancel/revert, and + Less Computer text/voice/stream/tool approval/cancel through high-level Core APIs +- #997 native QA/selection/capsule popup design: same-executable re-entry, + versioned serde JSONL, Markdown, drag/Esc, QA microphone and Enter submit, + session/sequence/kind guards, nonblocking pipes, crash restart, and snapshot replay +- stable/beta AppImage checks, delayed/hourly/manual scheduling, byte progress, + SHA-256 and pinned minisign verification, same-directory fsync and atomic replace; + deb/rpm use the release page +- Remote Input assets, Qwen vendor files, shared icons, version parsing, packaging, + and release workflow are independent of `src-tauri` + +## Automated evidence + +- `cargo test -p openless-core --locked` +- `cargo test -p openless-linux-egui --locked` +- `cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings` +- PR #1019 Core/public-surface/dependency contract scripts +- fcitx5 C++ build plus `input_target_contract` +- `cargo tree` and release ELF `ldd` checks for Tauri/Wry/WebKitGTK +- Linux Tauri-free source, packaging, workflow, production-mock, capability, popup, + settings-conflict, updater-validation, lifecycle, and staged-file gates + +## Deliberate limits and device evidence still required + +- Linux supports fcitx5 only; there is no IBus or global-hotkey fallback. +- Selection Voice remains hidden because the Linux production target/intent adapter + is not implemented. It must not be advertised through capabilities. Selection + polish and QA remain available. +- Generic Qwen is the Linux local runtime. Windows Foundry and Apple MLX are not + Linux parity requirements. +- Foreground-application identity and native post-insertion edit observation + are explicit `Unsupported` on Linux: fcitx5 exposes surrounding text but no + reliable app/control identity across X11/Wayland, PRIMARY cannot prove an + original selection, and there is deliberately no IBus/global-hotkey fallback. + The factory keeps Core's Noop HostContext/EditObservation adapters (reporting + `source_app = None`) instead of faking edits. These are L02/L03 follow-ups, + not simulated UI data or false capabilities. +- Mute/restore and start/stop cues are implemented (`audio_mute`/`audio_cue`), + but the cpal cue-playback and real sink mute/restore paths still require + X11/Wayland device runs before being recorded as verified; a Linux output + stream is a PipeWire/KDE sink-input, so device evidence must confirm the cue + does not disturb the session or trigger unexpected volume OSD. +- X11 and Wayland device runs are still required for focus, Unicode insertion, + popup positioning, tray, fcitx5 reload/rebind, microphone unplug/recovery, + Secret Service, real phone Remote Input, real Qwen inference, and signed + AppImage install/rollback. Ignored hardware tests or a green build are not + recorded as device proof. + +The automated scope is complete only when every command above passes at the +current PR #1019 head. The device-only rows remain “implemented, awaiting device +evidence” or “explicit unsupported” and must not be described as verified. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index a4b807be9..da2b58676 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -18,6 +18,96 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant 5.15.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -59,21 +149,21 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "libc", ] [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -86,14 +176,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.1", + "bitflags 2.13.2", "cc", - "jni 0.22.4", + "jni", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "thiserror 2.0.20", ] @@ -106,13 +196,29 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -145,7 +251,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] @@ -162,12 +267,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] -name = "ash" -version = "0.38.0+1.3.281" +name = "ashpd" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "libloading", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "zbus 5.19.0", ] [[package]] @@ -233,6 +347,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -323,7 +451,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -332,6 +460,56 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.19.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus 5.19.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.19.0", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -403,7 +581,8 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.1", + "annotate-snippets", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools", @@ -438,18 +617,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block" -version = "0.1.6" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -478,11 +648,20 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -517,7 +696,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -537,7 +716,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -593,7 +772,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", @@ -607,7 +786,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "polling", "rustix 1.1.4", "slab", @@ -649,9 +828,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -659,12 +838,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -674,6 +847,16 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -714,7 +897,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -729,9 +912,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -749,19 +932,18 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "termcolor", "unicode-width", ] [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -782,6 +964,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.9.4" @@ -824,45 +1012,50 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" -dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - -[[package]] -name = "coreaudio-sys" -version = "0.2.18" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bindgen", + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" dependencies = [ "alsa", - "core-foundation-sys", + "block2 0.6.2", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "futures", + "jni", "js-sys", "libc", "mach2", - "ndk 0.8.0", + "ndk", "ndk-context", - "oboe", - "wasm-bindgen", - "wasm-bindgen-futures", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pipewire", + "portable-atomic", + "pulseaudio", "web-sys", - "windows 0.54.0", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -909,9 +1102,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1102,7 +1295,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1114,7 +1309,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1149,9 +1344,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -1159,9 +1354,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash", "bytemuck", @@ -1188,31 +1383,33 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", - "winapi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ + "accesskit", "ahash", - "bitflags 2.13.1", + "bitflags 2.13.2", "emath", "epaint", "log", "nohash-hasher", "profiling", + "smallvec", + "unicode-segmentation", ] [[package]] name = "egui-wgpu" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash", "bytemuck", @@ -1221,7 +1418,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 1.0.69", + "thiserror 2.0.20", "type-map", "web-time", "wgpu", @@ -1230,15 +1427,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ - "ahash", + "accesskit_winit", "arboard", "bytemuck", "egui", "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", "profiling", "raw-window-handle", "smithay-clipboard", @@ -1249,11 +1449,10 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ - "ahash", "bytemuck", "egui", "glow", @@ -1267,15 +1466,15 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", ] @@ -1286,6 +1485,17 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-primitive-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" +dependencies = [ + "num-traits", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1309,9 +1519,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash", @@ -1327,9 +1537,9 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equivalent" @@ -1435,24 +1645,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "fixedbitset" -version = "0.5.7" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1467,6 +1672,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1485,7 +1696,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1519,6 +1730,21 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1526,6 +1752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1534,6 +1761,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -1561,7 +1799,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1582,6 +1820,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1609,7 +1848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1677,9 +1916,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glow" @@ -1699,7 +1938,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg_aliases", "cgl", "dispatch2", @@ -1759,45 +1998,6 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gpu-alloc" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" -dependencies = [ - "bitflags 2.13.1", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.1", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "h2" version = "0.4.19" @@ -1825,6 +2025,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1834,7 +2035,16 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -1851,9 +2061,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1932,9 +2142,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2143,9 +2353,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2163,9 +2373,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itertools" @@ -2182,22 +2392,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -2212,7 +2406,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2268,9 +2462,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2290,17 +2484,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - [[package]] name = "khronos_api" version = "3.1.0" @@ -2335,19 +2518,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.3", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.4", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", ] [[package]] @@ -2356,7 +2572,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -2439,21 +2655,9 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "matchit" @@ -2495,21 +2699,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metal" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] - [[package]] name = "mime" version = "0.3.17" @@ -2532,6 +2721,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2542,13 +2737,24 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2585,38 +2791,27 @@ dependencies = [ [[package]] name = "naga" -version = "24.0.0" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", "codespan-reporting", + "half", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", + "num-traits", + "once_cell", "rustc-hash 1.1.0", - "spirv", - "strum", - "termcolor", "thiserror 2.0.20", - "unicode-xid", -] - -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", + "unicode-ident", ] [[package]] @@ -2625,10 +2820,10 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2640,15 +2835,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2664,7 +2850,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "derive_builder", "getset", @@ -2693,7 +2879,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2812,6 +2998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2837,12 +3024,12 @@ dependencies = [ ] [[package]] -name = "objc" -version = "0.2.7" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ - "malloc_buf", + "libc", ] [[package]] @@ -2876,8 +3063,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -2892,21 +3079,48 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2918,19 +3132,42 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2941,8 +3178,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -2952,7 +3191,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -2965,7 +3204,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2977,7 +3216,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2995,8 +3234,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3008,7 +3247,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3019,7 +3260,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3030,7 +3271,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -3042,8 +3283,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3054,8 +3295,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3077,8 +3318,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3098,7 +3339,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3109,36 +3350,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni 0.21.1", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - [[package]] name = "oid-registry" version = "0.7.1" @@ -3188,30 +3406,44 @@ dependencies = [ name = "openless-linux-egui" version = "0.1.0" dependencies = [ - "arboard", "axum", "base64", + "chrono", "cpal", "dbus", "eframe", + "egui", + "egui_glow", "fs2", "futures-util", + "glow", + "glutin", "hyper-util", + "image", "keyring", "libc", "local-ip-address", "log", + "minisign-verify", "openless-core", + "raw-window-handle", "rcgen", + "reqwest", + "rfd", "rustls", + "semver", "serde", "serde_json", "sha2", + "simplelog", "tempfile", "time", "tokio", "tokio-rustls", "uuid", + "wayland-client", + "wayland-protocols-wlr", + "x11rb", "x509-parser", ] @@ -3225,15 +3457,6 @@ dependencies = [ "libredox", ] -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -3244,16 +3467,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3289,15 +3502,9 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -3324,17 +3531,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "phf" version = "0.13.1" @@ -3425,6 +3621,31 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.4", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3443,11 +3664,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3464,6 +3685,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3529,7 +3771,23 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", +] + +[[package]] +name = "pulseaudio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70623bd7967a9ca4c2ae0e807fc380b291f98480fc037042305ec643a4d3373" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "enum-primitive-derive", + "futures", + "log", + "mio", + "num-traits", + "thiserror 1.0.69", ] [[package]] @@ -3646,10 +3904,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -3671,6 +3939,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -3682,11 +3960,20 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.1" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] name = "rand_pcg" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3730,16 +4017,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_syscall" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3828,6 +4115,30 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3869,7 +4180,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3908,7 +4219,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3921,7 +4232,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3930,9 +4241,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "log", "once_cell", @@ -4013,7 +4324,7 @@ dependencies = [ "rand 0.8.8", "serde", "sha2", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -4049,7 +4360,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4073,7 +4384,16 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", ] [[package]] @@ -4154,6 +4474,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -4177,9 +4508,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smithay-client-toolkit" @@ -4187,7 +4518,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -4212,7 +4543,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -4263,15 +4594,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4290,28 +4612,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.119", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4331,9 +4631,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -4366,7 +4666,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "system-configuration-sys", ] @@ -4381,6 +4681,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + [[package]] name = "tar" version = "0.4.46" @@ -4392,6 +4705,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -4451,7 +4770,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4475,7 +4794,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4510,9 +4831,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -4536,6 +4857,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4547,14 +4869,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -4590,6 +4912,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4601,9 +4938,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -4620,6 +4957,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -4641,7 +4984,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4697,17 +5040,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -4786,15 +5118,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "untrusted" @@ -4815,6 +5141,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -4829,9 +5161,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4839,6 +5171,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -4881,9 +5219,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4894,9 +5232,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -4904,9 +5242,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4914,22 +5252,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -4967,7 +5305,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -4979,7 +5317,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cursor-icon", "wayland-backend", ] @@ -5001,7 +5339,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5013,7 +5351,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5026,7 +5364,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5039,7 +5377,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5052,7 +5390,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5084,9 +5422,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -5108,7 +5446,7 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2 0.6.4", @@ -5144,24 +5482,22 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "24.0.5" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", "document-features", - "js-sys", + "hashbrown 0.16.1", "log", - "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -5169,79 +5505,74 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "24.0.5" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", + "bit-set", "bit-vec", - "bitflags 2.13.1", + "bitflags 2.13.2", + "bytemuck", "cfg_aliases", "document-features", + "hashbrown 0.16.1", "indexmap", "log", "naga", "once_cell", "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.20", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-hal" -version = "24.0.4" +version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bitflags 2.13.1", - "bytemuck", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", - "core-graphics-types", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-descriptor", - "js-sys", - "khronos-egl", - "libc", "libloading", "log", - "metal", "naga", - "ndk-sys 0.5.0+25.2.9519653", - "objc", - "once_cell", - "ordered-float", - "parking_lot", - "profiling", + "portable-atomic", + "portable-atomic-util", "raw-window-handle", "renderdoc-sys", - "rustc-hash 1.1.0", - "smallvec", "thiserror 2.0.20", - "wasm-bindgen", - "web-sys", "wgpu-types", - "windows 0.58.0", ] [[package]] name = "wgpu-types" -version = "24.0.0" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "bytemuck", "js-sys", "log", + "thiserror 2.0.20", "web-sys", ] @@ -5278,45 +5609,58 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.54.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", ] [[package]] name = "windows" -version = "0.58.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] -name = "windows-core" -version = "0.54.0" +name = "windows-collections" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", ] [[package]] name = "windows-core" -version = "0.58.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5325,40 +5669,40 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", + "windows-implement", + "windows-interface", + "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] [[package]] -name = "windows-implement" -version = "0.58.0" +name = "windows-future" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] -name = "windows-interface" -version = "0.58.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -5376,6 +5720,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -5383,32 +5733,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-numerics" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-core 0.61.2", + "windows-link 0.1.3", ] [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] @@ -5417,17 +5778,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] @@ -5436,16 +5796,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -5481,22 +5832,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -5521,7 +5857,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5533,10 +5869,22 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] [[package]] name = "windows_aarch64_gnullvm" @@ -5550,12 +5898,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5568,12 +5910,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5598,12 +5934,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5616,12 +5946,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5634,12 +5958,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5652,12 +5970,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5679,8 +5991,8 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", @@ -5692,7 +6004,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk 0.9.0", + "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -5736,24 +6048,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.20", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.4" @@ -5842,7 +6136,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dlib", "log", "once_cell", @@ -5929,9 +6223,69 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.15.0", ] [[package]] @@ -5944,7 +6298,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -5955,23 +6324,55 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -6049,7 +6450,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -6082,6 +6483,12 @@ dependencies = [ "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" @@ -6111,18 +6518,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -6153,7 +6560,23 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -6166,7 +6589,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils 4.2.0", ] [[package]] @@ -6179,3 +6615,16 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", +] diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/assets/remote-input/app.js similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/app.js rename to openless-all/app/assets/remote-input/app.js diff --git a/openless-all/app/src-tauri/src/remote_server/assets/done.png b/openless-all/app/assets/remote-input/done.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/done.png rename to openless-all/app/assets/remote-input/done.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/icon.png b/openless-all/app/assets/remote-input/icon.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/icon.png rename to openless-all/app/assets/remote-input/icon.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/assets/remote-input/index.html similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/index.html rename to openless-all/app/assets/remote-input/index.html diff --git a/openless-all/app/src-tauri/src/remote_server/assets/mic.png b/openless-all/app/assets/remote-input/mic.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/mic.png rename to openless-all/app/assets/remote-input/mic.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/assets/remote-input/style.css similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/style.css rename to openless-all/app/assets/remote-input/style.css diff --git a/openless-all/app/src/lib/vocab-presets.json b/openless-all/app/assets/vocab-presets.json similarity index 100% rename from openless-all/app/src/lib/vocab-presets.json rename to openless-all/app/assets/vocab-presets.json diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index d1feab8f3..b52803d16 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -8687,7 +8687,10 @@ mod tests { ) .unwrap(); let first = backend.start().await.expect("first start must not fail"); - let second = backend.start().await.expect("handshake start must not fail"); + let second = backend + .start() + .await + .expect("handshake start must not fail"); assert!(first.backend.running); assert!(second.backend.running); let _ = data_dir; diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index e8d9ee790..45e1060ee 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -282,7 +282,7 @@ pub trait LocalAsrApi: Send + Sync { fn delete_model(&self, target: LocalAsrTarget) -> BoxFuture<'static, Result<(), BackendError>>; fn cleanup_incomplete( &self, - target: LocalAsrTarget, + _target: LocalAsrTarget, ) -> BoxFuture<'static, Result<(), BackendError>> { unsupported("local ASR incomplete download cleanup") } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 4d0d4bd69..af16b8821 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -356,4 +356,7 @@ pub use types::{ SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, StylePackChange, TranscriptAccumulator, TranscriptDelta, VocabPreset, VocabPresetStore, VocabularyChange, }; -pub use vocabulary::{list_vocab_presets, save_vocab_presets, DictionaryStore}; +pub use vocabulary::{ + builtin_vocab_presets, list_vocab_presets, resolve_vocab_presets, save_vocab_presets, + DictionaryStore, +}; 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 7ed16b35f..87a17fcec 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -765,7 +765,10 @@ pub fn is_stepfun_realtime_provider(id: &str) -> bool { } pub fn is_mimo_provider(id: &str) -> bool { - matches!(id, MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID) + matches!( + id, + MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ) } pub fn is_dashscope_multimodal_provider(id: &str) -> bool { diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 47ab7db4b..24fe057b1 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -517,8 +517,8 @@ fn validate_provider_endpoint(endpoint: &str, allow_websocket: bool) -> Result<( let url = url::Url::parse(endpoint).map_err(|_| invalid_request("provider endpoint is invalid"))?; if url.host_str().is_none() - || !matches!(url.scheme(), "http" | "https") - && !(allow_websocket && matches!(url.scheme(), "ws" | "wss")) + || !(matches!(url.scheme(), "http" | "https") + || allow_websocket && matches!(url.scheme(), "ws" | "wss")) { return Err(invalid_request("provider endpoint is invalid")); } @@ -1781,20 +1781,52 @@ mod tests { {"id":"legacy/chat"} ]}"#; for (kind, channel_kind, key_account, expected) in [ - (ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), - (ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash"]), + ( + ProviderKind::Llm, + ChannelKind::Llm, + LLM_API_KEY_ACCOUNT, + vec![ + "google/gemini-2.5-flash", + "google/gemini-tts", + "google/gemini-unknown", + "orcarouter/fusion-flash", + ], + ), + ( + ProviderKind::Asr, + ChannelKind::Asr, + ASR_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash"], + ), ] { let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; + let channel = + create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; let transport = Arc::new(FakeProviderTransport::default()); transport.push_response(200, catalog.as_bytes().to_vec()); - let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); - let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; + let service = ProviderService::new_with_transport( + credentials.clone(), + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + let request = ProviderRequest { + kind, + channel_id: Some(channel.clone()), + thinking_enabled: false, + }; assert!(service.list_models(request.clone()).await.is_err()); - let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; - credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); + let namespace = if kind == ProviderKind::Llm { + CredentialNamespace::Llm + } else { + CredentialNamespace::Asr + }; + credentials + .write( + CredentialKey::new(namespace, Some(channel), key_account).unwrap(), + SecretValue::new("fixture-key"), + ) + .await + .unwrap(); let result = service.list_models(request).await.unwrap(); assert_eq!(result.models, expected); let requests = transport.requests(); @@ -1898,22 +1930,44 @@ mod tests { #[tokio::test] async fn orcarouter_validation_uses_shared_audio_chat_transcription() { - let (endpoint, request) = spawn_http_response("200 OK", "application/json", - r#"{"choices":[{"message":{"content":"transcript"}}]}"#); + let (endpoint, request) = spawn_http_response( + "200 OK", + "application/json", + r#"{"choices":[{"message":{"content":"transcript"}}]}"#, + ); let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ - (ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), - ]).await; + let channel = create_channel_with_values( + &credentials, + ChannelKind::Asr, + "orcarouter", + &[ + (ASR_ENDPOINT_ACCOUNT, &endpoint), + (ASR_API_KEY_ACCOUNT, "fixture-key"), + ], + ) + .await; let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); - service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); - let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + service + .validate(ProviderRequest { + kind: ProviderKind::Asr, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + let request = + String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); assert!(request.starts_with("POST /v1/chat/completions ")); - let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + let body: serde_json::Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); let content = &body["messages"][0]["content"]; assert_eq!(content[0]["type"], "text"); assert_eq!(content[1]["input_audio"]["format"], "wav"); - assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); + assert!(!content[1]["input_audio"]["data"] + .as_str() + .unwrap() + .starts_with("data:")); } async fn service_with_fake_transport() -> (ProviderService, Arc, String) diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 37d0f7ccf..a84c81a40 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -105,6 +105,8 @@ pub struct SettingsEffectPlan { #[serde(default, skip_serializing_if = "Option::is_none")] pub active_asr_provider: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_at_login: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub windows_keyboard: Option>, } @@ -120,6 +122,7 @@ impl SettingsEffectPlan { previous.active_asr_provider.clone(), next.active_asr_provider.clone(), ), + launch_at_login: changed(previous.launch_at_login, next.launch_at_login), windows_keyboard: changed(previous.into(), next.into()), } } @@ -127,6 +130,7 @@ impl SettingsEffectPlan { pub fn is_empty(&self) -> bool { self.hotkeys.is_none() && self.active_asr_provider.is_none() + && self.launch_at_login.is_none() && self.windows_keyboard.is_none() } } @@ -136,6 +140,7 @@ impl SettingsEffectPlan { pub enum SettingsEffectKind { WindowsKeyboard, ActiveAsrProvider, + LaunchAtLogin, Hotkeys, } diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index 2066964ae..6d8229830 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -8,7 +8,7 @@ use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error, read_or_default}; use crate::shared_types::LEARNED_VOCAB_NOTE; -use crate::types::{DictionaryEntry, VocabPresetStore}; +use crate::types::{DictionaryEntry, VocabPreset, VocabPresetStore}; /// Number of recently added manual entries that are guaranteed ASR hotword /// seats before hit-count ranking is applied. @@ -266,6 +266,34 @@ pub fn list_vocab_presets(data_dir: &Path) -> Result Vec { + serde_json::from_str(include_str!("../../../assets/vocab-presets.json")) + .expect("bundled vocabulary presets must be valid JSON") +} + +pub fn resolve_vocab_presets(store: &VocabPresetStore) -> Vec { + let mut presets = builtin_vocab_presets() + .into_iter() + .filter(|preset| !store.disabled_builtin_preset_ids.contains(&preset.id)) + .collect::>(); + for replacement in &store.overrides { + if let Some(existing) = presets + .iter_mut() + .find(|preset| preset.id == replacement.id) + { + *existing = replacement.clone(); + } + } + presets.extend( + store + .custom + .iter() + .filter(|preset| !preset.id.is_empty()) + .cloned(), + ); + presets +} + pub fn save_vocab_presets(data_dir: &Path, store: &VocabPresetStore) -> Result<(), BackendError> { let json = serde_json::to_vec_pretty(store) .map_err(|_| persistence_error("encode vocabulary presets"))?; @@ -355,6 +383,29 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn bundled_presets_resolve_disables_overrides_and_custom_entries() { + let store = VocabPresetStore { + custom: vec![VocabPreset { + id: "custom".into(), + name: "自定义".into(), + phrases: vec!["OpenLess".into()], + }], + overrides: vec![VocabPreset { + id: "programmer".into(), + name: "工程师".into(), + phrases: vec!["Rust".into()], + }], + disabled_builtin_preset_ids: vec!["chef".into()], + }; + let resolved = resolve_vocab_presets(&store); + assert!(resolved.iter().any(|preset| preset.id == "custom")); + assert!(resolved + .iter() + .any(|preset| preset.id == "programmer" && preset.name == "工程师")); + assert!(!resolved.iter().any(|preset| preset.id == "chef")); + } + #[test] fn asr_priority_preserves_fresh_manual_entries_and_dedupes_case_variants() { let entry = |phrase: &str, hits: u64, note: Option<&str>| DictionaryEntry { diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 1e35030d1..a63fdfffa 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -13,17 +13,41 @@ tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt- fs2 = "0.4" futures-util = "0.3" log = "0.4" +simplelog = "0.12" +base64 = "0.22" +minisign-verify = "0.2.5" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +semver = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +[features] +default = ["x11-overlay"] +# The capsule runs under XWayland so it can be placed at the bottom centre of +# the work area and told never to take the keyboard. Without this feature the +# capsule falls back to the compositor's own placement (pure Wayland). +x11-overlay = ["dep:x11rb"] + [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -arboard = { version = "3", features = ["wayland-data-control"] } keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] } -cpal = "0.15" -eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +cpal = { version = "0.18.2", default-features = false, features = ["pipewire", "pulseaudio"] } +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } +egui = "=0.33.3" +# wlr-layer-shell 胶囊窗口:KWin 6.7+/sway/Hyprland 等合成器直接给 +# 「贴底居中 + 不抢键盘焦点」的 overlay surface;不支持时回退到 X11 叠加层。 +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +glutin = { version = "0.32", default-features = false, features = ["egl", "wayland"] } +egui_glow = "0.33" +glow = "0.16" +raw-window-handle = "0.6" +eframe = { version = "=0.33.3", default-features = false, features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] } +image = { version = "0.25.10", default-features = false, features = ["png"] } libc = "0.2" +x11rb = { version = "0.13.2", optional = true } axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" diff --git a/openless-all/app/linux-egui/build.rs b/openless-all/app/linux-egui/build.rs new file mode 100644 index 000000000..464c4683c --- /dev/null +++ b/openless-all/app/linux-egui/build.rs @@ -0,0 +1,32 @@ +//! Stamp the product version into the Linux egui binary. +//! +//! The egui host and the Tauri app ship as one product, so the version shown in +//! the UI must track `package.json` — the release workflow derives the package +//! version from the same file. `CARGO_PKG_VERSION` only carries the internal +//! crate version (`0.1.0`), which is not what users should see. + +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=../package.json"); + let version = std::env::var("OPENLESS_LINUX_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(read_package_version) + .unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into())); + println!("cargo:rustc-env=OPENLESS_APP_VERSION={version}"); +} + +/// Extract the top-level `version` field. The build script stays +/// dependency-free on purpose, so this is a small targeted scan rather than a +/// JSON parse: `package.json` declares exactly one `"version"` key. +fn read_package_version() -> Option { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?); + let raw = std::fs::read_to_string(manifest.join("../package.json")).ok()?; + let key = "\"version\""; + let rest = &raw[raw.find(key)? + key.len()..]; + let rest = &rest[rest.find(':')? + 1..]; + let rest = &rest[rest.find('"')? + 1..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} diff --git a/openless-all/app/linux-egui/examples/headless_host.rs b/openless-all/app/linux-egui/examples/headless_host.rs index d7c45080c..7e7e6bf04 100644 --- a/openless-all/app/linux-egui/examples/headless_host.rs +++ b/openless-all/app/linux-egui/examples/headless_host.rs @@ -168,7 +168,10 @@ async fn main() -> Result<(), BackendError> { backend .cancel_less_computer(Some(less_computer_session)) .await?; - assert!(backend.less_computer_capture_cancelled(less_computer_session)); + // Core 2.0 cancellation is terminal and releases the capture lease. A host + // that observes the cancellation after the await must not expect the old + // lease's flag to remain queryable. + assert_eq!(backend.less_computer_active_session(), None); backend.abort_less_computer_capture(less_computer_session)?; assert_eq!(backend.less_computer_active_session(), None); diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 5ed9a5784..bae2831af 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -9,12 +9,24 @@ use openless_core::{ #[derive(Debug, Clone, Default)] pub struct LinuxCpalRecorder { preferred_device_name: Option, + recordings_dir: Option, } impl LinuxCpalRecorder { pub fn new(preferred_device_name: Option) -> Self { Self { preferred_device_name, + recordings_dir: None, + } + } + + pub fn with_recordings_dir( + preferred_device_name: Option, + recordings_dir: std::path::PathBuf, + ) -> Self { + Self { + preferred_device_name, + recordings_dir: Some(recordings_dir), } } } @@ -32,11 +44,22 @@ impl AudioRecorder for LinuxCpalRecorder { .microphone_device_name .clone() .or_else(|| self.preferred_device_name.clone()); + // Platform effect, applied by the host recorder exactly like the Tauri + // audio adapter. Never owned by Core; restore is the guard's Drop. + let mute_during_recording = context.recording.mute_during_recording; + let recordings_dir = self.recordings_dir.clone(); Box::pin(async move { #[cfg(target_os = "linux")] { tokio::task::spawn_blocking(move || { - start_linux_recording(session_id, preferred_device_name, consumer, progress) + start_linux_recording( + session_id, + preferred_device_name, + recordings_dir, + mute_during_recording, + consumer, + progress, + ) }) .await .map_err(|error| { @@ -63,10 +86,77 @@ struct LinuxActiveRecording { stop: Arc, thread: Option>, runtime_error: Arc>>, + archive: Option>, + /// Holds the output-mute guard while capture is live. Its `Drop` restores + /// the sink on every terminal path (stop/cancel/error/drop/shutdown). + mute: Option, +} + +impl Drop for LinuxActiveRecording { + fn drop(&mut self) { + // Taking the guard here forces the field to be consumed (and therefore + // restored) even if `stop()` is never reached — e.g. the handle is + // dropped directly on an early error or during Core shutdown before it + // had a chance to call stop. Double restore is harmless because Drop + // of an already-taken guard is a no-op. + self.mute.take(); + } +} + +#[cfg(target_os = "linux")] +struct LinuxRecordingArchive { + path: std::path::PathBuf, + available: Arc, +} + +#[cfg(target_os = "linux")] +impl openless_core::RecordingArchive for LinuxRecordingArchive { + fn is_available(&self) -> bool { + self.available.load(std::sync::atomic::Ordering::Acquire) + } + + fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { + let path = self.path.clone(); + Box::pin(async move { + let wav = tokio::fs::read(path).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("read Linux recording archive: {error}"), + ) + })?; + canonical_wav_pcm(&wav).map(ToOwned::to_owned) + }) + } + + fn discard(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = self.path.clone(); + let available = Arc::clone(&self.available); + Box::pin(async move { + match tokio::fs::remove_file(path).await { + Ok(()) => available.store(false, std::sync::atomic::Ordering::Release), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + available.store(false, std::sync::atomic::Ordering::Release); + } + Err(error) => { + return Err(BackendError::new( + BackendErrorCode::Persistence, + format!("discard Linux recording archive: {error}"), + )); + } + } + Ok(()) + }) + } } #[cfg(target_os = "linux")] impl ActiveRecording for LinuxActiveRecording { + fn archive(&self) -> Option> { + self.archive + .as_ref() + .map(|archive| Arc::clone(archive) as Arc) + } + fn stop(mut self: Box) -> BoxFuture<'static, Result<(), BackendError>> { Box::pin(async move { self.stop.store(true, std::sync::atomic::Ordering::Release); @@ -100,18 +190,48 @@ impl ActiveRecording for LinuxActiveRecording { #[cfg(target_os = "linux")] fn start_linux_recording( - _session_id: SessionId, + session_id: SessionId, preferred_device_name: Option, + recordings_dir: Option, + mute_during_recording: bool, consumer: Arc, progress: Arc, ) -> Result, BackendError> { use std::sync::atomic::AtomicBool; + // Mute is best-effort and independent of capture availability (mirrors the + // Tauri reference): if it fails we log and continue recording. If capture + // later fails on this path, the guard drops here and restores the sink. + let mute = if mute_during_recording { + match crate::audio_mute::AudioMuteGuard::activate() { + Ok(guard) => Some(guard), + Err(error) => { + log::warn!("[audio-mute] failed to mute output; capture continues: {error}"); + None + } + } + } else { + None + }; let stop = Arc::new(AtomicBool::new(false)); let runtime_error = Arc::new(std::sync::Mutex::new(None)); let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); let stop_for_thread = Arc::clone(&stop); let runtime_error_for_thread = Arc::clone(&runtime_error); + let (writer, archive) = match recordings_dir { + Some(directory) => { + match LinuxWavWriter::create(directory.join(format!("{session_id}.wav"))) { + Ok((writer, archive)) => { + (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) + } + Err(error) => { + log::warn!("failed to create Linux recording archive: {error}"); + (None, None) + } + } + } + None => (None, None), + }; let thread = std::thread::Builder::new() .name("openless-linux-recorder".to_string()) .spawn(move || { @@ -119,6 +239,7 @@ fn start_linux_recording( preferred_device_name, consumer, progress, + writer, stop_for_thread, runtime_error_for_thread, startup_tx, @@ -136,13 +257,17 @@ fn start_linux_recording( stop, thread: Some(thread), runtime_error, + archive, + mute, })), Ok(Err(error)) => { let _ = thread.join(); + // `mute` is dropped on the error path, restoring the sink. Err(error) } Err(error) => { let _ = thread.join(); + // `mute` is dropped on the error path, restoring the sink. Err(BackendError::new( BackendErrorCode::Platform, format!("Linux recorder thread exited during startup: {error}"), @@ -156,38 +281,53 @@ fn run_audio_thread( preferred_device_name: Option, consumer: Arc, progress: Arc, + writer: Option>>, stop: Arc, runtime_error: Arc>>, startup: std::sync::mpsc::SyncSender>, ) { - use cpal::traits::{DeviceTrait, StreamTrait}; - - let result = (|| { - let host = cpal::default_host(); - let device = select_input_device(&host, preferred_device_name.as_deref())?; - let supported = device - .default_input_config() - .map_err(|error| classify_audio_error("default input config", error.to_string()))?; - let sample_format = supported.sample_format(); - let input_sample_rate = supported.sample_rate().0; - let channels = usize::from(supported.channels()); - let config: cpal::StreamConfig = supported.into(); - let stream = build_input_stream( - &device, - &config, - sample_format, - input_sample_rate, - channels, - consumer, - progress, - Arc::clone(&stop), - runtime_error, - )?; - stream - .play() - .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; - Ok::<_, BackendError>(stream) - })(); + let mut result = Err(BackendError::new( + BackendErrorCode::Platform, + "no Linux audio backend is available", + )); + for backend in audio_backend_order() { + let Some(host_id) = cpal::available_hosts() + .into_iter() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let host = match cpal::host_from_id(host_id) { + Ok(host) => host, + Err(error) => { + result = Err(classify_audio_error( + &format!("initialize {backend} backend"), + error.to_string(), + )); + log::warn!("{backend} audio backend unavailable: {error}"); + continue; + } + }; + match try_start_audio_stream( + &host, + backend, + preferred_device_name.as_deref(), + &consumer, + &progress, + &writer, + &stop, + &runtime_error, + ) { + Ok(stream) => { + result = Ok(stream); + break; + } + Err(error) => { + log::warn!("{backend} audio backend failed; trying next backend: {error}"); + result = Err(error); + } + } + } let stream = match result { Ok(stream) => { @@ -205,19 +345,68 @@ fn run_audio_thread( drop(stream); } +#[cfg(target_os = "linux")] +fn audio_backend_order() -> [&'static str; 3] { + // Native desktop servers are preferred because they handle device policy, + // hot-plugging and format conversion. ALSA remains the universal fallback. + ["pipewire", "pulseaudio", "alsa"] +} + +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +fn try_start_audio_stream( + host: &cpal::Host, + backend: &str, + preferred_device_name: Option<&str>, + consumer: &Arc, + progress: &Arc, + writer: &Option>>, + stop: &Arc, + runtime_error: &Arc>>, +) -> Result { + use cpal::traits::{DeviceTrait, StreamTrait}; + + let device = select_input_device(host, preferred_device_name).map_err(|error| { + BackendError::new(error.code, format!("{backend} backend: {}", error.message)) + })?; + let supported = device + .default_input_config() + .map_err(|error| classify_audio_error("default input config", error.to_string()))?; + let sample_format = supported.sample_format(); + let input_sample_rate = supported.sample_rate(); + let channels = usize::from(supported.channels()); + let config: cpal::StreamConfig = supported.into(); + let stream = build_input_stream( + &device, + &config, + sample_format, + input_sample_rate, + channels, + Arc::clone(consumer), + Arc::clone(progress), + writer.clone(), + Arc::clone(stop), + Arc::clone(runtime_error), + )?; + stream + .play() + .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; + Ok(stream) +} + #[cfg(target_os = "linux")] fn select_input_device( host: &cpal::Host, preferred_device_name: Option<&str>, ) -> Result { - use cpal::traits::{DeviceTrait, HostTrait}; + use cpal::traits::HostTrait; if let Some(preferred) = preferred_device_name.filter(|name| !name.trim().is_empty()) { let devices = host .input_devices() .map_err(|error| classify_audio_error("enumerate input devices", error.to_string()))?; for device in devices { - if device.name().ok().as_deref() == Some(preferred) { + if device.to_string() == preferred { return Ok(device); } } @@ -243,6 +432,7 @@ fn build_input_stream( channels: usize, consumer: Arc, progress: Arc, + writer: Option>>, stop: Arc, runtime_error: Arc>>, ) -> Result { @@ -254,17 +444,27 @@ fn build_input_stream( let progress = Arc::clone(&progress); let stop_for_error = Arc::clone(&stop); let runtime_error = Arc::clone(&runtime_error); + let writer = writer.clone(); let started = std::time::Instant::now(); let mut normalizer = openless_core::PcmNormalizer::default(); device .build_input_stream::<$sample, _, _>( - config, + *config, move |data: &[$sample], _| { let samples = data.iter().copied().map($to_f32).collect::>(); if let Some(chunk) = normalizer.process(&samples, channels, input_sample_rate) { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); + if let Some(writer) = &writer { + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&chunk.pcm_i16_le) + { + log::warn!("Linux recording archive write failed: {error}"); + } + } let _ = progress .publish_level(started.elapsed().as_millis() as u64, chunk.level); } @@ -309,6 +509,103 @@ fn build_input_stream( } } +#[cfg(target_os = "linux")] +struct LinuxWavWriter { + file: std::fs::File, + path: std::path::PathBuf, + bytes_written: u32, + available: Arc, +} + +#[cfg(target_os = "linux")] +impl LinuxWavWriter { + fn create(path: std::path::PathBuf) -> std::io::Result<(Self, Arc)> { + use std::io::Write as _; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + file.write_all(&wav_header(0))?; + let available = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let archive = Arc::new(LinuxRecordingArchive { + path: path.clone(), + available: Arc::clone(&available), + }); + Ok(( + Self { + file, + path, + bytes_written: 0, + available, + }, + archive, + )) + } + + fn append(&mut self, pcm: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + self.file.write_all(pcm)?; + self.bytes_written = self + .bytes_written + .saturating_add(pcm.len().min(u32::MAX as usize) as u32); + Ok(()) + } +} + +#[cfg(target_os = "linux")] +impl Drop for LinuxWavWriter { + fn drop(&mut self) { + use std::io::{Seek as _, SeekFrom, Write as _}; + let result = self + .file + .seek(SeekFrom::Start(0)) + .and_then(|_| self.file.write_all(&wav_header(self.bytes_written))) + .and_then(|_| self.file.sync_all()); + if let Err(error) = result { + self.available + .store(false, std::sync::atomic::Ordering::Release); + let _ = std::fs::remove_file(&self.path); + log::warn!("failed to finalize Linux recording archive: {error}"); + } + } +} + +pub(crate) fn wav_header(data_size: u32) -> [u8; 44] { + let mut header = [0u8; 44]; + header[0..4].copy_from_slice(b"RIFF"); + header[4..8].copy_from_slice(&data_size.saturating_add(36).to_le_bytes()); + header[8..12].copy_from_slice(b"WAVE"); + header[12..16].copy_from_slice(b"fmt "); + header[16..20].copy_from_slice(&16u32.to_le_bytes()); + header[20..22].copy_from_slice(&1u16.to_le_bytes()); + header[22..24].copy_from_slice(&1u16.to_le_bytes()); + header[24..28].copy_from_slice(&16_000u32.to_le_bytes()); + header[28..32].copy_from_slice(&32_000u32.to_le_bytes()); + header[32..34].copy_from_slice(&2u16.to_le_bytes()); + header[34..36].copy_from_slice(&16u16.to_le_bytes()); + header[36..40].copy_from_slice(b"data"); + header[40..44].copy_from_slice(&data_size.to_le_bytes()); + header +} + +fn canonical_wav_pcm(wav: &[u8]) -> Result<&[u8], BackendError> { + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || &wav[36..40] != b"data" + || !(wav.len() - 44).is_multiple_of(2) + { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "Linux recording archive is not canonical 16 kHz mono PCM WAV", + )); + } + Ok(&wav[44..]) +} + #[cfg(any(target_os = "linux", test))] fn classify_audio_error(context: &str, message: String) -> BackendError { let lower = message.to_ascii_lowercase(); @@ -336,4 +633,18 @@ mod tests { BackendErrorCode::Platform ); } + + #[test] + fn wav_archive_header_and_pcm_round_trip() { + let pcm = [1u8, 0, 2, 0]; + let mut wav = wav_header(pcm.len() as u32).to_vec(); + wav.extend_from_slice(&pcm); + assert_eq!(canonical_wav_pcm(&wav).unwrap(), pcm); + } + + #[cfg(target_os = "linux")] + #[test] + fn audio_backends_are_ordered_from_desktop_server_to_universal_fallback() { + assert_eq!(audio_backend_order(), ["pipewire", "pulseaudio", "alsa"]); + } } diff --git a/openless-all/app/linux-egui/src/audio_cue.rs b/openless-all/app/linux-egui/src/audio_cue.rs new file mode 100644 index 000000000..0d65577e2 --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_cue.rs @@ -0,0 +1,301 @@ +//! Native recording start/stop audio cues for Linux. +//! +//! The Windows/macOS Tauri shell synthesizes a "recording started" chime with +//! the Web Audio API in a webview (`app/src/lib/audioCue.ts`) and silences it +//! when the recording ends. This crate is the native egui host, so there is no +//! webview; the equivalent cue is rendered as PCM and played to the default +//! output sink with cpal (already a dependency for the microphone). +//! +//! Honesty rules: +//! - The frame is never blocked: `play_cue_start`/`play_cue_stop` enqueue a +//! detached worker thread and return immediately. Any real failure to open +//! the default output sink is logged and otherwise silent — a cue is +//! feedback, never a hard error, matching the reference's "silently degrade, +//! never throw" rule. +//! - Cues are gated by the caller on `audio_cue_on_record`, and the start cue +//! is additionally suppressed when `mute_during_recording` is active (an +//! audible start cue through a deliberately muted sink is both pointless and +//! a needless PipeWire/KDE sink-input blip). The stop cue may still play +//! after output is restored. +//! - Synthesis is pure (`render_cue_mono`) so it is unit-testable without any +//! audio device; the cpal playback path still needs real-device evidence on +//! X11/Wayland before it may be reported as verified. + +use std::sync::Arc; + +/// A single synthesized sine note relative to the cue start. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CueTone { + /// Frequency in Hz. + pub freq_hz: f32, + /// Start offset from the cue start in milliseconds. + pub start_ms: f32, + /// Duration in milliseconds. + pub duration_ms: f32, + /// Exponential-envelope peak gain (0..1). + pub peak_gain: f32, +} + +/// "Recording started" chime: rising minor third (A5 -> C#6), mirroring the +/// reference Web Audio cue so Linux and Windows/macOS share one sound. +pub fn start_cue_tones() -> Vec { + vec![ + CueTone { + freq_hz: 880.0, + start_ms: 0.0, + duration_ms: 130.0, + peak_gain: 0.16, + }, + CueTone { + freq_hz: 1108.73, + start_ms: 95.0, + duration_ms: 170.0, + peak_gain: 0.18, + }, + ] +} + +/// "Recording ended" cue: descending minor third (E5 -> C5). Soft and short so +/// it reads as a clear "done" without masking the terminal feedback. +pub fn stop_cue_tones() -> Vec { + vec![ + CueTone { + freq_hz: 659.25, + start_ms: 0.0, + duration_ms: 120.0, + peak_gain: 0.13, + }, + CueTone { + freq_hz: 523.25, + start_ms: 90.0, + duration_ms: 150.0, + peak_gain: 0.15, + }, + ] +} + +/// Total cue duration in milliseconds (end of the last tone). +pub fn cue_total_duration_ms(tones: &[CueTone]) -> u32 { + tones.iter().fold(0u32, |acc, tone| { + acc.max((tone.start_ms + tone.duration_ms).round() as u32) + }) +} + +/// Render a cue to mono interleaved `f32` samples in `[-1, 1]`. Pure — no +/// device access — so it is fully unit-testable on any target. +pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec { + if tones.is_empty() || sample_rate == 0 { + return Vec::new(); + } + let sr = sample_rate as f32; + let total_samples = + (((cue_total_duration_ms(tones) as f32) / 1000.0 * sr).ceil() as usize).max(1); + let mut out = vec![0.0f32; total_samples]; + for tone in tones { + let start = (tone.start_ms / 1000.0 * sr).round() as usize; + let dur = ((tone.duration_ms / 1000.0) * sr).round() as usize; + let attack = ((0.004 * sr).round() as usize).clamp(1, dur.max(1)); + let release_span = (dur.saturating_sub(attack)).max(1) as f32; + for i in 0..dur { + let idx = start + i; + if idx >= out.len() { + break; + } + let attack_env = if i < attack { + i as f32 / attack as f32 + } else { + 1.0 + }; + let release_env = if i >= attack { + let frac = (i - attack) as f32 / release_span; + (-5.0 * frac).exp() + } else { + 1.0 + }; + let env = attack_env * release_env; + let phase = std::f32::consts::TAU * tone.freq_hz * (idx as f32 / sr); + out[idx] += phase.sin() * tone.peak_gain * env; + } + } + for sample in &mut out { + *sample = sample.clamp(-1.0, 1.0); + } + out +} + +/// Play a start cue asynchronously (never blocks the caller/frame). +pub fn play_cue_start() { + play_cue(start_cue_tones()); +} + +/// Play a stop cue asynchronously (never blocks the caller/frame). +pub fn play_cue_stop() { + play_cue(stop_cue_tones()); +} + +/// Best-effort asynchronous playback on a detached worker thread. +fn play_cue(tones: Vec) { + if tones.is_empty() { + return; + } + std::thread::Builder::new() + .name("openless-audio-cue".to_string()) + .spawn(move || { + if let Err(error) = play_cue_blocking(&tones) { + log::debug!("[audio-cue] cue playback unavailable: {error}"); + } + }) + .map_err(|error| log::debug!("[audio-cue] failed to spawn cue thread: {error}")) + .ok(); +} + +#[cfg(target_os = "linux")] +fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no Linux default output device".to_string())?; + let supported = device + .default_output_config() + .map_err(|error| format!("default output config failed: {error}"))?; + let sample_format = supported.sample_format(); + let sample_rate = supported.sample_rate(); + let channels = usize::from(supported.channels()).max(1); + let config: cpal::StreamConfig = supported.into(); + let mono = Arc::new(render_cue_mono(tones, sample_rate)); + if mono.is_empty() { + return Ok(()); + } + let idx = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let build = |format: cpal::SampleFormat| -> Result { + macro_rules! make { + ($ty:ty, $convert:expr) => {{ + let mono = Arc::clone(&mono); + let idx = Arc::clone(&idx); + let done = Arc::clone(&done); + let device = &device; + let config = &config; + device.build_output_stream::<$ty, _, _>( + *config, + move |data: &mut [$ty], _: &cpal::OutputCallbackInfo| { + let frames = data.len() / channels; + let mut pos = idx.load(std::sync::atomic::Ordering::Acquire); + for frame in 0..frames { + let sample = if pos < mono.len() { mono[pos] } else { 0.0 }; + pos += 1; + let converted = $convert(sample); + for channel in 0..channels { + data[frame * channels + channel] = converted; + } + } + if pos >= mono.len() { + done.store(true, std::sync::atomic::Ordering::Release); + } + idx.store(pos, std::sync::atomic::Ordering::Release); + }, + move |_error| {}, + None, + ) + }}; + } + match format { + cpal::SampleFormat::F32 => make!(f32, |s: f32| s.clamp(-1.0, 1.0)), + cpal::SampleFormat::I16 => { + make!(i16, |s: f32| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16) + } + cpal::SampleFormat::U16 => make!(u16, |s: f32| { + (((s.clamp(-1.0, 1.0) + 1.0) / 2.0) * u16::MAX as f32) as u16 + }), + cpal::SampleFormat::I32 => { + make!(i32, |s: f32| (s.clamp(-1.0, 1.0) * i32::MAX as f32) as i32) + } + other => { + // Unusual sink format: fall back to f32 which most Linux sinks + // accept even when it is not the default config. + let _ = other; + make!(f32, |s: f32| s.clamp(-1.0, 1.0)) + } + } + }; + + let stream = build(sample_format) + .or_else(|_| build(cpal::SampleFormat::F32)) + .map_err(|error| format!("build output stream failed: {error}"))?; + stream + .play() + .map_err(|error| format!("start output stream failed: {error}"))?; + + // Keep the stream alive on this thread until the cue buffer is consumed or + // a short watchdog elapses, then drop it to release the sink. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !done.load(std::sync::atomic::Ordering::Acquire) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + drop(stream); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> { + Err("audio cue playback is only available on Linux".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_cue_is_a_rising_two_tone_and_stop_is_descending() { + let start = start_cue_tones(); + assert_eq!(start.len(), 2); + assert!(start[0].freq_hz < start[1].freq_hz, "start cue rises"); + let stop = stop_cue_tones(); + assert_eq!(stop.len(), 2); + assert!(stop[0].freq_hz > stop[1].freq_hz, "stop cue descends"); + } + + #[test] + fn total_duration_is_last_tone_end() { + let start = start_cue_tones(); + assert_eq!(cue_total_duration_ms(&start), 265); + assert!(cue_total_duration_ms(&stop_cue_tones()) > 0); + } + + #[test] + fn rendering_is_bounded_nonempty_and_expected_length() { + let sr = 48_000; + let mono = render_cue_mono(&start_cue_tones(), sr); + let expected = ((cue_total_duration_ms(&start_cue_tones()) as f32 / 1000.0) * sr as f32) + .ceil() as usize; + assert_eq!(mono.len(), expected); + assert!(mono.iter().any(|s| s.abs() > 1e-3), "cue is not silent"); + assert!( + mono.iter().all(|s| (-1.0..=1.0).contains(s)), + "cue stays within [-1, 1]" + ); + // Envelope is peak-limited well below full scale so it never clips. + let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs())); + assert!( + peak <= 0.34, + "start cue peak {peak} stays under envelope sum" + ); + } + + #[test] + fn empty_tones_render_to_empty_and_play_is_a_noop() { + assert!(render_cue_mono(&[], 44_100).is_empty()); + play_cue(Vec::new()); + } + + #[test] + fn mono_cue_respects_sample_rate_scaling() { + let at_44k = render_cue_mono(&stop_cue_tones(), 44_100); + let at_48k = render_cue_mono(&stop_cue_tones(), 48_000); + // Higher sample rate yields proportionally more samples for the same cue. + assert!(at_48k.len() > at_44k.len()); + } +} diff --git a/openless-all/app/linux-egui/src/audio_mute.rs b/openless-all/app/linux-egui/src/audio_mute.rs new file mode 100644 index 000000000..863d2d15e --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_mute.rs @@ -0,0 +1,212 @@ +//! Temporary system-output mute while recording. +//! +//! Linux parity with the legacy desktop [`AudioMuteGuard`] behavior. +//! `mute_during_recording` is applied by +//! the host recorder (never by Core) so the capture itself never hears the +//! user's speakers. Restore is guaranteed by RAII: the guard restores the +//! previous mute state when it is dropped, which happens on every terminal +//! path of the recorder lifecycle — normal stop, cancel, runtime fault/error +//! and process shutdown — because Core consumes/drops the active recording +//! handle on each of those. +//! +//! Muting is intentionally best-effort like the reference: if neither `wpctl` +//! (PipeWire) nor `pactl` (PulseAudio) can drive the default sink, activation +//! fails but capture still proceeds. The parsed state helpers are pure so +//! they can be unit-tested without a live audio server. + +/// The output-mute backend discovered at activation time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MuteBackend { + Wpctl, + Pactl, +} + +/// Guard that restores the previous default-sink mute state on drop. +#[derive(Debug)] +pub struct AudioMuteGuard { + inner: Option, +} + +impl AudioMuteGuard { + /// Mute the default output sink unless it is already muted, capturing the + /// previous state for later restore. Best-effort: on failure returns an + /// error and leaves the sink untouched. + pub fn activate() -> Result { + Ok(Self { + inner: Some(platform::activate()?), + }) + } + + /// A no-op guard used when `mute_during_recording` is disabled. + pub fn none() -> Self { + Self { inner: None } + } +} + +impl Drop for AudioMuteGuard { + fn drop(&mut self) { + if let Some(inner) = self.inner.take() { + inner.restore(); + } + } +} + +/// `true` when a `wpctl get-volume` capture reports the sink muted. +pub fn parse_wpctl_muted(output: &str) -> bool { + output.contains("[MUTED]") +} + +/// `true` when a `pactl get-sink-mute` capture reports the sink muted. +pub fn parse_pactl_muted(output: &str) -> bool { + let lower = output.to_ascii_lowercase(); + lower.contains("yes") || output.contains("是") +} + +#[cfg(target_os = "linux")] +mod platform { + use super::{parse_pactl_muted, parse_wpctl_muted, MuteBackend}; + use std::process::Command; + + #[derive(Debug)] + pub struct PlatformMuteGuard { + backend: MuteBackend, + was_muted: bool, + } + + pub fn activate() -> Result { + if let Ok(was_muted) = wpctl_muted() { + if !was_muted { + set_wpctl_muted(true)?; + } + return Ok(PlatformMuteGuard { + backend: MuteBackend::Wpctl, + was_muted, + }); + } + let was_muted = pactl_muted()?; + if !was_muted { + set_pactl_muted(true)?; + } + Ok(PlatformMuteGuard { + backend: MuteBackend::Pactl, + was_muted, + }) + } + + impl PlatformMuteGuard { + pub fn restore(self) { + let result = match self.backend { + MuteBackend::Wpctl => set_wpctl_muted(self.was_muted), + MuteBackend::Pactl => set_pactl_muted(self.was_muted), + }; + if let Err(error) = result { + log::warn!("[audio-mute] restore output mute failed: {error}"); + } + } + } + + fn wpctl_muted() -> Result { + let output = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .map_err(|error| format!("wpctl get-volume failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_wpctl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_wpctl_muted(muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + let output = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", value]) + .output() + .map_err(|error| format!("wpctl set-mute failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) + } + } + + fn pactl_muted() -> Result { + let output = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .map_err(|error| format!("pactl get-sink-mute failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_pactl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_pactl_muted(muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + let output = Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", value]) + .output() + .map_err(|error| format!("pactl set-sink-mute failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) + } + } +} + +#[cfg(not(target_os = "linux"))] +mod platform { + #[derive(Debug)] + pub struct PlatformMuteGuard; + + pub fn activate() -> Result { + Err("output mute is not supported on this platform".to_string()) + } + + impl PlatformMuteGuard { + pub fn restore(self) {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wpctl_output_parser_detects_muted_flag() { + assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]")); + assert!(!parse_wpctl_muted("Volume: 0.82")); + } + + #[test] + fn pactl_output_parser_detects_muted_flag_in_any_locale() { + assert!(parse_pactl_muted("Mute: yes")); + assert!(parse_pactl_muted("静音:是")); + assert!(!parse_pactl_muted("Mute: no")); + assert!(!parse_pactl_muted("静音:否")); + } + + #[test] + fn disabled_guard_is_a_noop_that_drops_without_panicking() { + // `none()` is a pure no-op guard; dropping it must not panic and has no + // command side effect to assert, so we only verify construction/drop. + let guard = AudioMuteGuard::none(); + drop(guard); + } +} diff --git a/openless-all/app/linux-egui/src/audio_player.rs b/openless-all/app/linux-egui/src/audio_player.rs new file mode 100644 index 000000000..7d694bf28 --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_player.rs @@ -0,0 +1,161 @@ +//! In-app playback for history recordings. +//! +//! Recordings are 16 kHz mono signed-16 WAV. This plays them through the default +//! CPAL output device with linear resampling to whatever rate the device runs +//! at, so the history page can show a real player bar instead of shelling out to +//! an external player. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +/// A playing clip. Dropping it stops playback. +pub struct ClipPlayer { + _stream: cpal::Stream, + played_samples: Arc, + total_samples: usize, + channels: usize, + sample_rate: u32, + finished: Arc, +} + +impl ClipPlayer { + /// Play `pcm` (16 kHz mono little-endian i16 bytes). + pub fn play(pcm: &[u8]) -> Result { + let mono: Vec = pcm + .chunks_exact(2) + .map(|pair| i16::from_le_bytes([pair[0], pair[1]]) as f32 / 32_768.0) + .collect(); + if mono.is_empty() { + return Err("empty recording".to_string()); + } + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no default output device".to_string())?; + let supported = device + .default_output_config() + .map_err(|error| error.to_string())?; + let sample_rate = supported.sample_rate(); + let channels = supported.channels() as usize; + let sample_format = supported.sample_format(); + let config: cpal::StreamConfig = supported.into(); + + // Linear resample 16 kHz -> device rate and fan out to every channel. + let ratio = 16_000.0_f64 / sample_rate as f64; + let out_frames = (mono.len() as f64 / ratio).ceil() as usize; + let mut interleaved: Vec = Vec::with_capacity(out_frames * channels); + for frame in 0..out_frames { + let source = frame as f64 * ratio; + let index = source.floor() as usize; + let frac = (source - index as f64) as f32; + let first = mono.get(index).copied().unwrap_or(0.0); + let second = mono.get(index + 1).copied().unwrap_or(first); + let value = first + (second - first) * frac; + for _ in 0..channels { + interleaved.push(value); + } + } + + let data = Arc::new(interleaved); + let played_samples = Arc::new(AtomicUsize::new(0)); + let finished = Arc::new(AtomicBool::new(false)); + let total_samples = data.len(); + + let stream = build_stream( + &device, + config, + sample_format, + data.clone(), + played_samples.clone(), + finished.clone(), + )?; + stream.play().map_err(|error| error.to_string())?; + + Ok(Self { + _stream: stream, + played_samples, + total_samples, + channels, + sample_rate, + finished, + }) + } + + /// Playback head in milliseconds. + pub fn position_ms(&self) -> u64 { + let frames = self.played_samples.load(Ordering::Relaxed) / self.channels.max(1); + (frames as u64 * 1000) / self.sample_rate.max(1) as u64 + } + + /// Clip length in milliseconds. + pub fn total_ms(&self) -> u64 { + let frames = self.total_samples / self.channels.max(1); + (frames as u64 * 1000) / self.sample_rate.max(1) as u64 + } + + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } +} + +fn build_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + format: cpal::SampleFormat, + data: Arc>, + played: Arc, + finished: Arc, +) -> Result { + let error_callback = |error| log::warn!("audio playback error: {error}"); + // Fill the output buffer from `data`, starting at the played head. + macro_rules! writer { + ($ty:ty, $convert:expr) => {{ + let data = data.clone(); + let played = played.clone(); + let finished = finished.clone(); + device + .build_output_stream( + config.clone(), + move |output: &mut [$ty], _| { + let convert: fn(f32) -> $ty = $convert; + let start = played.load(Ordering::Relaxed); + let mut done = false; + for (offset, slot) in output.iter_mut().enumerate() { + let index = start + offset; + match data.get(index) { + Some(value) => *slot = convert(*value), + None => { + done = true; + *slot = convert(0.0); + } + } + } + played.store(start + output.len(), Ordering::Relaxed); + if done { + finished.store(true, Ordering::Relaxed); + } + }, + error_callback, + None, + ) + .map_err(|error| error.to_string()) + }}; + } + match format { + cpal::SampleFormat::F32 => writer!(f32, |value| value), + cpal::SampleFormat::I16 => { + writer!(i16, |value| (value * 32_767.0).clamp(-32_768.0, 32_767.0) + as i16) + } + cpal::SampleFormat::U16 => { + writer!( + u16, + |value| ((value * 0.5 + 0.5) * 65_535.0).clamp(0.0, 65_535.0) as u16 + ) + } + other => Err(format!("unsupported output sample format: {other:?}")), + } +} diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 7c1ff6a7f..a02dc6665 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use futures_util::future::BoxFuture; use openless_core::{ - AudioConsumer, AudioRecorder, BackendConfig, BackendDependencies, BackendError, - BackendErrorCode, BackendRepositories, BackendServices, CredentialStore, DictationEngine, - DictationEngineRouter, MarketplaceConfig, ModelStore, ModelStoreConfig, OpenLessBackend, - PipelineDictationEngine, PolishFailurePolicy, ProviderService, SettingsRuntime, - SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, SharedCloudTranscriptionEngine, - SharedOmniDictationEngine, TextInserter, TextPolisher, TextPolisherRouter, TextStreamSink, - TranscriptOutput, TranscriptionEngine, TranscriptionRouter, TranscriptionSession, - SHARED_CLOUD_ASR_PROVIDER_TYPES, SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, + AudioRecorder, BackendConfig, BackendDependencies, BackendError, BackendErrorCode, + BackendRepositories, BackendServices, CredentialStore, DictationEngine, DictationEngineRouter, + MarketplaceConfig, OpenLessBackend, PipelineDictationEngine, PolishFailurePolicy, + ProviderService, SettingsRuntime, SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, + SharedCloudTranscriptionEngine, SharedOmniDictationEngine, TextInserter, TextPolisher, + TextPolisherRouter, TranscriptionEngine, TranscriptionRouter, SHARED_CLOUD_ASR_PROVIDER_TYPES, + SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, }; use crate::qa::LinuxQaRuntime; @@ -47,619 +46,6 @@ impl openless_core::TaskSpawner for LinuxTaskSpawner { } } -const QWEN_PREPARE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -const QWEN_TRANSCRIBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - -#[derive(Debug)] -struct QwenProcessOutput { - status: std::process::ExitStatus, - stdout: Vec, - stderr: Vec, -} - -async fn run_qwen_process( - executable: std::path::PathBuf, - args: Vec, - stdin: Option>, - cancelled: Arc, - timeout: std::time::Duration, -) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - let mut command = tokio::process::Command::new(&executable); - command - .args(args) - .stdin(if stdin.is_some() { - std::process::Stdio::piped() - } else { - std::process::Stdio::null() - }) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - crate::coding_agent::isolate_process_group(&mut command); - let mut child = command.spawn().map_err(|error| { - BackendError::new( - if matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied - ) { - BackendErrorCode::Unsupported - } else { - BackendErrorCode::Platform - }, - format!("Qwen ASR runtime unavailable: {error}"), - ) - })?; - let stdin_task = stdin.map(|input| { - let mut pipe = child.stdin.take().expect("piped Qwen stdin"); - tokio::spawn(async move { - pipe.write_all(&input).await?; - pipe.shutdown().await - }) - }); - let mut stdout = child.stdout.take().expect("piped Qwen stdout"); - let mut stderr = child.stderr.take().expect("piped Qwen stderr"); - let stdout_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let stderr_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let started = tokio::time::Instant::now(); - let status = loop { - tokio::select! { - status = child.wait() => break Ok(status.map_err(qwen_platform_error)?), - _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => { - let error = if cancelled.load(std::sync::atomic::Ordering::Acquire) { - Some(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )) - } else if started.elapsed() >= timeout { - Some(BackendError::new( - BackendErrorCode::Provider, - "Qwen ASR operation timed out", - ).retryable(true)) - } else { - None - }; - if let Some(error) = error { - crate::coding_agent::kill_process_group(&mut child)?; - let _ = child.wait().await; - break Err(error); - } - } - } - }; - let stdin_result = match stdin_task { - Some(task) => Some(task.await.map_err(qwen_internal_error)?), - None => None, - }; - let stdout = stdout_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let stderr = stderr_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let status = status?; - if status.success() { - if let Some(result) = stdin_result { - result.map_err(qwen_platform_error)?; - } - } - Ok(QwenProcessOutput { - status, - stdout, - stderr, - }) -} - -fn qwen_platform_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Platform, error.to_string()) -} - -fn qwen_internal_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Internal, error.to_string()) -} - -fn qwen_executable() -> Option { - crate::resources::detect_qwen_runtime_path() - .ok() - .filter(|path| qwen_executable_is_available(path)) -} - -fn qwen_executable_is_available(path: &std::path::Path) -> bool { - let Ok(metadata) = path.metadata() else { - return false; - }; - if !metadata.is_file() { - return false; - } - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(target_os = "linux"))] - true -} - -struct LinuxGenericAsrEngine { - root: std::sync::Arc>, - executable: Option, -} - -struct LinuxGenericLocalAsrRuntime { - root: std::sync::Arc>, - loaded_model: std::sync::Arc>>, - // ponytail: one shared cancellation flag serializes model operations; per-model tokens if parallel downloads are needed. - cancelled: std::sync::Arc, - executable: Option, -} - -impl Default for LinuxGenericLocalAsrRuntime { - fn default() -> Self { - Self::from_models_root(Self::default_root(), qwen_executable()) - } -} - -impl LinuxGenericLocalAsrRuntime { - const READY_SENTINEL: &'static str = openless_core::MODEL_READY_SENTINEL; - - fn default_root() -> std::path::PathBuf { - if let Some(data) = std::env::var_os("XDG_DATA_HOME") { - return std::path::PathBuf::from(data) - .join("OpenLess") - .join("models"); - } - std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join(".local") - .join("share") - .join("OpenLess") - .join("models") - } - - fn from_models_root(root: std::path::PathBuf, executable: Option) -> Self { - Self { - root: std::sync::Arc::new(std::sync::Mutex::new(root)), - loaded_model: std::sync::Arc::new(std::sync::Mutex::new(None)), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - } - } - - fn is_ready_dir(dir: &std::path::Path) -> bool { - dir.join(Self::READY_SENTINEL).is_file() - } - - fn ensure_qwen_target(target: &openless_core::LocalAsrTarget) -> Result<(), BackendError> { - let supported = target.runtime == openless_core::LocalAsrRuntime::Generic - && openless_core::LocalAsrModelId::from_wire_id(target.model_id()) - .is_some_and(openless_core::LocalAsrModelId::is_qwen); - if supported { - Ok(()) - } else { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux local ASR currently supports Qwen models only", - )) - } - } -} - -pub(crate) fn qwen_engine_available() -> bool { - qwen_executable().is_some() -} - -impl openless_core::ModelRuntimeAdapter for LinuxGenericLocalAsrRuntime { - fn engine_available(&self, runtime: openless_core::LocalAsrRuntime) -> bool { - runtime == openless_core::LocalAsrRuntime::Generic - && self - .executable - .as_deref() - .is_some_and(qwen_executable_is_available) - } - - fn supports_model(&self, target: &openless_core::LocalAsrTarget) -> bool { - Self::ensure_qwen_target(target).is_ok() - } - - fn rebind_storage( - &self, - models_root: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - *self.root.lock().expect("Linux ASR root lock poisoned") = models_root; - Box::pin(async { Ok(openless_core::StorageRebind::Applied) }) - } - - fn runtime_status( - &self, - settings: openless_core::LocalAsrSettings, - _model_dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - let available = self.engine_available(settings.runtime); - let loaded_model = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - let loaded = loaded_model - .lock() - .expect("Linux ASR loaded lock poisoned") - .clone(); - let loaded = available.then_some(loaded).flatten(); - Ok(openless_core::LocalAsrRuntimeStatus { - runtime: settings.runtime, - provider_id: settings.provider_id, - available, - loaded: loaded.is_some(), - active_model: settings.active_model.clone(), - model_id: loaded, - keep_loaded_secs: settings.keep_loaded_secs, - runtime_source: settings.runtime_source, - endpoint: None, - operation: None, - error: (!available).then(|| "packaged Qwen ASR runtime is not available".into()), - last_error: None, - last_prepare_ms: None, - last_transcribe_ms: None, - last_audio_ms: None, - }) - }) - } - - fn prepare( - &self, - target: openless_core::LocalAsrTarget, - _source: openless_core::FoundryRuntimeSource, - dir: std::path::PathBuf, - _progress: openless_core::ModelPrepareProgressSink, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let executable = self.executable.clone(); - let loaded = std::sync::Arc::clone(&self.loaded_model); - let cancelled = std::sync::Arc::clone(&self.cancelled); - Box::pin(async move { - cancelled.store(false, std::sync::atomic::Ordering::Release); - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("local ASR model is not downloaded: {}", target.model_id()), - )); - } - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - let output = run_qwen_process( - executable, - vec!["--help".into()], - None, - Arc::clone(&cancelled), - QWEN_PREPARE_TIMEOUT, - ) - .await?; - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime failed its self-check", - )); - } - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = - Some(target.model_id().to_string()); - Ok(target.model_id().to_string()) - }) - } - - fn release( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Ok(()); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = None; - Ok(()) - }) - } - - fn release_lease( - &self, - lease: openless_core::LocalAsrRuntimeLease, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = Arc::clone(&self.loaded_model); - Box::pin(async move { - let mut current = loaded.lock().expect("Linux ASR loaded lock poisoned"); - if current.as_deref() == Some(lease.target.model_id()) { - *current = None; - } - Ok(()) - }) - } - - fn preload( - &self, - target: openless_core::LocalAsrTarget, - model_dir: std::path::PathBuf, - _provider_type: String, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = model_dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if loaded - .lock() - .expect("Linux ASR loaded lock poisoned") - .as_deref() - != Some(target.model_id()) - { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "prepare the selected local ASR model before preloading", - )); - } - Ok(()) - }) - } - - fn cancel_prepare( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux Generic/Qwen runtime only supports generic models", - )) - }); - } - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - Box::pin(async { Ok(()) }) - } - - fn test_model( - &self, - target: openless_core::LocalAsrTarget, - dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - let executable = self.executable.clone(); - let cancelled = Arc::clone(&self.cancelled); - Box::pin(async move { - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "local ASR model is not downloaded", - )); - } - let audio = std::env::var_os("OPENLESS_QWEN_ASR_TEST_AUDIO") - .map(std::path::PathBuf::from) - .filter(|path| path.is_file()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "set OPENLESS_QWEN_ASR_TEST_AUDIO to an audio fixture for model testing", - ) - })?; - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - cancelled.store(false, std::sync::atomic::Ordering::Release); - let started = std::time::Instant::now(); - let output = run_qwen_process( - executable.clone(), - vec![ - "-d".into(), - dir.into_os_string(), - "-i".into(), - audio.into_os_string(), - "--silent".into(), - ], - None, - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Provider, - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); - } - Ok(openless_core::LocalAsrTestResult { - target, - backend: executable.to_string_lossy().into_owned(), - expected_text: std::env::var("OPENLESS_QWEN_ASR_TEST_EXPECTED").unwrap_or_default(), - transcribed_text: String::from_utf8_lossy(&output.stdout).trim().to_string(), - audio_ms: 0, - load_ms: 0, - transcribe_ms: started.elapsed().as_millis() as u64, - }) - }) - } -} - -struct LinuxGenericAsrSession { - model: Option, - root: std::sync::Arc>, - pcm: std::sync::Mutex>, - cancelled: std::sync::Arc, - executable: std::path::PathBuf, -} - -impl TranscriptionEngine for LinuxGenericAsrEngine { - fn start( - &self, - _session_id: openless_core::SessionId, - context: std::sync::Arc, - _partials: std::sync::Arc, - ) -> BoxFuture<'static, Result, BackendError>> { - let Some(executable) = self.executable.clone() else { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - )) - }); - }; - let session: std::sync::Arc = - std::sync::Arc::new(LinuxGenericAsrSession { - model: context.asr.model.clone(), - root: std::sync::Arc::clone(&self.root), - pcm: std::sync::Mutex::new(Vec::new()), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - }); - Box::pin(async move { Ok(session) }) - } -} - -impl AudioConsumer for LinuxGenericAsrSession { - fn consume_pcm_chunk(&self, pcm: &[u8]) { - if !self.cancelled.load(std::sync::atomic::Ordering::Acquire) { - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .extend_from_slice(pcm); - } - } -} - -impl TranscriptionSession for LinuxGenericAsrSession { - fn finish(&self) -> BoxFuture<'static, Result> { - let pcm = std::mem::take( - &mut *self - .pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned"), - ); - let model = self.model.clone(); - let root = std::sync::Arc::clone(&self.root); - let cancelled = std::sync::Arc::clone(&self.cancelled); - let executable = self.executable.clone(); - Box::pin(async move { - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - let duration_ms = (pcm.len() as u64).saturating_mul(1000) / 32_000; - if pcm.is_empty() { - return Ok(TranscriptOutput { - text: String::new(), - duration_ms, - }); - } - let model = model - .filter(|model| !model.trim().is_empty()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "Qwen ASR model is not selected", - ) - })?; - let model_dir = root - .lock() - .expect("Linux ASR root lock poisoned") - .join(&model); - if !LinuxGenericLocalAsrRuntime::is_ready_dir(&model_dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("Linux local ASR model is not prepared: {model}"), - )); - } - let output = run_qwen_process( - executable, - vec![ - "--stdin".into(), - "--silent".into(), - "-d".into(), - model_dir.into_os_string(), - ], - Some(openless_core::encode_dictation_wav(&pcm)?), - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - if !output.status.success() { - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(BackendError::new( - BackendErrorCode::Provider, - if message.is_empty() { - "Linux Generic/Qwen ASR failed".into() - } else { - message - }, - )); - } - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(TranscriptOutput { - text: result, - duration_ms, - }) - }) - } - - fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .clear(); - Box::pin(async { Ok(()) }) - } -} - /// Assemble the non-UI Linux runtime from shared provider Interfaces. /// /// The egui team only supplies a repaint callback and consumes the returned @@ -677,7 +63,6 @@ pub struct LinuxBackendBuilder { services: Option, host_actions: Option>, settings_runtime: Option>, - local_asr_runtime: Option>, polish_failure_policy: PolishFailurePolicy, task_spawner: Option>, } @@ -701,10 +86,11 @@ impl LinuxBackendBuilder { // their isolation must also hold when linked against a production lib. if let Some(home_dir) = config.home_dir.as_deref() { if let Err(error) = store.migrate_legacy(home_dir) { - // A locked/unavailable Secret Service must not disable local ASR. - // The migration marker remains unset, so unlocking and restarting - // retries the original sources. Log only the classification, never - // a provider/keyring message that might contain secret values. + // A locked/unavailable Secret Service is not a reason to block + // construction: the migration marker remains unset, so unlocking + // and restarting retries the original sources. Log only the + // classification, never a provider/keyring message that might + // contain secret values. log::warn!( "Legacy credential migration is incomplete ({:?}); unlock the credential vault and restart to retry. Original credentials were retained.", error.code @@ -722,27 +108,8 @@ impl LinuxBackendBuilder { for provider_type in SHARED_CLOUD_ASR_PROVIDER_TYPES { transcription.register(*provider_type, Arc::clone(&cloud_transcription))?; } - let configured_models_root = - openless_core::PreferencesStore::open(config.data_dir.join("preferences.json")) - .ok() - .map(|preferences| preferences.get().local_asr_models_base_dir) - .filter(|base| !base.trim().is_empty()) - .map(std::path::PathBuf::from) - .filter(|base| base.is_absolute()) - .map(|base| base.join("OpenLess").join("models")) - .unwrap_or_else(LinuxGenericLocalAsrRuntime::default_root); - let qwen_executable = qwen_executable(); - let linux_local_runtime = Arc::new(LinuxGenericLocalAsrRuntime::from_models_root( - configured_models_root, - qwen_executable.clone(), - )); - let linux_local_asr: Arc = Arc::new(LinuxGenericAsrEngine { - root: Arc::clone(&linux_local_runtime.root), - executable: qwen_executable, - }); - for provider_id in ["local-qwen3", "local-qwen3-c"] { - transcription.register(provider_id, Arc::clone(&linux_local_asr))?; - } + // Linux has no local inference runtime: only shared cloud ASR providers + // are registered above, and local-ASR requests stay unsupported in Core. let polisher = Arc::new(TextPolisherRouter::default()); let cloud_polisher: Arc = Arc::new(SharedCloudTextPolisher::new(Arc::clone(&credential_store))); @@ -756,17 +123,6 @@ impl LinuxBackendBuilder { )); let mut services = BackendServices::unsupported(); - if let Ok(model_config) = ModelStoreConfig::new( - linux_local_runtime - .root - .lock() - .expect("Linux ASR root lock poisoned") - .clone(), - ) { - if let Ok(model_store) = ModelStore::new(model_config) { - services.configure_model_store(Arc::new(model_store)); - } - } services.provider = Arc::new(ProviderService::new( Arc::clone(&credential_store), Arc::clone(&task_spawner), @@ -780,7 +136,6 @@ impl LinuxBackendBuilder { .with_auxiliary_polisher(auxiliary_polisher) .with_credential_store(credential_store) .with_services(services) - .with_local_asr_runtime(linux_local_runtime) .with_marketplace_config(MarketplaceConfig::production()) .with_settings_runtime(Arc::new(LinuxSettingsRuntime::new(store)))) } @@ -804,7 +159,6 @@ impl LinuxBackendBuilder { services: None, host_actions: None, settings_runtime: None, - local_asr_runtime: None, polish_failure_policy: PolishFailurePolicy::UseRawText, task_spawner: None, } @@ -857,14 +211,6 @@ impl LinuxBackendBuilder { self } - pub fn with_local_asr_runtime( - mut self, - runtime: Arc, - ) -> Self { - self.local_asr_runtime = Some(runtime); - self - } - pub fn with_polish_failure_policy(mut self, policy: PolishFailurePolicy) -> Self { self.polish_failure_policy = policy; self @@ -876,15 +222,11 @@ impl LinuxBackendBuilder { None => Arc::new(LinuxTaskSpawner::capture_current()?), }; let repositories = BackendRepositories::open(&self.config.data_dir)?; - let recorder = self - .recorder - .unwrap_or_else(|| Arc::new(LinuxCpalRecorder::new(None)) as Arc); - let recorder: Arc = Arc::new(openless_core::AudioRecorderRouter::new( - recorder, - openless_core::ExternalAudioRecorder::with_recordings_directory( - self.config.data_dir.join("recordings"), - ), - )); + let recordings_dir = self.config.data_dir.join("recordings"); + let recorder = self.recorder.unwrap_or_else(|| { + Arc::new(LinuxCpalRecorder::with_recordings_dir(None, recordings_dir)) + as Arc + }); let text_inserter = self .text_inserter .unwrap_or_else(|| Arc::new(Fcitx5TextInserter::new(true)) as Arc); @@ -948,10 +290,10 @@ impl LinuxBackendBuilder { task_spawner, credential_store, services, - local_asr_runtime: Some( - self.local_asr_runtime - .unwrap_or_else(|| Arc::new(LinuxGenericLocalAsrRuntime::default())), - ), + // Linux ships no local inference runtime. Leaving this unset + // makes every local-ASR call resolve to Core's unsupported + // adapter instead of fabricating a local capability. + local_asr_runtime: None, marketplace_config: self.marketplace_config, selection_runtime: Some(Arc::new(LinuxSelectionRuntime::new())), selection_polisher: Some(selection_polisher), @@ -975,7 +317,7 @@ mod tests { }; use openless_core::{ BackendErrorCode, CodingAgentProvider, CodingAgentTestRequest, InMemoryCredentialStore, - InsertOutcome, ModelRuntimeAdapter, ProviderKind, ProviderRequest, + InsertOutcome, ProviderKind, ProviderRequest, }; use super::*; @@ -1114,8 +456,8 @@ mod tests { .services() .provider .list_models(ProviderRequest { - thinking_enabled: false, kind: ProviderKind::Llm, + thinking_enabled: false, channel_id: None, }) .await @@ -1231,248 +573,12 @@ mod tests { } #[tokio::test] - async fn generic_local_asr_runtime_tracks_real_model_files_and_lifecycle() { - let root = std::env::temp_dir().join(format!( - "openless-linux-local-asr-{}", - uuid::Uuid::new_v4().simple() - )); - let models_root = root.join("OpenLess").join("models"); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root( - models_root.clone(), - Some(std::env::current_exe().unwrap()), - ); - let store = openless_core::ModelStore::new( - openless_core::ModelStoreConfig::new(models_root.clone()).unwrap(), - ) - .unwrap(); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = models_root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - std::fs::write(model_dir.join("weights.bin"), [1_u8, 2, 3]).unwrap(); - - let models = store - .list_models(openless_core::LocalAsrRuntime::Generic) - .unwrap(); - let model = models - .iter() - .find(|model| model.target.model_id() == target.model_id()) - .unwrap(); - assert!(model.installed); - assert!(model.downloaded_bytes >= 3); - assert_eq!( - runtime - .prepare( - target.clone(), - openless_core::FoundryRuntimeSource::Auto, - model_dir.clone(), - Arc::new(|_| {}), - ) - .await - .unwrap(), - target.model_id() - ); - assert!( - runtime - .runtime_status( - openless_core::LocalAsrSettings { - runtime: openless_core::LocalAsrRuntime::Generic, - provider_id: "local-qwen3".into(), - active_model: target.model_id().into(), - mirror: openless_core::LocalAsrMirror::Huggingface, - models_base_dir: Some(root.clone()), - models_root_dir: models_root.clone(), - engine_available: false, - language_hint: None, - runtime_source: None, - keep_loaded_secs: 0, - }, - model_dir.clone() - ) - .await - .unwrap() - .loaded - ); - let next_target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-1.7b", - ) - .unwrap(); - let next_model_dir = models_root.join(next_target.model_id()); - std::fs::create_dir_all(&next_model_dir).unwrap(); - std::fs::write( - next_model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - runtime - .prepare( - next_target.clone(), - openless_core::FoundryRuntimeSource::Auto, - next_model_dir, - Arc::new(|_| {}), - ) - .await - .unwrap(); - runtime - .release_lease(openless_core::LocalAsrRuntimeLease { - target: target.clone(), - generation: 1, - }) - .await - .unwrap(); - assert_eq!( - runtime.loaded_model.lock().unwrap().as_deref(), - Some(next_target.model_id()) - ); - runtime - .release(openless_core::LocalAsrRuntime::Generic) - .await - .unwrap(); - store.delete_model(&target).unwrap(); - assert!(!model_dir.exists()); - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn missing_qwen_runtime_never_reports_a_prepared_model() { - let root = std::env::temp_dir().join(format!( - "openless-linux-missing-qwen-runtime-{}", - uuid::Uuid::new_v4().simple() - )); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root(root.clone(), None); - - let error = runtime - .prepare( - target, - openless_core::FoundryRuntimeSource::Auto, - model_dir, - Arc::new(|_| {}), - ) - .await - .expect_err("model files alone must not fake a loaded runtime"); - - assert_eq!(error.code, BackendErrorCode::Unsupported); - assert!(runtime.loaded_model.lock().unwrap().is_none()); - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - async fn wait_for_pid(path: &std::path::Path) -> i32 { - for _ in 0..100 { - if let Ok(value) = std::fs::read_to_string(path) { - if let Ok(pid) = value.trim().parse() { - return pid; - } - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child PID was not written"); - } - - #[cfg(target_os = "linux")] - async fn assert_process_exited(pid: i32) { - for _ in 0..100 { - // SAFETY: signal 0 only checks whether the fixture process still exists. - if unsafe { libc::kill(pid, 0) } == -1 { - return; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child process {pid} survived"); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_cancellation_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-cancel-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let task = tokio::spawn(run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::clone(&cancelled), - std::time::Duration::from_secs(30), - )); - let child_pid = wait_for_pid(&pid_file).await; - cancelled.store(true, std::sync::atomic::Ordering::Release); - - let error = task.await.unwrap().unwrap_err(); - assert_eq!(error.code, BackendErrorCode::Cancelled); - assert_process_exited(child_pid).await; - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_timeout_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-timeout-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - - let error = run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::new(std::sync::atomic::AtomicBool::new(false)), - std::time::Duration::from_millis(100), - ) - .await - .unwrap_err(); - - assert!(error.message.contains("timed out")); - assert_process_exited(wait_for_pid(&pid_file).await).await; - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn shared_builder_restores_custom_model_root_and_filters_unsupported_models() { + async fn shared_provider_build_reports_local_asr_unsupported() { let data_dir = std::env::temp_dir().join(format!( - "openless-linux-custom-model-root-{}", + "openless-linux-local-asr-unsupported-{}-{}", + std::process::id(), uuid::Uuid::new_v4().simple() )); - let custom = data_dir.join("external"); - std::fs::create_dir_all(&data_dir).unwrap(); - let preferences = - openless_core::PreferencesStore::open(data_dir.join("preferences.json")).unwrap(); - let mut value = preferences.get(); - value.local_asr_models_base_dir = custom.to_string_lossy().into_owned(); - preferences.set(value).unwrap(); - let runtime = LinuxBackendBuilder::from_shared_providers(BackendConfig { data_dir: data_dir.clone(), ..BackendConfig::default() @@ -1480,26 +586,17 @@ mod tests { .unwrap() .build() .unwrap(); - let storage = runtime - .backend - .services() - .local_asr - .storage_settings() - .await - .unwrap(); - assert_eq!( - storage.models_root_dir, - custom.join("OpenLess").join("models") - ); - let models = runtime + + // Linux ships no local inference runtime: Core must answer Unsupported + // rather than fabricate a Generic/Qwen capability. + let error = runtime .backend .services() .local_asr - .list_models(openless_core::LocalAsrRuntime::Generic) + .runtime_status(openless_core::LocalAsrRuntime::Generic) .await - .unwrap(); - assert_eq!(models.len(), 2); - assert!(models.iter().all(|model| model.family == "qwen3")); + .expect_err("Linux must not advertise a local ASR engine"); + assert_eq!(error.code, BackendErrorCode::Unsupported); let _ = std::fs::remove_dir_all(data_dir); } diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index ca80cd467..1d7867da9 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -29,7 +29,7 @@ impl LinuxCapabilitySnapshot { x11_display: Option<&str>, fcitx5_ready: bool, tray_available: bool, - package_kind: LinuxPackageKind, + _package_kind: LinuxPackageKind, ) -> Self { let session = if wayland_display.is_some_and(|value| !value.trim().is_empty()) { LinuxDesktopSession::Wayland @@ -48,10 +48,16 @@ impl LinuxCapabilitySnapshot { supports_tray: desktop && tray_available, supports_overlay: session == LinuxDesktopSession::X11, supports_ime_input: desktop && fcitx5_ready, - supports_local_asr: desktop, + // Linux ships no local inference engine (Generic/Qwen, MLX or + // Foundry). Report false on every desktop session so the UI and + // downstream gate on the honest answer. + supports_local_asr: false, supports_local_qwen3_mlx: false, supports_in_app_dictation: false, - supports_auto_update: package_kind == LinuxPackageKind::AppImage, + // AppImage detection alone is not an updater capability. Keep + // this false until transport and a pinned minisign verifier + // have both initialized successfully. + supports_auto_update: false, }, permissions: PermissionSnapshot { microphone: if desktop { @@ -64,16 +70,23 @@ impl LinuxCapabilitySnapshot { } } - pub fn detect(tray_available: bool, package_kind: LinuxPackageKind) -> Self { + pub fn detect( + tray_available: bool, + package_kind: LinuxPackageKind, + updater_available: bool, + ) -> Self { let wayland = std::env::var("WAYLAND_DISPLAY").ok(); let x11 = std::env::var("DISPLAY").ok(); - Self::from_environment( + let mut snapshot = Self::from_environment( wayland.as_deref(), x11.as_deref(), fcitx5_available(), tray_available, package_kind, - ) + ); + snapshot.capabilities.supports_auto_update = + package_kind == LinuxPackageKind::AppImage && updater_available; + snapshot } } @@ -192,12 +205,10 @@ impl PlatformApi for LinuxPlatformApi { #[cfg(target_os = "linux")] fn enumerate_microphones() -> Result, BackendError> { - use cpal::traits::{DeviceTrait, HostTrait}; + use cpal::traits::HostTrait; let host = cpal::default_host(); - let default_name = host - .default_input_device() - .and_then(|device| device.name().ok()); + let default_name = host.default_input_device().map(|device| device.to_string()); let devices = host.input_devices().map_err(|error| { BackendError::new( BackendErrorCode::Platform, @@ -207,12 +218,7 @@ fn enumerate_microphones() -> Result, BackendError> { devices .enumerate() .map(|(index, device)| { - let name = device.name().map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to read Linux microphone name: {error}"), - ) - })?; + let name = device.to_string(); Ok(MicrophoneDevice { id: format!("cpal:{index}:{name}"), is_default: default_name.as_deref() == Some(name.as_str()), @@ -237,7 +243,8 @@ mod tests { ); assert_eq!(x11.session, LinuxDesktopSession::X11); assert!(x11.capabilities.supports_overlay); - assert!(x11.capabilities.supports_auto_update); + assert!(!x11.capabilities.supports_local_asr); + assert!(!x11.capabilities.supports_auto_update); let wayland = LinuxCapabilitySnapshot::from_environment( Some("wayland-0"), diff --git a/openless-all/app/linux-egui/src/coding_agent.rs b/openless-all/app/linux-egui/src/coding_agent.rs index e2c71250d..75598f4f6 100644 --- a/openless-all/app/linux-egui/src/coding_agent.rs +++ b/openless-all/app/linux-egui/src/coding_agent.rs @@ -110,12 +110,6 @@ pub(crate) fn isolate_process_group(command: &mut tokio::process::Command) { let _ = command; } -pub(crate) fn kill_process_group( - child: &mut tokio::process::Child, -) -> Result<(), openless_core::BackendError> { - kill_process_group_with_id(child, child.id()) -} - fn kill_process_group_with_id( child: &mut tokio::process::Child, _process_id: Option, @@ -334,21 +328,19 @@ mod tests { cancel_task.await.unwrap(); let pids = std::fs::read_to_string(&ready).expect("child must actually start"); let _ = std::fs::remove_file(&ready); - let mut running = Vec::new(); - for pid in pids.split_whitespace() { - if std::fs::read_to_string(format!("/proc/{pid}/stat")) - .ok() - .and_then(|stat| { - stat.rsplit_once(") ") - .map(|(_, rest)| !rest.starts_with('Z')) - }) - .unwrap_or(false) - { - running.push(pid.to_owned()); - // Only fixture PIDs read from our private ready file are killed. - unsafe { - libc::kill(pid.parse().unwrap(), libc::SIGKILL); - } + // SIGKILL is delivered immediately but the kernel retires the process + // (and its orphaned children) asynchronously, so give the group a + // bounded moment to disappear before calling the cancellation broken. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut running = alive_pids(&pids); + while !running.is_empty() && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + running = alive_pids(&pids); + } + // Only fixture PIDs read from our private ready file are killed. + for pid in &running { + unsafe { + libc::kill(pid.parse().unwrap(), libc::SIGKILL); } } let result = result.expect("cancelled child must exit promptly").unwrap(); @@ -358,4 +350,20 @@ mod tests { "cancelled process group is still running: {running:?}" ); } + + /// Fixture PIDs that are still alive (a zombie is already dead). + fn alive_pids(pids: &str) -> Vec { + pids.split_whitespace() + .filter(|pid| { + std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|stat| { + stat.rsplit_once(") ") + .map(|(_, rest)| !rest.starts_with('Z')) + }) + .unwrap_or(false) + }) + .map(str::to_owned) + .collect() + } } diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 10f349f9b..595c2fdf0 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -470,9 +470,13 @@ impl CredentialStore for LinuxCredentialStore { OMNI_MODEL_ACCOUNT, ), }; + // Linux ships no local inference engine, so every native/local ASR + // provider id is reported unconfigured rather than gated on a Qwen + // runtime that is never present. let local_asr_configured = match asr_provider_type.as_str() { - "local-qwen3" | "local-qwen3-c" => Some(crate::backend::qwen_engine_available()), - "local-qwen3-mlx" + "local-qwen3" + | "local-qwen3-c" + | "local-qwen3-mlx" | "local-whisper" | "apple-speech" | "foundry-local-whisper" diff --git a/openless-all/app/linux-egui/src/desktop.rs b/openless-all/app/linux-egui/src/desktop.rs new file mode 100644 index 000000000..3f7207f68 --- /dev/null +++ b/openless-all/app/linux-egui/src/desktop.rs @@ -0,0 +1,463 @@ +//! Linux desktop integration that does not depend on Tauri. +//! +//! The functions in this module deliberately report failures instead of +//! treating a best-effort desktop operation as successful. Slow operations +//! (D-Bus and process execution) are blocking and should be dispatched with +//! `tokio::task::spawn_blocking` by the UI bridge. + +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use std::time::Duration; + +const AUTOSTART_FILE: &str = "openless.desktop"; +const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug)] +pub enum DesktopError { + InvalidInput(String), + Io { + operation: &'static str, + source: io::Error, + }, + Dbus(String), + LauncherFailed { + program: String, + status: ExitStatus, + }, + LauncherUnavailable(Vec), +} + +impl fmt::Display for DesktopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(message) => f.write_str(message), + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Dbus(message) => write!(f, "desktop notification D-Bus error: {message}"), + Self::LauncherFailed { program, status } => { + write!(f, "{program} exited unsuccessfully ({status})") + } + Self::LauncherUnavailable(errors) => { + write!( + f, + "no desktop URL launcher was available: {}", + errors.join("; ") + ) + } + } + } +} + +impl std::error::Error for DesktopError {} + +fn io_error(operation: &'static str, source: io::Error) -> DesktopError { + DesktopError::Io { operation, source } +} + +/// Manages the per-user XDG autostart entry for OpenLess. +#[derive(Debug, Clone)] +pub struct AutostartManager { + entry_path: PathBuf, + executable: PathBuf, +} + +impl AutostartManager { + pub fn detect(executable: PathBuf) -> Result { + let config_home = match std::env::var_os("XDG_CONFIG_HOME") { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => std::env::var_os("HOME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .map(|home| home.join(".config")) + .ok_or_else(|| { + DesktopError::InvalidInput( + "neither XDG_CONFIG_HOME nor HOME is available".into(), + ) + })?, + }; + Self::new( + config_home.join("autostart").join(AUTOSTART_FILE), + executable, + ) + } + + pub fn new(entry_path: PathBuf, executable: PathBuf) -> Result { + validate_executable(&executable)?; + if !entry_path.is_absolute() { + return Err(DesktopError::InvalidInput( + "autostart entry path must be absolute".into(), + )); + } + Ok(Self { + entry_path, + executable, + }) + } + + pub fn entry_path(&self) -> &Path { + &self.entry_path + } + + pub fn is_enabled(&self) -> Result { + match fs::symlink_metadata(&self.entry_path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DesktopError::InvalidInput( + "refusing to trust a symlinked autostart entry".into(), + )), + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => Err(DesktopError::InvalidInput( + "autostart entry exists but is not a regular file".into(), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error("inspect autostart entry", error)), + } + } + + pub fn set_enabled(&self, enabled: bool) -> Result<(), DesktopError> { + if enabled { + // Inspect first so a hostile/pre-existing symlink is never silently + // replaced and reported as a successfully managed entry. + let _ = self.is_enabled()?; + let contents = desktop_entry(&self.executable)?; + atomic_write(&self.entry_path, contents.as_bytes(), Some(0o600)) + } else { + match fs::symlink_metadata(&self.entry_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(DesktopError::InvalidInput( + "refusing to remove a symlinked autostart entry".into(), + )) + } + Ok(metadata) if metadata.is_file() => { + fs::remove_file(&self.entry_path) + .map_err(|error| io_error("remove autostart entry", error))?; + sync_parent(&self.entry_path) + } + Ok(_) => Err(DesktopError::InvalidInput( + "autostart entry exists but is not a regular file".into(), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error("inspect autostart entry", error)), + } + } + } +} + +fn validate_executable(executable: &Path) -> Result<(), DesktopError> { + if !executable.is_absolute() { + return Err(DesktopError::InvalidInput( + "autostart executable must be absolute".into(), + )); + } + let value = executable.as_os_str().to_string_lossy(); + if value.contains(['\n', '\r', '\0']) { + return Err(DesktopError::InvalidInput( + "autostart executable contains a forbidden control character".into(), + )); + } + Ok(()) +} + +fn desktop_entry(executable: &Path) -> Result { + validate_executable(executable)?; + let escaped = executable + .as_os_str() + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('`', "\\`") + .replace('$', "\\$"); + Ok(format!( + "[Desktop Entry]\nType=Application\nVersion=1.0\nName=OpenLess\nComment=Start OpenLess in the background\nExec=\"{escaped}\" --minimized\nTerminal=false\nX-GNOME-Autostart-enabled=true\n" + )) +} + +/// A freedesktop.org desktop notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notification<'a> { + pub summary: &'a str, + pub body: &'a str, + pub icon: &'a str, + /// Zero lets the notification server choose its default timeout. + pub timeout_ms: i32, +} + +/// Sends a notification and returns the notification server's identifier. +#[cfg(target_os = "linux")] +pub fn notify(request: Notification<'_>) -> Result { + use dbus::arg::PropMap; + use dbus::blocking::Connection; + + if request.summary.contains('\0') || request.body.contains('\0') { + return Err(DesktopError::InvalidInput( + "notification text contains a NUL byte".into(), + )); + } + let connection = + Connection::new_session().map_err(|error| DesktopError::Dbus(error.to_string()))?; + let proxy = connection.with_proxy( + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + NOTIFICATION_TIMEOUT, + ); + let hints: PropMap = std::collections::HashMap::new(); + let (id,): (u32,) = proxy + .method_call( + "org.freedesktop.Notifications", + "Notify", + ( + "OpenLess", + 0u32, + request.icon, + request.summary, + request.body, + Vec::::new(), + hints, + request.timeout_ms, + ), + ) + .map_err(|error| DesktopError::Dbus(error.to_string()))?; + Ok(id) +} + +#[cfg(not(target_os = "linux"))] +pub fn notify(_request: Notification<'_>) -> Result { + Err(DesktopError::InvalidInput( + "desktop notifications are supported only on Linux".into(), + )) +} + +/// Opens an HTTP(S) URL using a desktop launcher and waits for the launcher to +/// acknowledge the request. Returning `Ok` never means merely "spawned". +pub fn open_external(url: &str) -> Result<(), DesktopError> { + validate_external_url(url)?; + open_external_with(url, &[("xdg-open", &[]), ("gio", &["open"])]) +} + +/// Opens a validated regular local file with the user's desktop handler. +pub fn open_local_file(path: &Path) -> Result<(), DesktopError> { + if !path.is_absolute() { + return Err(DesktopError::InvalidInput( + "local file path must be absolute".into(), + )); + } + let metadata = + fs::symlink_metadata(path).map_err(|error| io_error("inspect local file", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(DesktopError::InvalidInput( + "local file must be a regular non-symlink file".into(), + )); + } + let mut unavailable = Vec::new(); + for (program, prefix) in [("xdg-open", &[][..]), ("gio", &["open"][..])] { + match Command::new(program).args(prefix).arg(path).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + return Err(DesktopError::LauncherFailed { + program: program.into(), + status, + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + unavailable.push(format!("{program}: {error}")); + } + Err(error) => return Err(io_error("launch local file handler", error)), + } + } + Err(DesktopError::LauncherUnavailable(unavailable)) +} + +fn validate_external_url(url: &str) -> Result<(), DesktopError> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(DesktopError::InvalidInput( + "only HTTP(S) external URLs are allowed".into(), + )); + } + if url.chars().any(char::is_control) { + return Err(DesktopError::InvalidInput( + "external URL contains a control character".into(), + )); + } + Ok(()) +} + +fn open_external_with(url: &str, launchers: &[(&str, &[&str])]) -> Result<(), DesktopError> { + let mut unavailable = Vec::new(); + for (program, prefix) in launchers { + match Command::new(program).args(*prefix).arg(url).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + return Err(DesktopError::LauncherFailed { + program: (*program).into(), + status, + }) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + unavailable.push(format!("{program}: {error}")); + } + Err(error) => return Err(io_error("start desktop URL launcher", error)), + } + } + Err(DesktopError::LauncherUnavailable(unavailable)) +} + +/// Validates a user-selected destination for a safe atomic save. +pub fn validate_save_path(path: &Path) -> Result { + if !path.is_absolute() || path.file_name().is_none() { + return Err(DesktopError::InvalidInput( + "save destination must be an absolute file path".into(), + )); + } + let parent = path.parent().ok_or_else(|| { + DesktopError::InvalidInput("save destination has no parent directory".into()) + })?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| io_error("resolve save destination directory", error))?; + if !canonical_parent.is_dir() { + return Err(DesktopError::InvalidInput( + "save destination parent is not a directory".into(), + )); + } + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DesktopError::InvalidInput( + "refusing to overwrite a symlink".into(), + )), + Ok(metadata) if !metadata.is_file() => Err(DesktopError::InvalidInput( + "save destination exists but is not a regular file".into(), + )), + Ok(_) => Ok(canonical_parent.join(path.file_name().expect("checked above"))), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Ok(canonical_parent.join(path.file_name().expect("checked above"))) + } + Err(error) => Err(io_error("inspect save destination", error)), + } +} + +/// Atomically saves bytes without following an existing destination symlink. +pub fn atomic_save(path: &Path, bytes: &[u8]) -> Result { + let validated = validate_save_path(path)?; + atomic_write(&validated, bytes, Some(0o600))?; + Ok(validated) +} + +fn atomic_write(path: &Path, bytes: &[u8], unix_mode: Option) -> Result<(), DesktopError> { + let parent = path.parent().ok_or_else(|| { + DesktopError::InvalidInput("atomic-write destination has no parent".into()) + })?; + fs::create_dir_all(parent).map_err(|error| io_error("create destination directory", error))?; + let file_name = path.file_name().ok_or_else(|| { + DesktopError::InvalidInput("atomic-write destination has no filename".into()) + })?; + let temp = parent.join(format!( + ".{}.tmp-{}", + file_name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| io_error("create temporary file", error))?; + #[cfg(unix)] + if let Some(mode) = unix_mode { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(|error| io_error("set temporary file permissions", error))?; + } + file.write_all(bytes) + .map_err(|error| io_error("write temporary file", error))?; + file.sync_all() + .map_err(|error| io_error("sync temporary file", error))?; + fs::rename(&temp, path).map_err(|error| io_error("replace destination", error))?; + sync_parent(path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn sync_parent(path: &Path) -> Result<(), DesktopError> { + let parent = path + .parent() + .ok_or_else(|| DesktopError::InvalidInput("destination has no parent directory".into()))?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| io_error("sync destination directory", error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "openless-desktop-{name}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn autostart_is_atomic_and_round_trips() { + let root = temp_dir("autostart"); + let entry = root.join("config/autostart/openless.desktop"); + let manager = + AutostartManager::new(entry.clone(), PathBuf::from("/opt/Open Less/openless")).unwrap(); + assert!(!manager.is_enabled().unwrap()); + manager.set_enabled(true).unwrap(); + assert!(manager.is_enabled().unwrap()); + let text = fs::read_to_string(&entry).unwrap(); + assert!(text.contains("Exec=\"/opt/Open Less/openless\" --minimized")); + assert_eq!(text.matches("[Desktop Entry]").count(), 1); + manager.set_enabled(false).unwrap(); + assert!(!manager.is_enabled().unwrap()); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn autostart_refuses_to_replace_or_remove_symlinks() { + use std::os::unix::fs::symlink; + let root = temp_dir("autostart-symlink"); + let target = root.join("target"); + fs::write(&target, b"keep").unwrap(); + let entry = root.join("openless.desktop"); + symlink(&target, &entry).unwrap(); + let manager = AutostartManager::new(entry, PathBuf::from("/usr/bin/openless")).unwrap(); + assert!(manager.set_enabled(false).is_err()); + assert!(manager.set_enabled(true).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"keep"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn save_path_is_canonical_and_atomic() { + let root = temp_dir("save"); + let nested = root.join("nested"); + fs::create_dir(&nested).unwrap(); + let destination = nested.join("export.json"); + let saved = atomic_save(&destination, b"first").unwrap(); + assert!(saved.is_absolute()); + assert_eq!(fs::read(&destination).unwrap(), b"first"); + atomic_save(&destination, b"second").unwrap(); + assert_eq!(fs::read(&destination).unwrap(), b"second"); + assert_eq!(fs::read_dir(&nested).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unsafe_save_and_url_inputs_are_rejected() { + assert!(validate_save_path(Path::new("relative.txt")).is_err()); + assert!(validate_external_url("file:///etc/passwd").is_err()); + assert!(validate_external_url("https://example.test/\nattack").is_err()); + assert!(validate_external_url("https://example.test/path").is_ok()); + } +} diff --git a/openless-all/app/linux-egui/src/dictation_feedback.rs b/openless-all/app/linux-egui/src/dictation_feedback.rs new file mode 100644 index 000000000..2500dee48 --- /dev/null +++ b/openless-all/app/linux-egui/src/dictation_feedback.rs @@ -0,0 +1,416 @@ +//! 听写终态:给用户看的分类,以及胶囊何时自动收起。 +//! +//! Core 的 `mark_dictation_failed` 只把错误码写进 `message` +//! (`format!("{:?}", error.code)`,例如 `InvalidArgument`),那是日志用语: +//! 直接送进胶囊/状态栏既看不懂,也像是宿主崩了。说话时正常、不说话就报这个, +//! 因为空音频在 Core 里就是 `InvalidArgument`(`providers.rs` 的 +//! "recording contains no audio")。 +//! +//! 这里把「相位 + message」归一成可本地化的分类,并把 Tauri Host 的收起时序 +//! (成功/失败 2 秒、取消立即、进行中不收)固化成纯函数,宿主与单测共用同一份 +//! 规则,避免两边各写一套。 + +use std::time::Duration; + +use openless_core::{BackendError, BackendErrorCode, DictationPhase}; + +/// Tauri `coordinator.rs::CAPSULE_AUTO_HIDE_DELAY_MS`:终态在屏上的停留时长。 +pub const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; + +/// Core 在失败终态里塞进 `message` 的错误码名,一律不能当用户文案。 +pub fn is_backend_error_code(message: &str) -> bool { + matches!( + message.trim(), + "InvalidArgument" + | "InvalidState" + | "Busy" + | "Cancelled" + | "PermissionDenied" + | "Unsupported" + | "Provider" + | "Persistence" + | "Platform" + | "OutcomeUnknown" + | "Internal" + ) +} + +/// 胶囊这一帧该显示什么;具体文案由宿主用本地化 key 渲染。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapsuleOutcome { + /// 成功:宿主显示「已插入 N」。 + Inserted, + Cancelled, + Failed, + /// 进行中:原样转发 Core 的 message(通常是空串)。 + Progress(String), +} + +/// 相位 + message → 胶囊分类。绝不把内部错误码当文案。 +pub fn capsule_outcome(phase: DictationPhase, message: Option<&str>) -> CapsuleOutcome { + let message = message.unwrap_or("").trim(); + match phase { + DictationPhase::Completed => CapsuleOutcome::Inserted, + DictationPhase::Cancelled => CapsuleOutcome::Cancelled, + // Core 的失败 message 永远是错误码名,交给宿主显示本地化文案。 + DictationPhase::Failed => CapsuleOutcome::Failed, + _ => { + if message.is_empty() || message == "inserted" || is_backend_error_code(message) { + CapsuleOutcome::Progress(String::new()) + } else { + CapsuleOutcome::Progress(message.to_string()) + } + } + } +} + +/// 终态收起延时:成功/失败 2 秒、取消立即;进行中(含 `Inserting`)不收起。 +pub fn capsule_hide_delay(phase: DictationPhase) -> Option { + match phase { + DictationPhase::Completed | DictationPhase::Failed => { + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + } + DictationPhase::Cancelled => Some(Duration::ZERO), + _ => None, + } +} + +/// 延时到点时是否还该收起。 +/// +/// 判据是「这个会话已经不再进行」而不是「快照仍停在终态」:Core 在终态事件之后 +/// **立刻** `reset_dictation_session()`(见 `api.rs` 的 +/// `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`), +/// 整个 `DictationStateSnapshot` 被重置成 `Idle` 且 `session_id` 清空。 +/// 所以 2 秒后回看快照,`current_session` 是 `None` —— 早先要求 +/// `current_session == Some(scheduled)` 的写法让**报错路径永远收不回胶囊**。 +/// +/// 保留的防护:用户在这 2 秒里又按了录音 → 快照里是**另一个**会话 id → 不收; +/// 同一会话又回到进行中相位(理论上不会,兜底)→ 不收。 +pub fn capsule_hide_is_still_current( + current_session: Option<&str>, + scheduled_session: &str, + phase: DictationPhase, +) -> bool { + match current_session { + // Core 已经收尾(终态后重置,或根本没有会话语义)→ 正是该收起的时候。 + None => true, + Some(current) if current == scheduled_session => !phase_shows_capsule(phase), + // 另一个会话正在进行 → 不能把新胶囊一起关掉。 + Some(_) => false, + } +} + +/// 快照里会话已经消失、而宿主从未安排收起时,该补收的会话 id。 +/// +/// Core 只有**部分**错误路径会先 `mark_dictation_failed`(发布终态事件)再 reset; +/// 另一些(例如转写阶段的空音频)**只 reset 不发布**,宿主就永远等不到终态, +/// 药丸会一直贴在屏幕上。这里按快照自身判断「会话已经没了」,补一次收起。 +/// +/// 返回 `Some(session)` = 该为这个会话安排收起;`None` = 什么都不用做。 +pub fn capsule_needs_fallback_dismissal( + capsule_session: Option<&str>, + live_session: Option<&str>, + already_scheduled: Option<&str>, +) -> Option { + let capsule_session = capsule_session?; + // 同一会话仍在跑:等它自己的终态。 + if live_session == Some(capsule_session) { + return None; + } + // 已经为它安排过收起(事件路径已经处理):别重复计时。 + if already_scheduled == Some(capsule_session) { + return None; + } + Some(capsule_session.to_string()) +} + +/// 这个相位是否需要胶囊在屏幕上:只有进行中的相位才该按需拉起弹窗。 +/// +/// 终态不再拉起——否则一个迟到的终态事件会把刚刚自动收起的药丸又喊回来; +/// `Idle` 也不拉(它没有内容可显示)。 +pub fn phase_shows_capsule(phase: DictationPhase) -> bool { + matches!( + phase, + DictationPhase::Starting + | DictationPhase::Recording + | DictationPhase::Transcribing + | DictationPhase::Polishing + | DictationPhase::Inserting + ) +} + +/// 停止/取消听写时「本来就可能发生」的错误,不该弹成失败: +/// `InvalidArgument` = 没录到音频(没说话、麦克风没出声); +/// `InvalidState`/`Busy` = 会话已经收尾(连点两次、自动停止与手动停止撞车); +/// `Cancelled` = 用户自己取消。 +pub fn is_expected_stop_error(code: BackendErrorCode) -> bool { + matches!( + code, + BackendErrorCode::InvalidArgument + | BackendErrorCode::InvalidState + | BackendErrorCode::Busy + | BackendErrorCode::Cancelled + ) +} + +/// 归一化一次停止/取消听写的结果:预期内的错误当作「没有结果」而不是失败。 +/// +/// 真正的失败(网络、鉴权、持久化…)仍然原样向上报。 +pub fn normalize_stop_result( + result: Result, +) -> Result, BackendError> { + match result { + Ok(value) => Ok(Some(value)), + Err(error) if is_expected_stop_error(error.code) => Ok(None), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_timing_matches_the_tauri_host_contract() { + assert_eq!( + capsule_hide_delay(DictationPhase::Completed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert_eq!( + capsule_hide_delay(DictationPhase::Cancelled), + Some(Duration::ZERO) + ); + for phase in [ + DictationPhase::Idle, + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert_eq!(capsule_hide_delay(phase), None, "{phase:?} must stay up"); + } + } + + #[test] + fn silence_failure_is_a_normal_terminal_state() { + // 没说话 → Core 报 InvalidArgument(空音频):算终态、2 秒后收起。 + let phase = DictationPhase::Failed; + assert_eq!( + capsule_outcome(phase, Some("InvalidArgument")), + CapsuleOutcome::Failed + ); + assert_eq!( + capsule_hide_delay(phase), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(is_expected_stop_error(BackendErrorCode::InvalidArgument)); + } + + #[test] + fn backend_error_codes_never_become_capsule_copy() { + for code in [ + "InvalidArgument", + "InvalidState", + "Busy", + "Cancelled", + "PermissionDenied", + "Unsupported", + "Provider", + "Persistence", + "Platform", + "OutcomeUnknown", + "Internal", + ] { + assert!(is_backend_error_code(code), "{code} is an internal code"); + assert_eq!( + capsule_outcome(DictationPhase::Transcribing, Some(code)), + CapsuleOutcome::Progress(String::new()), + "{code} must not reach the capsule" + ); + } + assert!(!is_backend_error_code("12 characters")); + assert!(!is_backend_error_code("")); + } + + #[test] + fn progress_messages_still_pass_through() { + assert_eq!( + capsule_outcome(DictationPhase::Transcribing, Some("12 characters")), + CapsuleOutcome::Progress("12 characters".to_string()) + ); + // Core 成功时塞的内部词同样不显示。 + assert_eq!( + capsule_outcome(DictationPhase::Polishing, Some("inserted")), + CapsuleOutcome::Progress(String::new()) + ); + assert_eq!( + capsule_outcome(DictationPhase::Recording, None), + CapsuleOutcome::Progress(String::new()) + ); + } + + #[test] + fn a_new_session_cancels_the_pending_dismissal() { + assert!(capsule_hide_is_still_current( + Some("s1"), + "s1", + DictationPhase::Completed + )); + // 另一个会话(用户已经又按了录音)→ 不能收起新胶囊。 + assert!(!capsule_hide_is_still_current( + Some("s2"), + "s1", + DictationPhase::Completed + )); + // 同一会话又回到进行中相位 → 也不收。 + assert!(!capsule_hide_is_still_current( + Some("s1"), + "s1", + DictationPhase::Recording + )); + } + + #[test] + fn a_session_that_vanishes_without_a_terminal_event_still_hides_the_capsule() { + // Core 有些错误路径只 reset、不发布终态事件(转写阶段空音频就是), + // 宿主必须自己发现「胶囊的会话已经不在快照里」并补一次收起。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), None, None), + Some("s1".to_string()) + ); + // 会话仍在跑 → 等它自己的终态。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), Some("s1"), None), + None + ); + // 事件路径已经安排过 → 不重复计时。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), None, Some("s1")), + None + ); + // 新会话顶掉了旧会话(旧胶囊复用同一进程)→ 旧会话该收。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), Some("s2"), None), + Some("s1".to_string()) + ); + // 根本没有胶囊在屏上 → 什么都不做。 + assert_eq!(capsule_needs_fallback_dismissal(None, None, None), None); + } + + #[test] + fn a_vanished_session_hides_the_capsule_through_the_fallback_path() { + // 兜底链路的端到端判据:会话消失(无终态事件)→ 取兜底会话 → 按失败终态 + // 的时长 → 到点时判据为真 → 收起。 + let session = capsule_needs_fallback_dismissal(Some("s1"), None, None) + .expect("a vanished session must be picked up"); + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(capsule_hide_is_still_current( + None, + &session, + DictationPhase::Idle + )); + } + + #[test] + fn the_failure_path_ends_with_a_dismissal() { + // 串起报错路径的四环(不看实现,看行为): + // 1) 失败是终态 → 2 秒后收起; + // 2) 这 2 秒里 Core 已经把快照 reset 成 Idle、session_id 清空; + // 3) 到点时的判据必须为真 → 真的收起; + // 4) 但若这 2 秒里用户又按了录音(新会话)→ 不收,新胶囊活着。 + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(capsule_hide_is_still_current( + None, + "session", + DictationPhase::Idle + )); + assert!(!capsule_hide_is_still_current( + Some("next"), + "session", + DictationPhase::Starting + )); + } + + #[test] + fn the_core_reset_after_a_terminal_phase_still_dismisses_the_capsule() { + // 真实链路:Core 的失败路径是 + // `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`, + // 后者把整个快照重置成 Idle 并清空 session_id。2 秒后回看快照只剩 + // `None` —— 这正是「报错弹窗收不回」的原因,必须仍然收起。 + for phase in [ + DictationPhase::Idle, + DictationPhase::Completed, + DictationPhase::Failed, + DictationPhase::Cancelled, + ] { + assert!( + capsule_hide_is_still_current(None, "s1", phase), + "a finished session ({phase:?}) must still hide the capsule" + ); + // 同一 id 但相位已经落回 Idle:同样属于「不再进行」。 + assert!(capsule_hide_is_still_current(Some("s1"), "s1", phase)); + } + } + + #[test] + fn only_in_flight_phases_spawn_a_capsule() { + for phase in [ + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert!( + phase_shows_capsule(phase), + "{phase:?} must be able to spawn" + ); + } + for phase in [ + DictationPhase::Idle, + DictationPhase::Completed, + DictationPhase::Cancelled, + DictationPhase::Failed, + ] { + assert!(!phase_shows_capsule(phase), "{phase:?} must not re-spawn"); + } + } + + #[test] + fn expected_stop_errors_do_not_become_failures() { + for code in [ + BackendErrorCode::InvalidArgument, + BackendErrorCode::InvalidState, + BackendErrorCode::Busy, + BackendErrorCode::Cancelled, + ] { + assert!( + normalize_stop_result::<()>(Err(BackendError::new(code, "boom"))) + .unwrap() + .is_none(), + "{code:?} is an expected stop outcome" + ); + } + let real = normalize_stop_result::<()>(Err(BackendError::new( + BackendErrorCode::Provider, + "upstream 500", + ))); + assert_eq!(real.unwrap_err().code, BackendErrorCode::Provider); + assert_eq!( + normalize_stop_result(Ok(7)).unwrap(), + Some(7), + "successful stops keep their value" + ); + } +} diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index e33aeb61f..ae233dcb4 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -4,11 +4,11 @@ use std::time::Duration; use futures_util::future::BoxFuture; use openless_core::{ - BackendError, BackendErrorCode, InsertOutcome, InsertWriteResult, ResourceResolver, - TextInserter, TextInsertionSession, + BackendError, BackendErrorCode, InsertOutcome, InsertWriteResult, TextInserter, + TextInsertionSession, }; -use crate::{LinuxPackageKind, LinuxResourceLayout, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY}; +use crate::LinuxResourceLayout; #[cfg(target_os = "linux")] pub(crate) const DESTINATION: &str = "org.fcitx.Fcitx5"; @@ -18,38 +18,31 @@ pub(crate) const OBJECT_PATH: &str = "/openless"; pub(crate) const INTERFACE: &str = "org.fcitx.Fcitx.OpenLess1"; #[cfg(target_os = "linux")] const TIMEOUT: Duration = Duration::from_secs(3); +/// fcitx5's own management interface. `Restart` makes the daemon replace +/// itself in place: the call returns immediately and nothing of ours is +/// inherited, unlike the `fcitx5 -r` command (see `reload_running_fcitx5`). +#[cfg(target_os = "linux")] +pub(crate) const CONTROLLER_PATH: &str = "/controller"; +#[cfg(target_os = "linux")] +pub(crate) const CONTROLLER_INTERFACE: &str = "org.fcitx.Fcitx.Controller1"; +#[cfg(target_os = "linux")] +const CONTROLLER_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Clone, PartialEq, Eq)] pub struct FcitxPluginInstallPlan { - pub source_library: Option, - pub source_config: Option, pub target_library: PathBuf, pub target_config: PathBuf, - pub copy_required: bool, } impl FcitxPluginInstallPlan { pub fn for_layout(layout: &LinuxResourceLayout, home: &Path) -> Result { let target_library = home.join(".local/lib/fcitx5/libopenless.so"); let target_config = home.join(".local/share/fcitx5/addon/openless.conf"); - if layout.package_kind == LinuxPackageKind::AppImage { - let resolver = layout.resolver()?; - Ok(Self { - source_library: Some(resolver.resolve(Path::new(FCITX_PLUGIN_LIBRARY))?), - source_config: Some(resolver.resolve(Path::new(FCITX_PLUGIN_CONFIG))?), - target_library, - target_config, - copy_required: true, - }) - } else { - Ok(Self { - source_library: None, - source_config: None, - target_library, - target_config, - copy_required: false, - }) - } + let _ = layout; + Ok(Self { + target_library, + target_config, + }) } } @@ -57,152 +50,427 @@ impl FcitxPluginInstallPlan { pub enum FcitxPluginStatus { Ready, Missing, - Updated, } pub fn ensure_plugin_installed( plan: &FcitxPluginInstallPlan, ) -> Result { - if !plan.copy_required { - return if system_plugin_available() || user_plugin_available(plan) { - Ok(FcitxPluginStatus::Ready) - } else { - Ok(FcitxPluginStatus::Missing) - }; + if system_plugin_available() || user_plugin_available(plan) { + Ok(FcitxPluginStatus::Ready) + } else { + Ok(FcitxPluginStatus::Missing) } - let source_library = plan.source_library.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled library", - ) - })?; - let source_config = plan.source_config.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled config", - ) - })?; - let library = read_non_empty(source_library)?; - let config = read_non_empty(source_config)?; - let library_changed = target_differs(&plan.target_library, &library)?; - let config_changed = target_differs(&plan.target_config, &config)?; - if !library_changed && !config_changed { - return Ok(FcitxPluginStatus::Ready); +} + +fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { + plan.target_library.is_file() && plan.target_config.is_file() +} + +fn system_plugin_available() -> bool { + let config_dirs = [ + std::env::var_os("FCITX5_ADDON_DIR").map(PathBuf::from), + std::env::var_os("FCITX_ADDON_DIR").map(PathBuf::from), + Some(PathBuf::from("/usr/share/fcitx5/addon")), + Some(PathBuf::from("/usr/local/share/fcitx5/addon")), + ]; + let config = config_dirs + .into_iter() + .flatten() + .find(|dir| dir.join("openless.conf").is_file()); + let Some(config) = config else { return false }; + let mut library_dirs = vec![ + PathBuf::from("/usr/lib64/fcitx5"), + PathBuf::from("/usr/lib/fcitx5"), + PathBuf::from("/usr/local/lib/fcitx5"), + ]; + if let Ok(entries) = std::fs::read_dir("/usr/lib") { + library_dirs.extend(entries.flatten().map(|entry| entry.path().join("fcitx5"))); } - if library_changed { - atomic_write(&plan.target_library, &library, true)?; + if let Some(parent) = config.parent() { + library_dirs.push(parent.to_path_buf()); } - if config_changed { - atomic_write(&plan.target_config, &config, false)?; + library_dirs + .iter() + .any(|dir| dir.join("libopenless.so").is_file()) +} + +/// Path of the installed addon library the running fcitx5 would load: the user +/// install wins over the system one because fcitx5 searches it first. +/// Which copy of the addon fcitx5 will actually load. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginSource { + /// The package's `/usr/.../fcitx5/libopenless.so`. + System, + /// A per-user copy in `~/.local/` (manual install / AppImage). + User, + None, +} + +/// The package copy wins over a per-user copy. fcitx5 searches the user addon +/// directory first, so a leftover `~/.local` copy from an older manual install +/// would otherwise keep shadowing every package upgrade forever. +pub(crate) fn resolve_plugin_source(system: Option<&Path>, user: &Path) -> PluginSource { + if system.is_some() { + PluginSource::System + } else if user.is_file() { + PluginSource::User + } else { + PluginSource::None } - Ok(FcitxPluginStatus::Updated) } -fn read_non_empty(path: &Path) -> Result, BackendError> { - let bytes = std::fs::read(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to read fcitx5 resource {}: {error}", path.display()), - ) - })?; - if bytes.is_empty() { - return Err(BackendError::new( - BackendErrorCode::Platform, - format!("fcitx5 resource {} is empty", path.display()), - )); +/// A per-user copy is stale when the package already provides the addon and the +/// per-user files are still there to shadow it. +pub(crate) fn shadows_package_plugin(system_present: bool, user_library: &Path) -> bool { + system_present && user_library.is_file() +} + +/// Reasons fcitx5 has to be restarted before a plugin change takes effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadReason { + /// The installed plugin's content differs from the one we last loaded. + ContentChanged, + /// Same content, but the file is newer than the running daemon (a package + /// upgrade replaced the image the daemon still holds). + NewerThanDaemon, + /// The daemon has a *different* library mapped (a stale manual install, or + /// the pre-upgrade package image). Restarting is the only way to make it + /// pick up the copy we ship. + DaemonHoldsOtherCopy, +} + +/// Whether the running fcitx5 must be restarted. +/// +/// `marker` is the fingerprint of the plugin content recorded the last time the +/// host looked at it; a missing marker means "unknown baseline" and is treated +/// as a change (one restart, then the marker exists and the check is exact). +pub(crate) fn reload_reason( + marker: Option<&str>, + current: &str, + newer_than_daemon: bool, + daemon_holds_installed_copy: bool, +) -> Option { + if !daemon_holds_installed_copy { + return Some(ReloadReason::DaemonHoldsOtherCopy); + } + if marker != Some(current) { + return Some(ReloadReason::ContentChanged); + } + if newer_than_daemon { + return Some(ReloadReason::NewerThanDaemon); } - Ok(bytes) + None } -fn target_differs(path: &Path, expected: &[u8]) -> Result { - match std::fs::read(path) { - Ok(actual) => Ok(actual != expected), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), - Err(error) => Err(BackendError::new( - BackendErrorCode::Platform, - format!( - "failed to read existing fcitx5 file {}: {error}", - path.display() - ), - )), +/// Where the fingerprint of the plugin content last handed to fcitx5 lives. +pub fn plugin_fingerprint_path(data_dir: &Path) -> PathBuf { + data_dir.join("fcitx5-plugin.sha256") +} + +/// sha256 of a file, or `None` when it cannot be read. +pub(crate) fn file_fingerprint(path: &Path) -> Option { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("{:x}", hasher.finalize())) +} + +/// Drop a per-user copy that would shadow the package's addon, so a package +/// upgrade always wins. Best effort: a failure is a warning, never fatal. +fn remove_shadowing_user_copy(plan: &FcitxPluginInstallPlan, system_present: bool) -> bool { + if !shadows_package_plugin(system_present, &plan.target_library) { + return false; + } + log::warn!( + "[fcitx] removing the per-user addon copy {} — the package provides a newer \ + plugin and fcitx5 loads the user copy first", + plan.target_library.display() + ); + let mut removed = false; + for path in [&plan.target_library, &plan.target_config] { + match std::fs::remove_file(path) { + Ok(()) => removed = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!("[fcitx] could not remove {}: {error}", path.display()), + } } + removed } -fn atomic_write(path: &Path, bytes: &[u8], executable: bool) -> Result<(), BackendError> { - let parent = path.parent().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "fcitx5 target has no parent directory", - ) - })?; - std::fs::create_dir_all(parent).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to create fcitx5 target directory: {error}"), - ) - })?; - let temporary = parent.join(format!( - ".{}.{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("openless"), - std::process::id() - )); - std::fs::write(&temporary, bytes).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to stage fcitx5 resource: {error}"), - ) - })?; - #[cfg(unix)] - if executable { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o755)).map_err( - |error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to set fcitx5 plugin permissions: {error}"), - ) - }, - )?; - } - #[cfg(not(unix))] - let _ = executable; - // POSIX rename replaces an existing file atomically, so the previously - // working plugin remains available if staging or commit fails. The - // non-Unix branch exists only for portable unit tests/tooling, where the - // platform rename API may require explicitly removing the destination. - #[cfg(not(unix))] - if path.exists() { - std::fs::remove_file(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to replace fcitx5 resource: {error}"), - ) - })?; +fn installed_plugin_library(plan: &FcitxPluginInstallPlan) -> Option { + let system = system_plugin_library(); + match resolve_plugin_source(system.as_deref(), &plan.target_library) { + PluginSource::System => system, + PluginSource::User => Some(plan.target_library.clone()), + PluginSource::None => None, + } +} + +/// Addon-library search order, mirroring how fcitx5 resolves `Library=`. +/// +/// The packaging prefix (Debian multiarch, e.g. `/usr/lib/x86_64-linux-gnu`) +/// comes first because that is where the .deb/.rpm puts the plugin and what +/// package upgrades replace. A manually installed `/usr/local` copy is last on +/// purpose: it is never upgraded by the package manager, so treating it as +/// "the packaged plugin" made every start believe the plugin had changed. +pub(crate) fn plugin_library_search_dirs(usr_lib_subdirs: &[String]) -> Vec { + let mut dirs = Vec::new(); + let mut push = |dir: PathBuf| { + if !dirs.contains(&dir) { + dirs.push(dir); + } + }; + for subdir in usr_lib_subdirs { + push(PathBuf::from("/usr/lib").join(subdir).join("fcitx5")); } - std::fs::rename(&temporary, path).map_err(|error| { - let _ = std::fs::remove_file(&temporary); - BackendError::new( - BackendErrorCode::Platform, - format!("failed to commit fcitx5 resource: {error}"), - ) + push(PathBuf::from("/usr/lib/fcitx5")); + push(PathBuf::from("/usr/lib64/fcitx5")); + // /usr/local is deliberately *not* a package path: anything there is a + // leftover manual install that dpkg never replaces. Treating it as "the + // packaged plugin" made every start believe the addon had changed and + // kept a stale build live; `report_stale_manual_plugin` removes it instead. + dirs +} + +/// Sub-directory names of `/usr/lib` (the multiarch triples). +fn usr_lib_subdirs() -> Vec { + std::fs::read_dir("/usr/lib") + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default() +} + +/// The package's addon library, found through the same directories fcitx5 uses. +fn system_plugin_library() -> Option { + plugin_library_search_dirs(&usr_lib_subdirs()) + .into_iter() + .map(|dir| dir.join("libopenless.so")) + .find(|candidate| candidate.is_file()) +} + +/// Which `libopenless.so` the running daemon actually mapped, read from +/// `/proc//maps`. This is the authoritative answer to "what is live". +pub(crate) fn parse_maps_plugin_path(maps: &str) -> Option { + maps.lines().find_map(|line| { + let path = line.split_whitespace().last()?; + path.ends_with("/libopenless.so") + .then(|| PathBuf::from(path)) }) } -fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { - plan.target_library.is_file() && plan.target_config.is_file() +#[cfg(target_os = "linux")] +fn running_daemon_plugin() -> Option { + let pid = fcitx5_process_id()?; + let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).ok()?; + parse_maps_plugin_path(&maps) } -fn system_plugin_available() -> bool { - let library = [ - "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so", - "/usr/lib64/fcitx5/libopenless.so", - "/usr/lib/fcitx5/libopenless.so", - ] - .iter() - .any(|path| Path::new(path).is_file()); - library && Path::new("/usr/share/fcitx5/addon/openless.conf").is_file() +#[cfg(not(target_os = "linux"))] +fn running_daemon_plugin() -> Option { + None +} + +/// Point out (and drop when we may) an OpenLess plugin left behind by an older +/// manual install under `/usr/local`: dpkg never replaces it, it can shadow the +/// packaged copy for fcitx5, and it makes fingerprint checks ambiguous. +/// Only our own `libopenless.so` / `openless.conf` are ever touched. +fn report_stale_manual_plugin(installed: &Path, installed_fingerprint: &str) { + let library = PathBuf::from("/usr/local/lib/fcitx5/libopenless.so"); + let config = PathBuf::from("/usr/local/share/fcitx5/addon/openless.conf"); + if library == installed || !library.is_file() { + return; + } + let Some(fingerprint) = file_fingerprint(&library) else { + return; + }; + if fingerprint == installed_fingerprint { + return; + } + match std::fs::remove_file(&library) { + Ok(()) => { + log::warn!( + "[fcitx] removed the stale manual addon {} — the packaged plugin {} wins", + library.display(), + installed.display() + ); + // The addon conf in the same prefix shadows the packaged one; with + // the library gone fcitx5 would fail to load `openless` at all, so + // drop it as well (only ever our own file). + match std::fs::remove_file(&config) { + Ok(()) => log::warn!("[fcitx] removed the stale manual addon config {}", config.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!( + "[fcitx] could not remove {}: {error}; sudo rm -f {} {}", + config.display(), + library.display(), + config.display() + ), + } + } + Err(error) => log::warn!( + "[fcitx] the manual addon {} (fingerprint {}) differs from the packaged plugin \ + and is not replaced by the package manager; remove it with: sudo rm -f {} {} ({error})", + library.display(), + &fingerprint[..fingerprint.len().min(12)], + library.display(), + config.display() + ), + } +} + +/// `/proc/stat` -> `btime` (boot time as a UNIX timestamp in seconds). +pub(crate) fn parse_boot_time(proc_stat: &str) -> Option { + proc_stat.lines().find_map(|line| { + line.strip_prefix("btime ") + .and_then(|value| value.trim().parse::().ok()) + }) +} + +/// `/proc//stat` -> process start time in clock ticks since boot. +/// +/// The second field is the executable name in parentheses and may contain +/// spaces, so split after the last ')' before counting fields. +pub(crate) fn parse_process_start_ticks(proc_pid_stat: &str) -> Option { + let after_comm = proc_pid_stat.rsplit_once(')')?.1; + let mut fields = after_comm.split_whitespace(); + // After the comm field, state is field 3; starttime is field 22 => the 20th + // field of the remaining slice. + fields.nth(19)?.parse::().ok() +} + +/// USER_HZ for /proc values is 100 on Linux regardless of the kernel HZ. +const PROC_CLOCK_TICKS: u64 = 100; + +/// True when the installed addon library is newer than the running fcitx5, i.e. +/// a package upgrade replaced the .so while the daemon still holds the old +/// image. Without a restart the new matching rules never take effect. +pub(crate) fn plugin_is_newer_than_running_fcitx5( + plugin_modified: u64, + boot_time: u64, + process_start_ticks: u64, +) -> bool { + let process_started = boot_time + process_start_ticks / PROC_CLOCK_TICKS; + // One second of slack: both timestamps are second-resolution. + plugin_modified > process_started.saturating_add(1) +} + +/// Restart fcitx5 when the installed addon is newer than the running daemon so +/// an upgraded plugin is actually loaded. Returns true when fcitx5 was replaced. +pub fn reload_fcitx5_if_plugin_updated(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> bool { + let system = system_plugin_library(); + // A leftover per-user copy shadows the package plugin in fcitx5's search + // order, so drop it first and judge the package's copy. + remove_shadowing_user_copy(plan, system.is_some()); + let Some(library) = installed_plugin_library(plan) else { + return false; + }; + let Some(current) = file_fingerprint(&library) else { + return false; + }; + let fingerprint_path = plugin_fingerprint_path(data_dir); + let marker = std::fs::read_to_string(&fingerprint_path) + .ok() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()); + let newer = plugin_written_after_running_daemon(&library); + // Judge the copy fcitx5 actually mapped: a leftover manual install in a + // legacy prefix is never replaced by the package manager. + let loaded = running_daemon_plugin(); + let daemon_loaded = loaded.as_deref(); + report_stale_manual_plugin(&library, ¤t); + let daemon_holds_installed_copy = match daemon_loaded { + Some(loaded) => file_fingerprint(loaded).as_deref() == Some(current.as_str()), + None => true, + }; + log::info!( + "[fcitx] addon {} fingerprint={} loaded={} recorded={} newer_than_daemon={}", + library.display(), + ¤t[..current.len().min(12)], + daemon_loaded + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()), + marker + .as_deref() + .map(|value| &value[..value.len().min(12)]) + .unwrap_or(""), + newer, + ); + let reason = reload_reason( + marker.as_deref(), + ¤t, + newer, + daemon_holds_installed_copy, + ); + // Record the fingerprint *before* asking for the restart (and also when no + // restart is needed): a daemon that never comes back, or a start that had + // nothing to do, must not make the next start repeat the decision. + if let Some(parent) = fingerprint_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(error) = std::fs::write(&fingerprint_path, ¤t) { + log::warn!("[fcitx] could not record the plugin fingerprint: {error}"); + } + let Some(reason) = reason else { + return false; + }; + log::info!("[fcitx] restarting fcitx5 to load the addon update ({reason:?})"); + reload_running_fcitx5() +} + +/// Whether the addon file is newer than the running fcitx5 process. +fn plugin_written_after_running_daemon(library: &Path) -> bool { + let Some(modified) = std::fs::metadata(library) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + else { + return false; + }; + let (Some(proc_stat), Some(pid)) = ( + std::fs::read_to_string("/proc/stat").ok(), + fcitx5_process_id(), + ) else { + return false; + }; + let Some(proc_pid_stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok() else { + return false; + }; + let (Some(boot_time), Some(start_ticks)) = ( + parse_boot_time(&proc_stat), + parse_process_start_ticks(&proc_pid_stat), + ) else { + return false; + }; + plugin_is_newer_than_running_fcitx5(modified, boot_time, start_ticks) +} + +/// PID of the running fcitx5, by scanning /proc for the process name. +#[cfg(target_os = "linux")] +fn fcitx5_process_id() -> Option { + for entry in std::fs::read_dir("/proc").ok()?.flatten() { + let name = entry.file_name(); + let pid = name.to_string_lossy().parse::().ok(); + let Some(pid) = pid else { continue }; + let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else { + continue; + }; + if comm.trim() == "fcitx5" { + return Some(pid); + } + } + None +} + +#[cfg(not(target_os = "linux"))] +fn fcitx5_process_id() -> Option { + None } #[derive(Debug, Clone)] @@ -505,6 +773,23 @@ pub(crate) fn set_raw_hotkey(method: &str, symbol: u32, states: u32) -> Result<( send_message(method, |message| message.append2(symbol, states)) } +#[cfg(target_os = "linux")] +pub(crate) fn set_style_pack_hotkeys( + bindings: Vec<(String, u32, u32)>, +) -> Result<(), BackendError> { + send_message("SetStylePackHotkeys", |message| message.append1(bindings)) +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn set_style_pack_hotkeys( + _bindings: Vec<(String, u32, u32)>, +) -> Result<(), BackendError> { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "fcitx5 hotkey settings are only available on Linux", + )) +} + #[cfg(not(target_os = "linux"))] pub(crate) fn set_raw_hotkey( _method: &str, @@ -725,13 +1010,135 @@ pub fn available() -> bool { false } +/// Ask a running fcitx5 daemon to reload so it loads a freshly written +/// OpenLess addon, mirroring the legacy Tauri `linux_fcitx` adapter. +/// +/// Only an instance that currently owns the `org.fcitx.Fcitx5` DBus name is +/// restarted. On a first install fcitx5 may not be running yet; that is fine, +/// because the next fcitx5 start scans the per-user addon directory and loads +/// the addon on its own, so we never force-spawn a daemon (first-install +/// semantics are preserved). On an update the running instance is restarted so +/// the new `.so` is actually loaded (restart semantics). +/// +/// Failures are logged and never fatal: startup continues down the fcitx5 +/// DBus path instead of degrading to a global-hotkey fallback. Returns true +/// when a reload was issued against a live instance. #[cfg(target_os = "linux")] -fn copy_to_clipboard(text: &str) -> Result<(), BackendError> { - let mut clipboard = arboard::Clipboard::new() - .map_err(|error| platform_error(format!("failed to open Linux clipboard: {error}")))?; - clipboard - .set_text(text.to_string()) - .map_err(|error| platform_error(format!("failed to write Linux clipboard: {error}"))) +pub fn reload_running_fcitx5() -> bool { + if !fcitx5_name_has_owner() { + return false; + } + if restart_fcitx5_via_dbus() { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + return true; + } + spawn_detached_fcitx5_restart() +} + +/// `org.fcitx.Fcitx.Controller1.Restart` on `/controller`: the daemon replaces +/// itself, the call returns as soon as the method is dispatched, and nothing of +/// ours is inherited. +#[cfg(target_os = "linux")] +pub(crate) fn restart_fcitx5_via_dbus() -> bool { + use dbus::blocking::BlockingSender; + let Ok(connection) = dbus::blocking::Connection::new_session() else { + return false; + }; + let Ok(message) = dbus::Message::new_method_call( + DESTINATION, + CONTROLLER_PATH, + CONTROLLER_INTERFACE, + "Restart", + ) else { + return false; + }; + match connection.send_with_reply_and_block(message, CONTROLLER_TIMEOUT) { + Ok(_) => true, + Err(error) => { + log::warn!( + "[fcitx] D-Bus Restart unavailable ({error}); falling back to a detached fcitx5 -r" + ); + false + } + } +} + +/// Last resort when the controller interface is missing: spawn `fcitx5 -r` +/// fully detached. `fcitx5 -r` *becomes* the daemon and keeps running in the +/// foreground, so `.status()`/`.wait()` would block the caller forever — the +/// child is deliberately dropped instead. +#[cfg(target_os = "linux")] +fn spawn_detached_fcitx5_restart() -> bool { + use std::process::Stdio; + match std::process::Command::new("fcitx5") + .arg("-r") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + let pid = child.id(); + drop(child); + log::info!("[fcitx] spawned a detached fcitx5 -r (pid {pid})"); + true + } + Err(error) => { + log::warn!("[fcitx] could not spawn fcitx5 -r: {error}"); + false + } + } +} + +#[cfg(not(target_os = "linux"))] +pub fn reload_running_fcitx5() -> bool { + false +} + +/// Whether the fcitx5 daemon itself is registered on the session bus. This is +/// distinct from `available()` (which pings the OpenLess addon interface): the +/// daemon may be running without having loaded our addon yet, and that is +/// exactly the case where a reload is required. +#[cfg(target_os = "linux")] +fn fcitx5_name_has_owner() -> bool { + use dbus::blocking::BlockingSender; + let Ok(connection) = dbus::blocking::Connection::new_session() else { + return false; + }; + let Ok(message) = dbus::Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + ) else { + return false; + }; + connection + .send_with_reply_and_block(message.append1(DESTINATION), Duration::from_millis(1000)) + .map(|reply| reply.read1::().unwrap_or(false)) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +pub fn copy_to_clipboard(text: &str) -> Result<(), BackendError> { + use dbus::blocking::BlockingSender; + let connection = dbus::blocking::Connection::new_session().map_err(dbus_error)?; + let message = + dbus::Message::new_method_call(DESTINATION, OBJECT_PATH, INTERFACE, "SetClipboardText") + .map_err(|error| { + platform_error(format!("failed to build fcitx5 clipboard call: {error}")) + })? + .append1(text.to_string()); + let reply = connection + .send_with_reply_and_block(message, TIMEOUT) + .map_err(dbus_error)?; + if reply.read1::().unwrap_or(false) { + Ok(()) + } else { + Err(platform_error( + "fcitx5 clipboard addon is unavailable".to_string(), + )) + } } #[cfg(target_os = "linux")] @@ -752,77 +1159,242 @@ mod tests { use super::*; #[test] - fn appimage_plan_copies_only_from_the_versioned_resource_contract() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::AppImage, - resource_root: PathBuf::from("/app/usr/lib/openless/resources"), - }; - let plan = FcitxPluginInstallPlan::for_layout(&layout, Path::new("/home/test")).unwrap(); - assert!(plan.copy_required); + fn boot_time_and_process_start_are_parsed_from_proc() { + let stat = "cpu 1 2 3\nbtime 1700000000\nprocesses 42\n"; + assert_eq!(parse_boot_time(stat), Some(1_700_000_000)); + assert_eq!(parse_boot_time("cpu 1 2 3\n"), None); + + // Field 2 is the comm in parentheses and may contain spaces/parens; the + // 22nd field (starttime) sits 20 fields after it. Line copied from a real + // /proc//stat of this machine (starttime = 284037). + let pid_stat = "38066 (bash) S 32218 38066 38066 0 -1 4194304 245 0 0 0 0 0 0 0 20 0 1 0 284037 10760192 917 18446744073709551615 93845596229632"; + assert_eq!(parse_process_start_ticks(pid_stat), Some(284037)); + // A comm containing a closing parenthesis must not shift the fields. + let paren_comm = + "999 (fcitx5 (5.1)) S 1 999 999 0 -1 4194304 1 0 0 0 0 0 0 0 20 0 1 0 77777 13"; + assert_eq!(parse_process_start_ticks(paren_comm), Some(77777)); + assert_eq!(parse_process_start_ticks(""), None); + assert_eq!(parse_process_start_ticks("1 (short) S 1"), None); + } + + #[test] + fn a_plugin_newer_than_the_running_fcitx5_asks_for_a_restart() { + // fcitx5 started at boot + 2500 ticks (25 s). + let boot = 1_700_000_000; + let started = 2500; + // Plugin written before the daemon started: nothing to do. + assert!(!plugin_is_newer_than_running_fcitx5( + boot + 10, + boot, + started + )); + // Same second (the daemon read the file it just got): nothing to do. + assert!(!plugin_is_newer_than_running_fcitx5( + boot + 25, + boot, + started + )); + // Plugin replaced by a package upgrade while the daemon kept running. + assert!(plugin_is_newer_than_running_fcitx5( + boot + 600, + boot, + started + )); + } + + #[test] + fn a_daemon_holding_another_copy_always_asks_for_a_restart() { + // Even with a matching fingerprint, a daemon that mapped a different + // libopenless.so (stale manual /usr/local install, pre-upgrade image) + // keeps the old matching rules until it is restarted. assert_eq!( - plan.source_library.unwrap(), - PathBuf::from("/app/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so") + reload_reason(Some("abc"), "abc", false, false), + Some(ReloadReason::DaemonHoldsOtherCopy) + ); + // Steady state: nothing mapped differently, fingerprint recorded. + assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); + } + + #[test] + fn the_packaged_addon_is_searched_before_a_manual_install() { + let dirs = plugin_library_search_dirs(&["x86_64-linux-gnu".to_string()]); + // A manual /usr/local install is never treated as a package path; it is + // cleaned up separately, otherwise every start thinks the addon changed. + assert!( + !dirs.contains(&PathBuf::from("/usr/local/lib/fcitx5")), + "a manual install must not masquerade as the packaged addon: {dirs:?}" ); + let multiarch = dirs + .iter() + .position(|dir| dir == Path::new("/usr/lib/x86_64-linux-gnu/fcitx5")) + .expect("multiarch addon dir"); + assert!( + multiarch == 0, + "the multiarch package dir must be searched first: {dirs:?}" + ); + // No duplicate entries when /usr/lib scans repeat a prefix. + let twice = plugin_library_search_dirs(&[ + "x86_64-linux-gnu".to_string(), + "x86_64-linux-gnu".to_string(), + ]); + assert_eq!(twice, dirs); + } + + #[test] + fn the_daemon_maps_the_plugin_we_read_from_proc() { + let maps = "7f00-8000 r-xp 00000000 08:01 42 /usr/lib/fcitx5/libother.so +\ + 8000-9000 r-xp 00000000 08:01 43 /usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so +"; assert_eq!( - plan.target_config, - PathBuf::from("/home/test/.local/share/fcitx5/addon/openless.conf") + parse_maps_plugin_path(maps), + Some(PathBuf::from( + "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" + )) + ); + assert_eq!(parse_maps_plugin_path(""), None); + assert_eq!( + parse_maps_plugin_path("1-2 r-xp 0 00:00 0 /usr/lib/fcitx5/libopenless.so.old"), + None ); } #[test] - fn system_packages_never_copy_bundled_plugins_into_home() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::SystemPackage, - resource_root: PathBuf::from("/usr/lib/openless/resources"), - }; - let plan = FcitxPluginInstallPlan::for_layout(&layout, Path::new("/home/test")).unwrap(); - assert!(!plan.copy_required); - assert!(plan.source_library.is_none()); - assert!(plan.source_config.is_none()); + fn the_package_plugin_wins_over_a_per_user_copy() { + let system = Path::new("/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so"); + let user = Path::new("/home/u/.local/lib/fcitx5/libopenless.so"); + assert_eq!( + resolve_plugin_source(Some(system), user), + PluginSource::System + ); + // No package plugin: fall back to the per-user copy (AppImage/manual). + let missing = Path::new("/definitely/not/here/libopenless.so"); + assert_eq!( + resolve_plugin_source(None, &PathBuf::from("/tmp/x")), + PluginSource::None + ); + assert!(!missing.is_file()); + // The decision itself must not depend on the user copy existing. + assert_eq!( + resolve_plugin_source(None, Path::new("/definitely/not/here")), + PluginSource::None + ); } #[test] - fn appimage_installer_copies_then_reports_ready() { - let root = std::env::temp_dir().join(format!( - "openless-fcitx-appimage-{}", - uuid::Uuid::new_v4().simple() - )); - let resources = root.join("resources"); - let home = root.join("home"); - std::fs::create_dir_all(resources.join("linux-fcitx5-plugin")).unwrap(); - std::fs::write(resources.join(FCITX_PLUGIN_LIBRARY), b"plugin").unwrap(); - std::fs::write(resources.join(FCITX_PLUGIN_CONFIG), b"config").unwrap(); - let plan = FcitxPluginInstallPlan::for_layout( - &LinuxResourceLayout { - package_kind: LinuxPackageKind::AppImage, - resource_root: resources, - }, - &home, - ) - .unwrap(); + fn a_per_user_copy_only_shadows_when_the_package_has_the_addon() { + // Both branches need a real per-user file, but never a real *package* + // file: asserting on `/usr/lib/.../libopenless.so` only held on a + // machine that had the deb installed and failed on clean CI runners. + let home = tempfile::tempdir().expect("temp home"); + let user = home.path().join(".local/lib/fcitx5/libopenless.so"); + assert!(!shadows_package_plugin(true, &user)); + assert!(!shadows_package_plugin(false, &user)); + std::fs::create_dir_all(user.parent().expect("user addon dir")) + .expect("create user addon dir"); + std::fs::write(&user, b"stale per-user copy").expect("write user addon"); + // Presence of the package copy plus an existing user file is the case + // that used to keep an upgraded package plugin from ever loading. + assert!(shadows_package_plugin(true, &user)); + // Without a package copy there is nothing to shadow: the per-user file + // is the AppImage/manual plugin fcitx5 is meant to load. + assert!(!shadows_package_plugin(false, &user)); + } + #[test] + fn reload_is_driven_by_content_before_mtime() { + // Same content, daemon older than the file: mtime still asks for a reload. + assert_eq!( + reload_reason(Some("abc"), "abc", true, true), + Some(ReloadReason::NewerThanDaemon) + ); + // Same content, daemon newer: nothing to do (steady state every start). + assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); + // Different content: reload regardless of timestamps (downgrades, files + // restored from a backup, same-second package upgrades). assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated + reload_reason(Some("abc"), "def", false, true), + Some(ReloadReason::ContentChanged) ); - assert_eq!(std::fs::read(&plan.target_library).unwrap(), b"plugin"); - assert_eq!(std::fs::read(&plan.target_config).unwrap(), b"config"); + // Unknown baseline (first run of this check): reload once, then exact. assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Ready + reload_reason(None, "abc", false, true), + Some(ReloadReason::ContentChanged) ); + } + + #[test] + fn probing_the_plugin_never_writes_anything() { + // 用户拍板的策略:运行时只校验、绝不二次安装。这里把「探测不写入」 + // 锁进测试:两个目标路径都不存在时必须返回 Missing,且目录里不留任何 + // 新文件(旧实现会往 ~/.local 拷一份,反而盖过 deb 装的插件)。 + let home = tempfile::tempdir().unwrap(); + let layout = LinuxResourceLayout { + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), + }; + let plan = FcitxPluginInstallPlan::for_layout(&layout, home.path()).unwrap(); + let library_dir = plan.target_library.parent().unwrap().to_path_buf(); + let config_dir = plan.target_config.parent().unwrap().to_path_buf(); + + // The probe itself must not create the per-user addon tree. + assert!( + !library_dir.exists(), + "probe must not create {}", + library_dir.display() + ); + assert!( + !config_dir.exists(), + "probe must not create {}", + config_dir.display() + ); + } + + #[test] + fn removing_a_shadowing_copy_leaves_other_addons_alone() { + let home = tempfile::tempdir().unwrap(); + let layout = LinuxResourceLayout { + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), + }; + let plan = FcitxPluginInstallPlan::for_layout(&layout, home.path()).unwrap(); + for path in [&plan.target_library, &plan.target_config] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"stale").unwrap(); + } + // A neighbouring addon from another project must survive the cleanup. + let neighbour = plan + .target_config + .parent() + .unwrap() + .join("other-addon.conf"); + std::fs::write(&neighbour, b"keep me").unwrap(); - std::fs::write(plan.source_library.as_ref().unwrap(), b"updated plugin").unwrap(); + // Without a package plugin the per-user copy is the only one: keep it. + assert!(!remove_shadowing_user_copy(&plan, false)); + assert!(plan.target_library.is_file()); + + // With the package plugin present the stale copy would shadow it: remove. + assert!(remove_shadowing_user_copy(&plan, true)); + assert!(!plan.target_library.exists()); + assert!(!plan.target_config.exists()); + assert_eq!(std::fs::read(&neighbour).unwrap(), b"keep me"); + } + + #[test] + fn plugin_plan_is_probe_only_for_system_packages() { + let layout = LinuxResourceLayout { + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), + }; + let plan = FcitxPluginInstallPlan::for_layout(&layout, Path::new("/home/test")).unwrap(); assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated + plan.target_library, + PathBuf::from("/home/test/.local/lib/fcitx5/libopenless.so") ); assert_eq!( - std::fs::read(&plan.target_library).unwrap(), - b"updated plugin" + plan.target_config, + PathBuf::from("/home/test/.local/share/fcitx5/addon/openless.conf") ); - assert_eq!(std::fs::read(&plan.target_config).unwrap(), b"config"); - - let _ = std::fs::remove_dir_all(root); } } diff --git a/openless-all/app/linux-egui/src/hotkeys.rs b/openless-all/app/linux-egui/src/hotkeys.rs index aa1390ef5..b6ddcf472 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -64,6 +64,12 @@ pub enum LinuxHotkeyEvent { QaPressed, SelectionPolishPressed, TranslationPressed, + SwitchStylePressed, + OpenAppPressed, + StylePackPressed { + symbol: u32, + states: u32, + }, } pub struct Fcitx5HotkeyListener { @@ -325,6 +331,11 @@ fn event_from_signal( ("QaShortcutEvent", true) => Some(LinuxHotkeyEvent::QaPressed), ("SelectionPolishEvent", true) => Some(LinuxHotkeyEvent::SelectionPolishPressed), ("TranslationModifierEvent", true) => Some(LinuxHotkeyEvent::TranslationPressed), + ("SwitchStyleEvent", true) => Some(LinuxHotkeyEvent::SwitchStylePressed), + ("OpenAppEvent", true) => Some(LinuxHotkeyEvent::OpenAppPressed), + ("StylePackHotkeyEvent", true) => { + Some(LinuxHotkeyEvent::StylePackPressed { symbol, states }) + } _ => None, } } @@ -375,6 +386,21 @@ mod tests { event_from_signal("QaShortcutEvent", 0, 0, true, at, &press_ids), Some(LinuxHotkeyEvent::QaPressed) ); + assert_eq!( + event_from_signal("SwitchStyleEvent", 11, 12, true, at, &press_ids), + Some(LinuxHotkeyEvent::SwitchStylePressed) + ); + assert_eq!( + event_from_signal("OpenAppEvent", 13, 14, true, at, &press_ids), + Some(LinuxHotkeyEvent::OpenAppPressed) + ); + assert_eq!( + event_from_signal("StylePackHotkeyEvent", 15, 16, true, at, &press_ids), + Some(LinuxHotkeyEvent::StylePackPressed { + symbol: 15, + states: 16, + }) + ); let less_pressed = event_from_signal("LessComputerKeyEvent", 3, 4, true, at, &press_ids) .expect("Less Computer press"); let LinuxHotkeyEvent::LessComputerPressed { diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs new file mode 100644 index 000000000..e30b72896 --- /dev/null +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -0,0 +1,5507 @@ +//! Rust-native UI localization for the Linux egui host. +//! +//! This layer deliberately mirrors the Tauri UI's language choices +//! (`system`, `zh-CN`, `zh-TW`, `en`, `ja`, `ko`) so both UIs offer the same +//! set. The source of truth / fallback is `zh-CN`, exactly like the Tauri +//! `i18n/index.ts`; all five concrete locales are bundled statically so there +//! is no network fetch and no runtime loading. +//! +//! UI text is looked up through a typed catalog rather than string-typed +//! `format!` splices so the completeness/fallback contracts are enforceable +//! and a locale switch re-renders deterministically. + +use std::fmt::Display; + +/// The five concrete languages the Linux egui UI supports. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Lang { + ZhCn, + ZhTw, + En, + Ja, + Ko, +} + +/// The persisted UI-locale preference. `System` means "follow the host OS +/// locale"; `Lang(lang)` is an explicit user choice, matching the Tauri +/// `setLocalePreference` model where only an explicit tag is stored. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LocalePref { + System, + Lang(Lang), +} + +pub const LANGS: [Lang; 5] = [Lang::ZhCn, Lang::ZhTw, Lang::En, Lang::Ja, Lang::Ko]; + +/// JSON/wire tags, matching the Tauri `SUPPORTED_LOCALES`. +pub const FOLLOW_SYSTEM: &str = "system"; + +impl Lang { + /// Canonical BCP-47-ish tag for a concrete language. + pub fn tag(self) -> &'static str { + match self { + Lang::ZhCn => "zh-CN", + Lang::ZhTw => "zh-TW", + Lang::En => "en", + Lang::Ja => "ja", + Lang::Ko => "ko", + } + } + + /// Parse a BCP-47 tag / locale identifier into a supported language. + /// Handles region and script suffixes (`zh-Hant-TW`, `zh_TW`, `ja_JP`…). + pub fn parse(tag: &str) -> Option { + let normalized = tag.replace('-', "_").to_ascii_lowercase(); + if normalized.starts_with("zh") { + // Traditional markers win regardless of where they appear. + if normalized.contains("hant") + || normalized.contains("_tw") + || normalized.contains("_hk") + || normalized.contains("_mo") + { + return Some(Lang::ZhTw); + } + return Some(Lang::ZhCn); + } + if normalized.starts_with("ja") { + return Some(Lang::Ja); + } + if normalized.starts_with("ko") { + return Some(Lang::Ko); + } + if normalized.starts_with("en") { + return Some(Lang::En); + } + None + } +} + +impl LocalePref { + pub fn from_tag(tag: &str) -> LocalePref { + if tag.eq_ignore_ascii_case(FOLLOW_SYSTEM) { + LocalePref::System + } else if let Some(lang) = Lang::parse(tag) { + LocalePref::Lang(lang) + } else { + LocalePref::System + } + } + + pub fn to_tag(self) -> String { + match self { + LocalePref::System => FOLLOW_SYSTEM.to_string(), + LocalePref::Lang(lang) => lang.tag().to_string(), + } + } + + /// Resolve this preference against the running host into a concrete lang. + /// `System` falls through to `resolve_system_lang()`, mirroring Tauri's + /// `detectSystemLocale()`. + pub fn resolve(self) -> Lang { + match self { + LocalePref::System => resolve_system_lang(), + LocalePref::Lang(lang) => lang, + } + } +} + +/// Resolve the host OS locale into a supported language without touching any +/// UI. Uses the same precedence as typical Linux tooling: `LC_ALL`, then +/// `LC_MESSAGES`, then `LANG`; an unparseable or unset value falls back to `en` +/// rather than guessing. +pub fn resolve_system_lang() -> Lang { + for variable in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = std::env::var(variable) { + if let Some(lang) = Lang::parse(&value) { + return lang; + } + } + } + Lang::En +} + +/// One catalog row: a stable key plus the text in all five concrete locales. +/// Index `0` is the `zh-CN` source of truth. +pub struct Msg { + pub key: &'static str, + pub text: [&'static str; 5], +} + +/// Order helper so callers can write rows positionally and stay readable. +/// `[zh, zh_tw, en, ja, ko]` is the single canonical order used everywhere. +#[allow(dead_code)] +const fn row( + zh: &'static str, + zh_tw: &'static str, + en: &'static str, + ja: &'static str, + ko: &'static str, +) -> [&'static str; 5] { + [zh, zh_tw, en, ja, ko] +} + +// Global catalog of every UI string the Linux egui host renders. +// +// Convention: +// * `zh` (zh-CN) is the source of truth and is never empty. +// * A `[&str;5]` value that is empty for a non-zh locale means "fall back to +// zh-CN for this key" (`tr` handles that); the completeness test asserts +// that the zh-CN column is fully populated and that every key actually +// referenced resolves. +pub const CATALOG: &[Msg] = &[ + // ---- Shell / navigation ------------------------------------------------ + Msg { + key: "shell.workspace", + text: row( + "工作台", + "工作臺", + "Workspace", + "ワークスペース", + "작업 공간", + ), + }, + Msg { + key: "shell.capabilities", + text: row("能力", "能力", "Capabilities", "機能", "기능"), + }, + Msg { + key: "shell.version", + text: row( + "版本 {}", + "版本 {}", + "Version {}", + "バージョン {}", + "버전 {}", + ), + }, + Msg { + key: "nav.overview", + text: row("概览", "概覽", "Overview", "概要", "개요"), + }, + Msg { + key: "nav.history", + text: row("历史", "歷史", "History", "履歴", "기록"), + }, + Msg { + key: "nav.vocab", + text: row("词典", "詞典", "Dictionary", "辞書", "사전"), + }, + Msg { + key: "nav.styles", + text: row( + "风格包", + "風格包", + "Style Packs", + "スタイルパック", + "스타일 팩", + ), + }, + Msg { + key: "nav.marketplace", + text: row( + "风格市场", + "風格市場", + "Marketplace", + "マーケット", + "마켓", + ), + }, + Msg { + key: "nav.providers", + text: row( + "Provider 与设置", + "Provider 與設定", + "Providers & Settings", + "プロバイダーと設定", + "프로바이더 및 설정", + ), + }, + Msg { + key: "nav.models", + text: row( + "本地模型", + "本機模型", + "Local Models", + "ローカルモデル", + "로컬 모델", + ), + }, + Msg { + key: "nav.assistant", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "nav.settings", + text: row("设置", "設定", "Settings", "設定", "설정"), + }, + Msg { + key: "nav.group_style", + text: row("风格", "風格", "Style", "スタイル", "스타일"), + }, + Msg { + key: "nav.group_tools", + text: row("工具", "工具", "Tools", "ツール", "도구"), + }, + Msg { + key: "nav.polish_mode", + text: row( + "润色模式", + "潤色模式", + "Polish mode", + "推敲モード", + "다듬기 모드", + ), + }, + Msg { + key: "nav.translation", + text: row("翻译", "翻譯", "Translation", "翻訳", "번역"), + }, + Msg { + key: "nav.selection_ask", + text: row("划词追问", "劃詞追問", "Ask", "選択追問", "선택 질문"), + }, + Msg { + key: "nav.corrections", + text: row( + "纠错规则", + "糾錯規則", + "Corrections", + "修正ルール", + "교정 규칙", + ), + }, + // ---- Common controls ---------------------------------------------------- + Msg { + key: "btn.refresh", + text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "btn.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "btn.start", + text: row("开始", "開始", "Start", "開始", "시작"), + }, + Msg { + key: "btn.stop", + text: row("停止", "停止", "Stop", "停止", "중지"), + }, + Msg { + key: "btn.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "btn.close", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "btn.send", + text: row("发送", "傳送", "Send", "送信", "보내기"), + }, + Msg { + key: "btn.insert", + text: row("插入", "插入", "Insert", "挿入", "삽입"), + }, + Msg { + key: "btn.confirm_replace", + text: row( + "确认替换", + "確認替換", + "Confirm replace", + "置換を確定", + "바꾸기 확인", + ), + }, + Msg { + key: "btn.undo", + text: row("撤销", "復原", "Undo", "元に戻す", "실행 취소"), + }, + Msg { + key: "btn.run", + text: row("运行", "執行", "Run", "実行", "실행"), + }, + Msg { + key: "btn.allow", + text: row("允许", "允許", "Allow", "許可", "허용"), + }, + Msg { + key: "btn.deny", + text: row("拒绝", "拒絕", "Deny", "拒否", "거부"), + }, + Msg { + key: "btn.end_recording", + text: row( + "结束录音", + "結束錄音", + "Stop recording", + "録音終了", + "녹음 종료", + ), + }, + Msg { + key: "btn.stop_recording", + text: row( + "停止录音", + "停止錄音", + "Stop recording", + "録音停止", + "녹음 중지", + ), + }, + Msg { + key: "btn.voice_ask", + text: row( + "语音提问", + "語音提問", + "Voice ask", + "音声で質問", + "음성 질문", + ), + }, + Msg { + key: "heading.dictation", + text: row("听写", "聽寫", "Dictation", "ディクテーション", "받아쓰기"), + }, + Msg { + key: "heading.qa", + text: row("问答", "問答", "Q&A", "Q&A", "Q&A"), + }, + Msg { + key: "heading.overview", + text: row("概览", "概覽", "Overview", "概要", "개요"), + }, + Msg { + key: "heading.selection_preview", + text: row( + "选区预览", + "選區預覽", + "Selection preview", + "選択範囲プレビュー", + "선택 영역 미리보기", + ), + }, + Msg { + key: "heading.insert_preview", + text: row( + "插入预览", + "插入預覽", + "Insert preview", + "挿入プレビュー", + "삽입 미리보기", + ), + }, + Msg { + key: "heading.qa_preview", + text: row( + "划词追问", + "劃詞追問", + "Ask on selection", + "選択範囲で質問", + "선택어 질문", + ), + }, + Msg { + key: "heading.recent", + text: row("最近识别", "最近辨識", "Recent", "最近の認識", "최근 기록"), + }, + Msg { + key: "heading.local_models", + text: row( + "本地模型", + "本機模型", + "Local Models", + "ローカルモデル", + "로컬 모델", + ), + }, + // ---- Dictation / empty states ------------------------------------------ + Msg { + key: "dictation.recording", + text: row("正在录音", "正在錄音", "Recording…", "録音中…", "녹음 중…"), + }, + Msg { + key: "dictation.no_transcript", + text: row( + "尚无转写结果", + "尚無轉寫結果", + "No transcription yet", + "まだ文字起こしはありません", + "아직 받아쓰기 결과가 없습니다", + ), + }, + Msg { + key: "less_computer.done", + text: row( + "Less Computer 已完成", + "Less Computer 已完成", + "Less Computer finished", + "Less Computer が完了しました", + "Less Computer 완료", + ), + }, + Msg { + key: "less_computer.cancelled", + text: row( + "Less Computer 已取消", + "Less Computer 已取消", + "Less Computer cancelled", + "Less Computer をキャンセルしました", + "Less Computer 취소됨", + ), + }, + Msg { + key: "less_computer.no_output", + text: row( + "尚无 Agent 输出", + "尚無 Agent 輸出", + "No agent output yet", + "まだエージェントの出力はありません", + "아직 에이전트 출력이 없습니다", + ), + }, + Msg { + key: "approval.submitted", + text: row( + "审批已提交", + "審批已提交", + "Approval submitted", + "承認を送信しました", + "승인이 제출되었습니다", + ), + }, + Msg { + key: "approval.request_run", + text: row( + "请求执行:{}", + "請求執行:{}", + "Requested execution: {}", + "実行リクエスト: {}", + "실행 요청: {}", + ), + }, + Msg { + key: "selection.replace_completed", + text: row( + "最近一次选区替换已完成", + "最近一次選區替換已完成", + "Last selection replace completed", + "最後の選択範囲置換が完了しました", + "마지막 선택 영역 바꾸기가 완료되었습니다", + ), + }, + Msg { + key: "qa.submitted", + text: row( + "问答已提交", + "問答已提交", + "Question submitted", + "質問を送信しました", + "질문이 제출되었습니다", + ), + }, + Msg { + key: "qa.closed", + text: row( + "问答已关闭", + "問答已關閉", + "Q&A closed", + "Q&A を閉じました", + "Q&A가 닫혔습니다", + ), + }, + Msg { + key: "qa.recording_updated", + text: row( + "问答录音状态已更新", + "問答錄音狀態已更新", + "Q&A recording updated", + "Q&A の録音状態を更新しました", + "Q&A 녹음 상태가 업데이트되었습니다", + ), + }, + Msg { + key: "selection.replaced", + text: row( + "选区替换已确认", + "選區替換已確認", + "Selection replace confirmed", + "選択範囲の置換を確認しました", + "선택 영역 바꾸기가 확인되었습니다", + ), + }, + Msg { + key: "selection.cancelled", + text: row( + "选区替换已取消", + "選區替換已取消", + "Selection replace cancelled", + "選択範囲の置換をキャンセルしました", + "선택 영역 바꾸기가 취소되었습니다", + ), + }, + Msg { + key: "selection.reverted", + text: row( + "选区替换已撤销", + "選區替換已復原", + "Selection replace reverted", + "選択範囲の置換を元に戻しました", + "선택 영역 바꾸기가 취소되었습니다", + ), + }, + Msg { + key: "voice.cancelled", + text: row( + "语音会话已取消", + "語音工作階段已取消", + "Voice session cancelled", + "音声セッションをキャンセルしました", + "음성 세션이 취소되었습니다", + ), + }, + // ---- Overview metrics --------------------------------------------------- + Msg { + key: "metric.chars_today", + text: row( + "今日字数", + "今日字數", + "Chars today", + "今日の文字数", + "오늘 문자 수", + ), + }, + Msg { + key: "metric.duration_today", + text: row( + "今日时长", + "今日時長", + "Time today", + "今日の時間", + "오늘 시간", + ), + }, + Msg { + key: "metric.avg_latency", + text: row( + "平均延迟", + "平均延遲", + "Avg latency", + "平均遅延", + "평균 지연", + ), + }, + Msg { + key: "metric.total", + text: row( + "累计记录", + "累計記錄", + "Total records", + "累計記録", + "누적 기록", + ), + }, + Msg { + key: "metric.no_data_today", + text: row( + "今日暂无", + "今日暫無", + "None today", + "今日はありません", + "오늘 없음", + ), + }, + Msg { + key: "metric.near7", + text: row( + "近7天 {} 段 · 近30天 {} 段", + "近7天 {} 段 · 近30天 {} 段", + "{} in 7d · {} in 30d", + "直近7日 {} 件 · 30日 {} 件", + "7일 {}건 · 30일 {}건", + ), + }, + Msg { + key: "metric.total_segments", + text: row( + "共 {} 段", + "共 {} 段", + "{} segments", + "合計 {} 件", + "총 {}건", + ), + }, + Msg { + key: "loading.overview", + text: row( + "正在加载概览数据…", + "正在載入概覽資料…", + "Loading overview…", + "概要を読み込み中…", + "개요를 불러오는 중…", + ), + }, + Msg { + key: "overview.load_failed", + text: row( + "概览加载失败", + "概覽載入失敗", + "Failed to load overview", + "概要の読み込みに失敗しました", + "개요를 불러오지 못했습니다", + ), + }, + Msg { + key: "overview.provider_cards", + text: row( + "ASR 语音识别", + "ASR 語音辨識", + "ASR speech", + "ASR 音声認識", + "ASR 음성 인식", + ), + }, + Msg { + key: "overview.provider_cards_llm", + text: row( + "LLM 大模型", + "LLM 大模型", + "LLM model", + "LLM モデル", + "LLM 모델", + ), + }, + Msg { + key: "overview.not_set", + text: row( + "(未设置)", + "(未設定)", + "(not set)", + "(未設定)", + "(설정 안 됨)", + ), + }, + Msg { + key: "overview.configured", + text: row("已配置", "已設定", "Configured", "設定済み", "설정됨"), + }, + Msg { + key: "overview.configured_dot", + text: row( + "● 已配置", + "● 已設定", + "● Configured", + "● 設定済み", + "● 설정됨", + ), + }, + Msg { + key: "overview.unconfigured", + text: row("未配置", "未設定", "Not configured", "未設定", "미설정"), + }, + Msg { + key: "overview.recent_empty", + text: row( + "暂无识别记录,点击上方「开始」说第一句吧。", + "暫無辨識紀錄,點按上方「開始」說第一句吧。", + "No recent dictation yet — hit Start above to begin.", + "まだ認識記録はありません。上の「開始」を押してください。", + "아직 받아쓰기 기록이 없습니다. 위의 시작을 눌러주세요.", + ), + }, + Msg { + key: "overview.no_text", + text: row( + "(无文本)", + "(無文字)", + "(no text)", + "(テキストなし)", + "(텍스트 없음)", + ), + }, + Msg { + key: "overview.heatmap_title", + text: row( + "近一年每日活动次数", + "近一年每日活動次數", + "Daily activity · past year", + "過去1年の日別活動回数", + "지난 1년 일별 활동 횟수", + ), + }, + Msg { + key: "overview.heatmap_empty", + text: row( + "暂无活动数据", + "暫無活動資料", + "No activity data yet", + "まだ活動データはありません", + "아직 활동 데이터가 없습니다", + ), + }, + Msg { + key: "overview.heatmap_less", + text: row("少", "少", "Less", "少", "적음"), + }, + Msg { + key: "overview.heatmap_more", + text: row("多", "多", "More", "多", "많음"), + }, + Msg { + key: "overview.heatmap_footnote", + text: row( + "(近 {} 天 · {} 天有记录)", + "(近 {} 天 · {} 天有記錄)", + "({} days · {} active)", + "({} 日間・{} 日記録あり)", + "({}일 · {}일 기록)", + ), + }, + // ---- Overview page (2.0 dashboard) ------------------------------------ + Msg { + key: "overview.title", + text: row( + "今日概览", + "今日概覽", + "Today's overview", + "本日の概要", + "오늘 개요", + ), + }, + Msg { + key: "overview.mode_raw", + text: row("原文", "原文", "Verbatim", "原文", "원문"), + }, + Msg { + key: "overview.mode_light", + text: row( + "轻度润色", + "輕度潤色", + "Light polish", + "軽い推敲", + "가벼운 다듬기", + ), + }, + Msg { + key: "overview.mode_structured", + text: row( + "清晰结构", + "清晰結構", + "Structured", + "明確な構造", + "명확한 구조", + ), + }, + Msg { + key: "overview.mode_formal", + text: row( + "正式表达", + "正式表達", + "Formal", + "フォーマル", + "격식체", + ), + }, + Msg { + key: "overview.refresh", + text: row( + "刷新状态", + "重新整理狀態", + "Refresh status", + "状態を更新", + "상태 새로고침", + ), + }, + Msg { + key: "overview.stats_title", + text: row( + "使用记录", + "使用紀錄", + "Your activity", + "利用記録", + "사용 기록", + ), + }, + Msg { + key: "overview.metric_chars", + text: row( + "今日字数", + "今日字數", + "Characters today", + "本日の文字数", + "오늘 글자 수", + ), + }, + Msg { + key: "overview.metric_segments", + text: row("{} 段", "{} 段", "{} segments", "{} セグメント", "{} 세그먼트"), + }, + Msg { + key: "overview.metric_duration", + text: row( + "今日总时长", + "今日總時長", + "Total duration today", + "本日の合計時間", + "오늘 총 시간", + ), + }, + Msg { + key: "overview.metric_avg", + text: row( + "平均段落", + "平均段落", + "Avg per segment", + "平均セグメント", + "평균 세그먼트", + ), + }, + Msg { + key: "overview.metric_avg_trend", + text: row( + "今日均值", + "今日均值", + "Today's average", + "本日の平均", + "오늘 평균", + ), + }, + Msg { + key: "overview.metric_no_data", + text: row("暂无数据", "暫無數據", "No data", "データなし", "데이터 없음"), + }, + Msg { + key: "overview.history_error", + text: row( + "历史读取失败", + "歷史讀取失敗", + "Failed to load history", + "履歴の読み込みに失敗", + "기록을 불러오지 못함", + ), + }, + Msg { + key: "overview.metric_total", + text: row( + "累计记录", + "累計記錄", + "Total records", + "累計記録", + "누적 기록", + ), + }, + Msg { + key: "overview.metric_total_trend", + text: row( + "本机存档(上限 {})", + "本機存檔(上限 {})", + "Stored locally (max {})", + "ローカル保存(上限 {})", + "로컬 저장(최대 {})", + ), + }, + Msg { + key: "overview.period_last7", + text: row("近 7 天", "近 7 天", "Last 7 days", "直近 7 日", "최근 7일"), + }, + Msg { + key: "overview.period_last30", + text: row( + "近 30 天", + "近 30 天", + "Last 30 days", + "直近 30 日", + "최근 30일", + ), + }, + Msg { + key: "overview.daily_avg", + text: row("日均 {}", "日均 {}", "{} / day", "1日平均 {}", "일평균 {}"), + }, + Msg { + key: "overview.metric_count", + text: row("条数", "條數", "Count", "件数", "건수"), + }, + Msg { + key: "overview.metric_chars_name", + text: row("字数", "字數", "Characters", "文字数", "글자 수"), + }, + Msg { + key: "overview.metric_duration_name", + text: row("时长", "時長", "Duration", "時間", "시간"), + }, + Msg { + key: "overview.recent_title", + text: row( + "最近识别", + "最近識別", + "Recent transcripts", + "最近の認識", + "최근 인식", + ), + }, + Msg { + key: "overview.recent_all", + text: row("全部记录 →", "全部記錄 →", "View all →", "すべて表示 →", "전체 보기 →"), + }, + Msg { + key: "overview.recent_empty_hint", + text: row( + "还没有听写记录。跟着上方的引导试一次,结果会显示在这里。", + "還沒有聽寫紀錄。依照上方引導試一次,結果就會顯示在這裡。", + "No dictations yet. Follow the guide above to try one; your result will appear here.", + "まだ音声入力の記録がありません。上の案内に沿って試すと、ここに結果が表示されます。", + "아직 받아쓰기 기록이 없습니다. 위 안내에 따라 사용해 보면 결과가 여기에 표시됩니다.", + ), + }, + Msg { + key: "overview.recent_failed", + text: row( + "无法读取最近识别,请重试。", + "無法讀取最近辨識,請重試。", + "Could not load recent items — retry.", + "最近の認識を読み込めません。再試行してください。", + "최근 인식을 불러오지 못했습니다. 다시 시도하세요.", + ), + }, + Msg { + key: "overview.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "overview.activity_title", + text: row( + "年度活动", + "年度活動", + "Annual activity", + "年間アクティビティ", + "연간 활동", + ), + }, + Msg { + key: "overview.activity_count", + text: row( + "{} 次听写", + "{} 次聽寫", + "{} dictation(s)", + "{} 回の入力", + "{}회 받아쓰기", + ), + }, + Msg { + key: "overview.activity_error", + text: row( + "活动数据读取失败", + "活動資料讀取失敗", + "Failed to load activity", + "アクティビティの読み込みに失敗", + "활동 데이터를 불러오지 못함", + ), + }, + Msg { + key: "overview.services_title", + text: row( + "当前语音服务", + "目前的語音服務", + "Current voice services", + "使用中の音声サービス", + "현재 음성 서비스", + ), + }, + Msg { + key: "overview.asr_kind", + text: row( + "语音识别", + "語音辨識", + "Speech recognition", + "音声認識", + "음성 인식", + ), + }, + Msg { + key: "overview.llm_kind", + text: row( + "文字处理", + "文字處理", + "Text processing", + "テキスト処理", + "텍스트 처리", + ), + }, + Msg { + key: "overview.provider_help_asr", + text: row( + "将语音转成文字。", + "將語音轉成文字。", + "Turns your speech into text.", + "音声をテキストに変換します。", + "음성을 텍스트로 변환합니다.", + ), + }, + Msg { + key: "overview.provider_help_llm", + text: row( + "按你的风格整理和润色文字。", + "依照你的風格整理和潤飾文字。", + "Organizes and polishes text in your style.", + "あなたのスタイルに合わせて文章を整えます。", + "내 스타일에 맞게 글을 정리하고 다듬습니다.", + ), + }, + Msg { + key: "overview.configure_provider", + text: row("去配置", "前往設定", "Configure", "設定する", "설정하기"), + }, + Msg { + key: "overview.manage_provider", + text: row( + "管理服务", + "管理服務", + "Manage service", + "サービスを管理", + "서비스 관리", + ), + }, + Msg { + key: "overview.status_loading", + text: row( + "正在读取服务配置…", + "正在讀取服務設定…", + "Reading service configuration…", + "サービス設定を読み込み中…", + "서비스 설정을 불러오는 중…", + ), + }, + Msg { + key: "overview.credentials_error", + text: row( + "无法读取凭据状态", + "無法讀取憑證狀態", + "Could not read credential status", + "資格情報の状態を読み取れません", + "자격 증명 상태를 읽을 수 없음", + ), + }, + Msg { + key: "overview.week_days", + text: row( + "日|一|二|三|四|五|六", + "日|一|二|三|四|五|六", + "Sun|Mon|Tue|Wed|Thu|Fri|Sat", + "日|月|火|水|木|金|土", + "일|월|화|수|목|금|토", + ), + }, + Msg { + key: "overview.months", + text: row( + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec", + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "1월|2월|3월|4월|5월|6월|7월|8월|9월|10월|11월|12월", + ), + }, + Msg { + key: "overview.minutes", + text: row("{} 分钟", "{} 分鐘", "{} min", "{} 分", "{}분"), + }, + Msg { + key: "overview.hours_minutes", + text: row( + "{} 小时 {} 分", + "{} 小時 {} 分", + "{} h {} min", + "{} 時間 {} 分", + "{}시간 {}분", + ), + }, + Msg { + key: "overview.copy", + text: row("复制", "複製", "Copy", "コピー", "복사"), + }, + Msg { + key: "overview.copied", + text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨"), + }, + // ---- Shared controls --------------------------------------------------- + Msg { + key: "common.refresh", + text: row("刷新", "刷新", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "common.clear", + text: row("清空", "清空", "Clear", "クリア", "지우기"), + }, + Msg { + key: "common.loading", + text: row("加载中…", "加載中…", "Loading…", "読み込み中…", "로딩 중…"), + }, + Msg { + key: "common.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "common.copy", + text: row("复制", "複製", "Copy", "コピー", "복사"), + }, + Msg { + key: "common.copied", + text: row("已复制", "已複製", "Copied", "コピーしました", "복사됨"), + }, + Msg { + key: "settings.unsupported_linux", + text: row( + "当前平台暂不支持此设置", + "目前平台暫不支援此設定", + "This setting is not available on Linux", + "この設定は Linux では利用できません", + "이 설정은 Linux에서 사용할 수 없습니다", + ), + }, + Msg { + key: "status.copied", + text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨"), + }, + // egui-only: the style page's pack counter and new-pack hint are not part of + // the Tauri catalog (it renders the equivalent UI from Core data). + Msg { + key: "style.pack_count", + text: row( + "{} 个风格包", + "{} 個風格包", + "{} style packs", + "{} 個のスタイルパック", + "스타일 팩 {}개", + ), + }, + Msg { + key: "style.new_pack_hint", + text: row( + "从模板开始创建自己的风格", + "從範本開始建立自己的風格", + "Start from a template", + "テンプレートから作成", + "템플릿에서 시작", + ), + }, + // egui-only: the Core vocabulary presets are loaded from the backend in the + // Tauri app; the egui host lists the four built-ins by name. + Msg { + key: "vocab.presets_dev_tools", + text: row( + "开发工具", + "開發工具", + "Dev tools", + "開発ツール", + "개발 도구", + ), + }, + Msg { + key: "vocab.presets_products", + text: row( + "产品与平台", + "產品與平台", + "Products & platforms", + "製品とプラットフォーム", + "제품 및 플랫폼", + ), + }, + Msg { + key: "vocab.presets_terms", + text: row( + "技术术语", + "技術術語", + "Technical terms", + "技術用語", + "기술 용어", + ), + }, + Msg { + key: "vocab.presets_english", + text: row( + "英文写作", + "英文寫作", + "English writing", + "英語ライティング", + "영어 작문", + ), + }, + // egui-only: the not-yet-wired page placeholder and the marketplace's + // decorative preview text have no Tauri counterpart. + Msg { + key: "common.unsupported_title", + text: row( + "此页面暂未接线", + "此頁面暫未接線", + "This page is not wired up yet", + "このページは未接続です", + "이 페이지는 아직 연결되지 않았습니다", + ), + }, + Msg { + key: "common.unsupported_hint", + text: row( + "数据桥接将在后续阶段完成", + "資料橋接將在後續階段完成", + "Data wiring lands in a later stage", + "データ連携は後続の段階で完了します", + "데이터 연결은 이후 단계에서 완료됩니다", + ), + }, + Msg { + key: "marketplace.preview_placeholder", + text: row( + "本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。", + "本機預覽占位\n保留原始表達,優化語氣、結構與可讀性。\n這段內容會被實際風格包提示詞取代。", + "Local placeholder preview\nKeeps the original wording in context while improving tone, structure and readability.\nReplaced by the real style-pack prompt.", + "ローカル用のプレースホルダープレビュー\n原文の言い回しを保ちつつ、語調・構成・可読性を整えます。\n実際のスタイルパックのプロンプトに置き換わります。", + "로컬 자리표시자 미리보기\n원문 표현을 유지하면서 어조, 구조, 가독성을 다듬습니다.\n실제 스타일 팩 프롬프트로 대체됩니다.", + ), + }, + Msg { + key: "common.delete", + text: row("删除", "刪除", "Delete", "削除", "삭제"), + }, + Msg { + key: "common.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "common.confirm", + text: row("确认", "確認", "Confirm", "確認", "확인"), + }, + Msg { + key: "common.duration_minutes", + text: row("{} 分钟", "{} 分鐘", "{}m", "{} 分", "{}분"), + }, + // ---- History ----------------------------------------------------------- + Msg { + key: "history.kicker", + text: row("历史记录", "歷史記錄", "HISTORY", "履歴", "기록"), + }, + Msg { + key: "history.title", + text: row("历史记录", "歷史記錄", "History", "履歴", "기록"), + }, + Msg { + key: "history.desc", + text: row( + "本机保存的识别记录。", + "本機保存的識別記錄。", + "Locally stored transcripts.", + "ローカルに保存された認識記録。", + "로컬에 저장된 인식 기록.", + ), + }, + Msg { + key: "history.search_placeholder", + text: row( + "搜索转写内容…({})", + "搜尋轉寫內容…({})", + "Search transcripts… ({})", + "文字起こしを検索…({})", + "기록 검색…({})", + ), + }, + Msg { + key: "history.empty", + text: row( + "还没有历史记录。", + "還沒有歷史記錄。", + "No history yet.", + "履歴はまだありません。", + "아직 기록이 없습니다.", + ), + }, + Msg { + key: "history.search_no_match", + text: row( + "没有匹配「{}」的记录。", + "沒有符合「{}」的記錄。", + "No entries match “{}”.", + "「{}」に一致する項目はありません。", + "“{}”과(와) 일치하는 항목이 없습니다.", + ), + }, + Msg { + key: "history.load_failed", + text: row( + "加载历史失败:{}", + "加載歷史失敗:{}", + "Failed to load history: {}", + "履歴の読み込みに失敗:{}", + "기록 로드 실패: {}", + ), + }, + Msg { + key: "history.select_hint", + text: row( + "左侧选一条查看详情。", + "左側選一條查看詳情。", + "Select an entry on the left to see details.", + "左側から 1 件選択して詳細を表示。", + "왼쪽에서 하나를 선택하여 자세히 보기.", + ), + }, + Msg { + key: "history.recorded", + text: row("录音 {}", "錄音 {}", "Recorded {}", "録音 {}", "녹음 {}"), + }, + // egui-only: the in-app player bar toggles play/stop; the Tauri player uses + // an icon-only button. + Msg { + key: "history.stop_playback", + text: row("停止播放", "停止播放", "Stop", "停止", "정지"), + }, + Msg { + key: "history.play", + text: row( + "播放录音", + "播放錄音", + "Play recording", + "録音を再生", + "녹음 재생", + ), + }, + Msg { + key: "history.export", + text: row( + "导出录音", + "匯出錄音", + "Export recording", + "録音をエクスポート", + "녹음 내보내기", + ), + }, + Msg { + key: "history.retranscribe", + text: row( + "重新转写", + "重新轉寫", + "Retranscribe", + "再文字起こし", + "다시 받아쓰기", + ), + }, + Msg { + key: "history.raw_label", + text: row("原文", "原文", "Raw", "原文", "원문"), + }, + Msg { + key: "history.raw_empty", + text: row("(空)", "(空)", "(empty)", "(空)", "(비어 있음)"), + }, + Msg { + key: "history.step_asr", + text: row("识别", "辨識", "Transcribe", "認識", "인식"), + }, + Msg { + key: "history.step_polish", + text: row("润色", "潤飾", "Polish", "推敲", "다듬기"), + }, + Msg { + key: "history.step_insert", + text: row("插入", "插入", "Insert", "挿入", "삽입"), + }, + Msg { + key: "history.chars", + text: row("{} 字", "{} 字", "{} chars", "{} 文字", "{}자"), + }, + Msg { + key: "history.vocab_hits", + text: row( + "{} 个热词", + "{} 個熱詞", + "{} vocab hits", + "{} ホットワード", + "핫워드 {}개", + ), + }, + Msg { + key: "history.inserted", + text: row("已插入", "已插入", "Inserted", "入力済み", "입력됨"), + }, + Msg { + key: "history.paste_sent", + text: row("已尝试粘贴", "已嘗試粘貼", "Paste sent", "貼り付けを試行", "붙여넣기 시도됨"), + }, + Msg { + key: "history.copied_fallback", + text: row( + "已复制(需 {})", + "已複製(需 {})", + "Copied (use {})", + "コピー済み(要 {})", + "복사됨({} 필요)", + ), + }, + Msg { + key: "history.insert_failed", + text: row( + "插入失败", + "插入失敗", + "Insert failed", + "入力失敗", + "입력 실패", + ), + }, + Msg { + key: "history.confirm_clear", + text: row( + "确定清空全部 {} 条记录?此操作不可恢复。", + "確定清空全部 {} 條記錄?此操作不可恢復。", + "Delete all {} history entries? This cannot be undone.", + "全 {} 件の記録を削除しますか?この操作は取り消せません。", + "전체 {}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + ), + }, + Msg { + key: "history.confirm_delete", + text: row( + "确定删除这条记录?此操作不可恢复。", + "確定刪除這筆記錄?此操作無法復原。", + "Delete this record? This cannot be undone.", + "この記録を削除しますか?元に戻せません。", + "이 기록을 삭제할까요? 되돌릴 수 없습니다.", + ), + }, + Msg { + key: "history.clear_failed", + text: row( + "清空失败:{}", + "清空失敗:{}", + "Failed to clear history: {}", + "履歴の消去に失敗:{}", + "기록 비우기 실패: {}", + ), + }, + Msg { + key: "history.delete_failed", + text: row( + "删除失败:{}", + "刪除失敗:{}", + "Failed to delete entry: {}", + "記録の削除に失敗:{}", + "항목 삭제 실패: {}", + ), + }, + Msg { + key: "history.copy_failed", + text: row( + "复制失败:{}", + "複製失敗:{}", + "Failed to copy: {}", + "コピーに失敗:{}", + "복사 실패: {}", + ), + }, + Msg { + key: "history.export_failed", + text: row( + "导出失败:{}", + "匯出失敗:{}", + "Failed to export: {}", + "エクスポート失敗:{}", + "내보내기 실패: {}", + ), + }, + Msg { + key: "history.retranscribe_failed", + text: row( + "重新转录失败:{}", + "重新轉錄失敗:{}", + "Retranscribe failed: {}", + "再認識に失敗:{}", + "다시 인식 실패: {}", + ), + }, + // ---- Durations --------------------------------------------------------- + Msg { + key: "dur.ms", + text: row("{} 毫秒", "{} 毫秒", "{} ms", "{} ミリ秒", "{} 밀리초"), + }, + Msg { + key: "dur.sec", + text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{} 초"), + }, + Msg { + key: "dur.min_sec", + text: row( + "{} 分 {} 秒", + "{} 分 {} 秒", + "{}m {}s", + "{} 分 {} 秒", + "{}분 {}초", + ), + }, + // ---- Language selector -------------------------------------------------- + Msg { + key: "settings.language", + text: row("界面语言", "介面語言", "UI language", "UI 言語", "UI 언어"), + }, + Msg { + key: "settings.language_follow_system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따라가기", + ), + }, + Msg { + key: "lang.zh-CN", + text: row( + "简体中文", + "简体中文", + "Simplified Chinese", + "簡体中国語", + "중국어(간체)", + ), + }, + Msg { + key: "lang.zh-TW", + text: row( + "繁体中文", + "繁體中文", + "Traditional Chinese", + "繁体中国語", + "중국어(번체)", + ), + }, + Msg { + key: "lang.en", + text: row("English", "English", "English", "英語", "영어"), + }, + Msg { + key: "lang.ja", + text: row("日本語", "日本語", "Japanese", "日本語", "일본어"), + }, + Msg { + key: "lang.ko", + text: row("한국어", "한국어", "Korean", "韓国語", "한국어"), + }, + Msg { + key: "settings.locale_saved", + text: row( + "界面语言已更新", + "介面語言已更新", + "UI language updated", + "UI 言語を更新しました", + "UI 언어가 업데이트되었습니다", + ), + }, + // ---- Appearance (settings) --------------------------------------------- + Msg { + key: "settings.theme", + text: row("主题", "佈景主題", "Theme", "テーマ", "테마"), + }, + Msg { + key: "theme.light", + text: row("浅色", "淺色", "Light", "ライト", "라이트"), + }, + Msg { + key: "theme.dark", + text: row("深色", "深色", "Dark", "ダーク", "다크"), + }, + Msg { + key: "theme.system", + text: row( + "系统默认", + "系統預設", + "System default", + "システム既定", + "시스템 기본", + ), + }, + // ---- Status / host ------------------------------------------------------ + Msg { + key: "status.core_started", + text: row( + "Core 2.0 已启动", + "Core 2.0 已啟動", + "Core 2.0 ready", + "Core 2.0 起動済み", + "Core 2.0 시작됨", + ), + }, + Msg { + key: "status.startup_failed", + text: row( + "启动失败", + "啟動失敗", + "Startup failed", + "起動に失敗しました", + "시작 실패", + ), + }, + // ---- Buttons (models / providers / vocab / styles / marketplace / history) + Msg { key: "btn.create", text: row("创建", "建立", "Create", "作成", "생성") }, + Msg { key: "btn.delete", text: row("删除", "刪除", "Delete", "削除", "삭제") }, + Msg { key: "btn.confirm_delete", text: row("确认删除", "確認刪除", "Confirm delete", "削除を確認", "삭제 확인") }, + Msg { key: "btn.cancel_delete", text: row("取消删除", "取消刪除", "Cancel", "キャンセル", "삭제 취소") }, + Msg { key: "btn.enable", text: row("启用", "啟用", "Enable", "有効化", "사용") }, + Msg { key: "btn.disable", text: row("禁用", "停用", "Disable", "無効化", "사용 안 함") }, + Msg { key: "btn.move_up", text: row("上移", "上移", "Move up", "上へ移動", "위로 이동") }, + Msg { key: "btn.move_down", text: row("下移", "下移", "Move down", "下へ移動", "아래로 이동") }, + Msg { key: "btn.check_now", text: row("立即检查", "立即檢查", "Check now", "今すぐ確認", "지금 확인") }, + Msg { key: "btn.download_install", text: row("下载并安装", "下載並安裝", "Download & install", "ダウンロードしてインストール", "다운로드 및 설치") }, + Msg { key: "btn.open_releases", text: row("打开发布页", "開啟發布頁", "Open releases page", "リリースページを開く", "릴리스 페이지 열기") }, + Msg { key: "btn.accept", text: row("接受", "接受", "Accept", "承諾", "수락") }, + Msg { key: "btn.ignore", text: row("忽略", "忽略", "Ignore", "無視", "무시") }, + Msg { key: "btn.close_all", text: row("全部关闭", "全部關閉", "Dismiss all", "すべて閉じる", "모두 닫기") }, + Msg { key: "btn.apply", text: row("应用", "套用", "Apply", "適用", "적용") }, + Msg { key: "btn.hide_builtin", text: row("隐藏内置预设", "隱藏內建預設", "Hide built-in preset", "組み込みプリセットを非表示", "내장 프리셋 숨기기") }, + Msg { key: "btn.restore_builtin", text: row("恢复内置预设:{}", "還原內建預設:{}", "Restore built-in preset: {}", "組み込みプリセットを復元: {}", "내장 프리셋 복원: {}") }, + Msg { key: "btn.save_preset", text: row("保存预设", "儲存預設", "Save preset", "プリセットを保存", "프리셋 저장") }, + Msg { key: "btn.add", text: row("添加", "新增", "Add", "追加", "추가") }, + Msg { key: "btn.add_rule", text: row("添加规则", "新增規則", "Add rule", "ルールを追加", "규칙 추가") }, + Msg { key: "btn.save_style", text: row("保存风格包", "儲存風格包", "Save style pack", "スタイルパックを保存", "스타일 팩 저장") }, + Msg { key: "btn.cancel_edit", text: row("取消编辑", "取消編輯", "Cancel editing", "編集をキャンセル", "편집 취소") }, + Msg { key: "btn.new_style", text: row("新建风格包", "新增風格包", "New style pack", "新規スタイルパック", "새 스타일 팩") }, + Msg { key: "btn.import_zip", text: row("导入 ZIP", "匯入 ZIP", "Import ZIP", "ZIP をインポート", "ZIP 가져오기") }, + Msg { key: "btn.export_zip", text: row("导出 ZIP", "匯出 ZIP", "Export ZIP", "ZIP をエクスポート", "ZIP 내보내기") }, + Msg { key: "btn.preview_runtime", text: row("运行时 Prompt 预览", "執行期 Prompt 預覽", "Runtime prompt preview", "実行時プロンプトプレビュー", "실행 프롬프트 미리보기") }, + Msg { key: "btn.edit", text: row("编辑", "編輯", "Edit", "編集", "편집") }, + Msg { key: "btn.reset_builtin", text: row("恢复内置默认", "還原內建預設", "Restore built-in default", "組み込みデフォルトに戻す", "내장 기본값 복원") }, + Msg { key: "btn.set_active", text: row("设为 active", "設為 active", "Set active", "アクティブに設定", "활성 설정") }, + Msg { key: "btn.save_fields", text: row("保存字段/Secret", "儲存欄位/Secret", "Save fields/Secret", "欄位/Secret を保存", "필드/Secret 저장") }, + Msg { key: "btn.clear_secret", text: row("清除 Secret", "清除 Secret", "Clear Secret", "Secret を消去", "Secret 지우기") }, + Msg { key: "btn.validate", text: row("验证连接", "驗證連線", "Validate connection", "接続を検証", "연결 검증") }, + Msg { key: "btn.list_models", text: row("列出模型", "列出模型", "List models", "モデルを一覧表示", "모델 나열") }, + Msg { key: "btn.search_refresh", text: row("搜索/刷新", "搜尋/重新整理", "Search & refresh", "検索/更新", "검색/새로고침") }, + Msg { key: "btn.github_login", text: row("GitHub 登录", "GitHub 登入", "GitHub sign in", "GitHub にログイン", "GitHub 로그인") }, + Msg { key: "btn.logout", text: row("退出登录", "登出", "Sign out", "サインアウト", "로그아웃") }, + Msg { key: "btn.my_publish_like", text: row("我的发布/喜欢", "我的發布/喜歡", "My uploads / likes", "マイ投稿・お気に入り", "내 업로드/좋아요") }, + Msg { key: "btn.open_github", text: row("打开 GitHub", "開啟 GitHub", "Open GitHub", "GitHub を開く", "GitHub 열기") }, + Msg { key: "btn.check_auth", text: row("检查授权", "檢查授權", "Check authorization", "認証を確認", "인증 확인") }, + Msg { key: "btn.install", text: row("安装", "安裝", "Install", "インストール", "설치") }, + Msg { key: "btn.toggle_like", text: row("喜欢/取消喜欢", "喜歡/取消喜歡", "Like / unlike", "いいね/いいね解除", "좋아요/좋아요 취소") }, + Msg { key: "btn.detail", text: row("详情", "詳情", "Details", "詳細", "상세") }, + Msg { key: "btn.download_zip", text: row("下载 ZIP", "下載 ZIP", "Download ZIP", "ZIP をダウンロード", "ZIP 다운로드") }, + Msg { key: "btn.upload_update", text: row("上传/更新", "上傳/更新", "Upload / update", "アップロード/更新", "업로드/업데이트") }, + Msg { key: "btn.delete_publish", text: row("删除发布", "刪除發布", "Delete release", "リリースを削除", "배포 삭제") }, + Msg { key: "btn.clear_all", text: row("清空全部", "清空全部", "Clear all", "すべてクリア", "전체 지우기") }, + Msg { key: "btn.copy", text: row("复制", "複製", "Copy", "コピー", "복사") }, + Msg { key: "btn.repolish", text: row("重新润色", "重新潤飾", "Repolish", "再推敲", "다시 다듬기") }, + Msg { key: "btn.play_recording", text: row("播放录音", "播放錄音", "Play recording", "録音を再生", "녹음 재생") }, + Msg { key: "btn.export_recording", text: row("导出录音", "匯出錄音", "Export recording", "録音をエクスポート", "녹음 내보내기") }, + Msg { key: "btn.retranscribe", text: row("重新转写", "重新轉寫", "Retranscribe", "再文字起こし", "다시 받아쓰기") }, + Msg { key: "btn.preload_current", text: row("预加载当前模型", "預載目前模型", "Preload active model", "現在のモデルをプリロード", "현재 모델 미리 로드") }, + Msg { key: "btn.release_model", text: row("释放模型", "釋放模型", "Release model", "モデルを解放", "모델 해제") }, + Msg { key: "btn.cancel_prepare", text: row("取消准备", "取消準備", "Cancel preparation", "準備をキャンセル", "준비 취소") }, + Msg { key: "btn.download", text: row("下载", "下載", "Download", "ダウンロード", "다운로드") }, + Msg { key: "btn.cancel_download", text: row("取消下载", "取消下載", "Cancel download", "ダウンロードをキャンセル", "다운로드 취소") }, + Msg { key: "btn.verify_prepare", text: row("验证/准备", "驗證/準備", "Verify / prepare", "検証/準備", "검증/준비") }, + Msg { key: "btn.test", text: row("测试", "測試", "Test", "テスト", "테스트") }, + Msg { key: "btn.export_error_log", text: row("导出错误日志", "匯出錯誤日誌", "Export error log", "エラーログをエクスポート", "오류 로그 내보내기") }, + Msg { key: "btn.save_settings", text: row("保存设置", "儲存設定", "Save settings", "設定を保存", "설정 저장") }, + Msg { key: "btn.reset_pairing", text: row("重置配对码", "重置配對碼", "Reset pairing code", "ペアリングコードをリセット", "페어링 코드 재설정") }, + Msg { key: "btn.activate", text: row("激活", "啟用", "Activate", "アクティブ化", "활성화") }, + Msg { key: "btn.new_channel", text: row("新增渠道", "新增管道", "Add channel", "チャネルを追加", "채널 추가") }, + Msg { key: "btn.refresh_channel", text: row("刷新渠道", "重新整理管道", "Refresh channels", "チャネルを更新", "채널 새로고침") }, + Msg { key: "btn.restore_default", text: row("恢复内置默认", "還原內建預設", "Restore built-in default", "組み込みデフォルトに戻す", "내장 기본값 복원") }, + Msg { key: "btn.set_current", text: row("设为当前", "設為目前", "Set current", "現在に設定", "현재로 설정") }, + Msg { key: "btn.enable_label", text: row("启用", "啟用", "Enable", "有効化", "사용") }, + Msg { key: "btn.status_active", text: row(" · active", " · active", " · active", " · active", " · 활성") }, + Msg { key: "btn.status_disabled", text: row(" · 已禁用", " · 已停用", " · disabled", " · 無効", " · 비활성화됨") }, + // ---- Page / section headings + Msg { key: "head.software_update", text: row("软件更新", "軟體更新", "Software update", "ソフトウェア更新", "소프트웨어 업데이트") }, + Msg { key: "head.pending_corrections", text: row("待确认的手改建议", "待確認的手動修改建議", "Pending manual corrections", "保留中の手動修正候補", "대기 중인 수동 교정 제안") }, + Msg { key: "head.vocab_presets", text: row("词汇预设", "詞彙預設", "Vocabulary presets", "語彙プリセット", "어휘 프리셋") }, + Msg { key: "head.custom_vocab", text: row("自定义词汇", "自訂詞彙", "Custom vocabulary", "カスタム語彙", "사용자 어휘") }, + Msg { key: "head.correction_rules", text: row("纠错规则", "糾錯規則", "Correction rules", "修正ルール", "교정 규칙") }, + Msg { key: "head.style_pack_editor", text: row("风格包编辑器", "風格包編輯器", "Style pack editor", "スタイルパック編集", "스타일 팩 편집기") }, + Msg { key: "head.marketplace_mine", text: row("我的 Marketplace", "我的 Marketplace", "My Marketplace", "マイマーケットプレイス", "내 마켓플레이스") }, + Msg { key: "head.publish_local", text: row("发布本地风格包", "發佈本機風格包", "Publish a local style pack", "ローカルスタイルパックを公開", "로컬 스타일 팩 배포") }, + Msg { key: "head.marketplace_detail", text: row("详情:{}", "詳情:{}", "Details: {}", "詳細: {}", "상세: {}") }, + Msg { key: "head.history_empty", text: row("历史", "歷史", "History", "履歴", "기록") }, + // ---- Update UI + Msg { key: "update.available", text: row("可用版本:{}", "可用版本:{}", "Available version: {}", "利用可能なバージョン: {}", "사용 가능한 버전: {}") }, + Msg { key: "update.downloaded", text: row("已下载 {} 字节", "已下載 {} 位元組", "{} bytes downloaded", "{} バイトをダウンロード", "{}바이트 다운로드됨") }, + Msg { key: "update.manual_notice", text: row("deb/rpm 与开发构建由包管理器或发布页更新。", "deb/rpm 與開發建置由套件管理員或發布頁更新。", "deb/rpm and dev builds update via your package manager or the releases page.", "deb/rpm と開発ビルドはパッケージマネージャまたはリリースページで更新されます。", "deb/rpm 및 개발 빌드는 패키지 관리자 또는 릴리스 페이지로 업데이트됩니다.") }, + Msg { key: "update.system_managed", text: row("当前安装包由系统包管理器更新", "目前套件由系統套件管理員更新", "This build is updated by your system package manager", "このパッケージはシステムのパッケージマネージャで更新されます", "이 패키지는 시스템 패키지 관리자가 업데이트합니다") }, + Msg { key: "update.discovered", text: row("发现新版本 {}", "發現新版本 {}", "New version available: {}", "新しいバージョン: {}", "새 버전 발견: {}") }, + Msg { key: "update.up_to_date", text: row("当前已是最新版本", "目前已是最新版本", "You are up to date", "最新バージョン입니다", "최신 버전입니다") }, + Msg { key: "update.check_failed", text: row("检查更新失败:{}", "檢查更新失敗:{}", "Update check failed: {}", "更新確認に失敗: {}", "업데이트 확인 실패: {}") }, + Msg { key: "update.installed_restart", text: row("已安装 {},请重启 OpenLess", "已安裝 {},請重新啟動 OpenLess", "{} installed — restart OpenLess", "{} をインストールしました。OpenLess を再起動してください", "{} 설치됨 — OpenLess를 재시작하세요") }, + Msg { key: "update.install_failed", text: row("安装更新失败:{}", "安裝更新失敗:{}", "Update install failed: {}", "更新のインストールに失敗: {}", "업데이트 설치 실패: {}") }, + // ---- Settings / preferences + Msg { key: "settings.recording_input", text: row("录音与输入", "錄音與輸入", "Recording & input", "録音と入力", "녹음 및 입력") }, + Msg { key: "settings.rec_mode", text: row("录音方式", "錄音方式", "Recording mode", "録音方式", "녹음 방식") }, + Msg { key: "recmode.toggle", text: row("切换", "切換", "Toggle", "トグル", "전환") }, + Msg { key: "recmode.hold", text: row("按住说话", "按住說話", "Push to talk", "押して話す", "누르고 말하기") }, + Msg { key: "recmode.double_click", text: row("双击", "雙擊", "Double-click", "ダブルクリック", "더블 클릭") }, + Msg { key: "recmode.auto", text: row("自动识别", "自動辨識", "Auto detect", "自動認識", "자동 인식") }, + Msg { key: "settings.auto_stop", text: row("说完后自动停止", "說畢後自動停止", "Stop automatically after silence", "無音で自動停止", "침묵 시 자동 중지") }, + Msg { key: "settings.silence_duration", text: row("连续静音时长", "連續靜音時長", "Silence timeout", "無音の継続時間", "침묵 지속 시간") }, + Msg { key: "settings.seconds", text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{}초") }, + Msg { key: "settings.microphone", text: row("麦克风", "麥克風", "Microphone", "マイク", "마이크") }, + Msg { key: "settings.system_default", text: row("系统默认", "系統預設", "System default", "システム既定", "시스템 기본") }, + Msg { key: "settings.mute_while", text: row("录音期间暂时静音系统声音", "錄音期間暫時靜音系統聲音", "Mute system audio while recording", "録音中はシステム音声をミュート", "녹음 중 시스템 소리 음소거") }, + Msg { key: "settings.cue_audio", text: row("录音开始/结束播放提示音", "錄音開始/結束播放提示音", "Play cue sounds when recording starts/stops", "録音開始/終了時に合図音を再生", "녹음 시작/종료 시 알림음 재생") }, + Msg { key: "settings.appearance", text: row("外观", "外觀", "Appearance", "外観", "모양") }, + Msg { key: "theme.follow_system", text: row("跟随系统", "跟隨系統", "Follow system", "システムに従う", "시스템 따르기") }, + Msg { key: "settings.show_heatmap", text: row("显示活动热力图", "顯示活動熱力圖", "Show activity heatmap", "活動ヒートマップを表示", "활동 히트맵 표시") }, + Msg { key: "settings.hotkeys_group", text: row("fcitx5 快捷键", "fcitx5 快速鍵", "fcitx5 shortcuts", "fcitx5 ショートカット", "fcitx5 단축키") }, + Msg { key: "settings.streaming_insert", text: row("流式插入", "串流插入", "Streaming insert", "ストリーミング挿入", "스트리밍 삽입") }, + Msg { key: "settings.enable_coding_agent", text: row("启用 Less Computer", "啟用 Less Computer", "Enable Less Computer", "Less Computer を有効化", "Less Computer 사용") }, + Msg { key: "settings.start_minimized", text: row("启动时隐藏主窗口", "啟動時隱藏主視窗", "Hide main window on launch", "起動時にメインウィンドウを非表示", "시작 시 메인 창 숨기기") }, + Msg { key: "settings.launch_at_login", text: row("开机启动", "開機啟動", "Launch at login", "ログイン時に起動", "로그인 시 실행") }, + Msg { key: "settings.auto_update", text: row("自动检查更新", "自動檢查更新", "Automatically check for updates", "自動更新確認", "자동 업데이트 확인") }, + Msg { key: "settings.update_channel", text: row("更新渠道", "更新管道", "Update channel", "更新チャネル", "업데이트 채널") }, + Msg { key: "channel.stable", text: row("稳定版", "穩定版", "Stable", "安定版", "안정판") }, + Msg { key: "settings.enable_remote", text: row("启用远程输入", "啟用遠端輸入", "Enable remote input", "リモート入力を有効化", "원격 입력 사용") }, + Msg { key: "settings.port", text: row("端口 ", "連接埠 ", "Port ", "ポート ", "포트 ") }, + // ---- Hotkey control labels + Msg { key: "hotkey.dictation", text: row("听写", "聽寫", "Dictation", "ディクテーション", "받아쓰기") }, + Msg { key: "hotkey.translation", text: row("翻译修饰键", "翻譯修飾鍵", "Translate modifier", "翻訳修飾キー", "번역 수정자") }, + Msg { key: "hotkey.selection_polish", text: row("选区润色", "選區潤飾", "Polish selection", "選択範囲の推敲", "선택 다듬기") }, + Msg { key: "hotkey.switch_style", text: row("切换风格", "切換風格", "Switch style", "スタイル切替", "스타일 전환") }, + Msg { key: "hotkey.open_app", text: row("打开应用", "開啟應用", "Open app", "アプリを開く", "앱 열기") }, + Msg { key: "hotkey.coding_agent", text: row("Coding Agent 语音", "Coding Agent 語音", "Coding Agent voice", "Coding Agent 音声", "Coding Agent 음성") }, + Msg { key: "hotkey.enable", text: row("启用{}", "啟用{}", "Enable {}", "{} を有効化", "{} 사용") }, + // ---- Remote input + Msg { key: "remote.running", text: row("远程输入:运行中", "遠端輸入:執行中", "Remote input: running", "リモート入力: 実行中", "원격 입력: 실행 중") }, + Msg { key: "remote.starting", text: row("远程输入:启动中", "遠端輸入:啟動中", "Remote input: starting", "リモート入力: 起動中", "원격 입력: 시작 중") }, + Msg { key: "remote.stopped", text: row("远程输入:已停止", "遠端輸入:已停止", "Remote input: stopped", "リモート入力: 停止中", "원격 입력: 중지됨") }, + Msg { key: "remote.lang_conns", text: row("语言:{} · 连接数:{}", "語言:{} · 連線數:{}", "Language: {} · Connections: {}", "言語: {} · 接続数: {}", "언어: {} · 연결 수: {}") }, + Msg { key: "lbl.status_colon", text: row("状态:{}", "狀態:{}", "Status: {}", "状態: {}", "상태: {}") }, + Msg { key: "lbl.search", text: row("搜索", "搜尋", "Search", "検索", "검색") }, + Msg { key: "lbl.name", text: row("名称", "名稱", "Name", "名前", "이름") }, + Msg { key: "lbl.version", text: row("版本", "版本", "Version", "バージョン", "버전") }, + Msg { key: "lbl.description", text: row("描述", "描述", "Description", "説明", "설명") }, + Msg { key: "lbl.base_mode", text: row("基础模式", "基礎模式", "Base mode", "基本モード", "기본 모드") }, + Msg { key: "lbl.phrase", text: row("词语", "詞語", "Phrase", "語句", "단어") }, + Msg { key: "lbl.note", text: row("备注", "備註", "Note", "メモ", "메모") }, + Msg { key: "lbl.dictation_prompt", text: row("听写 Prompt", "聽寫 Prompt", "Dictation prompt", "ディクテーションプロンプト", "받아쓰기 프롬프트") }, + Msg { key: "lbl.selection_prompt", text: row("选区 Prompt(留空则使用 Core 默认)", "選區 Prompt(留空則使用 Core 預設)", "Selection prompt (blank uses Core default)", "選択範囲プロンプト(空欄は Core 既定)", "선택 프롬프트(비우면 Core 기본값)") }, + Msg { key: "lbl.current", text: row("当前", "目前", "Current", "現在", "현재") }, + Msg { key: "lbl.primary", text: row("主键", "主鍵", "Primary key", "主キー", "주 키") }, + Msg { key: "lbl.modifiers", text: row("修饰键(+ 分隔)", "修飾鍵(+ 分隔)", "Modifiers (separate with +)", "修飾キー(+ で区切る)", "수정자(+로 구분)") }, + Msg { key: "lbl.device_code", text: row("设备码:{}", "裝置碼:{}", "Device code: {}", "デバイスコード: {}", "기기 코드: {}") }, + Msg { key: "lbl.liked", text: row("喜欢的风格:{}", "喜歡的風格:{}", "Liked style packs: {}", "いいねしたスタイルパック: {}", "좋아요한 스타일 팩: {}") }, + Msg { key: "lbl.like_dl", text: row("喜欢 {} · 下载 {} · {}", "喜歡 {} · 下載 {} · {}", "{} likes · {} downloads · {}", "いいね {} · DL {} · {}", "좋아요 {} · 다운로드 {} · {}") }, + Msg { key: "lbl.published", text: row("已发布:{}", "已發佈:{}", "Published: {}", "公開済み: {}", "배포됨: {}") }, + Msg { key: "lbl.hits", text: row("命中 {}", "命中 {}", "Hits: {}", "ヒット数: {}", "적중: {}") }, + Msg { key: "lbl.preset_count", text: row("{} 个词", "{} 個詞", "{} phrases", "{} 語句", "{}개 단어") }, + Msg { key: "lbl.preset_note", text: row("预设由 Core 合并内置版本、用户覆盖和自定义内容。", "預設由 Core 合併內建版本、使用者覆蓋與自訂內容。", "Presets merge Core built-ins, user overrides and custom entries.", "プリセットは Core の内蔵版・ユーザー上書き・カスタムを統合します。", "프리셋은 Core 내장, 사용자 덮어쓰기, 사용자 지정을 통합합니다.") }, + Msg { key: "lbl.new_custom_preset", text: row("新建自定义预设", "新增自訂預設", "New custom preset", "新規カスタムプリセット", "새 사용자 프리셋") }, + Msg { key: "lbl.new_style_default", text: row("新风格", "新風格", "New style", "新規スタイル", "새 스타일") }, + Msg { key: "hint.preset_phrases", text: row("每行或逗号分隔一个词", "每行或逗號分隔一個詞", "One phrase per line or comma-separated", "各行に1語句、またはカンマ区切り", "한 줄에 하나 또는 쉼표로 구분") }, + Msg { key: "lbl.author_version", text: row("作者:{} · 版本 {}", "作者:{} · 版本 {}", "By {} · v{}", "作者: {} · バージョン {}", "작성자: {} · 버전 {}") }, + Msg { key: "lbl.style_note", text: row("风格包数据直接来自 Core repository;运行时 Prompt 由 Core 组合。", "風格包資料直接來自 Core repository;執行期 Prompt 由 Core 組合。", "Style packs come directly from the Core repository; Core composes runtime prompts.", "スタイルパックは Core リポジトリ由来で、実行時プロンプトは Core が組み立てます。", "스타일 팩은 Core 저장소에서 오며 Core가 실행 프롬프트를 구성합니다.") }, + Msg { key: "lbl.direct_hotkey", text: row("风格包直达快捷键", "風格包直達快速鍵", "Direct style-pack shortcut", "スタイルパック直接ショートカット", "스타일 팩 직접 단축키") }, + Msg { key: "lbl.choose_style", text: row("选择风格包", "選擇風格包", "Select a style pack", "スタイルパックを選択", "스타일 팩 선택") }, + Msg { key: "btn.save_direct_hotkey", text: row("保存直达快捷键", "儲存直達快速鍵", "Save direct shortcut", "直接ショートカットを保存", "직접 단축키 저장") }, + Msg { key: "btn.remove_direct_hotkey", text: row("移除直达快捷键", "移除直達快速鍵", "Remove direct shortcut", "直接ショートカットを削除", "직접 단축키 제거") }, + Msg { key: "lbl.choose_provider", text: row("选择 Provider", "選擇 Provider", "Select a provider", "プロバイダーを選択", "프로바이더 선택") }, + Msg { key: "providers.credentials", text: row("凭据渠道", "憑證管道", "Credential channels", "資格情報チャネル", "자격 증명 채널") }, + Msg { key: "providers.core_note", text: row("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。", "Provider 類型、預設 Endpoint/Model 與鑑權要求皆來自 Core descriptor。", "Provider type, default Endpoint/Model and auth requirements come from the Core descriptor.", "Provider 種別・既定 Endpoint/Model・認証要件は Core descriptor 由来です。", "Provider 유형, 기본 Endpoint/Model 및 인증 요구 사항은 Core descriptor에서 옵니다.") }, + Msg { key: "providers.empty", text: row("尚无渠道;先从上方 Core Provider 列表创建一个。", "尚無管道;請先從上方 Core Provider 清單建立一個。", "No channels yet — create one from the Core provider list above.", "チャネルがありません。上の Core Provider 一覧から作成してください。", "채널이 없습니다. 위 Core Provider 목록에서 생성하세요.") }, + Msg { key: "providers.loading_dir", text: row("正在读取 Core 渠道目录…", "正在讀取 Core 管道目錄…", "Reading the Core channel catalog…", "Core チャネル目録を読み込み中…", "Core 채널 목록을 읽는 중…") }, + Msg { key: "providers.reading_channel", text: row("正在读取 {} 渠道 {}…", "正在讀取 {} 管道 {}…", "Reading {} channel {}…", "{} チャネル {} を読み込み中…", "{} 채널 {} 읽는 중…") }, + Msg { key: "providers.editing", text: row("编辑渠道 {}", "編輯管道 {}", "Edit channel {}", "チャネル {} を編集", "채널 {} 편집") }, + Msg { key: "providers.auth_probe", text: row("鉴权:{} · 探针:{}", "鑑權:{} · 探測:{}", "Auth: {} · Probe: {}", "認証: {} · プローブ: {}", "인증: {} · 프로브: {}") }, + Msg { key: "providers.name", text: row("名称", "名稱", "Name", "名前", "이름") }, + Msg { key: "providers.model_list", text: row("模型列表(点击填入):", "模型清單(點擊填入):", "Models (click to fill):", "モデル一覧(クリックで入力)", "모델 목록(클릭하여 입력)") }, + Msg { key: "providers.no_cloud_note", text: row("此 Provider 不使用云凭据;模型由本地模型面板管理。", "此 Provider 不使用雲端憑證;模型由本機模型面板管理。", "This provider uses no cloud credentials; models are managed in Local Models.", "この Provider はクラウド資格情報を使いません。モデルはローカルモデルで管理します。", "이 프로바이더는 클라우드 자격 증명을 사용하지 않습니다. 모델은 로컬 모델에서 관리합니다.") }, + Msg { key: "providers.oauth_note", text: row("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。", "此 Provider 使用 OAuth;Linux egui 不讀取或顯示 OAuth token。", "This provider uses OAuth; the Linux egui UI never reads or shows the OAuth token.", "この Provider は OAuth を使用します。Linux egui は OAuth トークンを読み取らず表示もしません。", "이 프로바이더는 OAuth를 사용합니다. Linux egui는 OAuth 토큰을 읽거나 표시하지 않습니다.") }, + Msg { key: "providers.api_key_hint", text: row("API Key(留空表示不修改)", "API Key(留空表示不修改)", "API Key (blank leaves unchanged)", "API Key(空欄なら変更しない)", "API Key(비우면 변경 안 함)") }, + Msg { key: "auth.none", text: row("无需 Secret", "無需 Secret", "No secret", "Secret 不要", "Secret 불필요") }, + Msg { key: "auth.api_key", text: row("API Key", "API Key", "API Key", "API キー", "API 키") }, + Msg { key: "auth.endpoint_model_optional", text: row("Endpoint + Model,API Key 可选", "Endpoint + Model,API Key 可選", "Endpoint + Model, optional API Key", "Endpoint + Model、API Key は任意", "Endpoint + Model, API Key 선택") }, + Msg { key: "auth.api_key_unless_custom", text: row("公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key", "公共 Endpoint 需要 API Key;自建 Endpoint 可無 Key", "Public endpoints need an API Key; self-hosted may omit it", "公開 Endpoint は API Key が必要。自前 Endpoint は不要", "공개 엔드포인트는 API 키 필요, 자체 엔드포인트는 불필요") }, + Msg { key: "auth.volcengine", text: row("火山引擎凭据", "火山引擎憑證", "Volcengine credentials", "Volcengine 資格情報", "Volcengine 자격 증명") }, + Msg { key: "auth.xfyun", text: row("讯飞 AppID + API Key", "訊飛 AppID + API Key", "iFlytek AppID + API Key", "讯飛 AppID + API Key", "iFlytek AppID + API Key") }, + Msg { key: "auth.oauth", text: row("OAuth", "OAuth", "OAuth", "OAuth", "OAuth") }, + // ---- Empty / info labels + Msg { key: "history.failed", text: row("失败", "失敗", "Failed", "失敗", "실패") }, + Msg { key: "history.not_requested", text: row("未请求插入", "未請求插入", "Not requested", "挿入未要求", "삽입 요청 안 됨") }, + Msg { key: "models.loading_dir", text: row("正在加载模型目录…", "正在載入模型目錄…", "Loading the model catalog…", "モデル目録を読み込み中…", "모델 목록을 불러오는 중…") }, + Msg { key: "models.empty", text: row("模型目录未返回任何可用模型", "模型目錄未傳回任何可用模型", "No usable models were returned", "利用可能なモデルがありません", "사용 가능한 모델이 없습니다") }, + Msg { key: "models.installed", text: row("已安装", "已安裝", "Installed", "インストール済み", "설치됨") }, + Msg { key: "models.not_installed", text: row("未安装", "未安裝", "Not installed", "未インストール", "미설치") }, + Msg { key: "marketplace.not_loaded", text: row("尚未加载 Marketplace;点击“搜索/刷新”。", "尚未載入 Marketplace;點按「搜尋/重新整理」。", "Marketplace not loaded yet — use Search & refresh.", "Marketplace は未読込です。「検索/更新」を押してください。", "마켓플레이스가 아직 로드되지 않았습니다. 검색/새로고침을 누르세요.") }, + // ---- Status / toast messages + Msg { key: "status.done_chars", text: row("完成:{} 字", "完成:{} 字", "Done: {} chars", "完了:{} 文字", "완료: {} 글자") }, + Msg { key: "status.dictation_cancelled", text: row("听写已取消", "聽寫已取消", "Dictation cancelled", "ディクテーションをキャンセル", "받아쓰기 취소됨") }, + Msg { key: "status.auto_stopped", text: row("录音已自动结束", "錄音已自動結束", "Recording auto-stopped", "録音を自動終了", "녹음 자동 종료") }, + Msg { key: "status.less_compacted", text: row("Less Computer 已压缩上下文", "Less Computer 已壓縮上下文", "Less Computer compacted context", "Less Computer がコンテキストを圧縮", "Less Computer 컨텍스트 압축됨") }, + Msg { key: "status.less_waiting", text: row("Less Computer 等待审批", "Less Computer 等待審批", "Less Computer awaiting approval", "Less Computer が承認待ち", "Less Computer 승인 대기 중") }, + Msg { key: "status.less_tool", text: row("Less Computer 正在使用工具:{}", "Less Computer 正在使用工具:{}", "Less Computer is using a tool: {}", "Less Computer がツールを使用中: {}", "Less Computer 도구 사용 중: {}") }, + Msg { key: "status.less_running", text: row("Less Computer 正在运行", "Less Computer 正在執行", "Less Computer is running", "Less Computer 実行中", "Less Computer 실행 중") }, + Msg { key: "status.provider_models_loaded", text: row("已读取 {} 个模型", "已讀取 {} 個模型", "Loaded {} models", "{} 個のモデルを読み込み", "모델 {}개 로드됨") }, + Msg { key: "status.marketplace_loaded", text: row("Marketplace 已加载 {} 个风格包", "Marketplace 已載入 {} 個風格包", "Marketplace loaded {} style packs", "Marketplace が {} 個のスタイルパックを読込", "마켓플레이스 스타일 팩 {}개 로드됨") }, + Msg { key: "status.device_code", text: row("GitHub 设备码:{}", "GitHub 裝置碼:{}", "GitHub device code: {}", "GitHub デバイスコード: {}", "GitHub 기기 코드: {}") }, + Msg { key: "status.logged_in", text: row("Marketplace 已登录:{}", "Marketplace 已登入:{}", "Marketplace signed in as {}", "Marketplace に {} でログイン", "마켓플레이스에 {}로 로그인됨") }, + Msg { key: "status.logout_done", text: row("Marketplace 已退出登录", "Marketplace 已登出", "Marketplace signed out", "Marketplace をサインアウト", "마켓플레이스 로그아웃됨") }, + Msg { key: "status.oauth_pending", text: row("GitHub 授权仍在等待", "GitHub 授權仍在等待", "Waiting for GitHub authorization", "GitHub の認可を待機中", "GitHub 인가 대기 중") }, + Msg { key: "status.oauth_slowdown", text: row("GitHub 要求降低检查频率", "GitHub 要求降低檢查頻率", "GitHub asks you to slow down checks", "GitHub が確認頻度を下げるよう求めています", "GitHub가 확인 빈도를 낮추라고 요청함") }, + Msg { key: "status.detail_loaded", text: row("已加载风格详情:{}", "已載入風格詳情:{}", "Loaded style details: {}", "スタイル詳細を読込: {}", "스타일 상세 로드됨: {}") }, + Msg { key: "status.my_publish_likes", text: row("我的发布 {} 个,喜欢 {} 个", "我的發布 {} 個,喜歡 {} 個", "{} of my uploads · {} liked", "マイ投稿 {} 件・いいね {} 件", "내 업로드 {}개 · 좋아요 {}개") }, + Msg { key: "status.settings_saved", text: row("设置已保存", "設定已儲存", "Settings saved", "設定を保存しました", "설정 저장됨") }, + Msg { key: "status.remote_updated", text: row("远程输入状态已更新", "遠端輸入狀態已更新", "Remote input updated", "リモート入力を更新しました", "원격 입력 업데이트됨") }, + Msg { key: "status.preloaded", text: row("当前模型已预加载", "目前模型已預載", "Active model preloaded", "現在のモデルをプリロードしました", "현재 모델 미리 로드됨") }, + Msg { key: "status.model_released", text: row("模型已释放", "模型已釋放", "Model released", "モデルを解放しました", "모델 해제됨") }, + Msg { key: "status.cancel_prepare_ok", text: row("已请求取消模型准备", "已請求取消模型準備", "Cancellation requested", "準備のキャンセルを要求しました", "준비 취소 요청됨") }, + Msg { key: "status.activated", text: row("本地模型已激活并预加载", "本機模型已啟用並預載", "Local model activated and preloaded", "ローカルモデルをアクティブ化してプリロードしました", "로컬 모델 활성화 및 미리 로드됨") }, + Msg { key: "status.download_done", text: row("模型下载完成", "模型下載完成", "Model download finished", "モデルのダウンロードが完了", "모델 다운로드 완료") }, + Msg { key: "status.download_cancel_requested", text: row("已请求取消模型下载", "已請求取消模型下載", "Download cancellation requested", "ダウンロードのキャンセルを要求しました", "다운로드 취소 요청됨") }, + Msg { key: "status.download_cancelled", text: row("模型下载已取消", "模型下載已取消", "Model download cancelled", "モデルのダウンロードをキャンセル", "모델 다운로드 취소됨") }, + Msg { key: "status.prepare_done", text: row("模型验证完成:{}", "模型驗證完成:{}", "Model verification done: {}", "モデル検証が完了: {}", "모델 검증 완료: {}") }, + Msg { key: "status.test_done", text: row("模型测试完成:{}({} ms)", "模型測試完成:{}({} ms)", "Model test done: {} ({} ms)", "モデルテスト完了: {}({} ms)", "모델 테스트 완료: {}({}ms)") }, + Msg { key: "status.model_deleted", text: row("模型已删除", "模型已刪除", "Model deleted", "モデルを削除しました", "모델 삭제됨") }, + Msg { key: "status.channel_created", text: row("渠道已创建", "管道已建立", "Channel created", "チャネルを作成しました", "채널 생성됨") }, + Msg { key: "status.channel_active", text: row("active 渠道已更新", "active 管道已更新", "Active channel updated", "アクティブチャネルを更新しました", "활성 채널 업데이트됨") }, + Msg { key: "status.channel_enabled", text: row("渠道启用状态已更新", "管道啟用狀態已更新", "Channel enabled state updated", "チャネルの有効状態を更新しました", "채널 사용 상태 업데이트됨") }, + Msg { key: "status.channel_reordered", text: row("渠道顺序已更新", "管道順序已更新", "Channel order updated", "チャネルの順序を更新しました", "채널 순서 업데이트됨") }, + Msg { key: "status.channel_deleted", text: row("渠道已删除", "管道已刪除", "Channel deleted", "チャネルを削除しました", "채널 삭제됨") }, + Msg { key: "status.provider_type_updated", text: row("Provider 类型已更新", "Provider 類型已更新", "Provider type updated", "Provider 種別を更新しました", "Provider 유형 업데이트됨") }, + Msg { key: "status.channel_saved", text: row("渠道配置已保存", "管道設定已儲存", "Channel configuration saved", "チャネル設定を保存しました", "채널 설정 저장됨") }, + Msg { key: "status.secret_cleared", text: row("渠道 Secret 已清除", "管道 Secret 已清除", "Channel Secret cleared", "チャネルの Secret を消去しました", "채널 Secret 지워짐") }, + Msg { key: "status.provider_validated", text: row("Provider 验证通过({} ms)", "Provider 驗證通過({} ms)", "Provider validated ({} ms)", "Provider 検証成功({} ms)", "Provider 검증 통과({}ms)") }, + Msg { key: "status.export_log_done", text: row("错误日志已导出", "錯誤日誌已匯出", "Error log exported", "エラーログをエクスポートしました", "오류 로그 내보냄") }, + Msg { key: "status.hotkey_handled", text: row("已处理快捷键", "已處理快速鍵", "Hotkey handled", "ホットキーを処理しました", "단축키 처리됨") }, + Msg { key: "status.launch_handled", text: row("已处理启动请求", "已處理啟動請求", "Launch request handled", "起動要求を処理しました", "시작 요청 처리됨") }, + Msg { key: "status.request_restart", text: row("请手动重启 OpenLess", "請手動重新啟動 OpenLess", "Please restart OpenLess manually", "OpenLess を手動で再起動してください", "OpenLess를 수동으로 재시작하세요") }, + Msg { key: "status.tray_stopped", text: row("系统托盘已停止:{}", "系統托盤已停止:{}", "System tray stopped: {}", "システムトレイを停止: {}", "시스템 트레이 중지됨: {}") }, + Msg { key: "status.style_switched", text: row("已切换风格:{}", "已切換風格:{}", "Switched style: {}", "スタイルを切替: {}", "스타일 전환됨: {}") }, + Msg { key: "status.no_previous_style", text: row("没有可切换的上一风格", "沒有可切換的上一風格", "No previous style to switch to", "切替可能な前スタイルがありません", "전환할 이전 스타일이 없습니다") }, + Msg { key: "status.mic_selected", text: row("已选择麦克风:{}", "已選擇麥克風:{}", "Microphone selected: {}", "マイクを選択: {}", "마이크 선택됨: {}") }, + Msg { key: "status.preset_updated", text: row("词汇预设已更新", "詞彙預設已更新", "Vocabulary preset updated", "語彙プリセットを更新しました", "어휘 프리셋 업데이트됨") }, + Msg { key: "status.preset_gone", text: row("词汇预设已不存在", "詞彙預設已不存在", "Vocabulary preset no longer exists", "語彙プリセットはもうありません", "어휘 프리셋이 더 이상 없음") }, + Msg { key: "status.suggestion_handled", text: row("词汇建议已处理", "詞彙建議已處理", "Vocabulary suggestion handled", "語彙提案を処理しました", "어휘 제안 처리됨") }, + Msg { key: "status.vocab_saved", text: row("词汇已保存", "詞彙已儲存", "Vocabulary saved", "語彙を保存しました", "어휘 저장됨") }, + Msg { key: "status.vocab_updated", text: row("词汇已更新", "詞彙已更新", "Vocabulary updated", "語彙を更新しました", "어휘 업데이트됨") }, + Msg { key: "status.correction_saved", text: row("纠错规则已保存", "糾錯規則已儲存", "Correction rule saved", "修正ルールを保存しました", "교정 규칙 저장됨") }, + Msg { key: "status.correction_updated", text: row("纠错规则已更新", "糾錯規則已更新", "Correction rule updated", "修正ルールを更新しました", "교정 규칙 업데이트됨") }, + Msg { key: "status.style_hotkey_saved", text: row("风格包快捷键已更新", "風格包快速鍵已更新", "Style-pack shortcut updated", "スタイルパックのショートカットを更新しました", "스타일 팩 단축키 업데이트됨") }, + Msg { key: "status.style_imported", text: row("已导入风格包:{}", "已匯入風格包:{}", "Imported style pack: {}", "スタイルパックを読込: {}", "스타일 팩 가져옴: {}") }, + Msg { key: "status.style_saved", text: row("风格包已保存:{}", "風格包已儲存:{}", "Style pack saved: {}", "スタイルパックを保存: {}", "스타일 팩 저장됨: {}") }, + Msg { key: "status.style_updated", text: row("风格包已更新", "風格包已更新", "Style pack updated", "スタイルパックを更新しました", "스타일 팩 업데이트됨") }, + Msg { key: "status.style_preview", text: row("{}:单轮 {} 字,多轮 {} 字,热词 {} 个", "{}:單輪 {} 字,多輪 {} 字,熱詞 {} 個", "{}: {} chars/single · {} multi · {} hotwords", "{}: 単発 {} 文字・多発 {} 文字・ホットワード {} 個", "{}: 단일 {}자 · 다중 {}자 · 핫워드 {}개") }, + Msg { key: "status.marketplace_installed", text: row("已安装风格包:{}", "已安裝風格包:{}", "Installed style pack: {}", "スタイルパックをインストール: {}", "스타일 팩 설치됨: {}") }, + Msg { key: "status.marketplace_like", text: row("喜欢数:{}", "喜歡數:{}", "Likes: {}", "いいね数: {}", "좋아요 수: {}") }, + Msg { key: "status.marketplace_zip_saved", text: row("Marketplace ZIP 已保存", "Marketplace ZIP 已儲存", "Marketplace ZIP saved", "Marketplace ZIP を保存しました", "마켓플레이스 ZIP 저장됨") }, + Msg { key: "status.marketplace_published", text: row("发布状态:{} · {}", "發佈狀態:{} · {}", "Publish status: {} · {}", "公開状態: {} · {}", "배포 상태: {} · {}") }, + Msg { key: "status.marketplace_deleted", text: row("Marketplace 发布已删除", "Marketplace 發佈已刪除", "Marketplace release deleted", "Marketplace のリリースを削除しました", "마켓플레이스 배포 삭제됨") }, + Msg { key: "status.history_copied", text: row("历史文本已复制", "歷史文字已複製", "Text copied to clipboard", "クリップボードにコピーしました", "텍스트가 복사됨") }, + Msg { key: "status.copy_failed", text: row("复制失败:{}", "複製失敗:{}", "Copy failed: {}", "コピー失敗: {}", "복사 실패: {}") }, + Msg { key: "status.repolish_done", text: row("重新润色完成:{}", "重新潤飾完成:{}", "Repolish done: {}", "再推敲完了: {}", "다시 다듬기 완료: {}") }, + Msg { key: "status.history_deleted", text: row("历史记录已删除", "歷史紀錄已刪除", "History entry deleted", "履歴を削除しました", "기록 삭제됨") }, + Msg { key: "status.opened_player", text: row("已交给系统播放器", "已交給系統播放器", "Opened in the system player", "システムプレーヤーで開きました", "시스템 플레이어에서 열림") }, + Msg { key: "status.recording_exported", text: row("录音已导出:{}", "錄音已匯出:{}", "Recording exported: {}", "録音をエクスポート: {}", "녹음 내보냄: {}") }, + Msg { key: "status.retranscribed", text: row("重新转写完成:{}", "重新轉寫完成:{}", "Retranscription done: {}", "再文字起こし完了: {}", "다시 받아쓰기 완료: {}") }, + Msg { key: "status.dictation_phase", text: row("听写:{}", "聽寫:{}", "Dictation: {}", "ディクテーション: {}", "받아쓰기: {}") }, + Msg { key: "status.dictation_done", text: row("听写完成:{}", "聽寫完成:{}", "Dictation done: {}", "ディクテーション完了: {}", "받아쓰기 완료: {}") }, + Msg { key: "status.model_progress", text: row("模型 {}:{} {}/{}", "模型 {}:{} {}/{}", "Model {}: {} {}/{}", "モデル {}: {} {}/{}", "모델 {}: {} {}/{}") }, + Msg { key: "status.backlog_reset", text: row("事件积压 {} 条,已重置派生界面并重放可用事件", "事件積壓 {} 條,已重置衍生介面並重放可用事件", "{} events backlogged — reset derived UI and replayed available events", "{} 件のイベントが滞り、派生UIをリセットして再送しました", "이벤트 {}건 밀림 — 파생 UI 재설정 및 재생됨") }, + Msg { key: "status.backlog_replay", text: row("事件积压 {} 条,已从 Core 重放补齐", "事件積壓 {} 條,已從 Core 重放補齊", "{} events backlogged — replayed from Core", "{} 件のイベントが滞り、Core から再送しました", "이벤트 {}건 밀림 — Core에서 재생됨") }, + Msg { key: "status.dialog_cancelled", text: row("操作已取消", "操作已取消", "Operation cancelled", "操作をキャンセルしました", "작업 취소됨") }, + Msg { key: "status.voice_cancelled_ok", text: row("语音会话已取消", "語音工作階段已取消", "Voice session cancelled", "音声セッションをキャンセルしました", "음성 세션 취소됨") }, + Msg { key: "popup.ignore_no_session", text: row("已忽略没有活动会话的弹窗操作", "已忽略沒有活動工作階段的彈窗操作", "Ignored popup action without an active session", "アクティブなセッションのないポップアップ操作を無視しました", "활성 세션이 없는 팝업 동작 무시됨") }, + Msg { key: "popup.ignore_stale", text: row("已忽略迟到、重复或跨类型的弹窗操作", "已忽略遲到、重複或跨類型的彈窗操作", "Ignored late, duplicate or cross-kind popup action", "遅延・重複・異種のポップアップ操作を無視しました", "지연/중복/유형 오류 팝업 동작 무시됨") }, + Msg { key: "popup.ignore_late_qa", text: row("已忽略迟到的问答弹窗操作", "已忽略遲到的問答彈窗操作", "Ignored a late Q&A popup action", "遅れた Q&A ポップアップ操作を無視しました", "지연된 Q&A 팝업 동작 무시됨") }, + Msg { key: "popup.protocol_error", text: row("原生弹窗协议错误:{}", "原生彈窗協定錯誤:{}", "Native popup protocol error: {}", "ネイティブポップアップのプロトコルエラー: {}", "네이티브 팝업 프로토콜 오류: {}") }, + Msg { key: "popup.spawn_failed", text: row("原生弹窗启动失败:{}", "原生彈窗啟動失敗:{}", "Failed to start native popup: {}", "ネイティブポップアップの起動に失敗: {}", "네이티브 팝업 시작 실패: {}") }, + Msg { key: "popup.exited", text: row("原生弹窗异常退出:{}", "原生彈窗異常結束:{}", "Native popup exited unexpectedly: {}", "ネイティブポップアップが異常終了: {}", "네이티브 팝업 비정상 종료: {}") }, + Msg { key: "popup.start_failed", text: row("无法启动原生弹窗:{}", "無法啟動原生彈窗:{}", "Could not start the native popup: {}", "ネイティブポップアップを起動できません: {}", "네이티브 팝업을 시작할 수 없음: {}") }, + Msg { key: "popup.channel_rebuild", text: row("原生弹窗通道重建:{}", "原生彈窗通道重建:{}", "Rebuilt native popup channel: {}", "ネイティブポップアップのチャネルを再構築: {}", "네이티브 팝업 채널 재구축: {}") }, + Msg { key: "popup.recover_failed", text: row("原生弹窗恢复失败:{}", "原生彈窗恢復失敗:{}", "Native popup recovery failed: {}", "ネイティブポップアップの復元に失敗: {}", "네이티브 팝업 복구 실패: {}") }, + Msg { key: "popup.session_invalid", text: row("弹窗 session 无效:{}", "彈窗 session 無效:{}", "Invalid popup session: {}", "無効なポップアップセッション: {}", "잘못된 팝업 세션: {}") }, + Msg { key: "status.from_preset", text: row("预设:{}", "預設:{}", "Preset: {}", "プリセット: {}", "프리셋: {}") }, + Msg { key: "dialog.export_log_cancelled", text: row("日志导出已取消", "日誌匯出已取消", "Log export cancelled", "ログのエクスポートをキャンセル", "로그 내보내기 취소됨") }, + Msg { key: "dialog.style_import_cancelled", text: row("风格包导入已取消", "風格包匯入已取消", "Style-pack import cancelled", "スタイルパックのインポートをキャンセル", "스타일 팩 가져오기 취소됨") }, + Msg { key: "dialog.style_export_cancelled", text: row("风格包导出已取消", "風格包匯出已取消", "Style-pack export cancelled", "スタイルパックのエクスポートをキャンセル", "스타일 팩 내보내기 취소됨") }, + Msg { key: "dialog.recording_export_cancelled", text: row("录音导出已取消", "錄音匯出已取消", "Recording export cancelled", "録音のエクスポートをキャンセル", "녹음 내보내기 취소됨") }, + Msg { key: "dialog.marketplace_zip_cancelled", text: row("Marketplace 下载已取消", "Marketplace 下載已取消", "Marketplace download cancelled", "Marketplace のダウンロードをキャンセル", "마켓플레이스 다운로드 취소됨") }, + Msg { key: "status.history_cleared", text: row("历史已清空", "歷史已清空", "History cleared", "履歴をクリアしました", "기록이 지워졌습니다") }, + Msg { key: "status.remote_pin_reset", text: row("远程输入配对码已重置", "遠端輸入配對碼已重設", "Remote input pairing code reset", "リモート入力のペアリングコードを再発行しました", "원격 입력 페어링 코드 재설정됨") }, + Msg { key: "tray.show", text: row("显示 OpenLess", "顯示 OpenLess", "Show OpenLess", "OpenLess を表示", "OpenLess 표시") }, + Msg { key: "tray.previous_style", text: row("切换到上一风格", "切換到上一風格", "Switch to previous style", "前のスタイルに切り替え", "이전 스타일로 전환") }, + Msg { key: "tray.quit", text: row("退出", "結束", "Quit", "終了", "종료") }, + Msg { + key: "selection_ask.desc", + text: row( + "选中文字后语音提问,支持多轮追问。", + "選中文字後語音提問,支援多輪追問。", + "Select text and ask questions by voice, with multi-turn follow-ups.", + "テキストを選択して音声で質問。複数ターンの追問対応。", + "텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.", + ), + }, + Msg { + key: "selection_ask.guide_ask_desc", + text: row( + "按 {} 录音,再按一次提交。", + "按 {} 錄音,再按一次提交。", + "Press {} to record, then press again to submit.", + "{} で録音し、もう一度押して送信します。", + "{}로 녹음하고, 다시 눌러 전송하세요.", + ), + }, + Msg { + key: "selection_ask.guide_ask_title", + text: row( + "开口说出问题", + "開口說出問題", + "Say your question", + "声で質問する", + "말로 질문하기", + ), + }, + Msg { + key: "selection_ask.guide_dismiss", + text: row( + "关闭浮窗,结束本次对话", + "關閉浮窗,結束本次對話", + "Close the panel and end this conversation", + "パネルを閉じて、この会話を終了", + "패널을 닫고 이번 대화 종료", + ), + }, + Msg { + key: "selection_ask.guide_followup", + text: row( + "继续使用录音快捷键,即可多轮追问。", + "繼續使用錄音快捷鍵,即可多輪追問。", + "Use the recording shortcut again to ask a follow-up.", + "録音キーでもう一度、続けて質問できます。", + "녹음 단축키를 다시 눌러 후속 질문을 할 수 있어요.", + ), + }, + Msg { + key: "selection_ask.guide_open_desc", + text: row( + "按 {},开始一轮对话。", + "按 {},開始一輪對話。", + "Press {} to start a conversation.", + "{} で会話を始めます。", + "{}로 대화를 시작하세요.", + ), + }, + Msg { + key: "selection_ask.guide_open_title", + text: row( + "打开追问浮窗", + "開啟追問浮窗", + "Open the panel", + "パネルを開く", + "질문 패널 열기", + ), + }, + Msg { + key: "selection_ask.guide_select_title", + text: row( + "选中想了解的内容", + "選取想了解的內容", + "Select something to explore", + "知りたい内容を選択", + "궁금한 내용 선택", + ), + }, + Msg { + key: "selection_ask.guide_unset_desc", + text: row( + "先在快捷键设置中,为划词追问设置一个快捷键。", + "先到快捷鍵設定中,為劃詞追問設定快捷鍵。", + "Assign a Selection Ask shortcut in Shortcut settings first.", + "まずショートカット設定で選択追問のキーを割り当ててください。", + "먼저 단축키 설정에서 선택 질문 단축키를 지정하세요.", + ), + }, + Msg { + key: "selection_ask.history_desc", + text: row( + "开启后在本地保存问答记录,默认关闭。", + "開啟後在本地保存問答記錄,預設關閉。", + "Save Q&A records locally when enabled. Off by default.", + "有効時、Q&A 記録をローカルに保存。デフォルト OFF。", + "활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.", + ), + }, + Msg { + key: "selection_ask.history_title", + text: row("保存历史", "保存歷史", "Save history", "履歴を保存", "기록 저장"), + }, + Msg { + key: "selection_ask.howto_step2", + text: row( + "在任意 app 选中文字。", + "在任意 app 選中文字。", + "Select text in any app.", + "任意のアプリでテキストを選択。", + "아무 앱에서 텍스트 선택.", + ), + }, + Msg { + key: "selection_ask.howto_title", + text: row("使用方法", "使用方法", "How to use", "使い方", "사용 방법"), + }, + Msg { + key: "selection_ask.title", + text: row( + "划词追问", + "劃詞追問", + "Selection Ask", + "選択追問", + "선택 질문", + ), + }, + Msg { + key: "translation.desc", + text: row( + "录音后自动翻译为目标语言再插入。", + "錄音後自動翻譯為目標語言再插入。", + "Auto-translate recordings into a target language before insertion.", + "録音後に自動翻訳してから入力。", + "녹음 후 대상 언어로 자동 번역하여 삽입.", + ), + }, + Msg { + key: "translation.howto_step1", + text: row( + "在任意输入框聚焦光标。", + "在任意輸入框聚焦游標。", + "Place cursor in any text field.", + "任意の入力欄にカーソルを置く。", + "아무 입력 필드에 커서를 놓으세요.", + ), + }, + Msg { + key: "translation.howto_step2", + text: row( + "按 {} 开始录音。", + "按 {} 開始錄音。", + "Press {} to start recording.", + "{} を押して録音開始。", + "{} 를 눌러 녹음 시작.", + ), + }, + Msg { + key: "translation.howto_step3", + text: row( + "录音中按一下 {} 激活翻译。", + "錄音中按一下 {} 啟動翻譯。", + "Press {} once during recording to activate translation.", + "録音中に {} を一度押して翻訳を起動。", + "녹음 중 {} 를 한 번 눌러 번역 활성화.", + ), + }, + Msg { + key: "translation.howto_step4", + text: row( + "再按 {} 停止录音。", + "再按 {} 停止錄音。", + "Press {} again to stop.", + "再度 {} を押して停止。", + "다시 {} 를 눌러 정지.", + ), + }, + Msg { + key: "translation.howto_step5", + text: row( + "翻译结果自动插入到光标位置。", + "翻譯結果自動插入到游標位置。", + "Translated text is inserted at the cursor.", + "翻訳結果がカーソル位置に挿入されます。", + "번역 결과가 커서 위치에 삽입됩니다.", + ), + }, + Msg { + key: "translation.howto_title", + text: row("使用方法", "使用方法", "How to use", "使い方", "사용 방법"), + }, + Msg { + key: "translation.kicker", + text: row("翻译", "翻譯", "TRANSLATION", "翻訳", "번역"), + }, + Msg { + key: "translation.status_disabled", + text: row("未启用", "未啓用", "Disabled", "無効", "비활성화됨"), + }, + Msg { + key: "translation.status_enabled", + text: row("已启用", "已啓用", "Enabled", "有効", "활성화됨"), + }, + Msg { + key: "translation.style_desc", + text: row( + "自动继承「风格」页当前激活的风格包。", + "自動沿用「風格」頁目前啓用的風格包。", + "Automatically inherits the active style pack from the Style page.", + "「スタイル」ページで現在有効なスタイルパックを自動的に引き継ぎます。", + "「스타일」 페이지에서 현재 활성화된 스타일 팩을 자동으로 사용합니다.", + ), + }, + Msg { + key: "translation.style_title", + text: row( + "翻译风格", + "翻譯風格", + "Translation style", + "翻訳スタイル", + "번역 스타일", + ), + }, + Msg { + key: "translation.target_desc", + text: row( + "录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。", + "錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。", + "Press Shift during recording to trigger translation. \"Disabled\" makes Shift a no-op.", + "録音中に Shift で翻訳を起動。「無効」で Shift 無効化。", + "녹음 중 Shift 로 번역 실행. \"비활성화\" 시 Shift 무효.", + ), + }, + Msg { + key: "translation.target_disabled", + text: row( + "不启用(Shift 按下不触发翻译)", + "不啓用(Shift 按下不觸發翻譯)", + "Disabled (Shift does nothing)", + "無効(Shift で翻訳を発動しない)", + "비활성화 (Shift 로 번역 발동 안 함)", + ), + }, + Msg { + key: "translation.target_same_as_working", + text: row( + "目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。", + "目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。", + "The target matches your only working language, so translation cannot take effect — Shift will just run a normal polish. Pick a different target, or add another working language above.", + "ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。", + "대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.", + ), + }, + Msg { + key: "translation.target_title", + text: row( + "翻译目标语言", + "翻譯目標語言", + "Translation target language", + "翻訳ターゲット言語", + "번역 대상 언어", + ), + }, + Msg { + key: "translation.title", + text: row("翻译", "翻譯", "Translation", "翻訳", "번역"), + }, + Msg { + key: "translation.working_desc", + text: row( + "勾选日常使用的语言,影响润色与翻译效果。", + "勾選日常使用的語言,影響潤色與翻譯效果。", + "Select languages you use regularly to improve polish and translation.", + "日常使用する言語を選択し、整文と翻訳に反映。", + "일상적으로 사용하는 언어를 선택하여 정리와 번역에 반영.", + ), + }, + Msg { + key: "translation.working_title", + text: row( + "工作语言", + "工作語言", + "Working languages", + "作業言語", + "작업 언어", + ), + }, + Msg { + key: "vocab.corrections_empty", + text: row( + "还没有纠正规则。", + "還沒有糾正規則。", + "No correction rules yet.", + "補正ルールはまだありません。", + "아직 교정 규칙이 없습니다.", + ), + }, + Msg { + key: "vocab.corrections_learned_badge", + text: row("自动", "自動", "auto", "自動", "자동"), + }, + Msg { + key: "vocab.corrections_pattern_placeholder", + text: row( + "误识别写法,如 {num}粒", + "誤識別寫法,如 {num}粒", + "Mistaken text, e.g. {num}粒", + "誤認識された表記(例:{num}粒)", + "오인식 표현, 예: {num}粒", + ), + }, + Msg { + key: "vocab.corrections_replacement_placeholder", + text: row( + "目标写法,如 {num}例", + "目標寫法,如 {num}例", + "Target text, e.g. {num}例", + "修正後の表記(例:{num}例)", + "대상 표현, 예: {num}例", + ), + }, + Msg { + key: "vocab.corrections_tip", + text: row( + "修正常见 ASR 误识别,支持 {num} 数字通配。", + "修正常見 ASR 誤識別,支援 {num} 數字通配。", + "Fix common ASR mistakes. Supports {num} number wildcard.", + "ASR の誤認識を修正。{num} 数字ワイルドカード対応。", + "ASR 오인식 수정. {num} 숫자 와일드카드 지원.", + ), + }, + Msg { + key: "vocab.corrections_title", + text: row( + "纠正规则", + "糾正規則", + "Correction rules", + "補正ルール", + "교정 규칙", + ), + }, + Msg { + key: "vocab.desc", + text: row( + "添加生词或专业术语,提高识别准确率。", + "添加生詞或專業術語,提高識別準確率。", + "Add terms or jargon to improve recognition accuracy.", + "新語や専門用語を追加して認識精度を向上。", + "새 단어나 전문 용어를 추가하여 인식 정확도 향상.", + ), + }, + Msg { + key: "vocab.kicker", + text: row("词典", "詞典", "DICTIONARY", "辞書", "사전"), + }, + Msg { + key: "vocab.learned_section", + text: row( + "自动收集({})", + "自動收集({})", + "Auto-collected ({})", + "自動収集({})", + "자동 수집 ({})", + ), + }, + Msg { + key: "vocab.placeholder", + text: row( + "输入词语,按 Enter 或点添加…", + "輸入詞語,按 Enter 或點添加…", + "Type a word, press Enter or click Add…", + "単語を入力し、Enter または追加をクリック…", + "단어를 입력하고 Enter 또는 추가 클릭…", + ), + }, + Msg { + key: "vocab.presets_apply", + text: row( + "启用所选", + "啓用所選", + "Apply selected", + "選択中を有効化", + "선택 활성화", + ), + }, + Msg { + key: "vocab.presets_create", + text: row("新建预设", "新建預設", "New preset", "プリセット新規作成", "프리셋 새로 만들기"), + }, + Msg { + key: "vocab.presets_edit", + text: row("编辑 {}", "編輯 {}", "Edit {}", "{} を編集", "{} 편집"), + }, + Msg { + key: "vocab.presets_name_placeholder", + text: row("预设名称", "預設名稱", "Preset name", "プリセット名", "프리셋 이름"), + }, + Msg { + key: "vocab.presets_new_preset", + text: row("新预设", "新預設", "New preset", "新しいプリセット", "새 프리셋"), + }, + Msg { + key: "vocab.presets_save", + text: row("保存预设", "保存預設", "Save preset", "プリセットを保存", "프리셋 저장"), + }, + Msg { + key: "vocab.presets_tip", + text: row( + "可多选批量启用,支持编辑和新建。", + "可多選批量啟用,支援編輯和新建。", + "Multi-select to apply in batch. Supports edit and create.", + "複数選択で一括適用。編集・新規作成対応。", + "다중 선택 일괄 적용 가능. 편집 및 생성 지원.", + ), + }, + Msg { + key: "vocab.presets_title", + text: row( + "场景预设", + "場景預設", + "Scenario presets", + "シーンプリセット", + "시나리오 프리셋", + ), + }, + Msg { + key: "vocab.presets_words_placeholder", + text: row( + "词条(用逗号或换行分隔)", + "詞條(用逗號或換行分隔)", + "Terms (comma or newline separated)", + "語彙(カンマまたは改行区切り)", + "어휘(쉼표 또는 줄바꿈으로 구분)", + ), + }, + Msg { + key: "vocab.remove_all_learned", + text: row("全部删除", "全部刪除", "Remove all", "すべて削除", "모두 삭제"), + }, + Msg { + key: "vocab.section_title", + text: row("词条", "詞條", "Entries", "項目", "항목"), + }, + Msg { + key: "vocab.tip", + text: row( + "支持中英混合 · 数字开头按字面识别 · 命中次数自动计数", + "支持中英混合 · 數字開頭按字面識別 · 命中次數自動計數", + "Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically", + "日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント", + "한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트", + ), + }, + Msg { + key: "vocab.title", + text: row("词典", "詞典", "Dictionary", "辞書", "사전"), + }, + Msg { + key: "style.custom_prompt_save", + text: row("保存提示词", "保存提示詞", "Save prompt", "プロンプトを保存", "프롬프트 저장"), + }, + Msg { + key: "style.desc", + text: row( + "选择录音的默认输出风格。", + "選擇錄音的預設輸出風格。", + "Choose the default output style for recording.", + "録音のデフォルト出力スタイルを選択。", + "녹음의 기본 출력 스타일 선택.", + ), + }, + Msg { + key: "style.kicker", + text: row("风格", "風格", "STYLE", "スタイル", "스타일"), + }, + Msg { + key: "style.pack.builtin", + text: row("内置", "內建", "Built-in", "ビルトイン", "기본"), + }, + Msg { + key: "style.pack.current", + text: row("当前", "目前", "Current", "現在", "현재"), + }, + Msg { + key: "style.pack.dictation_prompt_title", + text: row( + "录音 / ASR Prompt", + "錄音 / ASR Prompt", + "Recording / ASR prompt", + "録音 / ASRプロンプト", + "녹음 / ASR 프롬프트", + ), + }, + Msg { + key: "style.pack.dictation_tab", + text: row( + "录音 / ASR 风格", + "錄音 / ASR 風格", + "Recording / ASR styles", + "録音 / ASRスタイル", + "녹음 / ASR 스타일", + ), + }, + Msg { + key: "style.pack.new_description", + text: row( + "简短描述这个风格的使用场景。", + "簡短描述這個風格的使用情境。", + "Briefly describe when to use this style.", + "このスタイルを使う場面を簡潔に説明してください。", + "이 스타일을 언제 사용하는지 간단히 설명하세요.", + ), + }, + Msg { + key: "style.pack.selection_tab", + text: row( + "选区润色", + "選區潤色", + "Selection polish", + "選択範囲の推敲", + "선택 영역 다듬기", + ), + }, + Msg { + key: "style.title", + text: row("输出风格", "輸出風格", "Output style", "出力スタイル", "출력 스타일"), + }, + Msg { + key: "marketplace.desc", + text: row( + "浏览、安装和分享社区风格包。", + "瀏覽、安裝和分享社區風格包。", + "Browse, install, and share community style packs.", + "コミュニティのスタイルパックを閲覧・インストール・共有。", + "커뮤니티 스타일 팩 둘러보기, 설치, 공유.", + ), + }, + Msg { + key: "marketplace.download_zip_btn", + text: row("下载 ZIP", "下載 ZIP", "Download ZIP", "ZIP をダウンロード", "ZIP 다운로드"), + }, + Msg { + key: "marketplace.empty", + text: row( + "还没有风格包", + "還沒有風格包", + "No style packs yet", + "まだスタイルパックがありません", + "아직 스타일 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.empty_hint", + text: row( + "换个搜索词,或自己上传一个分享给社区", + "換個搜尋詞,或自己上傳一個分享給社群", + "Try a different keyword, or upload your own", + "別のキーワードを試すか、自分のパックを共有してみましょう", + "다른 키워드로 검색하거나 직접 업로드해 보세요", + ), + }, + Msg { + key: "marketplace.install_btn", + text: row("安装到本地", "安裝到本機", "Install", "インストール", "설치"), + }, + Msg { + key: "marketplace.kicker", + text: row("风格市场", "風格市場", "MARKETPLACE", "マーケット", "마켓"), + }, + Msg { + key: "marketplace.my_packs_button_label", + text: row("我的发布", "我的發布", "My Packs", "自分の公開", "내 게시물"), + }, + Msg { + key: "marketplace.refresh_btn", + text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "marketplace.search_placeholder", + text: row( + "搜索名称 / 描述 / 标签…", + "搜尋名稱 / 描述 / 標籤…", + "Search name / description / tags…", + "名前 / 説明 / タグを検索…", + "이름 / 설명 / 태그 검색…", + ), + }, + Msg { + key: "marketplace.sort_liked", + text: row("我赞过的", "我讚過的", "Liked", "いいね済み", "좋아요한 팩"), + }, + Msg { + key: "marketplace.sort_new", + text: row("最新", "最新", "Newest", "新着", "최신"), + }, + Msg { + key: "marketplace.sort_popular", + text: row("按热度", "按熱度", "Popular", "人気順", "인기순"), + }, + Msg { + key: "hotkey.triggers.right_option", + text: row("右 Option", "右 Option", "Right Option", "右 Option", "오른쪽 Option"), + }, + Msg { + key: "modal.about.export_error_log", + text: row( + "导出错误日志", + "匯出錯誤日誌", + "Export error log", + "エラーログをエクスポート", + "오류 로그 내보내기", + ), + }, + Msg { + key: "modal.sections.help_center", + text: row("帮助中心", "幫助中心", "Help center", "ヘルプセンター", "도움말 센터"), + }, + Msg { + key: "modal.sections.release_notes", + text: row( + "发布日志", + "發佈日誌", + "Release notes", + "リリースノート", + "릴리스 노트", + ), + }, + Msg { + key: "overview.actions.shortcuts", + text: row("快捷键", "快捷鍵", "Shortcuts", "ショートカット", "단축키"), + }, + Msg { + key: "overview.llm_name", + text: row( + "OpenAI 兼容", + "OpenAI 兼容", + "OpenAI-compatible", + "OpenAI 互換", + "OpenAI 호환", + ), + }, + Msg { + key: "settings.about.beta_channel_label", + text: row( + "加入 Beta 渠道", + "加入 Beta 渠道", + "Join Beta channel", + "Beta チャンネルに参加", + "Beta 채널 참여", + ), + }, + Msg { + key: "settings.coding_agent.enable", + text: row( + "启用 Less Computer", + "啟用 Less Computer", + "Enable Less Computer", + "Less Computer を有効化", + "Less Computer 켜기", + ), + }, + Msg { + key: "settings.coding_console.permission_mode", + text: row( + "权限模式", + "權限模式", + "Permission mode", + "権限モード", + "권한 모드", + ), + }, + Msg { + key: "settings.coding_console.title", + text: row( + "Claude 控制台", + "Claude 主控台", + "Claude Console", + "Claude コンソール", + "Claude 콘솔", + ), + }, + Msg { + key: "settings.coding_console.workdir", + text: row( + "工作目录", + "工作目錄", + "Working directory", + "作業ディレクトリ", + "작업 디렉터리", + ), + }, + Msg { + key: "settings.data_storage.title", + text: row("数据存储", "資料儲存", "Data storage", "データ保存", "데이터 저장"), + }, + Msg { + key: "settings.debug.title", + text: row("调试工具", "除錯工具", "Debug tools", "デバッグツール", "디버그 도구"), + }, + Msg { + key: "settings.language.title", + text: row( + "界面语言", + "界面語言", + "Interface language", + "表示言語", + "인터페이스 언어", + ), + }, + Msg { + key: "settings.language.zh", + text: row("简体中文", "簡體中文", "简体中文", "简体中文", "简体中文"), + }, + Msg { + key: "settings.layout.title", + text: row("布局", "布局", "Layout", "レイアウト", "레이아웃"), + }, + Msg { + key: "settings.marketplace.github.open_github", + text: row("打开 GitHub", "開啟 GitHub", "Open GitHub", "GitHub を開く", "GitHub 열기"), + }, + Msg { + key: "settings.marketplace.title", + text: row("扩展市场", "擴充市集", "Marketplace", "拡張マーケット", "확장 마켓"), + }, + Msg { + key: "settings.network.use_system_proxy_label", + text: row( + "使用系统代理", + "使用系統代理", + "Use system proxy", + "システムプロキシを使用", + "시스템 프록시 사용", + ), + }, + Msg { + key: "settings.permissions.title", + text: row("权限", "權限", "Permissions", "権限", "권한"), + }, + Msg { + key: "settings.recording.auto_update_check_label", + text: row( + "自动检查更新", + "自動檢查更新", + "Auto-check for updates", + "アップデートを自動チェック", + "자동 업데이트 확인", + ), + }, + Msg { + key: "settings.recording.desc", + text: row( + "全局录音的快捷键与触发方式。", + "定義全局錄音的快捷鍵與觸發方式。", + "Global recording hotkey and trigger mode.", + "グローバル録音のショートカットとトリガー方式を定義します。", + "전역 녹음의 단축키와 트리거 방식을 정의합니다.", + ), + }, + Msg { + key: "settings.recording.insert_group_title", + text: row( + "插入与剪贴板", + "插入與剪貼板", + "Insertion & clipboard", + "挿入とクリップボード", + "삽입 및 클립보드", + ), + }, + Msg { + key: "settings.recording.microphone_system_default", + text: row( + "系统默认", + "系統默認", + "system default", + "システムデフォルト", + "시스템 기본값", + ), + }, + Msg { + key: "settings.recording.mode_label", + text: row("录音方式", "錄音方式", "Trigger mode", "録音方式", "녹음 방식"), + }, + Msg { + key: "settings.recording.mode_toggle", + text: row("切换式", "切換式", "Toggle", "トグル式", "토글 방식"), + }, + Msg { + key: "settings.recording.mute_during_recording_label", + text: row( + "录音时静音", + "錄音時靜音", + "Mute while recording", + "録音中はミュート", + "녹음 중 음소거", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_label", + text: row( + "模拟粘贴快捷键", + "模擬粘貼快捷鍵", + "Simulated paste shortcut", + "貼り付けショートカット", + "붙여넣기 단축키", + ), + }, + Msg { + key: "settings.recording.startup_group_title", + text: row("启动", "啟動", "Startup", "起動", "시작"), + }, + Msg { + key: "settings.remote_input.enable_label", + text: row( + "启用远程输入", + "啟用遠端輸入", + "Enable remote input", + "リモート入力を有効化", + "원격 입력 활성화", + ), + }, + Msg { + key: "settings.remote_input.port_label", + text: row("监听端口", "監聽連接埠", "Port", "待ち受けポート", "수신 포트"), + }, + Msg { + key: "settings.remote_input.title", + text: row("远程输入", "遠端輸入", "Remote Input", "リモート入力", "원격 입력"), + }, + Msg { + key: "settings.theme.dark", + text: row("深色", "深色", "Dark", "ダーク", "다크"), + }, + Msg { + key: "settings.theme.label", + text: row("主题", "主題", "Theme", "テーマ", "테마"), + }, + Msg { + key: "settings.theme.light", + text: row("浅色", "淺色", "Light", "ライト", "라이트"), + }, + Msg { + key: "marketplace.title", + text: row( + "风格包市场", + "風格包市場", + "Style Pack Marketplace", + "スタイルパック マーケット", + "스타일 팩 마켓", + ), + }, + Msg { + key: "settings.selection_workspace.title", + text: row( + "选区助手", + "選區助手", + "Selection Assistant", + "選択範囲アシスタント", + "선택 영역 도우미", + ), + }, + Msg { + key: "modal.sections.about", + text: row( + "关于与更新", + "關於與更新", + "About & updates", + "バージョンと更新", + "정보 및 업데이트", + ), + }, + Msg { + key: "modal.sections.advanced", + text: row( + "实验与扩展", + "實驗與擴充", + "Experiments & extensions", + "実験機能と拡張", + "실험 기능 및 확장", + ), + }, + Msg { + key: "modal.sections.appearance", + text: row( + "外观与语言", + "外觀與語言", + "Appearance & language", + "外観と言語", + "모양 및 언어", + ), + }, + Msg { + key: "modal.sections.general", + text: row( + "录音与输入", + "錄音與輸入", + "Recording & input", + "録音と入力", + "녹음 및 입력", + ), + }, + Msg { + key: "modal.sections.privacy", + text: row( + "权限与数据", + "權限與資料", + "Permissions & data", + "権限とデータ", + "권한 및 데이터", + ), + }, + Msg { + key: "modal.sections.services", + text: row( + "AI 服务与模型", + "AI 服務與模型", + "AI services & models", + "AI サービスとモデル", + "AI 서비스 및 모델", + ), + }, + Msg { + key: "modal.sections.shortcuts", + text: row( + "快捷键与选区", + "快捷鍵與選取文字", + "Shortcuts & selection", + "ショートカットと選択", + "단축키 및 선택", + ), + }, + Msg { + key: "settings.selection_workspace.hint", + text: row( + "选中文字后按同一快捷键:关闭语音编辑时直接润色;开启后口述指令,说完再选择「提问」或「编辑选区」。", + "選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。", + "Select text, then use one shortcut: polish when voice edit is off; hold and speak when voice edit is on, then choose Ask or Edit.", + "テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。", + "텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.", + ), + }, + Msg { + key: "settings.selection_workspace.voice_enable", + text: row("语音编辑", "語音編輯", "Voice edit", "音声編集", "음성 편집"), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_label", + text: row( + "启用多模态识别管线", + "啟用多模態辨識管線", + "Enable multimodal pipeline", + "マルチモーダルパイプラインを有効化", + "멀티모달 파이프라인 활성화", + ), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_title", + text: row( + "多模态识别管线", + "多模態辨識管線", + "Multimodal recognition pipeline", + "マルチモーダル認識パイプライン", + "멀티모달 인식 파이프라인 ", + ), + }, + Msg { + key: "settings.language.label", + text: row("语言", "語言", "Language", "言語", "언어"), + }, + Msg { + key: "settings.network.title", + text: row("网络", "網路", "Network", "ネットワーク", "네트워크"), + }, + Msg { + key: "settings.recording.title", + text: row( + "录音与输入", + "錄音與輸入", + "Recording & input", + "録音と入力", + "녹음 및 입력", + ), + }, + Msg { + key: "selection_ask.shortcut_settings", + text: row( + "快捷键设置", + "快捷鍵設定", + "Shortcut settings", + "ショートカット設定", + "단축키 설정", + ), + }, + Msg { + key: "vocab.corrections_only_learned", + text: row( + "只看自动收集的({})", + "只看自動收集的({})", + "Only auto-collected ({})", + "自動収集のみ表示({})", + "자동 수집만 보기 ({})", + ), + }, + Msg { + key: "vocab.corrections_remove_all_learned", + text: row( + "删除全部自动收集的", + "刪除全部自動收集的", + "Delete all auto-collected", + "自動収集をすべて削除", + "자동 수집 전체 삭제", + ), + }, + Msg { + key: "vocab.empty", + text: row( + "还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。", + "還沒有詞條。在上面輸入一個生詞或專業術語,讓模型在聽寫時優先匹配。", + "No entries yet. Add a new term or piece of jargon above so the model can prioritize it.", + "語彙がありません。新語や専門用語を上に入力すると、ディクテーション時に優先的にマッチします。", + "어휘가 없습니다. 위에 새 단어나 전문 용어를 입력하면 받아쓰기 시 우선 매칭됩니다.", + ), + }, + Msg { + key: "vocab.filter_all", + text: row("所有", "所有", "All", "すべて", "전체"), + }, + Msg { + key: "vocab.filter_auto", + text: row("自动添加", "自動新增", "Auto-Added", "自動追加", "자동 추가"), + }, + Msg { + key: "vocab.filter_manual", + text: row( + "手动添加", + "手動新增", + "Manually Added", + "手動追加", + "수동 추가", + ), + }, + Msg { + key: "vocab.new_word", + text: row("新词", "新詞", "New Word", "新語", "새 단어"), + }, + Msg { + key: "vocab.search_empty", + text: row( + "没有匹配的词条。", + "沒有符合的詞條。", + "No matching words.", + "一致する単語がありません。", + "일치하는 단어가 없습니다.", + ), + }, + Msg { + key: "vocab.search_placeholder", + text: row("搜索", "搜尋", "Search", "検索", "검색"), + }, + Msg { + key: "translation.howto_fallback_desc", + text: row( + "翻译失败时回退为插入原始转写,不会丢字。", + "翻譯失敗時回退為插入原始轉寫,不會丟字。", + "If translation fails, the raw transcript is inserted instead.", + "翻訳失敗時は原文がそのまま挿入されます。", + "번역 실패 시 원본 전사가 삽입됩니다.", + ), + }, + Msg { + key: "translation.howto_fallback_title", + text: row( + "安全兜底", + "安全兜底", + "Safety fallbacks", + "セーフティフォールバック", + "안전 폴백", + ), + }, + Msg { + key: "translation.howto_indicator_desc", + text: row( + "按 Shift 后屏幕底部会显示蓝色「正在翻译」标识。", + "按 Shift 後螢幕底部會顯示藍色「正在翻譯」標識。", + "A blue \"Translating\" indicator appears at the bottom of the screen after pressing Shift.", + "Shift を押すと画面下部に青い「翻訳中」表示が出ます。", + "Shift 를 누르면 화면 하단에 파란색 \"번역 중\" 표시가 나타납니다.", + ), + }, + Msg { + key: "translation.howto_indicator_title", + text: row( + "翻译模式指示", + "怎麼知道翻譯模式生效了", + "How to confirm translation mode is on", + "翻訳モードの確認方法", + "번역 모드 활성화 확인 방법", + ), + }, + Msg { + key: "translation.language_support_hint", + text: row( + "语音服务支持的语种可能不同;翻译目标不受界面语言限制。", + "語音服務支援的語種可能不同;翻譯目標不受介面語言限制。", + "Available speech languages depend on your provider. Translation targets are independent of the app language.", + "音声認識で使える言語はサービスによって異なります。翻訳先はアプリの表示言語とは独立しています。", + "음성 서비스에 따라 지원 언어가 다릅니다. 번역 언어는 앱 표시 언어와 별개입니다.", + ), + }, + Msg { + key: "translation.no_matching_languages", + text: row( + "没有匹配的语言", + "沒有符合的語言", + "No matching languages", + "一致する言語がありません", + "일치하는 언어가 없습니다", + ), + }, + Msg { + key: "translation.search_languages", + text: row( + "搜索语言…", + "搜尋語言…", + "Search languages…", + "言語を検索…", + "언어 검색…", + ), + }, + Msg { + key: "translation.selected_languages", + text: row( + "已选择 {} 种语言", + "已選擇 {} 種語言", + "{} languages selected", + "{} 言語を選択中", + "언어 {}개 선택됨", + ), + }, + Msg { + key: "modal.auto_save_hint", + text: row( + "修改后自动保存", + "修改後自動儲存", + "Changes save automatically", + "変更は自動保存されます", + "변경 사항이 자동 저장됩니다", + ), + }, + Msg { + key: "modal.search_placeholder", + text: row( + "查找设置分类…", + "尋找設定分類…", + "Find a settings category…", + "設定カテゴリを検索…", + "설정 카테고리 찾기…", + ), + }, + Msg { + key: "modal.service_views.asr", + text: row( + "语音识别", + "語音辨識", + "Speech recognition", + "音声認識", + "음성 인식", + ), + }, + Msg { + key: "modal.service_views.connections", + text: row("连接与扩展", "連線與擴充", "Connections", "接続と拡張", "연결 및 확장"), + }, + Msg { + key: "modal.service_views.llm", + text: row( + "语言模型", + "語言模型", + "Language models", + "言語モデル", + "언어 모델", + ), + }, + Msg { + key: "modal.service_views.models", + text: row("本地模型", "本機模型", "Local models", "ローカルモデル", "로컬 모델"), + }, + Msg { + key: "selection_ask.hotkey_title", + text: row( + "弹出浮窗的快捷键", + "彈出浮窗的快捷鍵", + "Hotkey to open the panel", + "フロートウィンドウのショートカット", + "플로팅 창 단축키", + ), + }, + Msg { + key: "settings.about.beta_channel_toggle_label", + text: row( + "启用 Beta 渠道", + "啟用 Beta 渠道", + "Enable Beta channel", + "Beta チャンネルを有効化", + "Beta 채널 사용", + ), + }, + Msg { + key: "settings.about.check_stable_update_btn", + text: row( + "检查正式版更新", + "檢查正式版更新", + "Check stable update", + "正式版を確認", + "정식판 확인", + ), + }, + Msg { + key: "settings.about.docs", + text: row("文档", "文檔", "Docs", "ドキュメント", "문서"), + }, + Msg { + key: "settings.about.feedback", + text: row("反馈", "反饋", "Feedback", "フィードバック", "피드백"), + }, + Msg { + key: "settings.about.links_title", + text: row( + "文档链接", + "文件連結", + "Documentation", + "ドキュメント", + "문서 링크", + ), + }, + Msg { + key: "settings.about.local_first", + text: row("本地优先", "本地優先", "Local-first", "ローカル優先", "로컬 우선"), + }, + Msg { + key: "settings.about.privacy_desc", + text: row( + "录音可能会发送到你配置的云端服务商进行转写。", + "錄音可能會傳送至你設定的雲端服務商進行轉寫。", + "Recordings may be sent to the cloud provider you configure for transcription.", + "録音は、設定したクラウドプロバイダーへ文字起こしのため送信される場合があります。", + "녹음은 전사를 위해 설정한 클라우드 공급자에게 전송될 수 있습니다.", + ), + }, + Msg { + key: "settings.about.qq", + text: row( + "社区 QQ 群", + "社區 QQ 羣", + "QQ community group", + "コミュニティ QQ グループ", + "커뮤니티 QQ 그룹", + ), + }, + Msg { + key: "settings.about.source", + text: row("源码", "源碼", "Source", "ソース", "소스"), + }, + Msg { + key: "settings.about.tagline", + text: row( + "自然说话,完美书写", + "自然說話,完美書寫", + "Speak naturally, write perfectly", + "自然に話し、きれいに書く", + "자연스럽게 말하고, 정확하게 작성하세요", + ), + }, + Msg { + key: "settings.advanced.local_asr_desc", + text: row( + "把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。", + "把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。", + "Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.", + "転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。", + "전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.", + ), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_title_hint", + text: row( + "用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。", + "用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。", + "One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.", + "1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。", + "단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.", + ), + }, + Msg { + key: "settings.advanced.platform_not_supported", + text: row( + "该平台暂未支持本地 ASR 模型集成。", + "該平臺暫未支持本地 ASR 模型集成。", + "Local ASR model integration is not supported on this platform.", + "このプラットフォームではローカル ASR モデル統合に対応していません。", + "이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_desc", + text: row( + "逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。", + "逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。", + "Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.", + "逐字リアルタイム挿入で体感遅延を低減。条件不一致時はワンショット貼り付けにフォールバック。", + "실시간 글자별 삽입으로 체감 지연 감소. 조건 불충족 시 일괄 붙여넣기로 전환.", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_label", + text: row( + "流式输入", + "流式輸入", + "Streaming insertion", + "ストリーミング入力", + "스트리밍 입력", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_save_clipboard_label", + text: row( + "同步到剪贴板", + "同步到剪貼簿", + "Copy to clipboard", + "クリップボードに保存", + "클립보드에 저장", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_title_linux", + text: row( + "流式输入(实验性)", + "流式輸入(實驗性)", + "Streaming insertion (Experimental)", + "ストリーミング入力(実験的)", + "스트리밍 입력 (실험적)", + ), + }, + Msg { + key: "settings.channels.add", + text: row("添加渠道", "新增渠道", "Add channel", "チャネルを追加", "채널 추가"), + }, + Msg { + key: "settings.channels.asr_title", + text: row( + "语音识别渠道", + "語音辨識渠道", + "Speech recognition channels", + "音声認識チャンネル", + "음성 인식 채널", + ), + }, + Msg { + key: "settings.channels.create", + text: row("创建", "建立", "Create", "作成", "만들기"), + }, + Msg { + key: "settings.channels.current", + text: row( + "当前使用", + "目前使用", + "Currently used", + "使用中", + "현재 사용 중", + ), + }, + Msg { + key: "settings.channels.delete", + text: row( + "删除渠道", + "刪除渠道", + "Delete channel", + "チャネルを削除", + "채널 삭제", + ), + }, + Msg { + key: "settings.channels.disabled", + text: row("已停用", "已停用", "Disabled", "無効", "사용 안 함"), + }, + Msg { + key: "settings.channels.elapsed", + text: row("耗时 {} ms", "耗時 {} ms", "Took {} ms", "所要時間 {} ms", "소요 시간 {} ms"), + }, + Msg { + key: "settings.channels.empty", + text: row( + "还没有渠道。点击「添加渠道」,连接你的第一个服务。", + "還沒有渠道。點選「新增渠道」,連接你的第一個服務。", + "No channels yet. Choose \"Add channel\" to connect your first service.", + "チャネルがまだありません。「チャネルを追加」で最初のサービスを接続しましょう。", + "아직 채널이 없습니다. \"채널 추가\"로 첫 서비스를 연결하세요.", + ), + }, + Msg { + key: "settings.channels.enabled", + text: row("启用", "啟用", "Enabled", "有効", "사용"), + }, + Msg { + key: "settings.channels.failed", + text: row( + "验证失败 · {}", + "驗證失敗 · {}", + "Check failed · {}", + "確認に失敗 · {}", + "확인 실패 · {}", + ), + }, + Msg { + key: "settings.channels.llm_title", + text: row( + "文字处理渠道", + "文字處理渠道", + "Text processing channels", + "テキスト処理チャンネル", + "텍스트 처리 채널", + ), + }, + Msg { + key: "settings.channels.name_hint", + text: row( + "名称仅用于区分同一供应商的多个渠道,不影响模型或连接。", + "名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。", + "This name distinguishes channels from the same provider. It does not affect the model or connection.", + "同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。", + "같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.", + ), + }, + Msg { + key: "settings.channels.name_placeholder", + text: row( + "例如:硅基流动-主号", + "例如:矽基流動-主帳號", + "e.g. SiliconFlow — main key", + "例:SiliconFlow — メインキー", + "예: SiliconFlow — 메인 키", + ), + }, + Msg { + key: "settings.channels.not_verified", + text: row( + "尚未验证", + "尚未驗證", + "Not checked yet", + "未確認", + "아직 확인하지 않음", + ), + }, + Msg { + key: "settings.channels.order_hint", + text: row( + "列表中第一个启用的渠道用于请求。拖动调整顺序;停用的渠道移到末尾。", + "請求會使用列表中第一個啟用的渠道。拖曳可調整順序;停用的渠道會移到末尾。", + "Requests use the first enabled channel. Drag to reorder; disabled channels move to the bottom.", + "有効なチャネルのうち、先頭のものを使用します。ドラッグで順序を変更できます。無効なチャネルは末尾に移動します。", + "사용 중인 채널 중 맨 위의 채널로 요청합니다. 드래그로 순서를 바꾸면 사용하지 않는 채널은 맨 아래로 이동합니다.", + ), + }, + Msg { + key: "settings.channels.passed", + text: row("验证通过", "驗證通過", "Check passed", "確認に成功", "확인 성공"), + }, + Msg { + key: "settings.channels.verify", + text: row("验证", "驗證", "Verify", "検証", "검증"), + }, + Msg { + key: "settings.coding_agent.desc", + text: row( + "按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。", + "按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。", + "Hold a key, speak, and your selected agent operates your computer. macOS only.", + "キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。", + "키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.", + ), + }, + Msg { + key: "settings.coding_agent.title", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "settings.coding_console.desc", + text: row( + "检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。", + "偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。", + "Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.", + "ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。", + "로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.", + ), + }, + Msg { + key: "settings.coding_console.detect", + text: row("检测", "偵測", "Detect", "検出", "감지"), + }, + Msg { + key: "settings.coding_console.status", + text: row("状态", "狀態", "Status", "状態", "상태"), + }, + Msg { + key: "settings.data_storage.desc", + text: row( + "本机保留的历史会话与对话上下文。", + "本機保留的歷史會話與對話上下文。", + "Conversation history and context kept on this device.", + "この端末に保存される会話履歴とコンテキスト。", + "이 기기에 보관되는 대화 기록과 컨텍스트.", + ), + }, + Msg { + key: "settings.debug.desc", + text: row( + "排查识别问题时使用,平时无需开启。", + "排查辨識問題時使用,平時無需開啟。", + "For troubleshooting recognition issues; off by default.", + "認識の問題を調査するときに使用。通常はオフのままで構いません。", + "인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.", + ), + }, + Msg { + key: "settings.language.desc", + text: row( + "切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。", + "切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。", + "Switch the UI language. Applies to the current session immediately and persists across launches.", + "UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。", + "UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.", + ), + }, + Msg { + key: "settings.language.en", + text: row("English", "English", "English", "English", "English"), + }, + Msg { + key: "settings.language.follow_system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따라가기", + ), + }, + Msg { + key: "settings.language.ja", + text: row("日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)"), + }, + Msg { + key: "settings.language.ko", + text: row("한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)"), + }, + Msg { + key: "settings.language.restart_hint", + text: row( + "部分原生菜单(系统托盘等)可能需要重启 App 才会切换。", + "部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。", + "Some native menus (system tray, etc.) may require an app restart to fully switch.", + "一部のネイティブメニュー(トレイ等)は再起動後に反映されます。", + "일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.", + ), + }, + Msg { + key: "settings.language.zh_tw", + text: row("繁體中文", "繁體中文", "繁體中文", "繁體中文", "繁體中文"), + }, + Msg { + key: "settings.marketplace.desc", + text: row( + "风格市场的上传身份。浏览与安装风格在「风格」页内完成。", + "風格市集的上傳身份。瀏覽與安裝風格在「風格」頁內完成。", + "Upload identity for the style marketplace. Browse and install styles on the Styles page.", + "スタイルマーケットの投稿者 ID。スタイルの閲覧とインストールは「スタイル」ページで行います。", + "스타일 마켓 업로드 신원. 스타일 둘러보기와 설치는 「스타일」 페이지에서 합니다.", + ), + }, + Msg { + key: "settings.marketplace.github.sign_in", + text: row( + "用 GitHub 账号登录", + "用 GitHub 帳號登入", + "Sign in with GitHub", + "GitHub でログイン", + "GitHub로 로그인", + ), + }, + Msg { + key: "settings.permissions.acc_label", + text: row( + "辅助功能", + "輔助功能", + "Accessibility", + "アクセシビリティ", + "접근성", + ), + }, + Msg { + key: "settings.permissions.desc_no_acc", + text: row( + "麦克风必需;全局快捷键状态用来检测 native hook 是否运行。", + "OpenLess 需要麥克風可用,並依賴全局快捷鍵監聽狀態判斷 native hook 是否正常工作。", + "OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.", + "OpenLess はマイクへのアクセスと、グローバルショートカット監視状態を通じてネイティブフックの正常動作を判定する必要があります。", + "OpenLess 는 마이크 사용과 전역 단축키 감지 상태를 통해 네이티브 후크의 정상 동작을 판정해야 합니다.", + ), + }, + Msg { + key: "settings.permissions.granted", + text: row("已授权", "已授權", "Granted", "許可済み", "허용됨"), + }, + Msg { + key: "settings.permissions.hotkey_label", + text: row( + "全局快捷键", + "全局快捷鍵", + "Global hotkey", + "グローバルショートカット", + "전역 단축키", + ), + }, + Msg { + key: "settings.permissions.indeterminate", + text: row("未确定", "未確定", "Undetermined", "未確定", "미결정"), + }, + Msg { + key: "settings.permissions.mic_label", + text: row("麦克风", "麥克風", "Microphone", "マイク", "마이크"), + }, + Msg { + key: "settings.permissions.network_label", + text: row("网络", "網絡", "Network", "ネットワーク", "네트워크"), + }, + Msg { + key: "settings.permissions.network_ok", + text: row("可用", "可用", "Available", "利用可能", "사용 가능"), + }, + Msg { + key: "settings.permissions.open_system", + text: row( + "打开系统设置", + "打開系統設置", + "Open System Settings", + "システム設定を開く", + "시스템 설정 열기", + ), + }, + Msg { + key: "settings.providers.credential_storage_notice", + text: row( + "凭据保存在系统凭据库中。", + "憑據保存在系統憑據庫中。", + "Credentials are stored in the OS credential vault.", + "資格情報は OS の資格情報ストアに保存されます。", + "자격 증명은 OS 자격 증명 저장소에 보관됩니다.", + ), + }, + Msg { + key: "settings.recording.audio_cue_label", + text: row( + "录音提示音", + "錄音提示音", + "Recording start sound", + "録音開始音", + "녹음 시작음", + ), + }, + Msg { + key: "settings.recording.audio_recording_max_entries_label", + text: row( + "原始录音保留条数", + "原始錄音保留條數", + "Max raw recordings", + "元音声の保持件数", + "원본 녹음 보관 개수", + ), + }, + Msg { + key: "settings.recording.history_group_title", + text: row( + "历史与上下文", + "歷史與上下文", + "History & context", + "履歴とコンテキスト", + "기록 및 컨텍스트", + ), + }, + Msg { + key: "settings.recording.history_max_entries_label", + text: row( + "历史条数上限", + "歷史條數上限", + "Max history entries", + "履歴件数の上限", + "기록 개수 상한", + ), + }, + Msg { + key: "settings.recording.history_retention_label", + text: row( + "历史保留天数", + "歷史保留天數", + "History retention (days)", + "履歴保持期間(日)", + "기록 보관 기간(일)", + ), + }, + Msg { + key: "settings.recording.hotkey_label", + text: row( + "录音快捷键", + "錄音快捷鍵", + "Recording hotkey", + "録音ショートカット", + "녹음 단축키", + ), + }, + Msg { + key: "settings.recording.microphone_label", + text: row( + "首选麦克风", + "首選麥克風", + "Preferred microphone", + "優先マイク", + "기본 선택 마이크", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_ctrl_shift_v", + text: row( + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)", + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)", + "Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)", + "Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)", + "Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_ctrl_v", + text: row( + "Ctrl+V(默认 / 多数应用)", + "Ctrl+V(默認 / 多數應用)", + "Ctrl+V (default / most apps)", + "Ctrl+V(既定 / ほとんどのアプリ)", + "Ctrl+V (기본 / 대부분 앱)", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_shift_insert", + text: row( + "Shift+Insert(xterm / urxvt)", + "Shift+Insert(xterm / urxvt)", + "Shift+Insert (xterm / urxvt)", + "Shift+Insert(xterm / urxvt)", + "Shift+Insert (xterm / urxvt)", + ), + }, + Msg { + key: "settings.recording.record_audio_for_debug_label", + text: row( + "保留原始录音(调试)", + "保留原始錄音(除錯)", + "Keep raw recording (debug)", + "元の録音を保持(デバッグ)", + "원본 녹음 보관(디버그)", + ), + }, + Msg { + key: "settings.recording.restore_clipboard_label", + text: row( + "插入后恢复剪贴板", + "插入後恢復剪貼板", + "Restore clipboard after insert", + "入力後にクリップボードを復元", + "입력 후 클립보드 복원", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_label", + text: row( + "静音后自动停止", + "靜音後自動停止", + "Auto-stop after silence", + "無音で自動停止", + "침묵 시 자동 중지", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_seconds_label", + text: row( + "静音时长", + "靜音時長", + "Silence duration", + "無音の長さ", + "침묵 시간", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_seconds_value", + text: row("{} 秒", "{} 秒", "{}s", "{} 秒", "{}초"), + }, + Msg { + key: "settings.recording.start_minimized_label", + text: row( + "启动时静默运行", + "啓動時靜默運行", + "Start minimized (no main window)", + "起動時にメインウィンドウを表示しない", + "시작 시 메인 창 숨기기", + ), + }, + Msg { + key: "settings.recording.startup_at_boot", + text: row( + "开机自启", + "開機自啓", + "Launch at login", + "起動時に自動起動", + "부팅 시 자동 시작", + ), + }, + Msg { + key: "settings.remote_input.default_mode_label", + text: row( + "默认录音方式", + "預設錄音方式", + "Default recording mode", + "既定の録音方式", + "기본 녹음 방식", + ), + }, + Msg { + key: "settings.remote_input.enable_desc", + text: row( + "手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)", + "手機/平板瀏覽器連到電腦錄音,語音即時落到電腦游標處(需 HTTPS,首次存取要信任憑證)", + "Record from a phone/tablet browser on your LAN; speech is typed at your computer's cursor (HTTPS required; trust the certificate on first visit)", + "スマホ/タブレットのブラウザから PC に接続して録音し、音声を PC のカーソル位置にリアルタイムで入力します(HTTPS が必要。初回アクセス時は証明書を信頼してください)", + "휴대폰/태블릿 브라우저를 PC에 연결해 녹음하고, 음성을 PC 커서 위치에 실시간으로 입력합니다(HTTPS 필요, 첫 접속 시 인증서를 신뢰해야 함)", + ), + }, + Msg { + key: "settings.remote_input.mode_hold", + text: row("按住说话", "按住說話", "Hold to talk", "押し続けて話す", "눌러서 말하기"), + }, + Msg { + key: "settings.remote_input.mode_toggle", + text: row( + "点击切换", + "點擊切換", + "Tap to toggle", + "タップで切替", + "탭하여 전환", + ), + }, + Msg { + key: "settings.shortcuts.agent_voice", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "settings.shortcuts.cancel", + text: row( + "取消本次录音", + "取消本次錄音", + "Cancel current recording", + "本回の録音をキャンセル", + "이번 녹음 취소", + ), + }, + Msg { + key: "settings.shortcuts.desc_no_acc", + text: row( + "所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。", + "所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。", + "All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.", + "すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。", + "모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.", + ), + }, + Msg { + key: "settings.shortcuts.open_app", + text: row( + "打开 OpenLess", + "打開 OpenLess", + "Open OpenLess", + "OpenLess を開く", + "OpenLess 열기", + ), + }, + Msg { + key: "settings.shortcuts.start_stop", + text: row( + "开始 / 停止录音", + "開始 / 停止錄音", + "Start / Stop recording", + "録音開始 / 停止", + "녹음 시작 / 정지", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_add", + text: row( + "添加风格快捷键", + "新增風格快捷鍵", + "Add style shortcut", + "スタイルショートカットを追加", + "스타일 단축키 추가", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_title", + text: row( + "风格直达快捷键", + "風格直達快捷鍵", + "Style shortcuts", + "スタイル直行ショートカット", + "스타일 바로가기 단축키", + ), + }, + Msg { + key: "settings.shortcuts.switch_style", + text: row( + "切换到上一个风格", + "切換到上一個風格", + "Switch to previous style", + "前のスタイルに切り替え", + "이전 스타일로 전환", + ), + }, + Msg { + key: "settings.shortcuts.title", + text: row( + "快捷键设置", + "快捷鍵設定", + "Shortcut settings", + "ショートカット設定", + "단축키 설정", + ), + }, + Msg { + key: "settings.theme.activity_heatmap_label", + text: row( + "概览页显示年度活动热力图", + "概覽頁顯示年度活動熱力圖", + "Show annual activity heatmap on Overview", + "概要ページに年間アクティビティを表示", + "개요 페이지에 연간 활동 표시", + ), + }, + Msg { + key: "settings.theme.conservative_layout_label", + text: row( + "保守排版", + "保守排版", + "Conservative layout", + "保守レイアウト", + "보수적 레이아웃", + ), + }, + Msg { + key: "settings.theme.stacked_row_layout_label", + text: row( + "易读布局(防溢出换行)", + "易讀布局(防溢出換行)", + "Readable layout (wrap rows)", + "読みやすいレイアウト(はみ出し防止)", + "읽기 쉬운 레이아웃(넘침 방지 줄바꿈)", + ), + }, + Msg { + key: "settings.theme.system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따르기", + ), + }, + Msg { + key: "settings.theme.title", + text: row("外观", "外觀", "Appearance", "外観", "모양"), + }, + Msg { + key: "vocab.delete_selected", + text: row( + "删除已选({})", + "刪除已選({})", + "Delete selected ({})", + "選択項目を削除({})", + "선택 항목 삭제({})", + ), + }, + Msg { + key: "vocab.select_all_visible", + text: row( + "选择当前结果", + "選取目前結果", + "Select current results", + "現在の結果を選択", + "현재 결과 선택", + ), + }, + Msg { + key: "vocab.selected_count", + text: row( + "已选择 {} 个词", + "已選取 {} 個詞", + "{} words selected", + "{} 語を選択中", + "단어 {}개 선택됨", + ), + }, + Msg { + key: "settings.providers.presets.ark", + text: row( + "ARK(火山方舟)", + "ARK(火山方舟)", + "ARK (Volcengine Ark)", + "ARK(Volcengine Ark)", + "ARK (Volcengine Ark)", + ), + }, + Msg { key: "settings.providers.presets.deepseek", text: row("DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek") }, + Msg { key: "settings.providers.presets.siliconflow", text: row("硅基流动", "硅基流動", "SiliconFlow", "SiliconFlow", "SiliconFlow") }, + Msg { key: "settings.providers.presets.atlascloud", text: row("Atlas Cloud", "Atlas Cloud", "Atlas Cloud", "Atlas Cloud", "Atlas Cloud") }, + Msg { key: "settings.providers.presets.openai", text: row("OpenAI", "OpenAI", "OpenAI", "OpenAI", "OpenAI") }, + Msg { + key: "settings.providers.presets.gemini", + text: row( + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + ), + }, + Msg { key: "settings.providers.presets.codexOAuth", text: row("Codex OAuth", "Codex OAuth", "Codex OAuth", "Codex OAuth", "Codex OAuth") }, + Msg { key: "settings.providers.presets.mimo", text: row("小米 MiMo", "小米 MiMo", "Xiaomi MiMo", "Xiaomi MiMo", "Xiaomi MiMo") }, + Msg { key: "settings.providers.presets.cometapi", text: row("CometAPI", "CometAPI", "CometAPI", "CometAPI", "CometAPI") }, + Msg { + key: "settings.providers.presets.openrouterFree", + text: row( + "OpenRouter(免费模型)", + "OpenRouter(免費模型)", + "OpenRouter (free models)", + "OpenRouter(無料モデル)", + "OpenRouter(무료 모델)", + ), + }, + Msg { key: "settings.providers.presets.orcarouter", text: row("OrcaRouter", "OrcaRouter", "OrcaRouter", "OrcaRouter", "OrcaRouter") }, + Msg { + key: "settings.providers.presets.alibabaCoding", + text: row( + "阿里云 Coding Plan", + "阿里雲 Coding Plan", + "Alibaba Cloud Coding Plan", + "Alibaba Cloud Coding Plan", + "Alibaba Cloud Coding Plan", + ), + }, + Msg { key: "settings.providers.presets.codingPlanX", text: row("CodingPlanX", "CodingPlanX", "CodingPlanX", "CodingPlanX", "CodingPlanX") }, + Msg { key: "settings.providers.presets.minimax", text: row("MiniMax(M3)", "MiniMax(M3)", "MiniMax (M3)", "MiniMax(M3)", "MiniMax (M3)") }, + Msg { + key: "settings.providers.presets.stepfun", + text: row( + "StepFun(阶跃星辰)", + "StepFun(階躍星辰)", + "StepFun", + "StepFun(階躍星辰)", + "StepFun", + ), + }, + Msg { key: "settings.providers.presets.opencode", text: row("OpenCode Zen", "OpenCode Zen", "OpenCode Zen", "OpenCode Zen", "OpenCode Zen") }, + Msg { + key: "settings.providers.presets.tencentTokenHub", + text: row( + "腾讯云 TokenHub", + "騰訊雲 TokenHub", + "Tencent Cloud TokenHub", + "Tencent Cloud TokenHub", + "Tencent Cloud TokenHub", + ), + }, + Msg { + key: "settings.providers.presets.customChatCompletions", + text: row( + "自定义 · Chat Completions", + "自訂 · Chat Completions", + "Custom · Chat Completions", + "カスタム · Chat Completions", + "사용자 지정 · Chat Completions", + ), + }, + Msg { + key: "settings.providers.presets.customResponses", + text: row( + "自定义 · Responses", + "自訂 · Responses", + "Custom · Responses", + "カスタム · Responses", + "사용자 지정 · Responses", + ), + }, + Msg { + key: "settings.providers.presets.customMessages", + text: row( + "自定义 · Messages", + "自訂 · Messages", + "Custom · Messages", + "カスタム · Messages", + "사용자 지정 · Messages", + ), + }, + Msg { key: "settings.providers.presets.custom", text: row("自定义", "自定義", "Custom", "カスタム", "사용자 정의") }, + Msg { + key: "settings.providers.presets.asrVolcengine", + text: row( + "火山引擎 bigasr", + "火山引擎 bigasr", + "Volcengine bigasr", + "Volcengine bigasr", + "Volcengine bigasr", + ), + }, + Msg { + key: "settings.providers.presets.asrBailian", + text: row( + "阿里云百炼实时 ASR", + "阿里雲百煉即時 ASR", + "Alibaba Bailian realtime ASR", + "Alibaba Bailian リアルタイム ASR", + "Alibaba Bailian 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrBailianQwen3", + text: row( + "阿里云百炼 Qwen3 实时 ASR", + "阿里雲百煉 Qwen3 即時 ASR", + "Bailian Qwen3 Realtime ASR", + "Bailian Qwen3 リアルタイム ASR", + "Bailian Qwen3 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrBailianFunAsrFlash", + text: row( + "阿里云百炼 Fun-ASR-Flash(录音文件)", + "阿里雲百煉 Fun-ASR-Flash(錄音檔)", + "Bailian Fun-ASR-Flash (recorded file)", + "Bailian Fun-ASR-Flash(録音ファイル)", + "Bailian Fun-ASR-Flash (녹음 파일)", + ), + }, + Msg { + key: "settings.providers.presets.asrSiliconflow", + text: row( + "硅基流动 SenseVoice", + "硅基流動 SenseVoice", + "SiliconFlow SenseVoice", + "SiliconFlow SenseVoice", + "SiliconFlow SenseVoice", + ), + }, + Msg { + key: "settings.providers.presets.asrStepfun", + text: row( + "阶跃星辰 StepAudio", + "階躍星辰 StepAudio", + "StepFun StepAudio ASR", + "StepFun StepAudio ASR", + "StepFun StepAudio ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrZhipu", + text: row( + "智谱 GLM-ASR", + "智譜 GLM-ASR", + "Zhipu GLM-ASR", + "Zhipu GLM-ASR", + "Zhipu GLM-ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrGroq", + text: row( + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + ), + }, + Msg { + key: "settings.providers.presets.asrWhisper", + text: row( + "OpenAI Whisper(兼容)", + "OpenAI Whisper(兼容)", + "OpenAI Whisper (compatible)", + "OpenAI Whisper(互換)", + "OpenAI Whisper(호환)", + ), + }, + Msg { + key: "settings.providers.presets.asrOpenrouter", + text: row( + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + ), + }, + Msg { key: "settings.providers.presets.asrZenmux", text: row("ZenMux", "ZenMux", "ZenMux", "ZenMux", "ZenMux") }, + Msg { + key: "settings.providers.presets.asrOpenAiCompatible", + text: row( + "自定义 OpenAI 兼容", + "自訂 OpenAI 相容", + "Custom OpenAI-compatible", + "カスタム OpenAI 互換", + "커스텀 OpenAI 호환", + ), + }, + Msg { + key: "settings.providers.presets.asrXiaomiMimo", + text: row( + "小米 MiMo ASR", + "小米 MiMo ASR", + "Xiaomi MiMo ASR", + "Xiaomi MiMo ASR", + "Xiaomi MiMo ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrIflytek", + text: row( + "讯飞实时语音转写", + "訊飛即時語音轉寫", + "iFlytek Realtime ASR", + "iFlytek リアルタイム音声認識", + "iFlytek 실시간 음성 인식", + ), + }, + Msg { + key: "settings.providers.presets.asrTencentCloud", + text: row( + "腾讯云混元实时 ASR", + "騰訊雲混元即時 ASR", + "Tencent Cloud Hunyuan Realtime ASR", + "Tencent Cloud Hunyuan リアルタイム ASR", + "Tencent Cloud Hunyuan 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrElevenLabs", + text: row( + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + ), + }, + Msg { + key: "settings.providers.presets.asrSherpaOnnxLocal", + text: row( + "本地 sherpa-onnx(实验性)", + "本地 sherpa-onnx(實驗性)", + "Local sherpa-onnx (Experimental)", + "ローカル sherpa-onnx(実験的)", + "로컬 sherpa-onnx(실험적)", + ), + }, + Msg { + key: "settings.providers.presets.asrFoundryLocalWhisper", + text: row( + "本地 Whisper(Foundry Local)", + "本地 Whisper(Foundry Local)", + "Local Whisper (Foundry Local)", + "ローカル Whisper(Foundry Local)", + "로컬 Whisper(Foundry Local)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalWhisper", + text: row( + "本地 Whisper(批量解码)", + "本地 Whisper(批次解碼)", + "Local Whisper (batch)", + "ローカル Whisper(バッチ)", + "로컬 Whisper(배치)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3", + text: row( + "本地 Qwen3-ASR", + "本地 Qwen3-ASR", + "Local Qwen3-ASR", + "ローカル Qwen3-ASR", + "로컬 Qwen3-ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3Mlx", + text: row( + "本地 Qwen3-ASR(MLX / Metal)", + "本地 Qwen3-ASR(MLX / Metal)", + "Local Qwen3-ASR (MLX / Metal)", + "ローカル Qwen3-ASR(MLX / Metal)", + "로컬 Qwen3-ASR(MLX / Metal)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3C", + text: row( + "本地 Qwen3-ASR(C / CPU)", + "本地 Qwen3-ASR(C / CPU)", + "Local Qwen3-ASR (C / CPU)", + "ローカル Qwen3-ASR(C / CPU)", + "로컬 Qwen3-ASR(C / CPU)", + ), + }, + Msg { + key: "settings.providers.presets.asrAppleSpeech", + text: row( + "Apple 语音(macOS)", + "Apple 語音(macOS)", + "Apple Speech (macOS)", + "Apple 音声認識 (macOS)", + "Apple 음성 (macOS)", + ), + }, + Msg { + key: "settings.providers.presets.omniOpenai", + text: row( + "OpenAI(支持音频)", + "OpenAI(支援音訊)", + "OpenAI (audio-capable)", + "OpenAI(音声対応)", + "OpenAI (오디오 지원)", + ), + }, + Msg { + key: "settings.providers.presets.omniGemini", + text: row( + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + ), + }, + Msg { + key: "settings.providers.presets.omniDashscope", + text: row( + "阿里云百炼 Omni", + "阿里雲百煉 Omni", + "Alibaba DashScope Omni", + "Alibaba DashScope Omni", + "Alibaba DashScope Omni", + ), + }, + Msg { key: "settings.recording.history_retention_never", text: row("不按时间清理", "不按時間清理", "Never", "時間で削除しない", "시간 기준 삭제 안 함") }, + Msg { key: "settings.recording.history_retention_days", text: row("{} 天", "{} 天", "{} days", "{} 日", "{}일") }, + Msg { key: "channel.beta", text: row("Beta", "Beta", "Beta", "ベータ", "베타") }, + Msg { + key: "style.pack.activate", + text: row("激活", "啟用", "Activate", "有効化", "활성화"), + }, + Msg { + key: "style.pack.add_pack_tile_hint", + text: row( + "从空白模板开始。", + "從空白範本開始。", + "Start from a blank template.", + "空のテンプレートから開始。", + "빈 템플릿으로 시작.", + ), + }, + Msg { + key: "style.pack.add_pack_tile_title", + text: row("新建风格包", "新建風格包", "New Pack", "新規パック", "새 팩"), + }, + Msg { + key: "style.pack.dictation_prompt_hint", + text: row( + "用于录音转写后的 ASR 文本;这里可以写口语整理、ASR 错字纠正和专有名词还原规则。", + "用於錄音轉寫後的 ASR 文本;這裡可以寫口語整理、ASR 錯字糾正和專有名詞還原規則。", + "For ASR text after dictation; write spoken-language cleanup, ASR typo fixes and term restoration rules here.", + "録音の書き起こし後のASRテキスト用。口語整理、ASR誤字修正、固有名詞の復元ルールをここに書けます。", + "녹음 후 받아쓰기한 ASR 텍스트용. 구어 정리, ASR 오타 수정, 고유명사 복원 규칙을 여기에 작성하세요.", + ), + }, + Msg { + key: "style.pack.edit", + text: row("编辑", "編輯", "Edit", "編集", "편집"), + }, + Msg { + key: "style.pack.export_short", + text: row("导出", "匯出", "Export", "エクスポート", "내보내기"), + }, + Msg { + key: "style.pack.import_zip", + text: row("导入 ZIP", "匯入 ZIP", "Import ZIP", "ZIP をインポート", "ZIP 가져오기"), + }, + Msg { + key: "style.pack.imported", + text: row("导入", "匯入", "Imported", "インポート", "가져옴"), + }, + Msg { + key: "style.pack.list_count", + text: row("{} 个风格包", "{} 個風格包", "{} packs", "{} 個", "{}개"), + }, + Msg { + key: "style.pack.list_title", + text: row("本地风格包", "本機風格包", "Local Packs", "ローカルパック", "로컬 팩"), + }, + Msg { + key: "marketplace.liked_empty", + text: row( + "你还没有赞过任何风格包", + "你還沒有讚過任何風格包", + "You have not liked any style packs yet", + "まだいいねしたパックがありません", + "아직 좋아요한 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.liked_empty_hint", + text: row( + "点开任一风格包,红色星星点亮后会出现在这里", + "點開任一風格包,紅色星星點亮後會出現在這裡", + "Open any pack and tap the star — liked packs appear here", + "パックを開いて星をタップするとここに表示されます", + "팩을 열고 별을 누르면 여기에 표시됩니다", + ), + }, + Msg { + key: "modal.descriptions.about", + text: row( + "查看当前版本、更新渠道与自动更新设置。", + "查看目前版本、更新管道與自動更新設定。", + "View your version, update channel and automatic update settings.", + "現在のバージョン、更新チャンネル、自動更新を確認します。", + "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.", + ), + }, + Msg { + key: "modal.descriptions.advanced", + text: row( + "按需配置 Less Computer、多模态与调试功能。", + "按需設定 Less Computer、多模態與除錯功能。", + "Configure Less Computer, multimodal processing and debugging as needed.", + "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。", + "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.", + ), + }, + Msg { + key: "modal.descriptions.appearance", + text: row( + "调整主题、页面排版和界面语言,让阅读更舒服。", + "調整主題、頁面排版和介面語言,讓閱讀更舒服。", + "Adjust the theme, page layout and interface language for comfortable reading.", + "テーマ、レイアウト、表示言語を読みやすく調整します。", + "테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.", + ), + }, + Msg { + key: "modal.descriptions.general", + text: row( + "选择麦克风、设置录音方式与文字输入,也可连接手机输入。", + "選擇麥克風、設定錄音方式與文字輸入,也可連接手機輸入。", + "Choose a microphone, adjust recording and text input, or connect your phone.", + "マイク、録音方法、文字入力を設定し、スマートフォンからの入力を接続します。", + "마이크, 녹음 방식, 텍스트 입력을 설정하고 휴대폰 입력을 연결합니다.", + ), + }, + Msg { + key: "modal.descriptions.privacy", + text: row( + "检查系统权限与连接状态,管理历史、录音和本地数据。", + "檢查系統權限與連線狀態,管理歷史、錄音和本機資料。", + "Check system permissions and connections. Manage history, recordings and local data.", + "システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。", + "시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.", + ), + }, + Msg { + key: "modal.descriptions.services", + text: row( + "选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。", + "選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。", + "Choose speech recognition and text processing services. Manage channels, local models and connections.", + "音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。", + "음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.", + ), + }, + Msg { + key: "modal.descriptions.shortcuts", + text: row( + "设置各功能的触发方式,以及选中文字后的操作。", + "設定各功能的觸發方式,以及選取文字後的操作。", + "Set up shortcuts and choose what happens when you select text.", + "各機能のショートカットと、テキスト選択後の操作を設定します。", + "기능별 단축키와 텍스트 선택 후 동작을 설정합니다.", + ), + }, + Msg { + key: "modal.advanced_pages.debug", + text: row( + "保留调试录音、探测光标上下文和导出日志。", + "保留偵錯錄音、探測游標上下文與匯出日誌。", + "Keep debug recordings, inspect cursor context, and export logs.", + "デバッグ録音の保持、カーソル周辺の確認、ログの書き出しを行います。", + "디버그 녹음을 보관하고 커서 문맥을 확인하며 로그를 내보냅니다.", + ), + }, + Msg { + key: "modal.advanced_pages.less_computer", + text: row( + "选择 Agent,配置模型、权限与工作目录。", + "選擇 Agent,設定模型、權限與工作目錄。", + "Choose an agent and configure its model, permissions, and working directory.", + "Agent を選び、モデル・権限・作業ディレクトリを設定します。", + "Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.", + ), + }, + Msg { + key: "modal.advanced_pages.multimodal", + text: row( + "管理多模态识别的实验性开关。", + "管理多模態辨識的實驗性開關。", + "Manage the experimental multimodal recognition switch.", + "実験的なマルチモーダル認識の有効・無効を設定します。", + "실험적 멀티모달 인식 기능의 사용 여부를 설정합니다.", + ), + }, + Msg { + key: "modal.service_views.omni", + text: row("多模态模型", "多模態模型", "Multimodal", "マルチモーダル", "멀티모달"), + }, + Msg { + key: "settings.about.beta_channel_desc", + text: row( + "开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。", + "開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。", + "When on, background auto-update follows Beta; when off, it uses stable. Use the button below to manually check Beta anytime.", + "オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。", + "켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.", + ), + }, + Msg { + key: "settings.about.check_beta_update_btn", + text: row( + "检查 Beta 更新", + "檢查 Beta 更新", + "Check Beta update", + "Beta を確認", + "Beta 확인", + ), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_hint", + text: row( + "开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。", + "開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。", + "Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.", + "有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。", + "활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.", + ), + }, + Msg { + key: "settings.language.label_desc", + text: row( + "选择「跟随系统」时按操作系统当前语言显示。", + "選擇「跟隨系統」時按操作系統當前語言顯示。", + "Choose \"Follow system\" to match the OS language at launch.", + "「システムに従う」を選ぶと OS の言語に合わせます。", + "\"시스템 따라가기\"를 선택하면 OS 언어를 따릅니다.", + ), + }, + Msg { + key: "settings.network.use_system_proxy_desc", + text: row( + "开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流与 Less Computer 不受此开关影响。", + "開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。", + "When on, requests follow the system proxy. When off, all requests connect directly (usually lower latency for domestic services), but overseas services such as GitHub sign-in and updates may fail. Realtime voice streams and Less Computer are unaffected.", + "オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。", + "켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.", + ), + }, + Msg { + key: "settings.permissions.denied", + text: row("未授权", "未授權", "Not granted", "未許可", "허용되지 않음"), + }, + Msg { + key: "settings.permissions.not_applicable", + text: row("无需授权", "無需授權", "Not required", "権限不要", "권한 불필요"), + }, + Msg { + key: "settings.recording.audio_cue_desc", + text: row( + "按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。", + "按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。", + "Play a short synthesized chime when you press the hotkey to start recording. Plays even when the capsule is hidden.", + "ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。", + "단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.", + ), + }, + Msg { + key: "settings.recording.audio_recording_max_entries_desc", + text: row( + "本地保留 wav 文件数上限,留空 = 200。", + "本地保留 wav 檔案數上限,留空 = 200。", + "Max wav files retained locally. Blank = 200.", + "ローカル保持 wav ファイル上限。空欄 = 200。", + "로컬 보관 wav 파일 상한. 빈칸 = 200.", + ), + }, + Msg { + key: "settings.recording.combo_disable_hint", + text: row( + "核心快捷键不可停用,录音必须绑定一个热键", + "核心快捷鍵不可停用,錄音必須綁定一個快捷鍵", + "Core hotkey cannot be disabled — recording needs a hotkey", + "コアショートカットは無効化できません(録音にはショートカットが必須です)", + "핵심 단축키는 비활성화할 수 없습니다 (녹음에는 단축키가 필수입니다)", + ), + }, + Msg { + key: "settings.recording.microphone_desc", + text: row( + "选择优先输入设备。设备断开时自动切到系统默认。", + "選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。", + "Choose the preferred input device; falls back to system default when unavailable.", + "優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。", + "우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.", + ), + }, + Msg { + key: "settings.recording.mode_auto", + text: row("自动", "自動", "Auto", "自動", "자동"), + }, + Msg { + key: "settings.recording.mode_desc", + text: row( + "切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。", + "切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。", + "Toggle = tap once to start, again to stop. Push-to-talk = hold to record.", + "トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。", + "토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.", + ), + }, + Msg { + key: "settings.recording.mode_hold", + text: row("按住说话", "按住說話", "Push-to-talk", "押し続けて話す", "눌러서 말하기"), + }, + Msg { + key: "settings.recording.mute_during_recording_desc", + text: row( + "录音期间临时静音系统输出,避免扬声器回音。", + "錄音期間臨時靜音系統輸出,避免揚聲器回音。", + "Temporarily mute system output during voice input to avoid speaker echo.", + "録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。", + "녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_desc", + text: row( + "插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。", + "插入時模擬按下的粘貼鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。", + "Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).", + "挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。", + "삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).", + ), + }, + Msg { + key: "settings.recording.polish_context_window_desc", + text: row( + "把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。", + "把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。", + "Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.", + "直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。", + "최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.", + ), + }, + Msg { + key: "settings.recording.polish_context_window_label", + text: row( + "对话上下文窗口(分钟)", + "對話上下文窗口(分鐘)", + "Polish context window (minutes)", + "会話コンテキスト窓(分)", + "대화 컨텍스트 윈도(분)", + ), + }, + Msg { + key: "settings.recording.restore_clipboard_desc", + text: row( + "粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。", + "粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。", + "Restore your original clipboard after a successful paste (Windows / Linux only).", + "ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。", + "붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_desc", + text: row( + "仅切换模式生效。检测到语音后,连续静音达到所选时长即自动结束并提交;一直没说话则 10 秒后取消。默认关闭;第二次按键停止和 Esc 取消仍然有效。", + "僅切換模式生效。偵測到語音後,連續靜音達到所選時長即自動結束並提交;一直沒說話則 10 秒後取消。預設關閉;第二次按鍵停止和 Esc 取消仍然有效。", + "Toggle only. After speech is detected, recording stops and submits automatically once silence lasts the chosen duration. Off by default; a second hotkey press and Esc still work.", + "トグルモードのみ有効。音声を検出した後、無音が選択した時間続いたら録音を自動停止して送信します。一度も話さない場合は10秒後にキャンセル。既定ではオフで、2回目のキー押下による停止と Esc によるキャンセルは引き続き有効です。", + "토글 모드에서만 동작합니다. 음성이 감지된 후 선택한 시간 동안 침묵이 이어지면 녹음을 자동으로 종료하고 제출합니다. 말을 전혀 하지 않으면 10초 후 취소됩니다. 기본적으로 꺼져 있으며, 두 번째 키 누름으로 중지하고 Esc로 취소하는 동작은 그대로 유지됩니다.", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_copy", + text: row( + "复制完整指纹", + "複製完整指紋", + "Copy full fingerprint", + "指紋全体をコピー", + "전체 지문 복사", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_label", + text: row( + "本机根证书 SHA-256", + "本機根憑證 SHA-256", + "This computer's root CA SHA-256", + "このコンピューターのルート CA SHA-256", + "이 컴퓨터의 루트 CA SHA-256", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_unavailable", + text: row( + "完整指纹不可用。请勿安装或信任下载的证书。", + "完整指紋無法取得。請勿安裝或信任下載的憑證。", + "The full fingerprint is unavailable. Do not install or trust a downloaded certificate.", + "完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。", + "전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.", + ), + }, + Msg { + key: "settings.remote_input.cert_verify_hint", + text: row( + "在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。", + "在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。", + "Find SHA-256 in the phone's system certificate details and compare all 64 characters with this value (ignore spaces and colons) before enabling full trust. A web page, profile name or identifier cannot prove identity. If the fingerprint differs or cannot be viewed in full, stop and remove the downloaded or installed profile.", + "スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。", + "휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.", + ), + }, + Msg { + key: "settings.remote_input.pin_label", + text: row("配对码", "配對碼", "Pairing code", "ペアリングコード", "페어링 코드"), + }, + Msg { + key: "settings.remote_input.security_hint", + text: row( + "仅同一局域网可访问,需输入配对码;不用时建议关闭。", + "僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。", + "Reachable only on the same LAN and requires the pairing code; turn it off when not in use.", + "同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。", + "같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.", + ), + }, + Msg { + key: "settings.remote_input.url_label", + text: row("访问网址", "存取網址", "Access URL", "アクセス URL", "접속 URL"), + }, + Msg { + key: "settings.selection_polish.direct_replace", + text: row( + "直接覆盖", + "直接覆蓋", + "Replace directly", + "直接置き換え", + "직접 교체", + ), + }, + Msg { + key: "settings.selection_polish.preview_confirm", + text: row( + "预览确认", + "預覽確認", + "Preview & confirm", + "プレビューして確認", + "미리보기 후 확인", + ), + }, + Msg { + key: "settings.selection_workspace.polish_delivery", + text: row( + "结果处理", + "結果處理", + "Result handling", + "結果の処理", + "결과 처리", + ), + }, + Msg { + key: "settings.selection_workspace.polish_hotkey", + text: row( + "选区助手快捷键", + "選區助手快捷鍵", + "Selection assistant shortcut", + "選択範囲アシスタントのショートカット", + "선택 영역 도우미 단축키", + ), + }, + Msg { + key: "settings.selection_workspace.polish_hotkey_desc", + text: row( + "关闭语音编辑时直接润色;开启语音编辑时按住口述指令(录音方式跟随全局设置)。", + "關閉語音編輯時直接潤色;開啟語音編輯時按住口述指令(錄音方式跟隨全域設定)。", + "Polishes directly when voice edit is off; hold to speak when voice edit is on (recording follows global settings).", + "音声編集オフ時は推敲、オン時は押しながら話す(録音方式はグローバル設定に従う)。", + "음성 편집 끄면 바로 다듬기, 켜면 누른 채 말하기(녹음 방식은 전역 설정 따름).", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_desc", + text: row( + "为常用风格包各配一个快捷键,按下直接切换;停用中的包会自动启用。", + "為常用風格包各配一個快捷鍵,按下直接切換;停用中的包會自動啟用。", + "Bind a shortcut to each favorite style pack for one-press switching; disabled packs are re-enabled automatically.", + "よく使うスタイルパックにショートカットを割り当てて一発切替;無効中のパックは自動で有効化されます。", + "자주 쓰는 스타일 팩에 단축키를 지정해 한 번에 전환합니다. 비활성화된 팩은 자동으로 다시 활성화됩니다.", + ), + }, + Msg { + key: "modal.about.docs_btn", + text: row( + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + ), + }, + Msg { + key: "modal.about.feedback_btn", + text: row( + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + ), + }, + Msg { + key: "settings.coding_agent.coming_soon_note", + text: row( + "配置即时保存;热键触发与执行链路随后续版本生效。", + "設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。", + "Config is saved now; hotkey triggering and the execution flow land in a later version.", + "設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。", + "설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.", + ), + }, + Msg { + key: "settings.coding_agent.exe", + text: row( + "可执行文件路径", + "可執行檔路徑", + "Executable path", + "実行ファイルのパス", + "실행 파일 경로", + ), + }, + Msg { + key: "settings.coding_agent.hotkey_hint", + text: row( + "开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。", + "開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。", + "When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.", + "有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。", + "켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.", + ), + }, + Msg { + key: "settings.coding_agent.model", + text: row("模型", "模型", "Model", "モデル", "모델"), + }, + Msg { + key: "settings.coding_agent.model_hint", + text: row( + "Haiku 最快 · Sonnet 均衡 · Opus 最强", + "Haiku 最快 · Sonnet 均衡 · Opus 最強", + "Haiku = fastest · Sonnet = balanced · Opus = strongest", + "Haiku = 最速 · Sonnet = バランス · Opus = 最強", + "Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강", + ), + }, + Msg { + key: "settings.coding_agent.model_placeholder", + text: row( + "默认 sonnet", + "預設 sonnet", + "Default: sonnet", + "デフォルト: sonnet", + "기본: sonnet", + ), + }, + Msg { + key: "settings.coding_agent.provider", + text: row( + "Agent 后端", + "Agent 後端", + "Agent backend", + "Agent バックエンド", + "Agent 백엔드", + ), + }, + Msg { + key: "settings.coding_console.mode.accept_edits", + text: row( + "放行(可恢复操作)", + "放行(可復原操作)", + "Allow (reversible)", + "許可(復元可能)", + "허용(복구 가능)", + ), + }, + Msg { + key: "settings.coding_console.mode.bypass_permissions", + text: row( + "完全放行(高风险)", + "完全放行(高風險)", + "Full bypass (risky)", + "完全許可(高リスク)", + "완전 허용(위험)", + ), + }, + Msg { + key: "settings.coding_console.mode.default", + text: row( + "默认(逐项确认)", + "預設(逐項確認)", + "Default (ask each)", + "デフォルト(都度確認)", + "기본(매번 확인)", + ), + }, + Msg { + key: "settings.coding_console.mode.plan", + text: row( + "只读 / 计划", + "唯讀 / 計畫", + "Read-only / plan", + "読み取り専用 / 計画", + "읽기 전용 / 계획", + ), + }, + Msg { + key: "settings.coding_console.workdir_desc", + text: row( + "可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。", + "選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。", + "Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.", + "任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。", + "선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.", + ), + }, + Msg { + key: "settings.coding_console.workdir_placeholder", + text: row( + "留空则在临时目录运行", + "留空則於暫存目錄執行", + "Empty = run in a temp dir", + "空欄なら一時ディレクトリで実行", + "비우면 임시 디렉터리에서 실행", + ), + }, + Msg { + key: "common.experimental", + text: row("实验性", "實驗性", "Experimental", "実験的", "실험적"), + }, + Msg { + key: "hotkey.mode_auto_suffix", + text: row( + "(自动识别)", + "(自動識別)", + " (auto-detect)", + "(自動判別)", + "(자동 인식)", + ), + }, + Msg { + key: "hotkey.mode_hold_suffix", + text: row( + "(按住说话)", + "(按住說話)", + " (push-to-talk)", + "(押し続けて話す)", + "(눌러서 말하기)", + ), + }, + Msg { + key: "hotkey.mode_toggle_suffix", + text: row( + "(开始 / 停止)", + "(開始 / 停止)", + " (start / stop)", + "(開始 / 停止)", + "(시작 / 정지)", + ), + }, + Msg { + key: "settings.coding_agent.voice_hotkey_desc", + text: row( + "按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。", + "按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。", + "Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.", + "押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。", + "누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.", + ), + }, + Msg { + key: "settings.recording.combo_conflict", + text: row( + "该快捷键组合不可用", + "此快捷鍵組合不可用", + "This shortcut combination is not available", + "このショートカットの組み合わせは使用できません", + "이 단축키 조합은 사용할 수 없습니다", + ), + }, + Msg { + key: "settings.recording.combo_record_btn", + text: row( + "录制快捷键", + "錄製快捷鍵", + "Record shortcut", + "ショートカットを記録", + "단축키 녹화", + ), + }, + Msg { + key: "settings.recording.combo_record_hint", + text: row( + "请按下快捷键组合…", + "請按下快捷鍵組合…", + "Press your shortcut combination…", + "ショートカットの組み合わせを押してください…", + "단축키 조합을 눌러 주세요…", + ), + }, + Msg { + key: "settings.shortcuts.disable", + text: row("停用", "停用", "Disable", "無効化", "비활성화"), + }, + Msg { + key: "settings.shortcuts.style_pack_disabled_suffix", + text: row("(已停用)", "(已停用)", " (disabled)", "(無効)", " (비활성화됨)"), + }, + Msg { + key: "settings.shortcuts.style_pack_remove", + text: row("移除", "移除", "Remove", "削除", "제거"), + }, + Msg { + key: "capsule.cancelled", + text: row("已取消", "已取消", "Cancelled", "キャンセルしました", "취소됨"), + }, + Msg { + key: "capsule.error", + text: row( + "出错了", + "出錯了", + "Something went wrong", + "エラーが発生しました", + "오류 발생", + ), + }, + Msg { + key: "capsule.inserted", + text: row("已插入 {}", "已插入 {}", "Inserted {}", "{} 文字を入力しました", "{}자 입력됨"), + }, + Msg { + key: "capsule.thinking", + text: row("thinking", "thinking", "thinking", "thinking", "thinking"), + }, + Msg { + key: "qa.close_tooltip", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "qa.composer_placeholder", + text: row( + "输入问题,Enter 发送", + "輸入問題,Enter 發送", + "Type a question. Enter to send", + "質問を入力。Enter で送信", + "질문을 입력하세요. Enter로 보내기", + ), + }, + Msg { + key: "qa.empty_desc", + text: row( + "选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。", + "選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。", + "Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.", + "テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。", + "텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.", + ), + }, + Msg { + key: "qa.empty_title", + text: row( + "有什么可以帮你?", + "有什麼可以幫你?", + "How can I help?", + "ご用件は?", + "무엇을 도와드릴까요?", + ), + }, + Msg { + key: "qa.error_retry_hint", + text: row( + "请再试一次。", + "請再試一次。", + "Please try again.", + "もう一度お試しください。", + "다시 시도해 주세요.", + ), + }, + Msg { + key: "qa.header_hint", + text: row("随时提问", "隨時提問", "Ask anytime", "いつでも質問", "언제든 질문하세요"), + }, + Msg { + key: "qa.selection_preview", + text: row( + "基于选中文本:", + "基於選中文本:", + "From selected text:", + "選択テキスト:", + "선택된 텍스트 기반:", + ), + }, + Msg { + key: "qa.thinking", + text: row("思考中…", "思考中…", "Thinking…", "思考中…", "생각 중…"), + }, + Msg { + key: "qa.title", + text: row("划词追问", "劃詞追問", "Ask", "質問", "질문"), + }, + Msg { + key: "selection.polish_preview.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "selection.polish_preview.confirm_replace", + text: row( + "确认并替换", + "確認並替換", + "Confirm & replace", + "確認して置き換え", + "확인 후 교체", + ), + }, + Msg { + key: "selection.polish_preview.source_prefix", + text: row("原文:", "原文:", "Original: ", "原文:", "원문: "), + }, + Msg { + key: "selection.polish_preview.subtitle", + text: row( + "可直接编辑;点击确认后才会替换原选区。", + "可直接編輯;點擊確認後才會替換原選區。", + "Editable; the original selection is replaced only after you confirm.", + "編集可能です。確認後はじめて元の選択範囲を置き換えます。", + "편집 가능합니다. 확인을 클릭한 뒤에만 원래 선택 영역을 교체합니다.", + ), + }, + Msg { + key: "selection.polish_preview.title", + text: row( + "选区润色预览", + "選區潤色預覽", + "Selection Polish Preview", + "選択範囲の推敲プレビュー", + "선택 영역 다듬기 미리보기", + ), + }, + Msg { + key: "capsule.translating", + text: row("正在翻译", "正在翻譯", "Translating", "翻訳中", "번역 중"), + }, + Msg { + key: "qa.edit_apply_replace", + text: row( + "预览并确认插入", + "確認並替換選區", + "Preview and confirm insert", + "プレビューして挿入を確認", + "미리보기 후 삽입 확인", + ), + }, + Msg { + key: "qa.edit_instruction_mode", + text: row( + "编辑指令", + "編輯指令", + "Edit instruction", + "編集指示", + "편집 지시", + ), + }, + Msg { + key: "qa.edit_revert_previous", + text: row( + "保留上一版本", + "保留上一版本", + "Keep previous version", + "前のバージョンを保持", + "이전 버전 유지", + ), + }, + Msg { + key: "qa.pin_tooltip", + text: row( + "固定(不自动关闭)", + "固定(不自動關閉)", + "Pin (stay open)", + "ピン留め(自動で閉じない)", + "고정(자동으로 닫히지 않음)", + ), + }, + Msg { + key: "qa.unpin_tooltip", + text: row("取消固定", "取消固定", "Unpin", "ピン留めを解除", "고정 해제"), + }, + Msg { + key: "less_computer.approval_rerun_warning", + text: row( + "注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用", + "注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用", + "Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.", + "注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。", + "주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.", + ), + }, + Msg { + key: "less_computer.approval_title", + text: row( + "执行被拦截的命令?", + "執行被攔截的指令?", + "Run blocked command?", + "ブロックされたコマンドを実行?", + "차단된 명령을 실행할까요?", + ), + }, + Msg { + key: "less_computer.approve", + text: row("允许", "允許", "Approve", "許可", "허용"), + }, + Msg { + key: "less_computer.compaction", + text: row( + "上下文已压缩", + "上下文已壓縮", + "Context compacted", + "コンテキストを圧縮しました", + "컨텍스트가 압축되었습니다", + ), + }, + Msg { + key: "less_computer.cost", + text: row("${}", "${}", "${}", "${}", "${}"), + }, + Msg { + key: "less_computer.deny", + text: row("拒绝", "拒絕", "Deny", "拒否", "거부"), + }, + Msg { + key: "less_computer.input_placeholder", + text: row( + "输入指令,Enter 发送", + "輸入指令,Enter 傳送", + "Type a command, Enter to send", + "指示を入力、Enter で送信", + "명령을 입력하고 Enter로 전송", + ), + }, + Msg { + key: "less_computer.send", + text: row("发送", "傳送", "Send", "送信", "전송"), + }, + Msg { + key: "less_computer.subtitle", + text: row( + "想让电脑做什么?", + "想讓電腦做什麼?", + "What should your computer do?", + "コンピュータに何をさせますか?", + "컴퓨터로 무엇을 할까요?", + ), + }, + Msg { + key: "less_computer.title", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "less_computer.tool", + text: row("调用了 {}", "呼叫了 {}", "Used {}", "{} を使用", "{} 사용"), + }, + Msg { + key: "less_computer.working", + text: row("正在操控电脑…", "正在操控電腦…", "Operating…", "操作中…", "조작 중…"), + }, +]; + +fn lang_index(lang: Lang) -> usize { + match lang { + Lang::ZhCn => 0, + Lang::ZhTw => 1, + Lang::En => 2, + Lang::Ja => 3, + Lang::Ko => 4, + } +} + +fn find_entry<'a>(entries: &'a [Msg], key: &str) -> Option<&'a Msg> { + entries.iter().find(|entry| entry.key == key) +} + +/// Translate a catalog key into the chosen language, falling back to the +/// `zh-CN` source of truth for any key whose requested locale is untranslated, +/// and finally to the bare key when the key is not present at all. +pub fn tr<'a, L: IntoLang>(entries: &'a [Msg], lang: L, key: &'a str) -> &'a str { + let lang = lang.into_lang(); + match find_entry(entries, key) { + Some(entry) => { + let value = entry.text[lang_index(lang)]; + if value.is_empty() && lang != Lang::ZhCn { + entry.text[0] + } else if value.is_empty() { + // zh-CN is the source of truth and should never be empty. + key + } else { + value + } + } + None => key, + } +} + +/// Translate and substitute `{}` (sequential) and `{n}` (positional) +/// placeholders. Reuses the same fallback rules as [`tr`]. +pub fn fmt(entries: &[Msg], lang: L, key: &str, args: &[&dyn Display]) -> String { + let template = tr(entries, lang, key); + let mut out = String::with_capacity(template.len()); + let mut rest = template; + let mut auto_index = 0usize; + while let Some(open) = rest.find('{') { + if let Some(relative_close) = rest[open + 1..].find('}') { + let close = open + 1 + relative_close; + let field = &rest[open + 1..close]; + let argument = if field.is_empty() { + let index = auto_index; + auto_index += 1; + index + } else if let Ok(index) = field.parse::() { + index + } else { + // Not a placeholder we understand (e.g. `{name}`): keep it. + out.push_str(&rest[..open]); + out.push('{'); + out.push_str(field); + out.push('}'); + rest = &rest[close + 1..]; + continue; + }; + out.push_str(&rest[..open]); + if let Some(value) = args.get(argument) { + out.push_str(&value.to_string()); + } + rest = &rest[close + 1..]; + continue; + } + // No closing brace: keep the `{` literally and make progress. + out.push_str(&rest[..open]); + out.push('{'); + rest = &rest[open + 1..]; + } + out.push_str(rest); + out +} + +/// Global lookup against [`CATALOG`]. +pub fn tr_catalog(lang: L, key: &'static str) -> &'static str { + tr(CATALOG, lang, key) +} + +/// Global formatted lookup against [`CATALOG`]. +pub fn fmt_catalog(lang: L, key: &str, args: &[&dyn Display]) -> String { + fmt(CATALOG, lang, key, args) +} + +/// Ergonomic conversion for the call sites that already hold a concrete +/// [`Lang`] or a [`LocalePref`]. Kept tiny so UI code stays readable. +pub trait IntoLang { + fn into_lang(self) -> Lang; +} + +impl IntoLang for Lang { + fn into_lang(self) -> Lang { + self + } +} + +impl IntoLang for LocalePref { + fn into_lang(self) -> Lang { + self.resolve() + } +} + +impl IntoLang for &LocalePref { + fn into_lang(self) -> Lang { + self.resolve() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CATALOG: &[Msg] = &[ + Msg { + key: "k.full", + text: row("完整", "完整", "Full", "完全", "전체"), + }, + // `k.missing_en` deliberately leaves `en` empty to exercise fallback. + Msg { + key: "k.missing_en", + text: row("来源", "來源", "", "ソース", "출처"), + }, + ]; + + #[test] + fn fallback_uses_zh_cn_source_of_truth_when_locale_is_empty() { + assert_eq!(tr(TEST_CATALOG, Lang::En, "k.missing_en"), "来源"); + assert_eq!(tr(TEST_CATALOG, Lang::ZhCn, "k.missing_en"), "来源"); + // Fully translated keys return their own locale. + assert_eq!(tr(TEST_CATALOG, Lang::En, "k.full"), "Full"); + } + + #[test] + fn unknown_key_returns_the_key_itself() { + assert_eq!(tr(TEST_CATALOG, Lang::Ja, "k.unknown"), "k.unknown"); + } + + #[test] + fn zh_cn_source_of_truth_is_fully_populated_and_complete() { + for entry in CATALOG { + assert!( + !entry.text[0].is_empty(), + "zh-CN must never be empty for key {}", + entry.key + ); + } + } + + #[test] + fn every_catalog_key_is_translated_in_all_supported_locales() { + for entry in CATALOG { + for lang in LANGS { + assert!( + !entry.text[lang_index(lang)].is_empty(), + "{} is missing a translation for {}", + entry.key, + lang.tag() + ); + } + } + } + + #[test] + fn catalog_keys_are_unique() { + for (i, left) in CATALOG.iter().enumerate() { + for right in &CATALOG[i + 1..] { + assert_ne!(left.key, right.key); + } + } + } + + #[test] + fn system_locale_detection_maps_common_locale_envs() { + assert_eq!(Lang::parse("zh_CN.UTF-8"), Some(Lang::ZhCn)); + assert_eq!(Lang::parse("zh-Hant-TW"), Some(Lang::ZhTw)); + assert_eq!(Lang::parse("zh_TW"), Some(Lang::ZhTw)); + assert_eq!(Lang::parse("ja_JP.UTF-8"), Some(Lang::Ja)); + assert_eq!(Lang::parse("ko_KR"), Some(Lang::Ko)); + assert_eq!(Lang::parse("en_US"), Some(Lang::En)); + assert_eq!(Lang::parse("fr_FR"), None); + assert_eq!(Lang::parse(""), None); + } + + #[test] + fn system_preference_resolves_from_the_host_locale() { + for (variable, value, expected) in [ + ("LC_ALL", "zh_TW.UTF-8", Lang::ZhTw), + ("LC_MESSAGES", "ko_KR.UTF-8", Lang::Ko), + ("LANG", "ja_JP", Lang::Ja), + ] { + // LocalePref::System must route through env-based detection. + let prev = std::env::var(variable).ok(); + std::env::set_var(variable, value); + assert_eq!(LocalePref::System.resolve(), expected); + match prev { + Some(value) => std::env::set_var(variable, value), + None => std::env::remove_var(variable), + } + } + } + + #[test] + fn locale_preference_roundtrips_through_wire_tags() { + assert_eq!(LocalePref::System, LocalePref::from_tag("system")); + assert_eq!( + LocalePref::Lang(Lang::ZhTw), + LocalePref::from_tag(Lang::ZhTw.tag()) + ); + // Unknown tags degrade to System (follow OS), never to a wrong guess. + assert_eq!(LocalePref::from_tag("xx_YY"), LocalePref::System); + } + + #[test] + fn positional_placeholders_are_substituted_in_any_locale() { + assert_eq!( + fmt_catalog(Lang::ZhCn, "metric.near7", &[&3, &9]), + "近7天 3 段 · 近30天 9 段" + ); + assert_eq!( + fmt_catalog(Lang::En, "metric.near7", &[&3, &9]), + "3 in 7d · 9 in 30d" + ); + assert_eq!( + fmt_catalog(Lang::Ja, "metric.near7", &[&3, &9]), + "直近7日 3 件 · 30日 9 件" + ); + // Unknown keys fall back to the key text with no substitution. + assert_eq!(fmt_catalog(Lang::En, "k.unknown", &[&1]), "k.unknown"); + } +} diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 228339b2e..9923f617a 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -5,45 +5,122 @@ //! and semantic events between that backend and the UI. mod audio; +mod audio_cue; +mod audio_mute; +mod audio_player; mod backend; mod capabilities; mod coding_agent; mod credentials; +mod desktop; +mod dictation_feedback; mod fcitx5; mod host_actions; mod hotkeys; +mod i18n; +mod logging; mod marketplace; +mod popup; +mod popup_layer; +mod popup_window; mod qa; +mod recordings; mod remote_input; mod resources; mod runtime; mod selection; mod settings; mod single_instance; +mod tray; +mod ui_state; +mod updater; pub use audio::LinuxCpalRecorder; +pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; +pub use audio_mute::AudioMuteGuard; +pub use audio_player::ClipPlayer; pub use backend::{LinuxBackendBuilder, LinuxBackendRuntime}; pub use capabilities::{LinuxCapabilitySnapshot, LinuxDesktopSession, LinuxPlatformApi}; pub use credentials::LinuxCredentialStore; +pub use desktop::{ + atomic_save, notify, open_external, open_local_file, validate_save_path, AutostartManager, + DesktopError, Notification, +}; +pub use dictation_feedback::{ + capsule_hide_delay, capsule_hide_is_still_current, capsule_needs_fallback_dismissal, + capsule_outcome, is_backend_error_code, is_expected_stop_error, normalize_stop_result, + phase_shows_capsule, CapsuleOutcome, CAPSULE_AUTO_HIDE_DELAY_MS, +}; pub use fcitx5::{ available as fcitx5_available, commit_text as fcitx5_commit_text, - ensure_plugin_installed as ensure_fcitx5_plugin_installed, - selection_text as fcitx5_selection_text, set_hotkeys as set_fcitx5_hotkeys, + copy_to_clipboard as fcitx5_copy_to_clipboard, + ensure_plugin_installed as ensure_fcitx5_plugin_installed, reload_fcitx5_if_plugin_updated, + reload_running_fcitx5, selection_text as fcitx5_selection_text, + set_hotkeys as set_fcitx5_hotkeys, set_less_computer_hotkey_raw as set_fcitx5_less_computer_hotkey_raw, Fcitx5TextInserter, FcitxPluginInstallPlan, FcitxPluginStatus, }; pub use host_actions::LinuxHostActions; + +/// Apply one QA/selection-voice edit through the same fcitx5 selection target +/// the `SelectionApi` uses. `session_id` is the selection-voice session the host +/// registered via `rekey_selection_target`, i.e. the Core's apply ticket +/// `session_id`. Returns `Cancelled` when the selection changed meanwhile. +pub fn apply_selection_voice_target( + session_id: &str, + source: &str, + replacement: &str, +) -> Result<(), openless_core::BackendError> { + fcitx5::apply_selection_target(session_id, source, replacement) +} pub use hotkeys::{Fcitx5HotkeyListener, LinuxHotkeyEvent}; +pub use i18n::{fmt_catalog as fmt_l10n, tr_catalog as tr_l10n, Lang, LocalePref, LANGS}; +pub use logging::{export_error_log, init_file_logger, log_path}; +pub use popup::{ + force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, + ApplyOutcome as PopupApplyOutcome, CapsulePopupState, HostToPopup, LessComputerApproval, + LessComputerEntry, LessComputerPopupState, PopupActionGuard, PopupChatMessage, PopupKind, + PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, + PreviewPopupState, ProtocolError as PopupProtocolError, + ProtocolErrorKind as PopupProtocolErrorKind, QaPopupState, MAX_JSONL_LINE_BYTES, + POPUP_PROTOCOL_VERSION, +}; +pub use popup_layer::{ + capsule_geometry, capsule_path_override, choose_capsule_path, detect_capsule_path, + has_layer_shell, layer_shell_available, pointer_events, probe_layer_shell, run_layer_capsule, + CapsuleGeometry, CapsulePath, LayerFrame, CAPSULE_PATH_ENV, CONFIGURE_TIMEOUT, LAYER_NAMESPACE, + LAYER_SHELL_GLOBAL, MAX_FRAME_PAUSE, +}; +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +pub use popup_window::X11Overlay; +pub use popup_window::{ + bottom_center, clamp_to_area, monitor_containing, place_overlay, popup_position, popup_size, + select_overlay_window, x11_available, OverlayEnvironment, OverlayPlacement, OverlayX11, + WindowCandidate, WindowMatch, X11Rect, CAPSULE_BOTTOM_GAP, CAPSULE_WINDOW_SIZE, + LESS_COMPUTER_WINDOW_SIZE, PREVIEW_MIN_SIZE, PREVIEW_WINDOW_SIZE, QA_WINDOW_SIZE, +}; + +pub use recordings::{read_recording_wav, recording_path, recording_pcm, RecordingError}; pub use resources::{ LinuxPackageKind, LinuxResourceLayout, LinuxResourceResolver, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY, }; pub use runtime::{LinuxNativeRuntime, LinuxRuntimePumpResult}; pub use selection::LinuxSelectionRuntime; -pub use settings::{LinuxSettingsEffects, LinuxSettingsRuntime}; +pub use settings::{is_bare_modifier_binding, LinuxSettingsEffects, LinuxSettingsRuntime}; pub use single_instance::{ LinuxLaunchIntent, SingleInstanceBroker, SingleInstanceGuard, SingleInstanceRole, }; +pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; +pub use ui_state::{load_locale_pref, save_locale_pref, ui_state_dir, ui_state_path, UiStateError}; +pub use updater::{ + install_verified_appimage, install_verified_appimage_with_limit, manifest_urls, AppImageTarget, + AppImageUpdater, CheckReason, DownloadProgress, InstalledUpdate, LinuxUpdateSupport, + PinnedMinisignVerifier, SignatureVerifier, UnavailableSignatureVerifier, UpdateChannel, + UpdateError, UpdateManifest, UpdateSchedule, BETA_RELEASES_API, DEFAULT_MAX_APPIMAGE_BYTES, + DEFAULT_MAX_MANIFEST_BYTES, DIRECT_RELEASE_BASE, MANIFEST_HOST, MANIFEST_SCHEMA_VERSION, + PERIODIC_CHECK_INTERVAL, PINNED_MINISIGN_PUBLIC_KEY, RELEASES_URL, STARTUP_CHECK_DELAY, +}; pub use openless_core::contract::*; @@ -339,11 +416,15 @@ impl LinuxHost { .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined { press_id, at }) .await .map(Some), - LinuxHotkeyEvent::QaPressed => self - .backend - .dispatch_cli_intent(CliIntent::ToggleQa) - .await - .map(Some), + LinuxHotkeyEvent::QaPressed => { + // 用户报「选区助手快捷键打不开」时,这一行 + 宿主的 ShowQa/HideQa + // 日志能直接区分「键没到」和「到了但被隐藏」。 + log::info!("[hotkey] selection-ask hotkey pressed; toggling the QA panel"); + self.backend + .dispatch_cli_intent(CliIntent::ToggleQa) + .await + .map(Some) + } LinuxHotkeyEvent::SelectionPolishPressed => { let preferences = self.backend.get_preferences(); let style_pack = self @@ -367,6 +448,35 @@ impl LinuxHost { } Ok(None) } + LinuxHotkeyEvent::SwitchStylePressed => { + self.backend.activate_previous_style_pack()?; + Ok(None) + } + LinuxHotkeyEvent::OpenAppPressed => { + self.backend.request_host_action(HostAction::ShowMain)?; + self.backend.request_host_action(HostAction::FocusMain)?; + Ok(None) + } + LinuxHotkeyEvent::StylePackPressed { symbol, states } => { + let preferences = self.backend.get_preferences(); + let pack_id = preferences + .style_pack_hotkeys + .iter() + .find_map(|hotkey| { + crate::settings::shortcut_to_raw(&hotkey.binding) + .ok() + .filter(|raw| *raw == (symbol, states)) + .map(|_| hotkey.pack_id.clone()) + }) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::Cancelled, + "style-pack hotkey no longer matches current settings", + ) + })?; + self.backend.activate_style_pack(&pack_id)?; + Ok(None) + } } } diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs new file mode 100644 index 000000000..ed7418812 --- /dev/null +++ b/openless-all/app/linux-egui/src/logging.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; + +const ROTATE_LIMIT_BYTES: u64 = 5 * 1024 * 1024; + +pub fn log_path(data_dir: &Path) -> PathBuf { + data_dir.join("logs").join("openless.log") +} + +pub fn init_file_logger(data_dir: &Path) -> Result { + use simplelog::{ + ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, + WriteLogger, + }; + + let path = log_path(data_dir); + let parent = path.parent().ok_or("log path has no parent")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + rotate_if_needed(&path).map_err(|error| error.to_string())?; + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| error.to_string())?; + let config = ConfigBuilder::new().set_time_format_rfc3339().build(); + CombinedLogger::init(vec![ + TermLogger::new( + LevelFilter::Info, + config.clone(), + TerminalMode::Mixed, + ColorChoice::Auto, + ), + WriteLogger::new(LevelFilter::Info, config, file), + ]) + .map_err(|error| error.to_string())?; + log::info!("Linux egui file logger ready: {}", path.display()); + Ok(path) +} + +fn rotate_if_needed(path: &Path) -> std::io::Result<()> { + let Ok(metadata) = std::fs::metadata(path) else { + return Ok(()); + }; + if metadata.len() <= ROTATE_LIMIT_BYTES { + return Ok(()); + } + let archive = path.with_file_name("openless.log.1"); + match std::fs::remove_file(&archive) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(path, archive) +} + +pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate::DesktopError> { + let bytes = std::fs::read(source).map_err(|source| crate::DesktopError::Io { + operation: "read error log", + source, + })?; + crate::atomic_save(destination, &bytes).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exports_the_current_log_with_atomic_save() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-export-{}", + uuid::Uuid::new_v4().simple() + )); + let source = log_path(&root); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, b"diagnostic\n").unwrap(); + let destination = root.join("exported.log"); + + export_error_log(&source, &destination).unwrap(); + + assert_eq!(std::fs::read(destination).unwrap(), b"diagnostic\n"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index eee6f63c8..eed04d297 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1,39 +1,57 @@ -#[cfg(any(target_os = "linux", test))] -mod ui_state; - #[cfg(not(target_os = "linux"))] fn main() { eprintln!("openless-linux-egui is only available on Linux"); } +#[cfg(target_os = "linux")] +mod ui; + #[cfg(target_os = "linux")] mod linux_app { - use super::ui_state::{Navigation, Page}; use std::future::Future; use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; + use crate::ui::bridge::{ + self, HostToWindow, UiBridgeClient, UiBridgeHost, WindowToHost, UI_BRIDGE_VERSION, + }; + use crate::ui::frontend::{self, view_model::FrontendViewModel}; + use crate::ui::{shell, theme}; + use chrono::Datelike; use eframe::egui; use openless_core::{ BackendConfig, BackendError, BackendEvent, BackendEventKind, BackendSnapshot, - DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, LocalAsrModel, - LocalAsrRuntime, QaStateEvent, QaStateKind, SelectionPhase, SelectionSnapshot, - TranscriptAccumulator, UserPreferences, + DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, QaStateEvent, + QaStateKind, SelectionPhase, SelectionSnapshot, TranscriptAccumulator, UserPreferences, }; use openless_linux_egui::{ - drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, - FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, - LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, - LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole, + capsule_hide_delay, capsule_hide_is_still_current, capsule_needs_fallback_dismissal, + capsule_outcome, fmt_l10n, load_locale_pref, normalize_stop_result, phase_shows_capsule, + save_locale_pref, tr_l10n, CapsuleOutcome, Lang, LocalePref, + }; + use openless_linux_egui::{ + drain_events, ensure_fcitx5_plugin_installed, fcitx5_copy_to_clipboard, notify, + open_external, write_jsonl, EventDrainOutcome, Fcitx5HotkeyListener, + FcitxPluginInstallPlan, FcitxPluginStatus, HostToPopup, LinuxBackendBuilder, + LinuxCapabilitySnapshot, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, + LinuxResourceLayout, LinuxUpdateSupport, Notification, PopupActionGuard, PopupChatMessage, + PopupKind, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, + SingleInstanceBroker, SingleInstanceRole, UpdateManifest, UpdateSchedule, + POPUP_PROTOCOL_VERSION, }; enum UiResult { - Environment(LinuxCapabilitySnapshot), Message(String), - Models(Result, String>), + /// 终态在屏上停留结束:胶囊可以收起了(handler 会再核对会话与相位)。 + CapsuleDismissDue { + session_id: String, + }, Remote(Result<(openless_core::RemoteInputStatus, String), String>), Providers(Result), + /// Credential channels for the settings modal's AI-services tab. + SettingsChannels(Result, String>), + ServiceConfigured([bool; 2]), ProviderEditor { kind: openless_core::ChannelKind, channel_id: String, @@ -45,13 +63,19 @@ mod linux_app { result: Result, String>, }, ProviderMutation(Result), - } - - #[derive(Clone)] - enum ModelsState { - Loading, - Loaded(Vec), - Failed(String), + Library(Result), + SettingsSaved(Box>), + Marketplace(Result, String>), + MarketplaceLikes(Result, String>), + MarketplaceFlow(Result), + MarketplaceAuthPoll(Result), + MarketplaceDetail(Result), + MarketplaceMine(Result<(Vec, Vec), String>), + Microphones(Result, String>), + Overview(Result), + UpdateCheck(Result, String>), + UpdateProgress(openless_linux_egui::DownloadProgress), + UpdateInstalled(Result), } #[derive(Clone)] @@ -62,6 +86,232 @@ mod linux_app { active_provider: String, } + struct LibraryPanel { + vocabulary: Vec, + correction_rules: Vec, + style_packs: Vec, + vocab_preset_store: openless_core::VocabPresetStore, + vocab_presets: Vec, + } + + /// One credential channel cached for the settings modal. + #[derive(Clone, Debug)] + struct SettingsChannelRow { + id: String, + name: String, + provider_type: String, + model: String, + enabled: bool, + last_ok: Option, + last_latency_ms: Option, + last_error: Option, + } + + /// 追问编辑态:Core 只在变化时下发 `Some(..)`,所以逐字段合并。 + #[derive(Clone, Copy, Debug, Default)] + struct QaEditFlags { + instruction_mode: bool, + apply_available: bool, + revert_available: bool, + } + + /// 固定(图钉)后不再响应宿主的自动收起;✕/Esc 仍照常关闭。 + fn qa_hides_on_host_action(pinned: bool) -> bool { + !pinned + } + + impl QaEditFlags { + fn merge(&mut self, state: &openless_core::QaStateEvent) { + if let Some(value) = state.edit_instruction_mode { + self.instruction_mode = value; + } + if let Some(value) = state.edit_apply_available { + self.apply_available = value; + } + if let Some(value) = state.edit_revert_available { + self.revert_available = value; + } + } + } + + #[derive(Default, Clone, Copy)] + struct SettingsDirty { + streaming_insert: bool, + coding_agent_enabled: bool, + start_minimized: bool, + launch_at_login: bool, + auto_update_check: bool, + update_channel: bool, + remote_input_enabled: bool, + remote_input_port: bool, + recording: bool, + microphone: bool, + appearance: bool, + hotkeys: bool, + } + + impl SettingsDirty { + fn any(&self) -> bool { + self.streaming_insert + || self.coding_agent_enabled + || self.start_minimized + || self.launch_at_login + || self.auto_update_check + || self.update_channel + || self.remote_input_enabled + || self.remote_input_port + || self.recording + || self.microphone + || self.appearance + || self.hotkeys + } + + fn merge(&self, latest: &UserPreferences, draft: &UserPreferences) -> UserPreferences { + let mut merged = latest.clone(); + if self.streaming_insert { + merged.streaming_insert = draft.streaming_insert; + } + if self.coding_agent_enabled { + merged.coding_agent_enabled = draft.coding_agent_enabled; + } + if self.start_minimized { + merged.start_minimized = draft.start_minimized; + } + if self.launch_at_login { + merged.launch_at_login = draft.launch_at_login; + } + if self.auto_update_check { + merged.auto_update_check = draft.auto_update_check; + } + if self.update_channel { + merged.update_channel = draft.update_channel; + } + if self.remote_input_enabled { + merged.remote_input_enabled = draft.remote_input_enabled; + } + if self.remote_input_port { + merged.remote_input_port = draft.remote_input_port; + } + if self.recording { + merged.hotkey.mode = draft.hotkey.mode; + merged.silence_auto_stop_enabled = draft.silence_auto_stop_enabled; + merged.silence_auto_stop_seconds = draft.silence_auto_stop_seconds; + merged.mute_during_recording = draft.mute_during_recording; + merged.audio_cue_on_record = draft.audio_cue_on_record; + merged.record_audio_for_debug = draft.record_audio_for_debug; + merged.restore_clipboard_after_paste = draft.restore_clipboard_after_paste; + merged.paste_shortcut = draft.paste_shortcut; + merged.history_retention_days = draft.history_retention_days; + merged.history_max_entries = draft.history_max_entries; + merged.remote_input_default_mode = draft.remote_input_default_mode.clone(); + } + if self.microphone { + merged.microphone_device_name = draft.microphone_device_name.clone(); + } + if self.appearance { + merged.theme_mode = draft.theme_mode; + merged.show_overview_activity_heatmap = draft.show_overview_activity_heatmap; + merged.stacked_row_layout = draft.stacked_row_layout; + merged.conservative_layout = draft.conservative_layout; + merged.use_system_proxy = draft.use_system_proxy; + merged.multimodal_pipeline_enabled = draft.multimodal_pipeline_enabled; + merged.selection_voice_enabled = draft.selection_voice_enabled; + } + if self.hotkeys { + merged.dictation_hotkey = draft.dictation_hotkey.clone(); + merged.hotkey = draft.hotkey.clone(); + merged.qa_hotkey = draft.qa_hotkey.clone(); + merged.translation_hotkey = draft.translation_hotkey.clone(); + merged.switch_style_hotkey = draft.switch_style_hotkey.clone(); + merged.open_app_hotkey = draft.open_app_hotkey.clone(); + merged.selection_polish_hotkey = draft.selection_polish_hotkey.clone(); + merged.coding_agent_voice_hotkey = draft.coding_agent_voice_hotkey.clone(); + } + merged + } + } + + // ---- Native Overview summary (Tauri parity) ----------------------------- + // + // The Tauri Overview derives its dashboard from three real Core sources: + // * `CredentialsStatus` -> active ASR/LLM provider and its configured state + // * `HistoryStore` -> today's metrics, total count and recent entries + // * `ActivityStore` -> trailing-window aggregates + daily heatmap + // Fetching happens off the egui frame in a tokio task (`load_overview`); the + // pure helpers below only shape already-loaded data and are unit tested + // without a runtime, a backend or any UI. + + /// Raw snapshot fetched asynchronously from Core for the Overview tab. + #[derive(Clone, Debug)] + struct OverviewData { + credentials: openless_core::CredentialsStatus, + history: Vec, + activity: Vec, + } + + #[derive(Clone, Debug, Default)] + struct RecentEntry { + created_at: String, + final_text: String, + raw_transcript: String, + mode: openless_core::PolishMode, + duration_ms: Option, + } + + /// One calendar day of activity. Used both by the trailing daily series + /// behind the period chart and by the calendar-year heatmap. + #[derive(Clone, Debug, Default, PartialEq, Eq)] + struct DailyActivity { + /// `YYYY-MM-DD` local date. + date: String, + count: u32, + chars: u64, + duration_ms: u64, + } + + /// Activity aggregate over a trailing calendar window. Zero days that never + /// recorded activity are absent from the store, so a window may cover more + /// calendar days than `active_days`. + #[derive(Clone, Debug, Default, PartialEq, Eq)] + struct ActivityAggregate { + active_days: usize, + segments: u64, + chars: u64, + duration_ms: u64, + } + + /// Fully derived, display-ready Overview summary (computed purely, tested). + #[derive(Clone, Debug, Default)] + struct OverviewSummary { + asr_provider: String, + llm_provider: String, + asr_configured: bool, + llm_configured: bool, + chars_today: u64, + segments_today: usize, + duration_ms_today: u64, + avg_latency_ms: u64, + history_total: usize, + recent: Vec, + last_7: ActivityAggregate, + last_30: ActivityAggregate, + /// Last 30 calendar days ending today, chronological (oldest first). + /// The 7-day view slices the tail. + activity_daily: Vec, + /// Calendar year rendered by the annual heatmap card. + heatmap_year: i32, + /// Every day of `heatmap_year`, chronological. Days without activity + /// are present with `count == 0` so the page can lay out the grid. + heatmap: Vec, + } + + #[derive(Clone, Debug)] + enum OverviewState { + Loading, + Loaded(OverviewData), + Failed(String), + } + #[derive(Clone)] enum ProvidersState { Loading, @@ -69,6 +319,152 @@ mod linux_app { Failed(String), } + /// Trailing window (days) covered by the period chart's daily series. + const OVERVIEW_DAILY_DAYS: i64 = 30; + + impl OverviewState { + fn summary(&self, today: chrono::NaiveDate) -> Option { + match self { + OverviewState::Loaded(data) => Some(overview_summary(data, today)), + OverviewState::Loading | OverviewState::Failed(_) => None, + } + } + } + + /// RFC3339 history timestamp -> local calendar date. A value that cannot + /// be parsed simply yields `None` and contributes nothing to the summary. + fn history_local_date(created_at: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(created_at) + .ok() + .map(|instant| instant.with_timezone(&chrono::Local).date_naive()) + } + + /// Sum one activity window's segments/chars/duration over `[today-days+1, today]`. + fn aggregate_window( + by_date: &std::collections::BTreeMap, + today: chrono::NaiveDate, + days: i64, + ) -> ActivityAggregate { + let start = today - chrono::Duration::days(days - 1); + let mut aggregate = ActivityAggregate::default(); + for (_, day) in by_date.range(start..=today) { + aggregate.active_days += 1; + aggregate.segments += u64::from(day.count); + aggregate.chars += day.chars; + aggregate.duration_ms += day.duration_ms; + } + aggregate + } + + /// Build the trailing daily series ending at `today` (inclusive), oldest + /// first. Days absent from the store render as zero. + fn build_daily_series( + by_date: &std::collections::BTreeMap, + today: chrono::NaiveDate, + days: i64, + ) -> Vec { + let mut series = Vec::with_capacity(days as usize); + for offset in (0..days).rev() { + let date = today - chrono::Duration::days(offset); + series.push(daily_activity(by_date, date)); + } + series + } + + /// Build the full calendar-year heatmap for `year`: January 1st through + /// December 31st, chronological, inactive days included. + fn build_calendar_year_heatmap( + by_date: &std::collections::BTreeMap, + year: i32, + ) -> Vec { + let mut days = Vec::with_capacity(366); + let Some(mut date) = chrono::NaiveDate::from_ymd_opt(year, 1, 1) else { + return days; + }; + while date.year() == year { + days.push(daily_activity(by_date, date)); + date += chrono::Duration::days(1); + } + days + } + + fn daily_activity( + by_date: &std::collections::BTreeMap, + date: chrono::NaiveDate, + ) -> DailyActivity { + let day = by_date.get(&date); + DailyActivity { + date: date.format("%Y-%m-%d").to_string(), + count: day.map(|day| day.count).unwrap_or(0), + chars: day.map(|day| day.chars).unwrap_or(0), + duration_ms: day.map(|day| day.duration_ms).unwrap_or(0), + } + } + + /// Shape the fetched Core snapshot into the display summary. Pure and free of + /// any runtime/IO so it can be exercised by focused unit tests. + fn overview_summary(data: &OverviewData, today: chrono::NaiveDate) -> OverviewSummary { + let mut segments_today = 0usize; + let mut chars_today = 0u64; + let mut duration_ms_today = 0u64; + for session in &data.history { + if history_local_date(&session.created_at) == Some(today) { + segments_today += 1; + chars_today += session.final_text.chars().count() as u64; + duration_ms_today += session.duration_ms.unwrap_or(0); + } + } + let avg_latency_ms = if segments_today > 0 { + duration_ms_today / segments_today as u64 + } else { + 0 + }; + + // Newest five entries. `created_at` is RFC3339 in a constant UTC offset, + // so lexicographic ordering is a valid chronological ordering. + let mut recent: Vec = data + .history + .iter() + .map(|session| RecentEntry { + created_at: session.created_at.clone(), + final_text: session.final_text.clone(), + raw_transcript: session.raw_transcript.clone(), + mode: session.mode, + duration_ms: session.duration_ms, + }) + .collect(); + recent.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + recent.truncate(5); + + let mut by_date: std::collections::BTreeMap< + chrono::NaiveDate, + &openless_core::ActivityDay, + > = std::collections::BTreeMap::new(); + for day in &data.activity { + if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") { + by_date.insert(date, day); + } + } + + OverviewSummary { + asr_provider: data.credentials.active_asr_provider.clone(), + llm_provider: data.credentials.active_llm_provider.clone(), + asr_configured: data.credentials.asr_configured, + llm_configured: data.credentials.llm_configured, + chars_today, + segments_today, + duration_ms_today, + avg_latency_ms, + history_total: data.history.len(), + recent, + last_7: aggregate_window(&by_date, today, 7), + last_30: aggregate_window(&by_date, today, 30), + activity_daily: build_daily_series(&by_date, today, OVERVIEW_DAILY_DAYS), + heatmap_year: today.year(), + heatmap: build_calendar_year_heatmap(&by_date, today.year()), + } + } + #[derive(Clone)] struct ProviderEditor { kind: openless_core::ChannelKind, @@ -80,7 +476,6 @@ mod linux_app { volcengine_service: String, auth_mode: String, resource_id: String, - app_id: String, // Secret inputs are intentionally write-only. Loading an editor never // exposes an existing key into egui state, logs or screenshots. primary_secret: String, @@ -98,29 +493,95 @@ mod linux_app { Failed(String), } + /// Draft of the open provider editor. It is the single source of truth for + /// the editor fields: pages push their input back here as actions and the + /// mirror writes it out each frame, so re-reading the channel list never + /// clobbers what the user is typing. + struct ProviderEditorForm { + channel_id: String, + provider_type: String, + label: String, + auth: frontend::view_model::SettingsProviderAuth, + name: String, + endpoint: String, + model: String, + resource_id: String, + auth_mode: String, + // Write-only secret drafts: they start empty on every load and are + // cleared as soon as they have been handed to Core. + primary_secret: String, + secondary_secret: String, + models: Vec, + models_loading: bool, + } + + impl ProviderEditorForm { + fn from_editor(editor: &ProviderEditor, lang: Lang) -> Self { + Self { + channel_id: editor.channel.id.clone(), + provider_type: editor.descriptor.provider_type.as_str().to_string(), + label: localized_provider_label( + lang, + editor.kind, + editor.descriptor.provider_type.as_str(), + ), + auth: settings_provider_auth(editor.descriptor.auth_requirement), + name: editor.name.clone(), + endpoint: editor.endpoint.clone(), + model: editor.model.clone(), + resource_id: editor.resource_id.clone(), + auth_mode: if editor.auth_mode.is_empty() { + "app_id_token".to_string() + } else { + editor.auth_mode.clone() + }, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + } + } + } + pub struct OpenLessEguiApp { - navigation: Navigation, - environment: Option, - environment_refreshing: bool, - plugin_check: Option>, - less_computer_running: bool, - remote_error: Option, tokio: Arc, native: Option, subscription: Option, snapshot: Option, preferences: Option, - models: ModelsState, + settings_dirty: SettingsDirty, + settings_channel_kind: openless_core::ChannelKind, + settings_channels: Vec, + settings_channels_loading: bool, + /// 语言模型 / 语音识别是否各自有启用的渠道(AI 服务页的状态点)。 + service_configured: [bool; 2], + /// 文本型设置行(端口/条数/路径…)只在偏好刚载入或外部变更时回灌, + /// 否则每帧覆盖会把用户正在输入的内容弹回去(表现为「输入框用不了」)。 + hydrate_text_fields: bool, + overview: OverviewState, + microphones: Vec, transcript: String, transcript_state: TranscriptAccumulator, transcript_session: Option, + recording_phase_active: bool, last_event_sequence: u64, less_computer_input: String, less_computer_output: String, less_computer_turn_start: usize, less_computer_session: Option, + /// Less Computer 面板要呈现的事件序列。宿主是唯一所有者,弹窗进程只负责画; + /// 每次重连都收到完整序列,窗口进程重启不丢历史。 + less_computer_entries: Vec, + /// 本轮尚未终结(面板显示「执行中…」)。 + less_computer_working: bool, + /// 已展示过的面板是否还在(托起面板时只推状态,不重复拉起进程)。 + less_computer_popup: Option, pending_approval: Option<(String, String)>, qa_visible: bool, + /// 划词追问的图钉:固定后 `HostAction::HideQa` 不再收起窗口。 + qa_pinned: bool, + /// 追问「编辑指令」三态(Core `QaStateEvent` 的部分更新)。 + qa_edit: QaEditFlags, qa_input: String, qa_state: Option, selection_preview_visible: bool, @@ -131,12 +592,78 @@ mod linux_app { providers: ProvidersState, selected_channel_id: Option, provider_editor: ProviderEditorState, + /// Draft mirrored into the view model while the editor is open. + provider_editor_form: Option, provider_models: Vec, new_provider_type: String, new_channel_name: String, pending_channel_delete: Option, + vocabulary: Vec, + correction_rules: Vec, + style_packs: Vec, + vocabulary_phrase: String, + vocabulary_note: String, + correction_pattern: String, + correction_replacement: String, + vocab_preset_store: openless_core::VocabPresetStore, + vocab_presets: Vec, + vocab_preset_name: String, + vocab_preset_phrases: String, + history_search: String, + qa_popup: Option, + preview_popup: Option, + capsule_popup: Option, + popup_action_guard: PopupActionGuard, + /// 当前胶囊展示的会话 id(用于「会话消失但没收到终态」的兜底收起)。 + capsule_session: Option, + /// 已经为哪个会话排过收起计时,避免重复计时。 + capsule_dismissal_scheduled: Option, + tray: Option, + exit_requested: bool, + update_support: LinuxUpdateSupport, + update_schedule: UpdateSchedule, + update_started: std::time::Instant, + /// 上次打「泵心跳」日志的时间。 + last_pump_heartbeat: std::time::Instant, + /// 用户是否希望主窗口开着。窗口本体在独立的 UI 进程里,宿主只负责 + /// 拉起来、看着它退出、再按需重拉。 + window_should_be_open: bool, + /// 当前 UI 窗口进程;它退出后置空(窗口与任务栏条目随之消失)。 + ui_window: Option, + /// 上次拉起 UI 进程的时刻:防抖,连续点托盘菜单不会拉出两个窗口。 + ui_window_spawned_at: Option, + /// 上一次发给 UI 的快照指纹;内容没变就不重复发。 + last_snapshot_fingerprint: Option, + /// 上一次发快照的时间(长连接保活)。 + last_snapshot_at: std::time::Instant, + /// 本帧从 UI 收到、待宿主执行的动作(按到达顺序)。 + pending_ui_actions: Vec, + /// 本帧从 UI 收到、待回包的延迟探针序号。 + pending_ui_pongs: Vec, + update_manifest: Option, + update_busy: bool, + update_progress: Option, + /// Currently playing history recording (session id + player handle). + history_clip: Option<(String, openless_linux_egui::ClipPlayer)>, + marketplace_items: Vec, + /// True once a marketplace list request has completed (ok or error), so + /// the page can leave its loading state even when the result is empty. + marketplace_attempted: bool, + marketplace_query: String, + marketplace_flow: Option, + marketplace_detail: Option, + marketplace_my_packs: Vec, + marketplace_my_likes: Vec, + style_editor: Option, + style_hotkey_pack_id: String, + style_hotkey_primary: String, + style_hotkey_modifiers: String, status: String, startup_error: Option, + locale_pref: LocalePref, + lang: Lang, + active_page: shell::Page, + frontend_vm: FrontendViewModel, tx: mpsc::Sender, rx: mpsc::Receiver, } @@ -145,8 +672,19 @@ mod linux_app { fn new( tokio: Arc, native: Result, + tray: Option, + update_support: LinuxUpdateSupport, + window_should_be_open: bool, ) -> Self { let (tx, rx) = mpsc::channel(); + let locale_pref = load_locale_pref(); + let lang = locale_pref.resolve(); + if let Some(tray) = tray.as_ref() { + // The tray renders labels in the resolved UI language. It runs + // in its own worker, so push the resolved language through the + // same control channel that updates microphone checkmarks. + let _ = tray.set_lang(lang); + } match native { Ok(native) => { let backend = native.host().backend(); @@ -154,28 +692,35 @@ mod linux_app { let preferences = backend.get_preferences(); let subscription = backend.subscribe(); let app = Self { - navigation: Navigation::default(), - environment: None, - environment_refreshing: false, - plugin_check: None, - less_computer_running: false, - remote_error: None, tokio, native: Some(native), subscription: Some(subscription), snapshot: Some(snapshot), preferences: Some(preferences), - models: ModelsState::Loading, + settings_dirty: SettingsDirty::default(), + settings_channel_kind: openless_core::ChannelKind::Llm, + settings_channels: Vec::new(), + settings_channels_loading: false, + service_configured: [false; 2], + hydrate_text_fields: true, + overview: OverviewState::Loading, + microphones: Vec::new(), transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, + recording_phase_active: false, last_event_sequence: 0, less_computer_input: String::new(), less_computer_output: String::new(), less_computer_turn_start: 0, less_computer_session: None, + less_computer_entries: Vec::new(), + less_computer_working: false, + less_computer_popup: None, pending_approval: None, qa_visible: false, + qa_pinned: false, + qa_edit: QaEditFlags::default(), qa_input: String::new(), qa_state: None, selection_preview_visible: false, @@ -186,43 +731,103 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), new_channel_name: String::new(), pending_channel_delete: None, - status: "Core 2.0 已启动".to_string(), + vocabulary: Vec::new(), + correction_rules: Vec::new(), + style_packs: Vec::new(), + vocabulary_phrase: String::new(), + vocabulary_note: String::new(), + correction_pattern: String::new(), + correction_replacement: String::new(), + vocab_preset_store: openless_core::VocabPresetStore::default(), + vocab_presets: Vec::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + history_search: String::new(), + qa_popup: None, + preview_popup: None, + capsule_popup: None, + popup_action_guard: PopupActionGuard::default(), + capsule_session: None, + capsule_dismissal_scheduled: None, + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + last_pump_heartbeat: std::time::Instant::now(), + window_should_be_open, + ui_window: None, + ui_window_spawned_at: None, + last_snapshot_fingerprint: None, + last_snapshot_at: std::time::Instant::now(), + pending_ui_actions: Vec::new(), + pending_ui_pongs: Vec::new(), + update_manifest: None, + update_busy: false, + update_progress: None, + history_clip: None, + marketplace_items: Vec::new(), + marketplace_attempted: false, + marketplace_query: String::new(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_hotkey_pack_id: String::new(), + style_hotkey_primary: String::new(), + style_hotkey_modifiers: String::new(), + status: tr_l10n(lang, "status.core_started").to_string(), startup_error: None, + locale_pref, + lang, + active_page: shell::Page::Overview, + frontend_vm: FrontendViewModel::default(), tx, rx, }; - app.load_models(); app.load_remote_status(); app.load_providers(openless_core::ChannelKind::Asr); + app.load_library(); + app.load_microphones(); + app.load_overview(); app } Err(error) => Self { - navigation: Navigation::default(), - environment: None, - environment_refreshing: false, - plugin_check: None, - less_computer_running: false, - remote_error: None, tokio, native: None, subscription: None, snapshot: None, + hydrate_text_fields: true, preferences: None, - models: ModelsState::Loading, + settings_dirty: SettingsDirty::default(), + settings_channel_kind: openless_core::ChannelKind::Llm, + settings_channels: Vec::new(), + settings_channels_loading: false, + service_configured: [false; 2], + overview: OverviewState::Loading, + microphones: Vec::new(), transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, + recording_phase_active: false, last_event_sequence: 0, less_computer_input: String::new(), less_computer_output: String::new(), less_computer_turn_start: 0, less_computer_session: None, + less_computer_entries: Vec::new(), + less_computer_working: false, + less_computer_popup: None, pending_approval: None, qa_visible: false, + qa_pinned: false, + qa_edit: QaEditFlags::default(), qa_input: String::new(), qa_state: None, selection_preview_visible: false, @@ -233,12 +838,63 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), new_channel_name: String::new(), pending_channel_delete: None, - status: "启动失败".to_string(), + vocabulary: Vec::new(), + correction_rules: Vec::new(), + style_packs: Vec::new(), + vocabulary_phrase: String::new(), + vocabulary_note: String::new(), + correction_pattern: String::new(), + correction_replacement: String::new(), + vocab_preset_store: openless_core::VocabPresetStore::default(), + vocab_presets: Vec::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + history_search: String::new(), + qa_popup: None, + preview_popup: None, + capsule_popup: None, + popup_action_guard: PopupActionGuard::default(), + capsule_session: None, + capsule_dismissal_scheduled: None, + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + last_pump_heartbeat: std::time::Instant::now(), + window_should_be_open, + ui_window: None, + ui_window_spawned_at: None, + last_snapshot_fingerprint: None, + last_snapshot_at: std::time::Instant::now(), + pending_ui_actions: Vec::new(), + pending_ui_pongs: Vec::new(), + update_manifest: None, + update_busy: false, + update_progress: None, + history_clip: None, + marketplace_items: Vec::new(), + marketplace_attempted: false, + marketplace_query: String::new(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_hotkey_pack_id: String::new(), + style_hotkey_primary: String::new(), + style_hotkey_modifiers: String::new(), + status: tr_l10n(lang, "status.startup_failed").to_string(), startup_error: Some(error), + locale_pref, + lang, + active_page: shell::Page::Overview, + frontend_vm: FrontendViewModel::default(), tx, rx, }, @@ -251,352 +907,1487 @@ mod linux_app { .map(|native| Arc::clone(native.host().backend())) } - fn spawn(&self, future: F) - where - F: Future> + Send + 'static, - { - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let message = future.await.unwrap_or_else(|error| error.to_string()); - let _ = tx.send(UiResult::Message(message)); - }); + fn popup_slot(&mut self, kind: PopupKind) -> &mut Option { + match kind { + PopupKind::Qa => &mut self.qa_popup, + PopupKind::Preview => &mut self.preview_popup, + PopupKind::Capsule => &mut self.capsule_popup, + PopupKind::LessComputer => &mut self.less_computer_popup, + } } - fn load_models(&self) { - let Some(backend) = self.backend() else { + fn ensure_popup(&mut self, kind: PopupKind) { + let lang = self.lang; + if self.popup_slot(kind).is_some() { return; - }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let models = backend - .services() - .local_asr - .list_models(LocalAsrRuntime::Generic) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Models(models)); + } + match std::env::current_exe() { + Ok(executable) => { + self.popup_action_guard.reset(kind); + let supervisor = PopupSupervisor::spawn(self.tokio.handle(), executable, kind); + *self.popup_slot(kind) = Some(supervisor); + } + Err(error) => self.status = fmt_l10n(lang, "popup.start_failed", &[&error]), + } + } + + fn send_popup(&mut self, kind: PopupKind, message: HostToPopup) { + let lang = self.lang; + // 记录胶囊当前承载的会话:兜底收起要靠它判断「会话是否还在快照里」。 + if let HostToPopup::Capsule { session_id, .. } = &message { + self.capsule_session = Some(session_id.clone()); + } + let retry = message.clone(); + if let Some(supervisor) = self.popup_slot(kind) { + if let Err(error) = supervisor.try_send(message) { + self.status = fmt_l10n(lang, "popup.channel_rebuild", &[&format!("{error:?}")]); + *self.popup_slot(kind) = None; + self.ensure_popup(kind); + if let Some(supervisor) = self.popup_slot(kind) { + if let Err(retry_error) = supervisor.try_send(retry) { + self.status = fmt_l10n( + lang, + "popup.recover_failed", + &[&format!("{retry_error:?}")], + ); + } + } + } + } + } + + fn hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { + self.send_popup( + kind, + HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }, + ); + } + + /// Less Computer 面板的当前快照:宿主是事件序列的唯一所有者,弹窗进程 + /// 每次重连都收到完整序列(重开窗口不丢历史)。 + fn less_computer_snapshot(&self, lang: Lang) -> HostToPopup { + let approval = self.pending_approval.as_ref().map(|(token, command)| { + openless_linux_egui::LessComputerApproval { + token: token.clone(), + command: command.clone(), + reason: tr_l10n(lang, "less_computer.approval_rerun_warning").to_string(), + } }); + HostToPopup::LessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: self + .less_computer_session + .map(|session| session.to_string()) + .unwrap_or_else(|| "less-computer".to_string()), + sequence: self.last_event_sequence.saturating_mul(2), + entries: self.less_computer_entries.clone(), + working: self.less_computer_working, + approval, + error: None, + } } - fn load_remote_status(&self) { - let Some(backend) = self.backend() else { + fn show_less_computer_popup(&mut self) { + self.ensure_popup(PopupKind::LessComputer); + let message = self.less_computer_snapshot(self.lang); + self.send_popup(PopupKind::LessComputer, message); + } + + /// ✕ 只收起面板:不动已完成的对话,也不结束进程。 + fn hide_less_computer_popup(&mut self) { + let session_id = self + .less_computer_session + .map(|session| session.to_string()) + .unwrap_or_else(|| "less-computer".to_string()); + let sequence = self.last_event_sequence.saturating_mul(2).saturating_add(1); + self.hide_popup(PopupKind::LessComputer, session_id, sequence); + } + + fn expected_popup_session(&self, kind: PopupKind) -> Option { + match kind { + PopupKind::Qa => self + .qa_state + .as_ref() + .map(|state| state.session_id.clone().unwrap_or_else(|| "qa".to_string())), + PopupKind::Preview => self + .selection + .as_ref() + .and_then(|selection| selection.session_id) + .map(|session_id| session_id.to_string()), + PopupKind::Capsule => self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|session_id| session_id.to_string()), + PopupKind::LessComputer => self + .less_computer_session + .map(|session_id| session_id.to_string()), + } + } + + fn show_qa_popup(&mut self) { + self.ensure_popup(PopupKind::Qa); + let Some(state) = self.qa_state.clone() else { return; }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = async { - let status = backend.services().remote_input.status()?; - let pin = if status.enabled { - backend - .services() - .remote_input - .read_pairing_pin() - .await? - .into_exposed() - } else { - String::new() - }; - Ok::<_, BackendError>((status, pin)) + self.send_popup( + PopupKind::Qa, + HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id: state.session_id.unwrap_or_else(|| "qa".to_string()), + sequence: self.last_event_sequence.saturating_mul(2), + phase: format!("{:?}", state.kind), + messages: state + .messages + .unwrap_or_default() + .into_iter() + .map(|message| PopupChatMessage { + role: message.role, + content: message.content, + selection_text: message.selection_text, + }) + .collect(), + selection_preview: state.selection_preview, + streaming_answer: state.chunk.unwrap_or_default(), + error: state.error, + edit_instruction_mode: self.qa_edit.instruction_mode, + edit_apply_available: self.qa_edit.apply_available, + edit_revert_available: self.qa_edit.revert_available, + pinned: self.qa_pinned, + viewer_login: self.marketplace_login(), + }, + ); + } + + /// 当前 GitHub 登录名(设置里登录后写入偏好),用于追问头像。 + fn marketplace_login(&self) -> String { + self.preferences + .as_ref() + .map(|prefs| prefs.marketplace_dev_login.trim().to_string()) + .unwrap_or_default() + } + + /// 「预览并确认插入」:沿用 Tauri `confirm_selection_voice_preview` 的 + /// 四步(取 owner → 取预览文本 → 开 apply ticket → 原生落字 → finish), + /// Linux 的原生落字走 fcitx5 选区替换。 + fn spawn_qa_edit_apply( + &self, + backend: std::sync::Arc, + qa_session: openless_core::SessionId, + ) { + let lang = self.lang; + self.spawn(async move { + let unavailable = || { + BackendError::new( + openless_core::BackendErrorCode::InvalidState, + "qa edit unavailable", + ) + }; + let services = backend.services(); + let snapshot = services.qa.snapshot().await?; + let owner = snapshot.conversation_id.ok_or_else(unavailable)?; + let preview = services + .selection_voice + .preview(Some(owner)) + .await? + .ok_or_else(unavailable)?; + let text = preview.text.trim().to_string(); + if text.is_empty() { + return Err(unavailable()); } - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Remote(result)); + let ticket = services + .qa + .begin_edit_preview_apply(qa_session, text) + .await?; + let outcome = match openless_linux_egui::apply_selection_voice_target( + &ticket.session_id.to_string(), + &ticket.source_text, + &ticket.replacement_text, + ) { + Ok(()) => openless_core::SelectionVoiceApplyOutcome::Inserted, + Err(_) => openless_core::SelectionVoiceApplyOutcome::Failed, + }; + let _ = services + .selection_voice + .finish_preview_apply(ticket.ticket_id, outcome) + .await; + if outcome.may_have_applied() { + // 只剩「这一轮已经落字」的收尾:结束后再允许新一轮。 + let _ = services.qa.dismiss_session(qa_session).await; + } + Ok(tr_l10n(lang, "selection.replaced").to_string()) }); } - fn load_providers(&self, kind: openless_core::ChannelKind) { - let Some(backend) = self.backend() else { + fn show_selection_popup(&mut self) { + self.ensure_popup(PopupKind::Preview); + let Some(selection) = self.selection.clone() else { return; }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = async { - let provider_kind = provider_kind(kind); - let mut channels = backend.list_channels(kind).await?; - channels.sort_by_key(|channel| channel.order); - Ok::<_, BackendError>(ProviderPanel { - kind, - descriptors: openless_core::provider_rules::provider_descriptors( - provider_kind, - ), - channels, - active_provider: backend.active_provider(provider_slot(kind)).await?, - }) + let Some(session_id) = selection.session_id else { + return; + }; + self.send_popup( + PopupKind::Preview, + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: self.last_event_sequence.saturating_mul(2), + text: selection.preview_text.unwrap_or_default(), + source: selection.source_text.unwrap_or_default(), + }, + ); + } + + fn show_capsule_popup(&mut self) { + self.ensure_popup(PopupKind::Capsule); + let Some(snapshot) = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.clone()) + else { + return; + }; + let Some(session_id) = snapshot.session_id else { + return; + }; + // 终态文案:Core 在失败时只给错误码名(`InvalidArgument`),成功时可能给 + // 内部状态词(`inserted`),都不能直接显示;分类规则在 dictation_feedback。 + let lang = self.lang; + let text = match capsule_outcome(snapshot.phase, snapshot.message.as_deref()) { + CapsuleOutcome::Inserted => { + frontend::popups::inserted_message(lang, self.transcript.chars().count()) } - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Providers(result)); - }); + CapsuleOutcome::Cancelled => tr_l10n(lang, "capsule.cancelled").to_string(), + CapsuleOutcome::Failed => tr_l10n(lang, "capsule.error").to_string(), + CapsuleOutcome::Progress(text) => text, + }; + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: self.last_event_sequence.saturating_mul(2), + phase: format!("{:?}", snapshot.phase), + text, + audio_level: Some(snapshot.level), + translation_active: snapshot.translation_active, + }, + ); + self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); } - fn load_provider_editor( - &self, - kind: openless_core::ChannelKind, - channel: openless_core::ChannelSummary, - descriptor: openless_core::ProviderDescriptor, - ) { - let Some(backend) = self.backend() else { + /// 终态后按 Tauri Host 的时序自动收起胶囊:成功/失败停留 2 秒、 + /// 取消立刻;进行中的相位不收。 + fn schedule_capsule_dismissal(&mut self, session_id: &str, phase: DictationPhase) { + // 诊断链路用(低噪声:一次听写一条):这条日志缺失 = 终态事件没到宿主。 + let Some(delay) = capsule_hide_delay(phase) else { + log::debug!("capsule: no dismissal for session {session_id} in {phase:?}"); + // 会话又回到进行中相位:旧计时作废。 + self.capsule_dismissal_scheduled = None; return; }; + self.capsule_dismissal_scheduled = Some(session_id.to_string()); + log::info!( + "capsule: dismissal scheduled in {}ms for session {session_id} ({phase:?})", + delay.as_millis() + ); + let session_id = session_id.to_string(); let tx = self.tx.clone(); - let channel_id = channel.id.clone(); self.tokio.spawn(async move { - let result = load_provider_editor(backend, kind, channel, descriptor) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderEditor { - kind, - channel_id, - result: Box::new(result), - }); + tokio::time::sleep(delay).await; + let _ = tx.send(UiResult::CapsuleDismissDue { session_id }); }); } - fn spawn_provider_mutation(&self, future: F) - where - F: Future> + Send + 'static, - { - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let _ = tx.send(UiResult::ProviderMutation( - future.await.map_err(|error| error.to_string()), - )); - }); - } - - fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { - let Some(backend) = self.backend() else { - return; - }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = backend - .services() - .provider - .list_models(openless_core::ProviderRequest { - thinking_enabled: backend.get_preferences().llm_thinking_enabled, - kind: provider_kind(kind), - channel_id: Some(channel_id.clone()), - }) - .await - .map(|models| models.models) - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderModels { - kind, - channel_id, - result, - }); - }); + /// 收起胶囊。三条窗口路径里只有 eframe 的两条支持「隐藏但保留进程」, + /// layer surface 没有隐藏语义(只能销毁表面),所以统一结束弹窗进程: + /// 下一次录音会在按热键那一刻按需重新拉起,用户看不到延迟。 + fn dismiss_capsule(&mut self) { + let had_process = self.popup_slot(PopupKind::Capsule).is_some(); + if let Some(supervisor) = self.popup_slot(PopupKind::Capsule).as_ref() { + let _ = supervisor.request_shutdown(); + } + *self.popup_slot(PopupKind::Capsule) = None; + self.capsule_session = None; + self.capsule_dismissal_scheduled = None; + // 这条日志缺失 = 收起决定没走到「结束弹窗进程」这一环。 + log::info!("capsule: dismissal applied (popup process was running: {had_process})"); } - fn apply_event(&mut self, event: BackendEvent) { - if event.sequence <= self.last_event_sequence { - return; - } - self.last_event_sequence = event.sequence; - let session_id = event.session_id; - match event.kind { - BackendEventKind::DictationStateChanged(state) => { - self.navigation.notify(Page::Dictation); - if state.phase == DictationPhase::Starting { - self.transcript_state = TranscriptAccumulator::default(); - self.transcript.clear(); - self.transcript_session = state.session_id; + fn poll_popup_supervisors(&mut self) { + let lang = self.lang; + let mut events = Vec::new(); + for kind in [PopupKind::Qa, PopupKind::Preview, PopupKind::Capsule] { + if let Some(supervisor) = self.popup_slot(kind) { + while let Ok(event) = supervisor.try_recv() { + events.push((kind, event)); } - self.status = format!("听写:{:?}", state.phase); } - BackendEventKind::TranscriptDelta(delta) - if session_id == self.transcript_session => - { - if self.transcript_state.apply(&delta).is_ok() { - self.transcript = self.transcript_state.text().to_string(); + } + for (kind, event) in events { + if let PopupSupervisorEvent::Message(message) = &event { + let Some(expected_session) = self.expected_popup_session(kind) else { + self.status = tr_l10n(lang, "popup.ignore_no_session").to_string(); + continue; + }; + if !self + .popup_action_guard + .accept(kind, message, &expected_session) + { + self.status = tr_l10n(lang, "popup.ignore_stale").to_string(); + continue; } } - BackendEventKind::PolishDelta(delta) if delta.is_final => { - self.transcript = delta.text; - } - BackendEventKind::DictationCompleted(result) => { - self.navigation.notify(Page::Dictation); - self.transcript = result.polished_text; - self.status = format!("听写完成:{:?}", result.inserted); - } - BackendEventKind::RecordingControlRequested(request) => { - if let Some(backend) = self.backend() { - self.spawn(async move { - match request.action { - openless_core::RecordingControlAction::Stop => { - backend.stop_dictation_session(request.session_id).await?; - } - openless_core::RecordingControlAction::Cancel => { - backend.cancel_dictation(Some(request.session_id)).await?; - } - } - Ok("录音已自动结束".to_string()) - }); + match event { + PopupSupervisorEvent::Message(PopupToHost::SubmitQa { + session_id, + text, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.submit_text(text).await?; + Ok(tr_l10n(lang, "qa.submitted").to_string()) + }); + } } - } - BackendEventKind::LessComputerEvent(event) => { - // Voice capture has its own session, preceding a chat User - // turn. Keep a navigation notice without assigning it to - // the current chat or inventing microphone readiness. - if matches!(&event.kind, LessComputerEventKind::VoiceState { .. }) { - self.navigation.notify(Page::Agent); - return; + PopupSupervisorEvent::Message(PopupToHost::ToggleQaRecording { + session_id, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.toggle_recording().await?; + Ok(tr_l10n(lang, "qa.recording_updated").to_string()) + }); + } } - // Less Computer events may complete after a newer turn has - // already started. Session ownership, not arrival time, - // decides whether a delta/terminal may mutate this view. - if let LessComputerEventKind::User { text, fresh } = &event.kind { - // Every User starts a new turn UUID, including a - // continuation. `fresh` describes conversation history, - // never whether this turn is allowed to receive output. - self.less_computer_session = session_id; - self.less_computer_running = true; - self.pending_approval = None; - if *fresh { - self.less_computer_output.clear(); - } else if !self.less_computer_output.is_empty() { - self.less_computer_output.push_str("\n\n"); + PopupSupervisorEvent::Message(PopupToHost::DismissQa { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok(tr_l10n(lang, "qa.closed").to_string()) + }); } - self.less_computer_turn_start = self.less_computer_output.len(); - self.less_computer_input = text.clone(); - } else if session_id != self.less_computer_session { - return; } - self.navigation.notify(Page::Agent); - match event.kind { - // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 - LessComputerEventKind::VoiceState { .. } => {} - LessComputerEventKind::User { .. } => {} - LessComputerEventKind::Started => { - self.less_computer_running = true; - self.status = "Less Computer 正在运行".to_string(); + PopupSupervisorEvent::Message(PopupToHost::SetPinned { + session_id, + pinned, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + self.qa_pinned = pinned; + self.show_qa_popup(); + } + PopupSupervisorEvent::Message(PopupToHost::SetEditInstructionMode { + session_id, + enabled, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .qa + .set_edit_instruction_mode(enabled) + .await?; + Ok(String::new()) + }); } - LessComputerEventKind::Delta { text } => { - self.less_computer_output.push_str(&text); + } + PopupSupervisorEvent::Message(PopupToHost::RevertEdit { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend + .services() + .qa + .revert_edit_preview(qa_session) + .await?; + Ok(tr_l10n(lang, "selection.reverted").to_string()) + }); + } } - LessComputerEventKind::Tool { name } => { - self.status = format!("Less Computer 正在使用工具:{name}"); + } + PopupSupervisorEvent::Message(PopupToHost::ApplyEdit { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); + if let Some(backend) = self.backend() { + self.spawn_qa_edit_apply(backend, qa_session); + } } - LessComputerEventKind::Compaction => { - self.status = "Less Computer 已压缩上下文".to_string(); + } + PopupSupervisorEvent::Message(PopupToHost::ConfirmPreview { + session_id, + text, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + let session_id = openless_core::SessionId::from_uuid(session_id); + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .selection + .confirm(session_id, Some(text)) + .await?; + Ok(tr_l10n(lang, "selection.replaced").to_string()) + }); + } } - LessComputerEventKind::Completed { text, .. } => { - self.less_computer_running = false; - // A terminal is authoritative even for final-only - // providers or after a missed partial event. - self.less_computer_output - .truncate(self.less_computer_turn_start); - self.less_computer_output.push_str(&text); - self.pending_approval = None; - self.status = "Less Computer 已完成".to_string(); + Err(error) => { + self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } - LessComputerEventKind::Approval { token, command, .. } => { - self.pending_approval = Some((token, command)); - self.status = "Less Computer 等待审批".to_string(); + }, + PopupSupervisorEvent::Message(PopupToHost::CancelPreview { + session_id, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + let session_id = openless_core::SessionId::from_uuid(session_id); + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .selection + .cancel(Some(session_id)) + .await?; + Ok(tr_l10n(lang, "selection.cancelled").to_string()) + }); + } } - LessComputerEventKind::Error { message } => { - self.less_computer_running = false; - self.pending_approval = None; - self.status = message; + Err(error) => { + self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } - LessComputerEventKind::Cancelled => { - self.less_computer_running = false; - self.pending_approval = None; - self.status = "Less Computer 已取消".to_string(); + }, + PopupSupervisorEvent::Message(PopupToHost::Ready { .. }) => match kind { + PopupKind::Qa => self.show_qa_popup(), + PopupKind::Preview => self.show_selection_popup(), + PopupKind::Capsule => self.show_capsule_popup(), + PopupKind::LessComputer => self.show_less_computer_popup(), + }, + PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { + if let Some(snapshot) = self.snapshot.as_mut() { + snapshot.dictation.message = None; } } - } - BackendEventKind::LocalAsrDownloadProgress(progress) => { - self.navigation.notify(Page::Models); - self.status = format!( - "模型 {}:{:?} {}/{}", - progress.model_id, - progress.phase, - progress.bytes_downloaded, - progress.bytes_total - ); - if matches!( - progress.phase, - openless_core::LocalAsrDownloadPhase::Finished - | openless_core::LocalAsrDownloadPhase::Failed - | openless_core::LocalAsrDownloadPhase::Cancelled - ) { - self.models = ModelsState::Loading; - self.load_models(); - } - } - BackendEventKind::PreferencesChanged(_) => { - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); + PopupSupervisorEvent::Message(PopupToHost::CancelDictation { .. }) => { + // 胶囊 ✕:放弃这次听写。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { + self.spawn(async move { + // 连点两次 ✕、会话已收尾之类的错误是预期内的, + // 归一掉,不要再弹成失败。 + normalize_stop_result( + backend.cancel_dictation(Some(session)).await, + )?; + Ok(String::new()) + }); + } } - self.load_remote_status(); - } - BackendEventKind::QaState(state) => { - if state.kind == QaStateKind::AnswerDelta { - if let Some(current) = self - .qa_state - .as_mut() - .filter(|current| current.session_id == state.session_id) - { - self.navigation.notify(Page::Qa); - // Core deltas deliberately omit messages. Preserve - // the conversation and append only this turn's text; - // the following Answer replaces it with Core history. - current.kind = state.kind; - current - .chunk - .get_or_insert_with(String::new) - .push_str(state.chunk.as_deref().unwrap_or_default()); + PopupSupervisorEvent::Message(PopupToHost::StopDictation { .. }) => { + // 胶囊 ✓:结束录音并落字。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { + self.spawn(async move { + // 没说话(空音频 → InvalidArgument)也是预期内的终态: + // 胶囊会显示本地化文案并自动收起,这里不再报错误。 + normalize_stop_result( + backend.stop_dictation_session(session).await, + )?; + Ok(String::new()) + }); } - } else if matches!( - state.kind, - QaStateKind::Idle - | QaStateKind::Loading - | QaStateKind::Thinking - | QaStateKind::Recording - ) || self - .qa_state - .as_ref() - .is_none_or(|current| current.session_id == state.session_id) + } + PopupSupervisorEvent::Message(PopupToHost::SubmitLessComputer { + session_id, + text, + .. + }) if self + .less_computer_session + .map(|session| session.to_string()) + .as_deref() + == Some(session_id.as_str()) => { - self.navigation.notify(Page::Qa); - self.qa_state = Some(state); + if let Some(backend) = self.backend() { + // Core 自己解析 provider / 模型 / 权限 / workdir; + // 宿主只负责把用户文本交给它(Tauri `lessComputerSubmitText`)。 + self.spawn(async move { + backend.submit_less_computer(text).await?; + Ok(String::new()) + }); + } } - } - BackendEventKind::SelectionStateChanged(snapshot) => { - self.navigation.notify(Page::Selection); - if snapshot.phase == SelectionPhase::Preview { - self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); - self.selection_preview_visible = true; + PopupSupervisorEvent::Message(PopupToHost::ApproveLessComputer { + token, + approved, + .. + }) => { + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend + .services() + .less_computer + .approve(token, approved) + .await?; + } + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::CancelLessComputer { .. }) => { + let session = self.less_computer_session; + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend.cancel_less_computer(session).await?; + } + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::DismissLessComputer { .. }) => { + // 只收起面板:已完成的一轮保留在宿主状态里,下次打开仍在。 + self.hide_less_computer_popup(); + } + PopupSupervisorEvent::Message( + PopupToHost::SubmitQa { .. } + | PopupToHost::ToggleQaRecording { .. } + | PopupToHost::DismissQa { .. } + | PopupToHost::SetPinned { .. } + | PopupToHost::SetEditInstructionMode { .. } + | PopupToHost::ApplyEdit { .. } + | PopupToHost::RevertEdit { .. } + | PopupToHost::SubmitLessComputer { .. }, + ) => { + self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); + } + PopupSupervisorEvent::ProtocolError(error) => { + self.status = fmt_l10n(lang, "popup.protocol_error", &[&error]); + } + PopupSupervisorEvent::SpawnFailed(error) => { + self.status = fmt_l10n(lang, "popup.spawn_failed", &[&error]); + *self.popup_slot(kind) = None; + } + PopupSupervisorEvent::Exited { code, crashed } => { + if crashed { + self.status = fmt_l10n(lang, "popup.exited", &[&format!("{code:?}")]); + } + *self.popup_slot(kind) = None; + if crashed { + match kind { + PopupKind::Qa if self.qa_visible => self.show_qa_popup(), + PopupKind::Preview if self.selection_preview_visible => { + self.show_selection_popup(); + } + PopupKind::Capsule + if self.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.dictation.phase != DictationPhase::Idle + }) => + { + self.show_capsule_popup(); + } + _ => {} + } + } } - self.selection = Some(snapshot); - } - BackendEventKind::RemoteInputStatusChanged(_) - | BackendEventKind::RemoteInputFailed(_) => { - self.navigation.notify(Page::Remote); - self.load_remote_status(); } - _ => {} } } - fn poll(&mut self, ctx: &egui::Context) { - if let Some(native) = &self.native { + fn spawn(&self, future: F) + where + F: Future> + Send + 'static, + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let message = future.await.unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(message)); + }); + } + + fn load_remote_status(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let status = backend.services().remote_input.status()?; + let pin = if status.enabled { + backend + .services() + .remote_input + .read_pairing_pin() + .await? + .into_exposed() + } else { + String::new() + }; + Ok::<_, BackendError>((status, pin)) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Remote(result)); + }); + } + + fn load_providers(&self, kind: openless_core::ChannelKind) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let provider_kind = provider_kind(kind); + let mut channels = backend.list_channels(kind).await?; + channels.sort_by_key(|channel| channel.order); + Ok::<_, BackendError>(ProviderPanel { + kind, + descriptors: openless_core::provider_rules::provider_descriptors( + provider_kind, + ), + channels, + active_provider: backend.active_provider(provider_slot(kind)).await?, + }) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Providers(result)); + }); + } + + fn load_library(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = (|| { + let preferences = backend.get_preferences(); + let vocab_preset_store = backend.list_vocabulary_presets()?; + let vocab_presets = openless_core::resolve_vocab_presets(&vocab_preset_store); + Ok::<_, BackendError>(LibraryPanel { + vocabulary: backend.list_vocabulary()?, + correction_rules: backend.list_correction_rules()?, + style_packs: backend.list_style_packs(&preferences.active_style_pack_id)?, + vocab_preset_store, + vocab_presets, + }) + })() + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Library(result)); + }); + } + + /// Refresh the required-service dots on the AI-services tabs. + fn load_service_configured(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let mut configured = [false; 2]; + for (index, kind) in [ + (0usize, openless_core::ChannelKind::Llm), + (1usize, openless_core::ChannelKind::Asr), + ] { + if let Ok(channels) = backend.list_channels(kind).await { + configured[index] = channels.iter().any(|channel| channel.enabled); + } + } + let _ = tx.send(UiResult::ServiceConfigured(configured)); + }); + } + + /// Load the credential channels for the settings modal's AI-services tab. + fn load_settings_channels(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + let kind = self.settings_channel_kind; + self.settings_channels_loading = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let channels = backend.list_channels(kind).await?; + let account = model_account(kind).to_string(); + let mut rows = Vec::with_capacity(channels.len()); + for channel in channels { + let model = read_provider_value(&backend, kind, &channel.id, &account) + .await? + .unwrap_or_default(); + rows.push(SettingsChannelRow { + id: channel.id.clone(), + name: channel.name.clone(), + provider_type: channel.provider_type.clone(), + model, + enabled: channel.enabled, + last_ok: channel.last_test.as_ref().map(|test| test.ok), + last_latency_ms: channel + .last_test + .as_ref() + .and_then(|test| test.latency_ms), + last_error: channel + .last_test + .as_ref() + .and_then(|test| test.error.clone()), + }); + } + Ok::<_, BackendError>(rows) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::SettingsChannels(result)); + }); + } + + fn load_marketplace(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + self.marketplace_attempted = false; + let query = self.marketplace_query.trim().to_string(); + // The backend only ranks by popular/new; 「我赞过的」 is a filter over + // the signed-in user's like list, exactly like the Tauri page. + let sort = match self.frontend_vm.marketplace_sort { + frontend::view_model::MarketplaceSort::Popular + | frontend::view_model::MarketplaceSort::Liked => "popular", + frontend::view_model::MarketplaceSort::New => "new", + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let likes = backend + .services() + .marketplace + .my_likes() + .await + .map_err(|error| error.to_string()); + let result = backend + .services() + .marketplace + .list(openless_core::MarketplaceQuery { + query: (!query.is_empty()).then_some(query), + sort: Some(sort.to_string()), + limit: Some(100), + }) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceLikes(likes)); + let _ = tx.send(UiResult::Marketplace(result)); + }); + } + + fn load_marketplace_mine(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let packs = backend.services().marketplace.my_packs().await?; + let likes = backend.services().marketplace.my_likes().await?; + Ok::<_, BackendError>((packs, likes)) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceMine(result)); + }); + } + + fn load_microphones(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .platform + .microphone_devices() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Microphones(result)); + }); + } + + /// Load the real Core-backed Overview data off the egui frame. The only + /// blocking reads (`list_history`, `list_activity`) are pushed to a + /// blocking task so an egui frame never waits on disk/repository IO. + fn load_overview(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let credentials = backend.get_credentials_status().await?; + let history_backend = Arc::clone(&backend); + let activity_backend = Arc::clone(&backend); + let history = + tokio::task::spawn_blocking(move || history_backend.list_history()) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + let activity = + tokio::task::spawn_blocking(move || activity_backend.list_activity()) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok::<_, BackendError>(OverviewData { + credentials, + history, + activity, + }) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Overview(result)); + }); + } + + fn request_update_check(&mut self, channel: openless_core::shared_types::UpdateChannel) { + let lang = self.lang; + let LinuxUpdateSupport::AppImage(updater) = self.update_support.clone() else { + self.status = tr_l10n(lang, "update.system_managed").to_string(); + return; + }; + if self.update_busy { + return; + } + self.update_busy = true; + self.update_progress = None; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = updater + .check(channel) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::UpdateCheck(result)); + }); + } + + fn drain_tray(&mut self, _ctx: &egui::Context) { + let lang = self.lang; + let mut commands = Vec::new(); + if let Some(tray) = &self.tray { + tray.drain(|command| commands.push(command)); + if let Some(error) = tray.take_error() { + self.status = fmt_l10n(lang, "status.tray_stopped", &[&error]); + self.tray = None; + } + } + for command in commands { + match command { + openless_linux_egui::TrayCommand::ShowMain => { + // 托盘是用户的显式动作:拉起窗口进程(已有窗口时由它自己抬起)。 + self.request_main_window(); + } + openless_linux_egui::TrayCommand::ActivatePreviousStyle => { + if let Some(backend) = self.backend() { + self.spawn(async move { + let pack = backend.activate_previous_style_pack()?; + Ok(match pack { + Some(pack) => { + fmt_l10n(lang, "status.style_switched", &[&pack.name]) + } + None => tr_l10n(lang, "status.no_previous_style").to_string(), + }) + }); + } + } + openless_linux_egui::TrayCommand::SelectMicrophone(name) => { + if let Some(backend) = self.backend() { + let selected = if name.is_empty() { + tr_l10n(lang, "settings.system_default").to_string() + } else { + name.clone() + }; + self.spawn(async move { + backend.select_microphone_device(name)?; + Ok(fmt_l10n(lang, "status.mic_selected", &[&selected])) + }); + } + } + openless_linux_egui::TrayCommand::Quit => { + // 宿主退出前会给 UI 发 Shutdown(见 `run_host` 收尾)。 + self.exit_requested = true; + } + } + } + } + + /// Payload Core needs for the current draft. Returns `None` while no + /// channel editor is loaded, so a stale frame cannot rename or re-\n /// credential the wrong channel. + fn editor_from_form(&self) -> Option { + let form = self.provider_editor_form.as_ref()?; + let ProviderEditorState::Loaded(loaded) = &self.provider_editor else { + return None; + }; + if loaded.channel.id != form.channel_id { + return None; + } + let mut editor = (**loaded).clone(); + editor.name = form.name.clone(); + editor.endpoint = form.endpoint.clone(); + editor.model = form.model.clone(); + editor.resource_id = form.resource_id.clone(); + editor.auth_mode = form.auth_mode.clone(); + editor.primary_secret = form.primary_secret.clone(); + editor.secondary_secret = form.secondary_secret.clone(); + Some(editor) + } + + /// Open a channel's provider editor. The descriptor comes from Core's + /// provider rules, so the UI never invents a field shape; without one + /// the editor stays closed instead of guessing. + fn open_provider_editor(&mut self, index: usize) { + let Some(channel_id) = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()) + else { + return; + }; + let panel = match &self.providers { + ProvidersState::Loaded(panel) => panel.clone(), + _ => return, + }; + let Some((channel, descriptor)) = provider_channel_descriptor(&panel, &channel_id) + else { + return; + }; + let kind = panel.kind; + self.selected_channel_id = Some(channel_id.clone()); + self.provider_editor = ProviderEditorState::Loading { kind, channel_id }; + self.provider_editor_form = None; + self.load_provider_editor(kind, channel, descriptor); + } + + fn close_provider_editor(&mut self) { + self.provider_editor = ProviderEditorState::Idle; + self.provider_editor_form = None; + self.frontend_vm.provider_editor = None; + } + + /// Core owns the model catalog; the host only forwards the request. + fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .provider + .list_models(openless_core::ProviderRequest { + kind: provider_kind(kind), + thinking_enabled: false, + channel_id: Some(channel_id.clone()), + }) + .await + .map(|models| models.models) + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderModels { + kind, + channel_id, + result, + }); + }); + } + + fn load_provider_editor( + &self, + kind: openless_core::ChannelKind, + channel: openless_core::ChannelSummary, + descriptor: openless_core::ProviderDescriptor, + ) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + let channel_id = channel.id.clone(); + self.tokio.spawn(async move { + let result = load_provider_editor(backend, kind, channel, descriptor) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderEditor { + kind, + channel_id, + result: Box::new(result), + }); + }); + } + + /// Play the native recording start/stop cue on a worker thread, gated by + /// the `audio_cue_on_record` preference. The start cue is additionally + /// suppressed while `mute_during_recording` is active: playing into a + /// deliberately muted sink is both inaudible and a needless PipeWire/ + /// KDE sink-input blip. The stop cue plays after output has been + /// restored. Absent preferences default to Core's defaults (cue on, + /// mute off). + fn play_record_cue(&self, at_start: bool) { + let enabled = self + .preferences + .as_ref() + .map(|prefs| prefs.audio_cue_on_record) + .unwrap_or(true); + if !enabled { + return; + } + if at_start + && self + .preferences + .as_ref() + .map(|prefs| prefs.mute_during_recording) + .unwrap_or(false) + { + return; + } + if at_start { + openless_linux_egui::play_cue_start(); + } else { + openless_linux_egui::play_cue_stop(); + } + } + + fn apply_event(&mut self, event: BackendEvent) { + let lang = self.lang; + if event.sequence <= self.last_event_sequence { + return; + } + let event_sequence = event.sequence; + self.last_event_sequence = event.sequence; + let session_id = event.session_id; + match event.kind { + BackendEventKind::DictationStateChanged(state) => { + // Native start/stop audio cues are a Linux host effect (no + // webview to synthesize them), gated by `audio_cue_on_record` + // and muted-aware. They must never block this frame, so the + // cue module plays on its own worker thread. + let was_recording = self.recording_phase_active; + self.recording_phase_active = state.phase == DictationPhase::Recording; + if state.phase == DictationPhase::Recording && !was_recording { + self.play_record_cue(true); + } else if !self.recording_phase_active && was_recording { + self.play_record_cue(false); + } + if state.phase == DictationPhase::Starting { + self.transcript_state = TranscriptAccumulator::default(); + self.transcript.clear(); + self.transcript_session = state.session_id; + } + // 终态不写状态栏:`Failed` / `Completed` / `Cancelled` 是 Core 的 + // 内部词,用户已经能从胶囊看到本地化文案(Tauri 也只在那里显示)。 + if capsule_hide_delay(state.phase).is_some() { + log::debug!( + "dictation terminal phase {:?} (session {:?})", + state.phase, + state.session_id + ); + } else { + self.status = fmt_l10n( + lang, + "status.dictation_phase", + &[&format!("{:?}", state.phase)], + ); + } + if let Some(session_id) = state.session_id { + // 上一轮胶囊被自动收起后进程已经不在了:进行中的相位必须按需 + // 重新拉起,否则 send_popup 会因为没有 supervisor 而静默丢弃; + // 终态则不拉,免得把刚收起的药丸又喊回来。 + if phase_shows_capsule(state.phase) { + self.ensure_popup(PopupKind::Capsule); + } + // 进行中的 message 也要过一遍分类:Core 偶尔把内部错误码 + // 写在这里,不能当成文案直接显示。 + let text = match capsule_outcome(state.phase, state.message.as_deref()) { + CapsuleOutcome::Progress(text) => text, + _ => String::new(), + }; + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: event_sequence.saturating_mul(2), + phase: format!("{:?}", state.phase), + text, + audio_level: Some(state.level), + translation_active: state.translation_active, + }, + ); + // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 + self.schedule_capsule_dismissal(&session_id.to_string(), state.phase); + } + } + BackendEventKind::TranscriptDelta(delta) + if session_id == self.transcript_session + && self.transcript_state.apply(&delta).is_ok() => + { + self.transcript = self.transcript_state.text().to_string(); + } + BackendEventKind::PolishDelta(delta) if delta.is_final => { + self.transcript = delta.text; + } + BackendEventKind::DictationCompleted(result) => { + self.transcript = result.polished_text; + self.status = fmt_l10n( + lang, + "status.dictation_done", + &[&format!("{:?}", result.inserted)], + ); + } + BackendEventKind::RecordingControlRequested(request) => { + if let Some(backend) = self.backend() { + self.spawn(async move { + match request.action { + openless_core::RecordingControlAction::Stop => { + normalize_stop_result( + backend.stop_dictation_session(request.session_id).await, + )?; + } + openless_core::RecordingControlAction::Cancel => { + normalize_stop_result( + backend.cancel_dictation(Some(request.session_id)).await, + )?; + } + } + Ok(tr_l10n(lang, "status.auto_stopped").to_string()) + }); + } + } + BackendEventKind::LessComputerEvent(event) => { + // Less Computer events may complete after a newer turn has + // already started. Session ownership, not arrival time, + // decides whether a delta/terminal may mutate this view. + if let LessComputerEventKind::User { text, fresh } = &event.kind { + // Every User starts a new turn UUID, including a + // continuation. `fresh` describes conversation history, + // never whether this turn is allowed to receive output. + self.less_computer_session = session_id; + self.pending_approval = None; + if *fresh { + self.less_computer_output.clear(); + } else if !self.less_computer_output.is_empty() { + self.less_computer_output.push_str("\n\n"); + } + self.less_computer_turn_start = self.less_computer_output.len(); + self.less_computer_input = text.clone(); + if *fresh { + self.less_computer_entries.clear(); + } + self.less_computer_entries + .push(openless_linux_egui::LessComputerEntry { + kind: "user".to_string(), + text: text.clone(), + }); + } else if session_id != self.less_computer_session { + return; + } + match event.kind { + // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 + LessComputerEventKind::VoiceState { .. } => {} + LessComputerEventKind::User { .. } => {} + LessComputerEventKind::Started => { + self.status = tr_l10n(lang, "status.less_running").to_string(); + self.less_computer_working = true; + } + LessComputerEventKind::Delta { text } => { + self.less_computer_output.push_str(&text); + append_assistant_entry(&mut self.less_computer_entries, &text); + } + LessComputerEventKind::Tool { name } => { + self.status = fmt_l10n(lang, "status.less_tool", &[&name]); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "tool".to_string(), + // 行内标记的文案在宿主侧本地化:面板只画文本。 + text: fmt_l10n(lang, "less_computer.tool", &[&name]), + }, + ); + } + LessComputerEventKind::Compaction => { + self.status = tr_l10n(lang, "status.less_compacted").to_string(); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "compaction".to_string(), + text: tr_l10n(lang, "less_computer.compaction").to_string(), + }, + ); + } + LessComputerEventKind::Completed { text, cost_usd } => { + // A terminal is authoritative even for final-only + // providers or after a missed partial event. + self.less_computer_output + .truncate(self.less_computer_turn_start); + self.less_computer_output.push_str(&text); + self.pending_approval = None; + self.less_computer_working = false; + // 终局正文替换掉流式累积的那条助手条目。 + match self + .less_computer_entries + .iter_mut() + .rev() + .find(|entry| entry.kind == "assistant") + { + Some(entry) => entry.text = text.clone(), + None => self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "assistant".to_string(), + text: text.clone(), + }, + ), + } + if let Some(cost) = cost_usd { + let cost_text = + fmt_l10n(lang, "less_computer.cost", &[&format!("{cost:.3}")]); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "note".to_string(), + text: cost_text, + }, + ); + } + self.status = tr_l10n(lang, "less_computer.done").to_string(); + } + LessComputerEventKind::Approval { token, command, .. } => { + self.pending_approval = Some((token, command)); + self.status = tr_l10n(lang, "status.less_waiting").to_string(); + } + LessComputerEventKind::Error { message } => { + self.pending_approval = None; + self.less_computer_working = false; + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "error".to_string(), + text: message.clone(), + }, + ); + self.status = message; + } + LessComputerEventKind::Cancelled => { + self.pending_approval = None; + self.less_computer_working = false; + self.status = tr_l10n(lang, "less_computer.cancelled").to_string(); + } + } + } + BackendEventKind::PreferencesChanged(_) => { + // 外部改动(Core 事件 / 托盘 / 另一窗口)要重新灌一次文本行。 + self.hydrate_text_fields = true; + if let Some(backend) = self.backend() { + let latest = backend.get_preferences(); + self.preferences = Some(match self.preferences.as_ref() { + Some(draft) if self.settings_dirty.any() => { + self.settings_dirty.merge(&latest, draft) + } + _ => latest, + }); + } + self.load_remote_status(); + self.load_library(); + } + BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => { + self.load_library() + } + BackendEventKind::QaState(state) => { + self.qa_edit.merge(&state); + if state.kind == QaStateKind::AnswerDelta { + if let Some(current) = self + .qa_state + .as_mut() + .filter(|current| current.session_id == state.session_id) + { + // Core deltas deliberately omit messages. Preserve + // the conversation and append only this turn's text; + // the following Answer replaces it with Core history. + current.kind = state.kind; + current + .chunk + .get_or_insert_with(String::new) + .push_str(state.chunk.as_deref().unwrap_or_default()); + } + } else if matches!( + state.kind, + QaStateKind::Idle + | QaStateKind::Loading + | QaStateKind::Thinking + | QaStateKind::Recording + ) || self + .qa_state + .as_ref() + .is_none_or(|current| current.session_id == state.session_id) + { + self.qa_state = Some(state); + } + if let Some(state) = self.qa_state.clone() { + let session_id = + state.session_id.clone().unwrap_or_else(|| "qa".to_string()); + self.send_popup( + PopupKind::Qa, + HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence: event_sequence.saturating_mul(2), + phase: format!("{:?}", state.kind), + messages: state + .messages + .unwrap_or_default() + .into_iter() + .map(|message| PopupChatMessage { + role: message.role, + content: message.content, + selection_text: message.selection_text, + }) + .collect(), + selection_preview: state.selection_preview, + streaming_answer: state.chunk.unwrap_or_default(), + error: state.error, + edit_instruction_mode: self.qa_edit.instruction_mode, + edit_apply_available: self.qa_edit.apply_available, + edit_revert_available: self.qa_edit.revert_available, + pinned: self.qa_pinned, + viewer_login: self.marketplace_login(), + }, + ); + } + } + BackendEventKind::SelectionStateChanged(snapshot) => { + if snapshot.phase == SelectionPhase::Preview { + self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); + self.selection_preview_visible = true; + } + if let Some(session_id) = snapshot.session_id { + self.send_popup( + PopupKind::Preview, + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: event_sequence.saturating_mul(2), + text: snapshot.preview_text.clone().unwrap_or_default(), + source: snapshot.source_text.clone().unwrap_or_default(), + }, + ); + } + self.selection = Some(snapshot); + } + BackendEventKind::RemoteInputStatusChanged(_) + | BackendEventKind::RemoteInputFailed(_) => self.load_remote_status(), + _ => {} + } + } + + /// `update()` 是唯一 drain 原生事件(热键、单实例拉起意图)的地方,而 + /// 最小化/隐藏的窗口会让 eframe 的定时重绘停摆 —— 那样按热键什么都不会 + /// 发生(胶囊、QA 面板都不弹)。这里必须用**真线程**:`self.tokio` 是 + /// current-thread 运行时,`spawn` 的任务只在别处 `block_on` 时才被推进, + /// 当作后台泵用就是「写完看着对、最小化后照样死」(实测心跳会在窗口 + /// 收走的那一刻停)。线程只做一件事:`request_repaint()` 把事件循环戳醒。 + /// 泵心跳:宿主循环每 10s 一条。窗口关掉之后这条心跳**不能**停 —— + /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 + /// 的判据),用户/支持可以直接看日志确认。 + fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { + if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { + return; + } + self.last_pump_heartbeat = std::time::Instant::now(); + // 宿主没有窗口,所以心跳只看「窗口进程还在不在」—— + // 这正是关窗后必须继续为 true 的那一项能力。 + let _ = ctx; + log::info!( + "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + self.ui_window.is_some(), + self.window_should_be_open, + self.tray.is_some(), + self.recording_phase_active, + ); + } + + // 宿主没有窗口:ctx 只为保持调用形状(事件泵不再依赖任何视口状态)。 + fn poll(&mut self, _ctx: &egui::Context) { + let lang = self.lang; + // 用户再次启动应用(桌面图标 / 命令行 / 单实例转发)是**显式**的 + // 打开窗口意图;Core 的 HostAction::ShowMain 不能拿来当这个用 + // (弹窗流程里也会发,会让弹一次面板冒出一个主窗口)。 + let mut launch_intent_window_requested = false; + if let Some(native) = &self.native { let (launch_intents, hotkey_events, errors) = native.drain_native_events(); let host = native.host_arc(); for intent in launch_intents { + log::info!("[ui-host] launch intent from the user: {intent:?}"); + launch_intent_window_requested = true; let host = Arc::clone(&host); self.spawn(async move { host.dispatch_launch_intent(intent).await?; - Ok("已处理启动请求".to_string()) + Ok(tr_l10n(lang, "status.launch_handled").to_string()) }); } for event in hotkey_events { let host = Arc::clone(&host); self.spawn(async move { host.dispatch_hotkey_event(event).await?; - Ok("已处理快捷键".to_string()) + Ok(tr_l10n(lang, "status.hotkey_handled").to_string()) }); } if let Some(error) = errors.last() { @@ -611,1501 +2402,2850 @@ mod linux_app { for action in actions { match action { HostAction::ShowMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + // Core 的 ShowMain 是「把主窗口推到前面」的提示(弹窗流程里也会发), + // 不是用户动作:宿主不因此拉起窗口进程,否则弹一次面板就可能 + // 冒出一个主窗口。真正的用户动作是托盘「显示主窗口」。 + } + HostAction::ShowLessComputer => { + // Core 在每次 Less Computer 轮次开始前发这个动作(Tauri 里 + // 它显示 `less-computer` 窗口)。宿主是序列所有者,这里 + // 拉起/刷新面板即可。 + self.show_less_computer_popup(); + } + HostAction::FocusMain => { + // 宿主没有窗口可聚焦;已有窗口的聚焦由 UI 进程自己处理。 + // 这里刻意什么都不做:把它当成「用户想打开主窗口」会让 + // 弹窗一出现就冒出主窗口。 + } + HostAction::Notify(message) => { + self.status = message.clone(); + std::thread::spawn(move || { + if let Err(error) = notify(Notification { + summary: "OpenLess", + body: &message, + icon: "openless", + timeout_ms: 0, + }) { + eprintln!("OpenLess desktop notification failed: {error}"); + } + }); + } + HostAction::OpenExternalUrl(url) | HostAction::OpenSystemSettings(url) => { + std::thread::spawn(move || { + if let Err(error) = open_external(&url) { + eprintln!("OpenLess external URL failed: {error}"); + } + }); + } + HostAction::RequestRestart => { + self.status = tr_l10n(lang, "status.request_restart").to_string(); + } + HostAction::ShowSelectionPreview => { + self.selection_preview_visible = true; + self.show_selection_popup(); + } + HostAction::HideSelectionPreview => { + self.selection_preview_visible = false; + let session_id = self + .selection + .as_ref() + .and_then(|selection| selection.session_id) + .map(|id| id.to_string()) + .unwrap_or_else(|| "selection".to_string()); + self.hide_popup( + PopupKind::Preview, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + HostAction::ShowQa => { + log::info!("[hotkey] QA panel show requested by the host action"); + self.qa_visible = true; + self.show_qa_popup(); + } + HostAction::HideQa => { + if !qa_hides_on_host_action(self.qa_pinned) { + continue; + } + self.qa_visible = false; + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()) + .unwrap_or_else(|| "qa".to_string()); + self.hide_popup( + PopupKind::Qa, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + HostAction::ShowDictationFeedback => self.show_capsule_popup(), + HostAction::HideDictationFeedback => { + let session_id = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|id| id.to_string()) + .unwrap_or_else(|| "dictation".to_string()); + self.hide_popup( + PopupKind::Capsule, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + } + } + } + if launch_intent_window_requested { + self.request_main_window(); + } + self.poll_popup_supervisors(); + let mut events = Vec::new(); + let drain = self + .subscription + .as_mut() + .map(|subscription| drain_events(subscription, |event| events.push(event))); + for event in events { + self.apply_event(event); + } + if let Some(EventDrainOutcome::Lagged { dropped, .. }) = drain { + if let Some(backend) = self.backend() { + // Broadcast lag does not imply Core lost the events. Replay + // from the last applied sequence first; duplicate delivery + // from the live receiver is rejected by apply_event above. + let replay = backend.replay_events_after(self.last_event_sequence); + let snapshot = backend.snapshot(); + if replay.truncated { + // The bounded tail cannot reconstruct derived text/UI + // state. Reset it before applying the authoritative tail + // so no stale transcript, approval or preview survives. + self.transcript_state = TranscriptAccumulator::default(); + self.transcript.clear(); + self.transcript_session = snapshot.dictation.session_id; + self.less_computer_input.clear(); + self.less_computer_output.clear(); + self.less_computer_turn_start = 0; + self.less_computer_session = None; + self.pending_approval = None; + self.qa_state = None; + self.qa_visible = false; + self.selection = None; + self.selection_draft.clear(); + self.selection_preview_visible = false; + } + self.snapshot = Some(snapshot); + for event in replay.events { + self.apply_event(event); + } + self.status = if replay.truncated { + fmt_l10n(lang, "status.backlog_reset", &[&dropped]) + } else { + fmt_l10n(lang, "status.backlog_replay", &[&dropped]) + }; + } + } + while let Ok(result) = self.rx.try_recv() { + match result { + UiResult::Message(message) => self.status = message, + UiResult::CapsuleDismissDue { session_id } => { + let current = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|id| id.to_string()); + let phase = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.phase) + .unwrap_or(DictationPhase::Idle); + if capsule_hide_is_still_current(current.as_deref(), &session_id, phase) { + self.dismiss_capsule(); + } else { + log::info!( + "capsule: dismissal skipped for session {session_id} — \ + a newer session is active (snapshot {current:?}, {phase:?})" + ); + } + } + UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), + UiResult::Remote(Err(error)) => self.status = error, + UiResult::Providers(Ok(panel)) => { + if panel.kind != self.provider_kind { + continue; + } + if !panel.descriptors.iter().any(|descriptor| { + descriptor.provider_type.as_str() == self.new_provider_type + }) { + self.new_provider_type = panel + .descriptors + .first() + .map(|descriptor| descriptor.provider_type.as_str().to_string()) + .unwrap_or_default(); + } + let selected = self + .selected_channel_id + .as_ref() + .filter(|id| panel.channels.iter().any(|channel| &channel.id == *id)) + .cloned() + .or_else(|| { + panel + .channels + .iter() + .find(|channel| channel.id == panel.active_provider) + .map(|channel| channel.id.clone()) + }) + .or_else(|| panel.channels.first().map(|channel| channel.id.clone())); + self.selected_channel_id = selected.clone(); + if self.pending_channel_delete.as_ref().is_some_and(|id| { + !panel.channels.iter().any(|channel| &channel.id == id) + }) { + self.pending_channel_delete = None; + } + self.providers = ProvidersState::Loaded(panel.clone()); + self.provider_models.clear(); + if let Some(channel_id) = selected { + // A refresh (enable toggle, save, validation) must not + // throw away the editor draft the user is editing: only + // a different channel re-reads the descriptor. + let already_loaded = matches!( + &self.provider_editor, + ProviderEditorState::Loaded(editor) + if editor.channel.id == channel_id + ); + if !already_loaded { + if let Some((channel, descriptor)) = + provider_channel_descriptor(&panel, &channel_id) + { + self.provider_editor = ProviderEditorState::Loading { + kind: panel.kind, + channel_id, + }; + self.provider_editor_form = None; + self.load_provider_editor(panel.kind, channel, descriptor); + } + } + } else { + self.provider_editor = ProviderEditorState::Idle; + } + } + UiResult::Providers(Err(error)) => { + self.providers = ProvidersState::Failed(error.clone()); + self.status = error; + } + UiResult::ProviderEditor { + kind, + channel_id, + result, + } => { + if kind != self.provider_kind + || self.selected_channel_id.as_deref() != Some(channel_id.as_str()) + { + continue; + } + match *result { + Ok(editor) => { + // Reads race with channel switching and mutation + // refreshes. Only the still-selected channel may install + // its editor, otherwise late credential data is ignored. + self.provider_editor_form = + Some(ProviderEditorForm::from_editor(&editor, self.lang)); + self.provider_editor = + ProviderEditorState::Loaded(Box::new(editor)); + } + Err(error) => { + self.provider_editor = ProviderEditorState::Failed(error.clone()); + self.provider_editor_form = None; + self.status = error; + } } - HostAction::ShowLessComputer => { - self.navigation.open(Page::Agent); - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + UiResult::ProviderModels { + kind, + channel_id, + result, + } => { + if kind == self.provider_kind + && self.selected_channel_id.as_deref() == Some(channel_id.as_str()) + { + match result { + Ok(models) => { + self.status = fmt_l10n( + lang, + "status.provider_models_loaded", + &[&models.len()], + ); + if let Some(form) = self.provider_editor_form.as_mut() { + form.models = models.clone(); + form.models_loading = false; + } + self.provider_models = models; + } + Err(error) => { + if let Some(form) = self.provider_editor_form.as_mut() { + form.models.clear(); + form.models_loading = false; + } + self.status = error; + } + } } - HostAction::FocusMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + UiResult::ProviderMutation(result) => { + match result { + Ok(message) => self.status = message, + Err(error) => self.status = error, } - HostAction::Notify(message) => self.status = message, - HostAction::OpenExternalUrl(url) | HostAction::OpenSystemSettings(url) => { - std::thread::spawn(move || { - let _ = std::process::Command::new("xdg-open").arg(url).status(); - }); + self.providers = ProvidersState::Loading; + self.provider_editor = ProviderEditorState::Idle; + self.provider_models.clear(); + self.load_providers(self.provider_kind); + } + UiResult::Library(Ok(library)) => { + self.vocabulary = library.vocabulary; + self.correction_rules = library.correction_rules; + self.style_packs = library.style_packs; + self.vocab_preset_store = library.vocab_preset_store; + self.vocab_presets = library.vocab_presets; + } + UiResult::Library(Err(error)) => self.status = error, + UiResult::SettingsSaved(result) => match *result { + Ok(outcome) => { + self.preferences = Some(outcome.preferences.clone()); + // Core 可能夹取过值(例如条数下限 5),保存后重新灌一次文本行。 + self.hydrate_text_fields = true; + if let Some(native) = &self.native { + self.snapshot = Some(native.host().snapshot()); + } + self.settings_dirty = SettingsDirty::default(); + self.status = tr_l10n(lang, "status.settings_saved").to_string(); + // Appearance (e.g. the Overview heatmap toggle) and any + // provider/credential edits may change Overview state. + self.load_overview(); + if let Some(backend) = self.backend() { + let config = openless_core::RemoteInputConfig { + enabled: outcome.preferences.remote_input_enabled, + port: outcome.preferences.remote_input_port, + }; + self.spawn(async move { + backend.services().remote_input.configure(config).await?; + Ok(tr_l10n(lang, "status.remote_updated").to_string()) + }); + } } - HostAction::RequestRestart => { - self.status = "请手动重启 OpenLess".to_string(); + Err(error) => self.status = error, + }, + UiResult::Marketplace(Ok(items)) => { + self.status = fmt_l10n(lang, "status.marketplace_loaded", &[&items.len()]); + self.marketplace_items = items; + self.marketplace_attempted = true; + } + UiResult::Marketplace(Err(error)) => { + self.status = error; + self.marketplace_attempted = true; + } + UiResult::MarketplaceLikes(Ok(likes)) => self.marketplace_my_likes = likes, + UiResult::MarketplaceLikes(Err(error)) => { + // Not signed in / offline: keep the previous like set. + log::debug!("marketplace likes unavailable: {error}"); + } + UiResult::SettingsChannels(Ok(rows)) => { + self.settings_channels = rows; + self.settings_channels_loading = false; + } + UiResult::ServiceConfigured(configured) => { + self.service_configured = configured; + } + UiResult::SettingsChannels(Err(error)) => { + self.settings_channels_loading = false; + self.frontend_vm.settings_notice = Some(error); + } + UiResult::MarketplaceFlow(Ok(flow)) => { + self.status = fmt_l10n(lang, "status.device_code", &[&flow.user_code]); + self.marketplace_flow = Some(flow); + } + UiResult::MarketplaceFlow(Err(error)) => self.status = error, + UiResult::MarketplaceAuthPoll(Ok(result)) => match result { + openless_core::OAuthPollResult::Authorized { login } => { + self.marketplace_flow = None; + self.status = fmt_l10n(lang, "status.logged_in", &[&login]); } - HostAction::ShowSelectionPreview => { - self.navigation.open(Page::Selection); - self.selection_preview_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + openless_core::OAuthPollResult::Pending => { + self.status = tr_l10n(lang, "status.oauth_pending").to_string(); } - HostAction::HideSelectionPreview => { - self.selection_preview_visible = false; + openless_core::OAuthPollResult::SlowDown => { + self.status = tr_l10n(lang, "status.oauth_slowdown").to_string(); } - HostAction::ShowQa => { - self.navigation.open(Page::Qa); - self.qa_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + openless_core::OAuthPollResult::Error { message } => { + self.status = message; + } + }, + UiResult::MarketplaceAuthPoll(Err(error)) => self.status = error, + UiResult::MarketplaceDetail(Ok(detail)) => { + self.status = + fmt_l10n(lang, "status.detail_loaded", &[&detail.summary.name]); + self.marketplace_detail = Some(detail); + } + UiResult::MarketplaceDetail(Err(error)) => self.status = error, + UiResult::MarketplaceMine(Ok((packs, likes))) => { + self.status = fmt_l10n( + lang, + "status.my_publish_likes", + &[&packs.len(), &likes.len()], + ); + self.marketplace_my_packs = packs; + self.marketplace_my_likes = likes; + } + UiResult::MarketplaceMine(Err(error)) => self.status = error, + UiResult::Microphones(Ok(devices)) => { + self.microphones = devices.clone(); + let selected = self + .preferences + .as_ref() + .map(|prefs| prefs.microphone_device_name.as_str()) + .unwrap_or_default(); + if let Some(tray) = &self.tray { + let microphones = devices + .into_iter() + .map(|device| openless_linux_egui::TrayMicrophone { + selected: !selected.is_empty() + && (selected == device.id || selected == device.name), + name: device.name, + is_default: device.is_default, + }) + .collect(); + if let Err(error) = tray.set_microphones(microphones) { + self.status = error.to_string(); + } } - HostAction::HideQa => self.qa_visible = false, - HostAction::ShowDictationFeedback | HostAction::HideDictationFeedback => {} + } + UiResult::Microphones(Err(error)) => self.status = error, + UiResult::Overview(Ok(data)) => self.overview = OverviewState::Loaded(data), + UiResult::Overview(Err(error)) => { + self.status = error.clone(); + self.overview = OverviewState::Failed(error); + } + UiResult::UpdateCheck(Ok(Some(manifest))) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.discovered", &[&manifest.version]); + self.update_manifest = Some(manifest); + } + UiResult::UpdateCheck(Ok(None)) => { + self.update_busy = false; + self.status = tr_l10n(lang, "update.up_to_date").to_string(); + } + UiResult::UpdateCheck(Err(error)) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.check_failed", &[&error]); + } + UiResult::UpdateProgress(progress) => self.update_progress = Some(progress), + UiResult::UpdateInstalled(Ok(installed)) => { + self.update_busy = false; + self.update_manifest = None; + self.status = + fmt_l10n(lang, "update.installed_restart", &[&installed.version]); + } + UiResult::UpdateInstalled(Err(error)) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.install_failed", &[&error]); } } } - let mut events = Vec::new(); - let drain = self - .subscription - .as_mut() - .map(|subscription| drain_events(subscription, |event| events.push(event))); - for event in events { - self.apply_event(event); + if let Some(backend) = self.backend() { + self.snapshot = Some(backend.snapshot()); } - if let Some(EventDrainOutcome::Lagged { dropped, .. }) = drain { - if let Some(backend) = self.backend() { - // Broadcast lag does not imply Core lost the events. Replay - // from the last applied sequence first; duplicate delivery - // from the live receiver is rejected by apply_event above. - let replay = backend.replay_events_after(self.last_event_sequence); - let snapshot = backend.snapshot(); - if replay.truncated { - // The bounded tail cannot reconstruct derived text/UI - // state. Reset it before applying the authoritative tail - // so no stale transcript, approval or preview survives. - self.transcript_state = TranscriptAccumulator::default(); - self.transcript.clear(); - self.transcript_session = snapshot.dictation.session_id; - self.less_computer_input.clear(); - self.less_computer_output.clear(); - self.less_computer_turn_start = 0; - self.less_computer_session = None; - self.less_computer_running = false; - self.pending_approval = None; - self.qa_state = None; - self.qa_visible = false; - self.selection = None; - self.selection_draft.clear(); - self.selection_preview_visible = false; + self.reconcile_capsule_liveness(); + } + + /// 兜底:Core 有错误路径只 reset 会话、不发布终态事件,宿主就永远等不到 + /// 「终态 → 收起」,药丸会一直贴在屏上。这里每帧按快照判断会话是否已经 + /// 消失,消失且没排过收起就补一次(时长按失败终态,文案仍由胶囊自己决定)。 + fn reconcile_capsule_liveness(&mut self) { + let live = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|session_id| session_id.to_string()); + let Some(session) = capsule_needs_fallback_dismissal( + self.capsule_session.as_deref(), + live.as_deref(), + self.capsule_dismissal_scheduled.as_deref(), + ) else { + return; + }; + log::info!( + "capsule: session {session} vanished without a terminal event — \ + scheduling the fallback dismissal" + ); + self.schedule_capsule_dismissal(&session, DictationPhase::Failed); + } + + /// Apply a newly chosen UI locale immediately: persist it as Linux-UI + /// state (never Core business truth), resolve it to a concrete language + /// and let the next frame re-render every localized surface. Persistence + /// is offloaded off the egui frame so the write can never stall a repaint. + fn apply_locale_pref(&mut self, pref: LocalePref) { + if pref == self.locale_pref { + return; + } + self.locale_pref = pref; + self.lang = pref.resolve(); + if let Some(tray) = &self.tray { + let _ = tray.set_lang(self.lang); + } + let runtime = self.tokio.clone(); + runtime.spawn_blocking(move || { + let _ = save_locale_pref(pref); + }); + } + + /// The language selector row shown in Settings. Changing it re-renders + /// the whole window immediately (shell, headings, labels, popups later + /// pick it up from the persisted UI state on their next launch). + // ── Frontend bridge ───────────────────────────────────────────────── + + /// Sync backend state into the frontend view model each frame before + /// rendering. Only fields that have real data sources are populated; + /// unwired fields remain in their default empty / loading state. + fn sync_view_model(&mut self) { + // Capture overview error before taking a mutable borrow on frontend_vm. + let overview_err = self.overview_error(); + let backend = self.backend(); + let lang = self.lang; + let permissions = self.permission_snapshot(); + // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 + // 内容每帧弹回旧值。 + let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); + + let vm = &mut self.frontend_vm; + + // Map shell::Page to frontend::Page. + vm.active_page = match self.active_page { + shell::Page::Overview => frontend::view_model::Page::Overview, + shell::Page::History => frontend::view_model::Page::History, + shell::Page::Vocabulary => frontend::view_model::Page::Vocab, + shell::Page::Styles => frontend::view_model::Page::Style, + shell::Page::Marketplace => frontend::view_model::Page::Marketplace, + shell::Page::Providers => frontend::view_model::Page::Settings, + shell::Page::Assistant => frontend::view_model::Page::SelectionAsk, + shell::Page::Translation => frontend::view_model::Page::Translation, + shell::Page::Corrections => frontend::view_model::Page::Corrections, + }; + + vm.status = self.status.clone(); + vm.version = env!("OPENLESS_APP_VERSION").to_string(); + vm.lang = lang; + // UI 进程没有 Core 偏好,明暗主题必须随视图模型一起过去。 + vm.theme_mode = self + .preferences + .as_ref() + .map(|preferences| preferences.theme_mode) + .unwrap_or_default(); + if let Some(prefs) = &self.preferences { + vm.dictation_hotkey = prefs.dictation_hotkey.display_label(); + vm.qa_hotkey = prefs + .qa_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.translation_hotkey = prefs.translation_hotkey.display_label(); + } + + // Overview: wire real data when available. + if let Some(summary) = self.overview.summary(chrono::Local::now().date_naive()) { + vm.overview_loading = false; + vm.overview_error = None; + vm.overview = Some(frontend::view_model::OverviewSummary { + asr_provider: summary.asr_provider, + llm_provider: summary.llm_provider, + asr_configured: summary.asr_configured, + llm_configured: summary.llm_configured, + chars_today: summary.chars_today, + segments_today: summary.segments_today, + duration_ms_today: summary.duration_ms_today, + avg_latency_ms: summary.avg_latency_ms, + history_total: summary.history_total, + recent: summary + .recent + .into_iter() + .map(|entry| frontend::view_model::OverviewRecentEntry { + created_at: entry.created_at, + final_text: entry.final_text, + raw_transcript: entry.raw_transcript, + mode: overview_mode(entry.mode), + duration_ms: entry.duration_ms, + }) + .collect(), + activity_daily: summary + .activity_daily + .into_iter() + .map(overview_activity_day) + .collect(), + heatmap_year: summary.heatmap_year, + heatmap: summary + .heatmap + .into_iter() + .map(overview_heatmap_day) + .collect(), + }); + } else if let Some(error) = overview_err { + vm.overview_loading = false; + vm.overview_error = Some(error); + vm.overview = None; + } else { + vm.overview_loading = true; + vm.overview_error = None; + vm.overview = None; + } + + // Settings: populate from preferences. + if let Some(prefs) = &self.preferences { + let s = &mut vm.settings; + s.streaming_insert = prefs.streaming_insert; + s.start_minimized = prefs.start_minimized; + s.auto_update = prefs.auto_update_check; + s.remote_input = prefs.remote_input_enabled; + if hydrate_text { + s.remote_port = prefs.remote_input_port.to_string(); + } + s.activity_heatmap = prefs.show_overview_activity_heatmap; + s.theme = match prefs.theme_mode { + openless_core::shared_types::ThemeMode::System => 0, + openless_core::shared_types::ThemeMode::Light => 1, + openless_core::shared_types::ThemeMode::Dark => 2, + }; + vm.translation_working_languages = prefs.working_languages.clone(); + vm.translation_target_language = prefs.translation_target_language.clone(); + // Tauri offers 切换式 / 按住说话 / 自动识别 — the legacy DoubleClick + // value stays untouched in the store, it just has no chip here. + s.recording_mode = match prefs.hotkey.mode { + openless_core::shared_types::HotkeyMode::Hold => 1, + openless_core::shared_types::HotkeyMode::Auto => 2, + _ => 0, + }; + s.restore_clipboard = prefs.restore_clipboard_after_paste; + // Tauri 只提供 Ctrl+V / Ctrl+Shift+V 两项。 + s.paste_shortcut = match prefs.paste_shortcut { + openless_core::shared_types::PasteShortcut::CtrlShiftV => 1, + _ => 0, + }; + s.silence_auto_stop = prefs.silence_auto_stop_enabled; + s.silence_seconds = + prefs.silence_auto_stop_seconds.round().clamp(1.0, 5.0) as usize; + s.microphone_name = prefs.microphone_device_name.clone(); + s.microphone_options = self + .microphones + .iter() + .map(|device| device.name.clone()) + .collect(); + s.mute_while_recording = prefs.mute_during_recording; + s.audio_cue = prefs.audio_cue_on_record; + s.launch_at_login = prefs.launch_at_login; + s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; + s.record_audio_for_debug = prefs.record_audio_for_debug; + if hydrate_text { + s.history_max_entries = prefs + .history_max_entries + .map(|value| value.to_string()) + .unwrap_or_default(); + s.retention_days = prefs.history_retention_days.to_string(); + s.polish_context_window = prefs.polish_context_window_minutes.to_string(); + s.audio_recording_max_entries = prefs + .audio_recording_max_entries + .map(|value| value.to_string()) + .unwrap_or_default(); + } + s.remote_default_mode = usize::from(prefs.remote_input_default_mode == "hold"); + s.system_proxy = prefs.use_system_proxy; + s.multimodal = prefs.multimodal_pipeline_enabled; + s.less_computer = prefs.coding_agent_enabled; + s.coding_agent_provider = match prefs.coding_agent_provider.as_str() { + "opencode-cli" => 1, + "codex-cli" => 2, + "dsh-cli" => 3, + _ => 0, + }; + s.coding_agent_permission = match prefs.coding_agent_permission_mode.as_str() { + "plan" => 1, + "default" => 2, + "bypassPermissions" => 3, + _ => 0, + }; + if hydrate_text { + s.coding_agent_model = prefs.coding_agent_model.clone().unwrap_or_default(); + s.coding_agent_workdir = prefs.coding_agent_workdir.clone().unwrap_or_default(); + s.coding_agent_exe = prefs.coding_agent_exe.clone().unwrap_or_default(); + } + s.selection_polish_delivery = match prefs.selection_polish_output_mode { + openless_core::shared_types::SelectionPolishOutputMode::DirectReplace => 0, + openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm => 1, + }; + s.beta_channel = matches!( + prefs.update_channel, + openless_core::shared_types::UpdateChannel::Beta + ); + // 多模态 / 平台能力:决定 AI 服务页的视图与更新控件。 + // 远程输入的实时状态:配对码 / 访问网址 / 证书指纹。 + if let Some((status, pin)) = &self.remote_access { + vm.remote_running = status.running; + vm.remote_urls_stale = status.urls_stale; + vm.remote_pin = pin.clone(); + vm.remote_urls = status.urls.clone(); + vm.remote_cert_fingerprint = status.ca_fingerprint_sha256.clone(); + } else { + vm.remote_running = false; + vm.remote_urls_stale = false; + vm.remote_pin = String::new(); + vm.remote_urls = Vec::new(); + vm.remote_cert_fingerprint = None; + } + // 必配服务的状态点由 `load_service_configured` 异步刷新。 + vm.service_configured = self.service_configured; + vm.multimodal_view = prefs.multimodal_pipeline_enabled; + vm.pipeline_multimodal = + prefs.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal; + // Linux 宿主没有本地推理引擎。 + vm.supports_local_asr = false; + // 热键后端是否真的起来了:没有 fcitx5 监听器时隐藏「快捷键」分区, + // 与 Tauri 的 `visibleSettingsSections(supportsDesktopHotkey)` 一致。 + vm.hotkeys_supported = self + .native + .as_ref() + .is_some_and(LinuxNativeRuntime::hotkeys_available); + vm.auto_update_capable = self.update_support.supports_auto_update(); + vm.permissions = permissions; + vm.selection_polish_hotkey = prefs + .selection_polish_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + // 风格包直选:只展示已经录过的快捷键(录制器尚未实现)。 + s.style_pack_hotkeys = prefs + .style_pack_hotkeys + .iter() + .map(|entry| { + let pack = self + .style_packs + .iter() + .find(|pack| pack.id == entry.pack_id); + frontend::view_model::StylePackHotkeyRow { + pack_id: entry.pack_id.clone(), + name: pack + .map(|pack| pack.name.clone()) + .unwrap_or_else(|| entry.pack_id.clone()), + hotkey: entry.binding.display_label(), + } + }) + .collect(); + // 草稿行的默认风格包:第一个还没绑定快捷键的(Tauri 的「+添加」下拉)。 + if vm.style_hotkey_draft_pack >= vm.style_packs.len() { + vm.style_hotkey_draft_pack = vm.style_packs.len().saturating_sub(1); + } + } + + // AI services tab: provider picker plus the cached channel list. + vm.channel_providers = openless_core::provider_rules::provider_descriptors( + provider_kind(self.settings_channel_kind), + ) + .into_iter() + .map(|descriptor| frontend::view_model::SettingsChannelProvider { + provider_type: descriptor.provider_type.as_str().to_string(), + label: localized_provider_label( + lang, + self.settings_channel_kind, + descriptor.provider_type.as_str(), + ), + }) + .collect(); + vm.channels_loading = self.settings_channels_loading; + let active_channel = self + .settings_channels + .iter() + .position(|channel| channel.enabled) + .unwrap_or(usize::MAX); + vm.channels = self + .settings_channels + .iter() + .enumerate() + .map(|(index, channel)| frontend::view_model::SettingsChannel { + name: channel.name.clone(), + provider: localized_provider_label( + lang, + self.settings_channel_kind, + &channel.provider_type, + ), + provider_type: channel.provider_type.clone(), + model: channel.model.clone(), + is_active: index == active_channel, + enabled: channel.enabled, + last_check: match ( + channel.last_ok, + channel.last_latency_ms, + channel.last_error.as_deref(), + ) { + (Some(true), Some(ms), _) => Some(format!( + "{} · {}", + tr_l10n(lang, "settings.channels.passed"), + fmt_l10n(lang, "settings.channels.elapsed", &[&ms]), + )), + (Some(true), None, _) => { + Some(tr_l10n(lang, "settings.channels.passed").to_string()) + } + (Some(false), _, error) => Some(fmt_l10n( + lang, + "settings.channels.failed", + &[&error.unwrap_or_default()], + )), + _ => None, + }, + }) + .collect(); + + // Provider editor: mirrored from the host draft each frame. The page + // stays a pure renderer; Core still owns the credential schema. + vm.provider_editor = self.provider_editor_form.as_ref().map(|form| { + frontend::view_model::SettingsProviderEditor { + channel_id: form.channel_id.clone(), + provider: form.label.clone(), + provider_type: form.provider_type.clone(), + name: form.name.clone(), + endpoint: form.endpoint.clone(), + model: form.model.clone(), + resource_id: form.resource_id.clone(), + auth_mode: form.auth_mode.clone(), + auth: form.auth, + primary_secret: form.primary_secret.clone(), + secondary_secret: form.secondary_secret.clone(), + models: form.models.clone(), + models_loading: form.models_loading, + busy: matches!(self.provider_editor, ProviderEditorState::Loading { .. }), + } + }); + + // History: wire from Core when backend is available. + if let Some(backend) = backend { + match backend.list_history() { + Ok(history) => { + // The wav on disk is the real source of truth: older records + // carry no `has_audio_recording` flag, so the detail panel's + // play/export/retranscribe actions would disappear. + let recordings_dir = backend.config().data_dir.clone(); + // Core stores history newest-first; keep that order. + vm.history_entries = history + .into_iter() + .map(|item| { + let has_audio = item.has_audio_recording.unwrap_or(false) + || openless_linux_egui::recording_path( + &recordings_dir, + &item.id, + ) + .map(|path| path.exists()) + .unwrap_or(false); + frontend::view_model::HistoryEntry { + id: item.id, + created_at: item.created_at, + mode: overview_mode(item.mode), + // A record's style pack name is not resolvable here without the + // pack catalog, so the pill falls back to the polish mode label + // (which is what records without a style pack show anyway). + style_label: polish_mode_label(lang, item.mode).to_string(), + raw_transcript: item.raw_transcript, + final_text: item.final_text, + duration_ms: item.duration_ms, + insert_status: match item.insert_status { + HistoryInsertStatus::Inserted => { + frontend::view_model::HistoryInsertStatus::Inserted + } + HistoryInsertStatus::CopiedFallback => { + frontend::view_model::HistoryInsertStatus::CopiedFallback + } + HistoryInsertStatus::PasteSent => { + frontend::view_model::HistoryInsertStatus::PasteSent + } + HistoryInsertStatus::Failed => { + frontend::view_model::HistoryInsertStatus::Failed + } + HistoryInsertStatus::NotRequested => { + frontend::view_model::HistoryInsertStatus::NotRequested + } + }, + has_audio, + asr_provider: item.asr_provider, + asr_model: item.asr_model, + asr_ms: item.asr_ms, + llm_provider: item.llm_provider, + llm_model: item.llm_model, + polish_ms: item.polish_ms, + app_name: item.app_name, + dictionary_count: item.dictionary_entry_count, + } + }) + .collect(); + vm.history_loading = false; + vm.history_error = None; } - self.snapshot = Some(snapshot); - for event in replay.events { - self.apply_event(event); + Err(error) => { + vm.history_loading = false; + vm.history_error = Some(error.to_string()); } - self.status = if replay.truncated { - format!("事件积压 {dropped} 条,已重置派生界面并重放可用事件") + } + } + + // In-app playback progress (dropped once the clip finishes). + if self + .history_clip + .as_ref() + .is_some_and(|(_, player)| player.is_finished()) + { + self.history_clip = None; + } + vm.history_playback = self.history_clip.as_ref().map(|(id, player)| { + frontend::view_model::HistoryPlayback { + id: id.clone(), + position_ms: player.position_ms(), + total_ms: player.total_ms(), + } + }); + + // Vocabulary + correction rules: the library path is always wired, so + // an empty store is an empty list — never an "unsupported" page. + vm.vocab_unsupported = false; + vm.vocab_entries = self + .vocabulary + .iter() + .map(|entry| frontend::view_model::VocabEntry { + phrase: entry.phrase.clone(), + hits: entry.hits as usize, + enabled: entry.enabled, + learned: false, + }) + .collect(); + vm.vocab_rules = self + .correction_rules + .iter() + .map(|rule| frontend::view_model::CorrectionRule { + pattern: rule.pattern.clone(), + replacement: rule.replacement.clone(), + enabled: rule.enabled, + learned: false, + }) + .collect(); + vm.vocab_saved_presets = self + .vocab_presets + .iter() + .map(|preset| frontend::view_model::SavedVocabPreset { + name: preset.name.clone(), + phrases: preset.phrases.join("、"), + }) + .collect(); + + // Style packs: wired too; an empty list is a valid state. + vm.style_unsupported = false; + vm.style_packs = self + .style_packs + .iter() + .map(|pack| frontend::view_model::StylePack { + id: pack.id.clone(), + name: pack.name.clone(), + description: pack.description.clone(), + // Localized mode label (Core's display_name is zh-only). + tags: vec![polish_mode_label(lang, pack.base_mode).to_string()], + is_builtin: pack.kind == openless_core::StylePackKind::Builtin, + enabled: pack.enabled, + is_active: pack.active, + selection_active: self + .preferences + .as_ref() + .is_some_and(|prefs| prefs.selection_polish_style_pack_id == pack.id), + }) + .collect(); + + // Translation and selection-ask are always wired through Core; the + // pages only render state that is already loaded. + vm.translation_unsupported = false; + vm.selection_unsupported = false; + + // Marketplace: wired through Core; the list loads lazily on first visit. + vm.marketplace_unsupported = false; + vm.marketplace_loading = !self.marketplace_attempted; + if !self.marketplace_items.is_empty() { + vm.marketplace_loading = false; + vm.marketplace_packs = self + .marketplace_items + .iter() + .map(|item| frontend::view_model::MarketplacePack { + name: item.name.clone(), + version: item.version.clone(), + description: item.description.clone(), + mode: item.base_mode.clone(), + author: item.author_login.clone(), + tags: item.tags.clone(), + likes: item.like_count as u32, + downloads: item.download_count as u32, + liked: self.marketplace_my_likes.contains(&item.id), + }) + .collect(); + } + + // Startup error. + if let Some(error) = &self.startup_error { + vm.status = format!("启动失败: {error}"); + } + } + + /// Returns the overview error string if the overview is in a failed state. + fn overview_error(&self) -> Option { + match &self.overview { + crate::linux_app::OverviewState::Failed(error) => Some(error.clone()), + _ => None, + } + } + + /// Apply a settings toggle from the frontend to the live preferences. + /// 隐私分区的真实状态:Linux 没有系统级授权弹窗,能列出的设备 / 已启动的 + /// 热键适配器就是「已授权」,macOS 才有的辅助功能 / 本地网络一律「不适用」。 + fn permission_snapshot(&self) -> frontend::view_model::SettingsPermissions { + use frontend::view_model::PermissionState; + frontend::view_model::SettingsPermissions { + microphone: if self.microphones.is_empty() { + PermissionState::Unknown + } else { + PermissionState::Granted + }, + accessibility: PermissionState::Unsupported, + network: PermissionState::Unsupported, + hotkey: if self.native.is_some() { + PermissionState::Granted + } else { + PermissionState::Unknown + }, + } + } + + fn apply_settings_toggle(&mut self, field: frontend::view_model::SettingsField) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + match field { + frontend::view_model::SettingsField::StreamingInsert => { + preferences.streaming_insert = !preferences.streaming_insert; + self.settings_dirty.streaming_insert = true; + } + frontend::view_model::SettingsField::StartMinimized => { + preferences.start_minimized = !preferences.start_minimized; + self.settings_dirty.start_minimized = true; + } + frontend::view_model::SettingsField::AutoUpdate => { + preferences.auto_update_check = !preferences.auto_update_check; + self.settings_dirty.auto_update_check = true; + } + frontend::view_model::SettingsField::RemoteInput => { + preferences.remote_input_enabled = !preferences.remote_input_enabled; + self.settings_dirty.remote_input_enabled = true; + } + frontend::view_model::SettingsField::ActivityHeatmap => { + preferences.show_overview_activity_heatmap = + !preferences.show_overview_activity_heatmap; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::RestoreClipboard => { + preferences.restore_clipboard_after_paste = + !preferences.restore_clipboard_after_paste; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::SystemProxy => { + preferences.use_system_proxy = !preferences.use_system_proxy; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::Multimodal => { + preferences.multimodal_pipeline_enabled = + !preferences.multimodal_pipeline_enabled; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::LessComputer => { + preferences.coding_agent_enabled = !preferences.coding_agent_enabled; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::SilenceAutoStop => { + preferences.silence_auto_stop_enabled = !preferences.silence_auto_stop_enabled; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::AudioCue => { + preferences.audio_cue_on_record = !preferences.audio_cue_on_record; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::MuteWhileRecording => { + preferences.mute_during_recording = !preferences.mute_during_recording; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::RecordAudioForDebug => { + preferences.record_audio_for_debug = !preferences.record_audio_for_debug; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::StreamingSaveClipboard => { + preferences.streaming_insert_save_clipboard = + !preferences.streaming_insert_save_clipboard; + self.settings_dirty.streaming_insert = true; + } + frontend::view_model::SettingsField::LaunchAtLogin => { + preferences.launch_at_login = !preferences.launch_at_login; + self.settings_dirty.launch_at_login = true; + } + frontend::view_model::SettingsField::BetaChannel => { + // The Beta toggle is the same knob as the update channel. + if let Some(preferences) = self.preferences.as_mut() { + preferences.update_channel = if preferences.update_channel + == openless_core::shared_types::UpdateChannel::Beta + { + openless_core::shared_types::UpdateChannel::Stable + } else { + openless_core::shared_types::UpdateChannel::Beta + }; + self.settings_dirty.update_channel = true; + } + self.frontend_vm.settings.beta_channel = self + .preferences + .as_ref() + .map(|prefs| { + prefs.update_channel == openless_core::shared_types::UpdateChannel::Beta + }) + .unwrap_or(false); + } + } + self.save_settings_if_dirty(); + } + + /// Apply a settings combo change from the frontend. + fn apply_settings_combo( + &mut self, + field: frontend::view_model::SettingsComboField, + index: usize, + ) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + match field { + frontend::view_model::SettingsComboField::Theme => { + preferences.theme_mode = match index { + 0 => openless_core::shared_types::ThemeMode::System, + 1 => openless_core::shared_types::ThemeMode::Light, + 2 => openless_core::shared_types::ThemeMode::Dark, + _ => return, + }; + self.settings_dirty.appearance = true; + self.frontend_vm.settings.theme = index; + } + frontend::view_model::SettingsComboField::Language => { + let pref = match index { + 0 => LocalePref::System, + 1 => LocalePref::Lang(Lang::ZhCn), + 2 => LocalePref::Lang(Lang::ZhTw), + 3 => LocalePref::Lang(Lang::En), + 4 => LocalePref::Lang(Lang::Ja), + 5 => LocalePref::Lang(Lang::Ko), + _ => return, + }; + self.apply_locale_pref(pref); + self.frontend_vm.settings.language = index; + } + frontend::view_model::SettingsComboField::RecordingMode => { + // Tauri 的三档:切换式 / 按住说话 / 自动识别。 + preferences.hotkey.mode = match index { + 1 => openless_core::shared_types::HotkeyMode::Hold, + 2 => openless_core::shared_types::HotkeyMode::Auto, + _ => openless_core::shared_types::HotkeyMode::Toggle, + }; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsComboField::CodingAgentProvider => { + preferences.coding_agent_provider = match index { + 1 => "opencode-cli", + 2 => "codex-cli", + 3 => "dsh-cli", + _ => "claude-code-cli", + } + .to_string(); + self.settings_dirty.coding_agent_enabled = true; + } + frontend::view_model::SettingsComboField::CodingAgentPermission => { + preferences.coding_agent_permission_mode = match index { + 1 => "plan", + 2 => "default", + 3 => "bypassPermissions", + _ => "acceptEdits", + } + .to_string(); + self.settings_dirty.coding_agent_enabled = true; + } + frontend::view_model::SettingsComboField::SelectionPolishDelivery => { + preferences.selection_polish_output_mode = match index { + 1 => openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm, + _ => openless_core::shared_types::SelectionPolishOutputMode::DirectReplace, + }; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsComboField::SilenceSeconds => { + preferences.silence_auto_stop_seconds = index as f32 + 1.0; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsComboField::Microphone => { + preferences.microphone_device_name = if index == 0 { + String::new() + } else { + self.frontend_vm + .settings + .microphone_options + .get(index - 1) + .cloned() + .unwrap_or_default() + }; + self.settings_dirty.microphone = true; + } + frontend::view_model::SettingsComboField::PasteShortcut => { + preferences.paste_shortcut = match index { + 1 => openless_core::shared_types::PasteShortcut::CtrlShiftV, + 2 => openless_core::shared_types::PasteShortcut::ShiftInsert, + _ => openless_core::shared_types::PasteShortcut::CtrlV, + }; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsComboField::RemoteDefaultMode => { + preferences.remote_input_default_mode = if index == 1 { + "hold".to_string() } else { - format!("事件积压 {dropped} 条,已从 Core 重放补齐") + "toggle".to_string() }; + self.settings_dirty.remote_input_enabled = true; } } - while let Ok(result) = self.rx.try_recv() { - match result { - UiResult::Environment(environment) => { - self.environment = Some(environment); - self.environment_refreshing = false; + self.save_settings_if_dirty(); + } + + /// 快捷键录入完成:写入对应偏好,并以 strict 模式保存以便立即应用热键副作用。 + fn apply_shortcut_captured( + &mut self, + field: frontend::view_model::ShortcutField, + primary: String, + modifiers: Vec, + ) { + let binding = openless_core::shared_types::ShortcutBinding { primary, modifiers }; + if let Err(error) = openless_core::validate_shortcut_binding(&binding) { + self.frontend_vm.settings_notice = Some(fmt_l10n( + self.lang, + "settings.recording.combo_conflict", + &[&error.to_string()], + )); + self.frontend_vm.shortcut_recording = None; + return; + } + // 修饰键触发(按住说话)照常保存:插件只观察不吞修饰键, + // 按住期间若又按了别的键就判定为组合键、放弃触发。 + let draft_pack_id = self + .frontend_vm + .style_packs + .get(self.frontend_vm.style_hotkey_draft_pack) + .map(|pack| pack.id.clone()); + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + use frontend::view_model::ShortcutField; + match field { + ShortcutField::Dictation => preferences.dictation_hotkey = binding, + ShortcutField::Translation => preferences.translation_hotkey = binding, + ShortcutField::Qa => preferences.qa_hotkey = Some(binding), + ShortcutField::SwitchStyle => preferences.switch_style_hotkey = Some(binding), + ShortcutField::OpenApp => preferences.open_app_hotkey = Some(binding), + ShortcutField::CodingAgentVoice => { + preferences.coding_agent_voice_hotkey = Some(binding); + // 「按住说话」有了触发键,Agent 也就该启用(Tauri 同样顺带打开)。 + preferences.coding_agent_enabled = true; + } + ShortcutField::SelectionPolish => { + preferences.selection_polish_hotkey = Some(binding) + } + ShortcutField::StylePack(index) => { + if let Some(row) = preferences.style_pack_hotkeys.get_mut(index) { + row.binding = binding; } - UiResult::Message(message) => self.status = message, - UiResult::Models(Ok(models)) => self.models = ModelsState::Loaded(models), - UiResult::Models(Err(error)) => { - self.models = ModelsState::Failed(error.clone()); - self.status = error; + } + ShortcutField::StyleDraft => { + if let Some(pack_id) = draft_pack_id { + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); + preferences.style_pack_hotkeys.push( + openless_core::shared_types::StylePackHotkey { pack_id, binding }, + ); + self.frontend_vm.style_hotkey_draft_open = false; } - UiResult::Remote(Ok(remote)) => { - self.remote_error = None; - self.remote_access = Some(remote); + } + } + self.settings_dirty.hotkeys = true; + self.frontend_vm.shortcut_recording = None; + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + /// 停用某个快捷键绑定(核心录音快捷键没有停用,UI 里也不给按钮)。 + fn apply_shortcut_disable(&mut self, field: frontend::view_model::ShortcutField) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + use frontend::view_model::ShortcutField; + match field { + ShortcutField::Qa => preferences.qa_hotkey = None, + ShortcutField::SwitchStyle => preferences.switch_style_hotkey = None, + ShortcutField::OpenApp => preferences.open_app_hotkey = None, + ShortcutField::CodingAgentVoice => preferences.coding_agent_voice_hotkey = None, + ShortcutField::SelectionPolish => preferences.selection_polish_hotkey = None, + ShortcutField::StylePack(index) => { + if let Some(row) = preferences.style_pack_hotkeys.get(index) { + let pack_id = row.pack_id.clone(); + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); } - UiResult::Remote(Err(error)) => { - self.remote_access = None; - self.remote_error = Some(error.clone()); - self.status = error; + } + // 录音/翻译必须保留一个绑定;草稿行还没有内容。 + ShortcutField::Dictation + | ShortcutField::Translation + | ShortcutField::StyleDraft => { + return; + } + } + self.settings_dirty.hotkeys = true; + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + fn apply_style_hotkey_remove(&mut self, index: usize) { + let pack_id = self + .frontend_vm + .settings + .style_pack_hotkeys + .get(index) + .map(|row| row.pack_id.clone()); + let Some(pack_id) = pack_id else { + return; + }; + if let Some(preferences) = self.preferences.as_mut() { + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); + } + self.settings_dirty.hotkeys = true; + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + /// 换绑到另一个风格包(目标包已有绑定时忽略,与 Tauri 的下拉置灰同义)。 + fn apply_style_hotkey_repack(&mut self, index: usize, pack_index: usize) { + let pack_id = self + .frontend_vm + .settings + .style_pack_hotkeys + .get(index) + .map(|row| row.pack_id.clone()); + let target = self + .frontend_vm + .style_packs + .get(pack_index) + .map(|pack| pack.id.clone()); + let (Some(current), Some(target)) = (pack_id, target) else { + return; + }; + if current == target { + return; + } + if let Some(preferences) = self.preferences.as_mut() { + if preferences + .style_pack_hotkeys + .iter() + .any(|entry| entry.pack_id == target) + { + return; + } + if let Some(entry) = preferences + .style_pack_hotkeys + .iter_mut() + .find(|entry| entry.pack_id == current) + { + entry.pack_id = target; + } + } + self.settings_dirty.hotkeys = true; + self.save_settings_if_dirty(); + } + + /// Apply a settings text field change from the frontend. + fn apply_settings_text( + &mut self, + field: frontend::view_model::SettingsTextField, + text: String, + ) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + match field { + frontend::view_model::SettingsTextField::RemotePort => { + if let Ok(port) = text.parse::() { + preferences.remote_input_port = port; + self.settings_dirty.remote_input_port = true; + self.frontend_vm.settings.remote_port = text; } - UiResult::Providers(Ok(panel)) => { - if panel.kind != self.provider_kind { - continue; - } - if !panel.descriptors.iter().any(|descriptor| { - descriptor.provider_type.as_str() == self.new_provider_type - }) { - self.new_provider_type = panel - .descriptors - .first() - .map(|descriptor| descriptor.provider_type.as_str().to_string()) - .unwrap_or_default(); - } - let selected = self - .selected_channel_id - .as_ref() - .filter(|id| panel.channels.iter().any(|channel| &channel.id == *id)) - .cloned() - .or_else(|| { - panel - .channels - .iter() - .find(|channel| channel.id == panel.active_provider) - .map(|channel| channel.id.clone()) + } + frontend::view_model::SettingsTextField::RetentionDays => { + let parsed = text.trim().parse::().unwrap_or(0).min(365); + preferences.history_retention_days = parsed; + self.settings_dirty.recording = true; + self.frontend_vm.settings.retention_days = parsed.to_string(); + } + frontend::view_model::SettingsTextField::PolishContextWindow => { + let parsed = text.trim().parse::().unwrap_or(0).min(60); + preferences.polish_context_window_minutes = parsed; + self.settings_dirty.recording = true; + self.frontend_vm.settings.polish_context_window = parsed.to_string(); + } + frontend::view_model::SettingsTextField::AudioRecordingMaxEntries => { + preferences.audio_recording_max_entries = text + .trim() + .parse::() + .ok() + .map(|value| value.clamp(1, 200)); + self.settings_dirty.recording = true; + self.frontend_vm.settings.audio_recording_max_entries = text; + } + frontend::view_model::SettingsTextField::CodingAgentModel => { + preferences.coding_agent_model = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + self.settings_dirty.coding_agent_enabled = true; + self.frontend_vm.settings.coding_agent_model = text; + } + frontend::view_model::SettingsTextField::CodingAgentWorkdir => { + preferences.coding_agent_workdir = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + self.settings_dirty.coding_agent_enabled = true; + self.frontend_vm.settings.coding_agent_workdir = text; + } + frontend::view_model::SettingsTextField::CodingAgentExe => { + preferences.coding_agent_exe = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + self.settings_dirty.coding_agent_enabled = true; + self.frontend_vm.settings.coding_agent_exe = text; + } + frontend::view_model::SettingsTextField::HistoryMaxEntries => { + preferences.history_max_entries = text + .trim() + .parse::() + .ok() + .map(|value| value.clamp(5, 200)); + self.settings_dirty.recording = true; + self.frontend_vm.settings.history_max_entries = text; + } + } + self.save_settings_if_dirty(); + } + + /// Apply a settings action button from the frontend. + fn apply_settings_action(&mut self, field: frontend::view_model::SettingsActionField) { + match field { + frontend::view_model::SettingsActionField::ExportDiagnostics => { + if let Some(backend) = self.backend() { + let source = openless_linux_egui::log_path(&backend.config().data_dir); + let lang = self.lang; + self.spawn(async move { + let destination = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("Log", &["log"]) + .set_file_name("openless.log") + .save_file() }) - .or_else(|| panel.channels.first().map(|channel| channel.id.clone())); - self.selected_channel_id = selected.clone(); - if self.pending_channel_delete.as_ref().is_some_and(|id| { - !panel.channels.iter().any(|channel| &channel.id == id) - }) { - self.pending_channel_delete = None; - } - self.providers = ProvidersState::Loaded(panel.clone()); - self.provider_models.clear(); - if let Some(channel_id) = selected { - if let Some((channel, descriptor)) = - provider_channel_descriptor(&panel, &channel_id) - { - self.provider_editor = ProviderEditorState::Loading { - kind: panel.kind, - channel_id, - }; - self.load_provider_editor(panel.kind, channel, descriptor); - } - } else { - self.provider_editor = ProviderEditorState::Idle; - } - } - UiResult::Providers(Err(error)) => { - self.providers = ProvidersState::Failed(error.clone()); - self.status = error; + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.export_log_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::export_error_log(&source, &destination) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.export_log_done").to_string()) + }); } - UiResult::ProviderEditor { - kind, - channel_id, - result, - } => { - if kind != self.provider_kind - || self.selected_channel_id.as_deref() != Some(channel_id.as_str()) - { - continue; - } - match *result { - Ok(editor) => { - // Reads race with channel switching and mutation - // refreshes. Only the still-selected channel may install - // its editor, otherwise late credential data is ignored. - self.provider_editor = - ProviderEditorState::Loaded(Box::new(editor)); + } + frontend::view_model::SettingsActionField::CheckBetaUpdate => { + self.request_update_check(openless_core::shared_types::UpdateChannel::Beta); + } + frontend::view_model::SettingsActionField::CopyCertFingerprint => { + let fingerprint = self + .remote_access + .as_ref() + .and_then(|(status, _)| status.ca_fingerprint_sha256.clone()); + match fingerprint { + Some(fingerprint) => match fcitx5_copy_to_clipboard(&fingerprint) { + Ok(()) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "status.copied").to_string()); } Err(error) => { - self.provider_editor = ProviderEditorState::Failed(error.clone()); - self.status = error; + self.frontend_vm.settings_notice = + Some(fmt_l10n(self.lang, "status.copy_failed", &[&error])); } + }, + None => { + self.frontend_vm.settings_notice = Some( + tr_l10n( + self.lang, + "settings.remote_input.cert_fingerprint_unavailable", + ) + .to_string(), + ); } } - UiResult::ProviderModels { - kind, - channel_id, - result, - } => { - if kind == self.provider_kind - && self.selected_channel_id.as_deref() == Some(channel_id.as_str()) - { - match result { - Ok(models) => { - self.status = format!("已读取 {} 个模型", models.len()); - self.provider_models = models; - } - Err(error) => self.status = error, - } + } + frontend::view_model::SettingsActionField::CheckUpdate => { + let channel = self + .preferences + .as_ref() + .map(|prefs| prefs.update_channel) + .unwrap_or_default(); + self.request_update_check(channel); + } + frontend::view_model::SettingsActionField::OpenGitHub => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenHelp => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenReleaseNotes => { + let _ = open_external("https://github.com/earendil-works/openless/releases"); + } + frontend::view_model::SettingsActionField::OpenFeedback => { + let _ = open_external("https://github.com/earendil-works/openless/issues"); + } + frontend::view_model::SettingsActionField::CopyQQ => { + match fcitx5_copy_to_clipboard("1078960553") { + Ok(()) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "status.copied").to_string()); } - } - UiResult::ProviderMutation(result) => { - match result { - Ok(message) => self.status = message, - Err(error) => self.status = error, + Err(error) => { + self.frontend_vm.settings_notice = Some(fmt_l10n( + self.lang, + "status.copy_failed", + &[&error.to_string()], + )); } - self.providers = ProvidersState::Loading; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(self.provider_kind); } } } - if let Some(backend) = self.backend() { - self.snapshot = Some(backend.snapshot()); + } + + /// Persist dirty settings if any fields have been changed. + fn save_settings_if_dirty(&mut self) { + if !self.settings_dirty.any() { + return; + } + if let (Some(native), Some(draft), Some(snapshot)) = + (&self.native, self.preferences.clone(), &self.snapshot) + { + let host = native.host_arc(); + let revision = snapshot.preferences_revision; + let dirty = self.settings_dirty; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let outcome = tokio::task::spawn_blocking(move || { + let save = |preferences, revision| { + if dirty.hotkeys { + host.update_settings_strict(preferences, revision) + } else { + host.save_settings(preferences, revision) + } + }; + match save(draft.clone(), revision) { + Err(error) if error.code == openless_core::BackendErrorCode::Busy => { + let latest_snapshot = host.snapshot(); + let latest = host.backend().get_preferences(); + save( + dirty.merge(&latest, &draft), + latest_snapshot.preferences_revision, + ) + } + result => result, + } + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + let _ = tx.send(UiResult::SettingsSaved(Box::new(outcome))); + }); } } - fn dictation_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("听写"); - let phase = self - .snapshot - .as_ref() - .map(|snapshot| snapshot.dictation.phase) - .unwrap_or(DictationPhase::Idle); - ui.horizontal(|ui| { - if ui - .add_enabled(phase == DictationPhase::Idle, egui::Button::new("开始")) - .clicked() - { - if let Some(backend) = self.backend() { - self.transcript.clear(); - self.spawn(async move { - backend.start_dictation().await?; - Ok("正在录音".to_string()) - }); + /// Dispatch frontend actions to existing Core / backend methods. + fn apply_frontend_actions( + &mut self, + actions: Vec, + // 宿主没有窗口:窗口类动作由 UI 进程就地处理,这里保留参数是为了 + // 让调用点保持「渲染层 → 动作 → 宿主」的形状。 + _ctx: &egui::Context, + ) { + for action in actions { + match action { + frontend::view_model::FrontendAction::Navigate(page) => { + self.active_page = match page { + frontend::view_model::Page::Overview => shell::Page::Overview, + frontend::view_model::Page::History => shell::Page::History, + frontend::view_model::Page::Vocab => shell::Page::Vocabulary, + frontend::view_model::Page::Style => shell::Page::Styles, + frontend::view_model::Page::Marketplace => shell::Page::Marketplace, + frontend::view_model::Page::SelectionAsk => shell::Page::Assistant, + frontend::view_model::Page::Translation => shell::Page::Translation, + frontend::view_model::Page::Corrections => shell::Page::Corrections, + frontend::view_model::Page::Settings => shell::Page::Providers, + }; + if page == frontend::view_model::Page::Marketplace { + // The list is fetched lazily; entering the page is what + // triggers the first load. + self.load_marketplace(); + } } - } - if ui - .add_enabled( - phase == DictationPhase::Recording, - egui::Button::new("停止"), - ) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - let result = backend.stop_dictation().await?; - Ok(format!("完成:{} 字", result.polished_text.chars().count())) - }); + frontend::view_model::FrontendAction::ToggleSettings => { + self.frontend_vm.settings_open = !self.frontend_vm.settings_open; + if self.frontend_vm.settings_open { + self.frontend_vm.active_page = frontend::view_model::Page::Settings; + // 每次打开设置都刷新「必配服务」状态点。 + self.load_service_configured(); + if self.settings_channels.is_empty() { + self.load_settings_channels(); + } + } } - } - if ui - .add_enabled(phase != DictationPhase::Idle, egui::Button::new("取消")) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_dictation(None).await?; - Ok("听写已取消".to_string()) - }); + frontend::view_model::FrontendAction::CloseSettings => { + self.frontend_vm.settings_open = false; + self.frontend_vm.active_page = frontend::view_model::Page::Overview; } - } - }); - ui.label(if self.transcript.is_empty() { - "尚无转写结果" - } else { - &self.transcript - }); - } - - fn less_computer_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("Less Computer"); - ui.text_edit_multiline(&mut self.less_computer_input); - ui.horizontal(|ui| { - if ui.button("运行").clicked() && !self.less_computer_input.trim().is_empty() { - if let Some(backend) = self.backend() { - let prompt = self.less_computer_input.clone(); - self.less_computer_output.clear(); - self.spawn(async move { - backend.submit_less_computer(prompt).await?; - Ok("Less Computer 已完成".to_string()) - }); + frontend::view_model::FrontendAction::SidebarToggleStyle => { + self.frontend_vm.style_open = !self.frontend_vm.style_open; } - } - if ui.button("取消").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_less_computer(None).await?; - Ok("Less Computer 已取消".to_string()) - }); + frontend::view_model::FrontendAction::OverviewRefresh => { + self.overview = OverviewState::Loading; + self.load_overview(); + } + frontend::view_model::FrontendAction::OverviewPeriod(period) => { + self.frontend_vm.overview_period = period.min(1); + } + frontend::view_model::FrontendAction::OverviewMetric(metric) => { + self.frontend_vm.overview_metric = metric.min(2); + } + frontend::view_model::FrontendAction::SidebarToggleTools => { + self.frontend_vm.tools_open = !self.frontend_vm.tools_open; + } + frontend::view_model::FrontendAction::WindowClose + | frontend::view_model::FrontendAction::WindowMinimize + | frontend::view_model::FrontendAction::WindowMaximize => { + // 窗口控制由 UI 进程就地处理(只有它有窗口);宿主收到说明 + // 某个 UI 分支忘了拦,记一条即可,不影响任何状态。 + log::debug!("[ui-host] window action reached the host: {action:?}"); + } + frontend::view_model::FrontendAction::MarketplaceRefresh => { + self.load_marketplace(); + } + frontend::view_model::FrontendAction::MarketplaceMyPacks => { + self.load_marketplace_mine(); + } + frontend::view_model::FrontendAction::MarketplaceSearch(query) => { + self.marketplace_query = query.clone(); + // Echo it back so the field never reverts while typing. + self.frontend_vm.marketplace_query = query; + self.load_marketplace(); + } + frontend::view_model::FrontendAction::MarketplaceCloseDetail => { + self.frontend_vm.marketplace_selected = None; + } + frontend::view_model::FrontendAction::MarketplaceInstall(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let lang = self.lang; + self.spawn(async move { + let pack = backend.services().marketplace.install(id).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_installed", + &[&pack.name], + )) + }); + } + } + } + frontend::view_model::FrontendAction::MarketplaceDownload(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let lang = self.lang; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let bytes = backend + .services() + .marketplace + .download_archive(id.clone()) + .await?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!( + "openless-marketplace-{id}.zip" + )) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.marketplace_zip_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + }) + }) + .await + .map_err( + |error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + }, + )??; + Ok::<_, BackendError>( + tr_l10n(lang, "status.marketplace_zip_saved") + .to_string(), + ) + } + .await + .unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(result)); + }); + } + } } - } - }); - ui.label(if self.less_computer_output.is_empty() { - "尚无 Agent 输出" - } else { - &self.less_computer_output - }); - } - - fn agent_approval_ui(&mut self, ui: &mut egui::Ui) { - if let Some((token, command)) = self.pending_approval.clone() { - egui::ScrollArea::vertical() - .id_salt("approval_command") - .max_height(72.0) - .show(ui, |ui| { - ui.label(format!("请求执行:{command}")); - }); - ui.horizontal(|ui| { - for (label, approved) in [("允许", true), ("拒绝", false)] { - if ui.button(label).clicked() { + frontend::view_model::FrontendAction::MarketplaceToggleLike(index) => { + if let Some(item) = self.marketplace_items.get(index) { if let Some(backend) = self.backend() { - let token = token.clone(); - self.pending_approval = None; + let id = item.id.clone(); + // Optimistic flip so the star reacts immediately. + let was_liked = self.marketplace_my_likes.contains(&id); + if was_liked { + self.marketplace_my_likes.retain(|liked| liked != &id); + } else { + self.marketplace_my_likes.push(id.clone()); + } + if let Some(pack) = + self.frontend_vm.marketplace_packs.get_mut(index) + { + pack.liked = !was_liked; + pack.likes = if was_liked { + pack.likes.saturating_sub(1) + } else { + pack.likes.saturating_add(1) + }; + } + let lang = self.lang; + let restore_id = id.clone(); self.spawn(async move { - backend - .services() - .less_computer - .approve(token, approved) - .await?; - Ok("审批已提交".to_string()) + let result = + backend.services().marketplace.toggle_like(id).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_like", + &[&result.like_count], + )) }); + let _ = restore_id; } } } - }); - } - } - - fn qa_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("问答"); - if !self.qa_visible { - ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); - if ui.button("打开问答").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.show().await?; - Ok("问答已打开".to_string()) - }); + frontend::view_model::FrontendAction::MarketplaceSort(sort) => { + self.frontend_vm.marketplace_sort = sort; + self.load_marketplace(); } - } - return; - } - if let Some(state) = &self.qa_state { - if let Some(messages) = &state.messages { - for message in messages { - ui.label(format!("{}:{}", message.role, message.content)); + frontend::view_model::FrontendAction::HistoryRefresh => { + self.frontend_vm.history_loading = true; + self.frontend_vm.history_error = None; + self.frontend_vm.history_confirm = None; } - } - if let Some(chunk) = &state.chunk { - ui.label(chunk); - } - if let Some(error) = &state.error { - ui.colored_label(egui::Color32::RED, error); - } - } - ui.text_edit_multiline(&mut self.qa_input); - ui.horizontal(|ui| { - let recording = self - .qa_state - .as_ref() - .is_some_and(|state| state.kind == QaStateKind::Recording); - if ui - .button(if recording { - "结束录音" - } else { - "语音提问" - }) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.toggle_recording().await?; - Ok("问答录音状态已更新".to_string()) - }); + frontend::view_model::FrontendAction::HistorySelect(index) => { + self.frontend_vm.history_selected = index; } - } - if ui.button("发送").clicked() && !self.qa_input.trim().is_empty() { - if let Some(backend) = self.backend() { - let text = std::mem::take(&mut self.qa_input); - self.spawn(async move { - backend.services().qa.submit_text(text).await?; - Ok("问答已提交".to_string()) - }); + frontend::view_model::FrontendAction::HistoryRequestClear => { + self.frontend_vm.history_confirm = + Some(frontend::view_model::HistoryConfirm::Clear); } - } - if ui.button("关闭").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.dismiss().await?; - Ok("问答已关闭".to_string()) - }); + frontend::view_model::FrontendAction::HistoryRequestDelete(index) => { + self.frontend_vm.history_confirm = + Some(frontend::view_model::HistoryConfirm::Delete(index)); } - } - if ui.button("取消本轮").clicked() { - if let Some(backend) = self.backend() { - let session_id = self - .qa_state + frontend::view_model::FrontendAction::HistoryCancelConfirm => { + self.frontend_vm.history_confirm = None; + } + frontend::view_model::FrontendAction::HistoryConfirmAction => { + match self.frontend_vm.history_confirm.take() { + Some(frontend::view_model::HistoryConfirm::Clear) => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend.clear_history()?; + Ok(tr_l10n(lang, "status.history_cleared").to_string()) + }); + } + } + Some(frontend::view_model::HistoryConfirm::Delete(index)) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) + { + let id = entry.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.delete_history(&id)?; + Ok(tr_l10n(lang, "status.history_deleted").to_string()) + }); + } + } + } + None => {} + } + } + frontend::view_model::FrontendAction::HistoryPlay(index) => { + let Some(entry) = self.frontend_vm.history_entries.get(index) else { + return; + }; + let id = entry.id.clone(); + // Same clip again -> stop; otherwise start the new one. + let same = self + .history_clip .as_ref() - .and_then(|state| state.session_id.as_deref()) - .and_then(|id| uuid::Uuid::parse_str(id).ok()) - .map(openless_core::SessionId::from_uuid); - self.spawn(async move { - backend.services().qa.cancel(session_id).await?; - Ok("问答本轮已取消".to_string()) - }); + .is_some_and(|(playing, _)| playing == &id); + self.history_clip = None; + if same { + return; + } + if let Some(backend) = self.backend() { + let data_dir = backend.config().data_dir.clone(); + match openless_linux_egui::read_recording_wav(&data_dir, &id) + .and_then(|wav| { + openless_linux_egui::recording_pcm(&wav).map(|pcm| pcm.to_vec()) + }) + .map_err(|error| error.to_string()) + .and_then(|pcm| openless_linux_egui::ClipPlayer::play(&pcm)) + { + Ok(player) => self.history_clip = Some((id, player)), + Err(error) => self.status = error, + } + } } - } - }); - } - - fn selection_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("选区润色"); - let Some(selection) = self.selection.clone() else { - ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。"); - ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。"); - return; - }; - ui.label(format!("当前状态:{:?}", selection.phase)); - if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { - ui.strong("选区预览"); - ui.text_edit_multiline(&mut self.selection_draft); - ui.horizontal(|ui| { - if ui.button("确认替换").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - let text = self.selection_draft.clone(); - self.spawn(async move { - backend - .services() - .selection - .confirm(session_id, Some(text)) - .await?; - Ok("选区替换已确认".to_string()) - }); + frontend::view_model::FrontendAction::HistoryRetranscribe(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) { + let id = entry.id.clone(); + let data_dir = backend.config().data_dir.clone(); + let lang = self.lang; + self.spawn(async move { + let recording_id = id.clone(); + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav( + &data_dir, + &recording_id, + ) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let pcm = openless_linux_egui::recording_pcm(&wav) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })? + .to_vec(); + let started = std::time::Instant::now(); + let result = backend + .services() + .auxiliary + .retranscribe_pcm(pcm) + .await + .map_err(|failure| failure.error)?; + let entry = backend.apply_history_retranscription( + &id, + result.text, + &result.asr, + started.elapsed().as_millis() as u64, + )?; + Ok(fmt_l10n(lang, "status.retranscribed", &[&entry.final_text])) + }); + } } } - if ui.button("取消").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { + frontend::view_model::FrontendAction::HistoryExport(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) { + let id = entry.id.clone(); + let data_dir = backend.config().data_dir.clone(); + let lang = self.lang; + self.spawn(async move { + let file_name = format!("openless-recording-{id}.wav"); + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("WAV audio", &["wav"]) + .set_file_name(file_name) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.recording_export_cancelled"), + ) + })?; + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav(&data_dir, &id) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let saved = tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &wav) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(fmt_l10n( + lang, + "status.recording_exported", + &[&saved.display()], + )) + }); + } + } + } + frontend::view_model::FrontendAction::VocabFilter(index) => { + self.frontend_vm.vocab_filter = index.min(2); + } + frontend::view_model::FrontendAction::VocabSearch(query) => { + self.frontend_vm.vocab_query = query; + } + frontend::view_model::FrontendAction::VocabAddPhrase(phrase) => { + if let Some(backend) = self.backend() { + let lang = self.lang; self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) + backend.add_vocabulary(phrase, None)?; + Ok(tr_l10n(lang, "status.vocab_saved").to_string()) }); } } - }); - } else if selection.phase == SelectionPhase::Completed - && selection.revert_outcome.is_none() - { - ui.horizontal(|ui| { - ui.label("最近一次选区替换已完成"); - if ui.button("撤销").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { + frontend::view_model::FrontendAction::VocabRemovePhrase(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.vocabulary.get(index) { + let id = entry.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.remove_vocabulary(&id)?; + Ok(tr_l10n(lang, "status.vocab_updated").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::VocabTogglePhrase(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.vocabulary.get(index) { + let id = entry.id.clone(); + let enabled = !entry.enabled; + let lang = self.lang; + self.spawn(async move { + backend.set_vocabulary_enabled(&id, enabled)?; + Ok(tr_l10n(lang, "status.vocab_updated").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::VocabAddRule { + pattern, + replacement, + } => { + if let Some(backend) = self.backend() { + let lang = self.lang; self.spawn(async move { - backend.services().selection.revert(session_id).await?; - Ok("选区替换已撤销".to_string()) + backend.add_correction_rule(pattern, replacement)?; + Ok(tr_l10n(lang, "status.correction_saved").to_string()) }); } } - }); - } - } - - fn models_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.heading("本地模型"); - if ui.button("刷新").clicked() { - self.models = ModelsState::Loading; - self.load_models(); - } - }); - let models = match self.models.clone() { - ModelsState::Loading => { - ui.label("正在加载模型目录…"); - return; - } - ModelsState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; - } - ModelsState::Loaded(models) if models.is_empty() => { - ui.label("模型目录未返回任何可用模型"); - return; - } - ModelsState::Loaded(models) => models, - }; - for model in models { - ui.horizontal(|ui| { - ui.label(format!( - "{} · {} · {}", - model.display_name, - model.family, - if model.installed { - "已安装" - } else { - "未安装" + frontend::view_model::FrontendAction::VocabRemoveRule(index) => { + if let Some(backend) = self.backend() { + if let Some(rule) = self.correction_rules.get(index) { + let id = rule.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.remove_correction_rule(&id)?; + Ok(tr_l10n(lang, "status.correction_updated").to_string()) + }); + } } - )); - if !model.installed && ui.button("下载").clicked() { + } + frontend::view_model::FrontendAction::VocabToggleRule(index) => { + if let Some(backend) = self.backend() { + if let Some(rule) = self.correction_rules.get(index) { + let id = rule.id.clone(); + let enabled = !rule.enabled; + let lang = self.lang; + self.spawn(async move { + backend.set_correction_rule_enabled(&id, enabled)?; + Ok(tr_l10n(lang, "status.correction_updated").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::VocabApplyPreset(index) => { + if let Some(backend) = self.backend() { + if let Some(preset) = self.vocab_presets.get(index) { + let phrases = preset.phrases.clone(); + let name = preset.name.clone(); + let lang = self.lang; + self.spawn(async move { + for phrase in phrases { + backend.add_vocabulary( + phrase, + Some(fmt_l10n(lang, "status.from_preset", &[&name])), + )?; + } + Ok(tr_l10n(lang, "status.preset_updated").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::VocabCreatePreset { name, phrases } => { if let Some(backend) = self.backend() { - let target = model.target.clone(); + let lang = self.lang; self.spawn(async move { - backend - .services() - .local_asr - .start_download(target, None) - .await?; - Ok("模型下载完成".to_string()) + let mut phrase_list: Vec = phrases + .split([',', ',', '\n']) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(ToOwned::to_owned) + .collect(); + phrase_list.sort(); + phrase_list.dedup(); + let mut store = backend.list_vocabulary_presets()?; + store.custom.push(openless_core::VocabPreset { + id: uuid::Uuid::new_v4().to_string(), + name: name.trim().to_string(), + phrases: phrase_list, + }); + backend.save_vocabulary_presets(&store)?; + Ok(tr_l10n(lang, "status.preset_updated").to_string()) }); } } - if model.installed && ui.button("激活").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); + frontend::view_model::FrontendAction::StyleActivate(index) => { + let Some(pack) = self.style_packs.get(index) else { + return; + }; + let id = pack.id.clone(); + if self.frontend_vm.style_selection_workflow { + // Selection polish keeps its own active pack + // (`prefs.selection_polish_style_pack_id`). + if let Some(preferences) = self.preferences.as_mut() { + preferences.selection_polish_style_pack_id = id; + self.settings_dirty.appearance = true; + } + self.save_settings_if_dirty(); + } else if let Some(backend) = self.backend() { + let lang = self.lang; self.spawn(async move { - let descriptor = - openless_core::provider_rules::provider_descriptor( - openless_core::ProviderKind::Asr, - "local-qwen3-c", - ) + backend.activate_style_pack(&id)?; + Ok(tr_l10n(lang, "status.style_updated").to_string()) + }); + } + } + frontend::view_model::FrontendAction::StyleExport(index) => { + if let Some(backend) = self.backend() { + if let Some(pack) = self.style_packs.get(index) { + let id = pack.id.clone(); + let lang = self.lang; + self.spawn(async move { + let bytes = backend.export_style_pack_bytes(&id)?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!("openless-style-{id}.zip")) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? .ok_or_else(|| { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Unsupported, - "local Qwen provider is unavailable", + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_export_cancelled"), ) })?; - let provider_type = descriptor.provider_type.as_str().to_string(); - let existing = backend - .list_channels(openless_core::ChannelKind::Asr) - .await? - .into_iter() - .find(|channel| channel.provider_type == provider_type) - .map(|channel| channel.id); - let provider_id = match existing { - Some(provider_id) => provider_id, - None => { - backend - .create_channel( - openless_core::ChannelKind::Asr, - provider_type, - descriptor.label_key, - ) - .await? - } - }; - backend - .activate_local_asr(openless_core::LocalAsrActivationRequest { - target, - provider_id, + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) }) - .await?; - Ok("本地模型已激活并预加载".to_string()) - }); + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.style_updated").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::StyleEdit(index) => { + if let Some(pack) = self.style_packs.get(index).cloned() { + self.style_editor = Some(pack); + self.frontend_vm.style_editor_open = true; + self.frontend_vm.style_prompt = self + .style_editor + .as_ref() + .map(|e| e.prompt.clone()) + .unwrap_or_default(); + } + } + frontend::view_model::FrontendAction::StyleSaveEditor(prompt) => { + if let Some(mut pack) = self.style_editor.take() { + pack.prompt = prompt; + if let Some(backend) = self.backend() { + let exists = self.style_packs.iter().any(|p| p.id == pack.id); + let lang = self.lang; + self.spawn(async move { + let saved = if exists { + backend.update_style_pack(pack)? + } else { + backend.create_style_pack(pack)? + }; + Ok(fmt_l10n(lang, "status.style_saved", &[&saved.name])) + }); + } } + self.frontend_vm.style_editor_open = false; + } + frontend::view_model::FrontendAction::StyleCloseEditor => { + self.style_editor = None; + self.frontend_vm.style_editor_open = false; + } + frontend::view_model::FrontendAction::StyleNewPack => { + self.style_editor = Some(openless_core::StylePack { + id: uuid::Uuid::new_v4().to_string(), + name: tr_l10n(self.lang, "lbl.new_style_default").to_string(), + ..Default::default() + }); + self.frontend_vm.style_editor_open = true; } - if ui.button("取消").clicked() { + frontend::view_model::FrontendAction::StyleImport => { if let Some(backend) = self.backend() { - let target = model.target.clone(); + let lang = self.lang; self.spawn(async move { - backend.services().local_asr.cancel_download(target).await?; - Ok("模型下载已取消".to_string()) + let path = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .pick_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_import_cancelled"), + ) + })?; + let pack = tokio::task::spawn_blocking(move || { + backend.import_style_pack_path(&path) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok(fmt_l10n(lang, "status.style_imported", &[&pack.name])) }); } } - }); - } - } - - fn provider_management_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.strong("凭据渠道"); - for (kind, label) in [ - (openless_core::ChannelKind::Asr, "ASR"), - (openless_core::ChannelKind::Llm, "LLM"), - ] { - if ui - .selectable_label(self.provider_kind == kind, label) - .clicked() - && self.provider_kind != kind - { - self.provider_kind = kind; - self.providers = ProvidersState::Loading; - self.selected_channel_id = None; - self.pending_channel_delete = None; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(kind); + frontend::view_model::FrontendAction::SelectionAskToggleHistory => { + self.frontend_vm.qa_save_history = !self.frontend_vm.qa_save_history; } - } - if ui.button("刷新渠道").clicked() { - self.providers = ProvidersState::Loading; - self.load_providers(self.provider_kind); - } - }); - - let panel = match self.providers.clone() { - ProvidersState::Loading => { - ui.label("正在读取 Core 渠道目录…"); - return; - } - ProvidersState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; - } - ProvidersState::Loaded(panel) => panel, - }; - - ui.group(|ui| { - ui.label("新增渠道"); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("new-provider-type") - .selected_text( - panel - .descriptors + frontend::view_model::FrontendAction::TranslationToggleLanguage(language) => { + if let Some(preferences) = self.preferences.as_mut() { + match preferences + .working_languages .iter() - .find(|item| item.provider_type.as_str() == self.new_provider_type) - .map(provider_descriptor_label) - .unwrap_or_else(|| "选择 Provider".to_string()), - ) - .show_ui(ui, |ui| { - for descriptor in &panel.descriptors { - ui.selectable_value( - &mut self.new_provider_type, - descriptor.provider_type.as_str().to_string(), - provider_descriptor_label(descriptor), - ); + .position(|value| value == &language) + { + Some(index) => { + preferences.working_languages.remove(index); + } + None => preferences.working_languages.push(language), } - }); - ui.text_edit_singleline(&mut self.new_channel_name); - if ui - .add_enabled( - !self.new_provider_type.is_empty(), - egui::Button::new("创建"), - ) - .clicked() - { - if let (Some(backend), Some(descriptor)) = ( - self.backend(), - panel - .descriptors - .iter() - .find(|item| item.provider_type.as_str() == self.new_provider_type), - ) { - let kind = panel.kind; - let provider_type = descriptor.provider_type.as_str().to_string(); - let name = if self.new_channel_name.trim().is_empty() { - descriptor.label_key.clone() - } else { - self.new_channel_name.trim().to_string() - }; - self.new_channel_name.clear(); - self.spawn_provider_mutation(async move { + self.settings_dirty.appearance = true; + } + self.save_settings_if_dirty(); + } + frontend::view_model::FrontendAction::TranslationSetTarget(language) => { + if let Some(preferences) = self.preferences.as_mut() { + preferences.translation_target_language = language; + self.settings_dirty.appearance = true; + } + self.save_settings_if_dirty(); + } + frontend::view_model::FrontendAction::SettingsToggle(field) => { + self.apply_settings_toggle(field); + } + frontend::view_model::FrontendAction::SettingsCombo(field, index) => { + self.apply_settings_combo(field, index); + } + frontend::view_model::FrontendAction::SettingsText(field, text) => { + self.apply_settings_text(field, text); + } + frontend::view_model::FrontendAction::SettingsAction(field) => { + self.apply_settings_action(field); + } + frontend::view_model::FrontendAction::SettingsSection(section) => { + self.frontend_vm.settings_section = section; + } + frontend::view_model::FrontendAction::SettingsServicesView(view) => { + self.frontend_vm.services_view = view.min(3); + let kind = if view == 1 { + openless_core::ChannelKind::Asr + } else { + openless_core::ChannelKind::Llm + }; + if self.settings_channel_kind != kind { + self.settings_channel_kind = kind; + // The editor belongs to one channel kind: switching the + // AI-services tab must not carry it across. + self.close_provider_editor(); + self.selected_channel_id = None; + self.load_settings_channels(); + self.load_service_configured(); + } else if self.settings_channels.is_empty() { + self.load_settings_channels(); + self.load_service_configured(); + } + } + frontend::view_model::FrontendAction::SettingsChannelFormOpen(open) => { + self.frontend_vm.channel_form_open = open; + if open { + self.frontend_vm.channel_form_name.clear(); + self.frontend_vm.channel_provider_index = 0; + } + } + frontend::view_model::FrontendAction::SettingsChannelProvider(index) => { + self.frontend_vm.channel_provider_index = index; + } + frontend::view_model::FrontendAction::SettingsChannelName(name) => { + self.frontend_vm.channel_form_name = name; + } + frontend::view_model::FrontendAction::SettingsChannelCreate => { + let kind = self.settings_channel_kind; + let provider_type = self + .frontend_vm + .channel_providers + .get(self.frontend_vm.channel_provider_index) + .map(|provider| provider.provider_type.clone()); + let name = self.frontend_vm.channel_form_name.trim().to_string(); + if let (Some(backend), Some(provider_type)) = + (self.backend(), provider_type) + { + let lang = self.lang; + self.spawn(async move { backend.create_channel(kind, provider_type, name).await?; - Ok("渠道已创建".to_string()) + Ok(tr_l10n(lang, "status.channel_created").to_string()) }); + self.frontend_vm.channel_form_open = false; + self.load_settings_channels(); + self.load_service_configured(); } } - }); - ui.small("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。"); - }); - - if panel.channels.is_empty() { - ui.label("尚无渠道;先从上方 Core Provider 列表创建一个。"); - return; - } - - for (index, channel) in panel.channels.iter().enumerate() { - let active = channel.id == panel.active_provider; - ui.horizontal(|ui| { - let selected = self.selected_channel_id.as_deref() == Some(channel.id.as_str()); - if ui - .selectable_label( - selected, - format!( - "{} · {}{}{}", - channel.name, - channel.provider_type, - if active { " · active" } else { "" }, - if channel.enabled { "" } else { " · 已禁用" }, - ), - ) - .clicked() - { - self.selected_channel_id = Some(channel.id.clone()); - self.provider_models.clear(); - if let Some((channel, descriptor)) = - provider_channel_descriptor(&panel, &channel.id) - { - self.provider_editor = ProviderEditorState::Loading { - kind: panel.kind, - channel_id: channel.id.clone(), - }; - self.load_provider_editor(panel.kind, channel, descriptor); + frontend::view_model::FrontendAction::ShortcutMenu(field) => { + self.frontend_vm.shortcut_menu = field; + if field.is_some() { + // 打开菜单即退出录制(Tauri 点「录制快捷键」时同时收起菜单)。 + self.frontend_vm.shortcut_recording = None; } } - if !active && channel.enabled && ui.button("设为 active").clicked() { - if let Some(backend) = self.backend() { - let slot = provider_slot(panel.kind); - let channel_id = channel.id.clone(); - self.spawn_provider_mutation(async move { - backend.set_active_provider(slot, channel_id).await?; - Ok("active 渠道已更新".to_string()) - }); + frontend::view_model::FrontendAction::ShortcutRecording(field) => { + self.frontend_vm.shortcut_pending_modifier = None; + self.frontend_vm.shortcut_recording = field; + if field.is_some() { + self.frontend_vm.shortcut_menu = None; } + self.frontend_vm.settings_notice = None; } - if ui - .button(if channel.enabled { "禁用" } else { "启用" }) - .clicked() - { - if let Some(backend) = self.backend() { - let kind = panel.kind; - let channel_id = channel.id.clone(); - let enabled = !channel.enabled; - self.spawn_provider_mutation(async move { - backend - .set_channel_enabled(kind, channel_id, enabled) - .await?; - Ok("渠道启用状态已更新".to_string()) - }); - } + frontend::view_model::FrontendAction::ShortcutCaptured( + field, + primary, + modifiers, + ) => { + self.apply_shortcut_captured(field, primary, modifiers); } - if index > 0 && ui.button("上移").clicked() { - if let Some(backend) = self.backend() { - let kind = panel.kind; - let mut ids = panel - .channels - .iter() - .map(|item| item.id.clone()) - .collect::>(); - ids.swap(index, index - 1); - self.spawn_provider_mutation(async move { - backend.reorder_channels(kind, ids).await?; - Ok("渠道顺序已更新".to_string()) - }); - } + frontend::view_model::FrontendAction::ShortcutDisable(field) => { + self.apply_shortcut_disable(field); } - if index + 1 < panel.channels.len() && ui.button("下移").clicked() { - if let Some(backend) = self.backend() { - let kind = panel.kind; - let mut ids = panel - .channels + frontend::view_model::FrontendAction::StyleHotkeyDraft(open) => { + self.frontend_vm.style_hotkey_draft_open = open; + if open { + let used: Vec = self + .frontend_vm + .settings + .style_pack_hotkeys .iter() - .map(|item| item.id.clone()) - .collect::>(); - ids.swap(index, index + 1); - self.spawn_provider_mutation(async move { - backend.reorder_channels(kind, ids).await?; - Ok("渠道顺序已更新".to_string()) - }); + .map(|row| row.pack_id.clone()) + .collect(); + self.frontend_vm.style_hotkey_draft_pack = self + .frontend_vm + .style_packs + .iter() + .position(|pack| !used.contains(&pack.id)) + .unwrap_or(0); + } else { + self.frontend_vm.shortcut_recording = None; } } - if self.pending_channel_delete.as_deref() == Some(channel.id.as_str()) { - if ui.button("确认删除").clicked() { - self.pending_channel_delete = None; + frontend::view_model::FrontendAction::StyleHotkeyDraftPack(index) => { + self.frontend_vm.style_hotkey_draft_pack = index; + } + frontend::view_model::FrontendAction::StyleHotkeyRemove(index) => { + self.apply_style_hotkey_remove(index); + } + frontend::view_model::FrontendAction::StyleHotkeyRepack(index, pack_index) => { + self.apply_style_hotkey_repack(index, pack_index); + } + frontend::view_model::FrontendAction::SettingsChannelSelect(index) => { + self.open_provider_editor(index); + } + frontend::view_model::FrontendAction::SettingsChannelMove { index, delta } => { + let kind = self.settings_channel_kind; + let mut ids: Vec = self + .settings_channels + .iter() + .map(|channel| channel.id.clone()) + .collect(); + let target = index as isize + delta; + if target >= 0 && (target as usize) < ids.len() { + ids.swap(index, target as usize); if let Some(backend) = self.backend() { - let kind = panel.kind; - let channel_id = channel.id.clone(); - self.spawn_provider_mutation(async move { - backend.delete_channel(kind, channel_id).await?; - Ok("渠道已删除".to_string()) + let lang = self.lang; + self.spawn(async move { + backend.reorder_channels(kind, ids).await?; + Ok(tr_l10n(lang, "status.channel_reordered").to_string()) }); + self.load_settings_channels(); } } - if ui.button("取消删除").clicked() { - self.pending_channel_delete = None; - } - } else if ui.button("删除").clicked() { - // Channel deletion may remove the last usable provider - // and its persisted secrets, so require a deliberate - // second click even in this intentionally compact UI. - self.pending_channel_delete = Some(channel.id.clone()); } - }); - } - - match self.provider_editor.clone() { - ProviderEditorState::Idle => {} - ProviderEditorState::Loading { kind, channel_id } => { - ui.label(format!("正在读取 {:?} 渠道 {channel_id}…", kind)); - } - ProviderEditorState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - } - ProviderEditorState::Loaded(editor) => { - let mut editor = *editor; - ui.separator(); - ui.strong(format!("编辑渠道 {}", editor.channel.id)); - let mut provider_type = editor.descriptor.provider_type.as_str().to_string(); - egui::ComboBox::from_id_salt("edit-provider-type") - .selected_text(provider_descriptor_label(&editor.descriptor)) - .show_ui(ui, |ui| { - for descriptor in &panel.descriptors { - ui.selectable_value( - &mut provider_type, - descriptor.provider_type.as_str().to_string(), - provider_descriptor_label(descriptor), - ); - } - }); - if provider_type != editor.descriptor.provider_type.as_str() { - if let Some(backend) = self.backend() { - let kind = editor.kind; - let channel_id = editor.channel.id.clone(); - self.spawn_provider_mutation(async move { + frontend::view_model::FrontendAction::SettingsChannelProviderType { + index, + provider_type, + } => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { backend - .set_channel_provider_type(kind, channel_id, provider_type) + .set_channel_provider_type(kind, id, provider_type) .await?; - Ok("Provider 类型已更新".to_string()) + Ok(tr_l10n(lang, "status.provider_type_updated").to_string()) }); + // The descriptor changed with the provider type: the + // editor must re-read it instead of keeping old fields. + self.close_provider_editor(); + self.selected_channel_id = None; + self.load_settings_channels(); + self.load_providers(kind); } - return; } - - ui.label(format!( - "鉴权:{} · 探针:{:?}", - auth_requirement_label(editor.descriptor.auth_requirement), - editor.descriptor.validation_probe - )); - ui.horizontal(|ui| { - ui.label("名称"); - ui.text_edit_singleline(&mut editor.name); - }); - provider_fields_ui(ui, &mut editor); - - ui.horizontal(|ui| { - if ui.button("保存字段/Secret").clicked() { - if let Some(backend) = self.backend() { - let saved = editor.clone(); - self.spawn_provider_mutation(async move { - save_provider_editor(backend, saved).await?; - Ok("渠道配置已保存".to_string()) - }); + frontend::view_model::FrontendAction::SettingsChannelActivate(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + backend.set_active_provider(provider_slot(kind), id).await?; + Ok(tr_l10n(lang, "status.channel_active").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + } + } + frontend::view_model::FrontendAction::SettingsProviderField(field, value) => { + if let Some(form) = self.provider_editor_form.as_mut() { + match field { + frontend::view_model::SettingsProviderField::Name => { + form.name = value + } + frontend::view_model::SettingsProviderField::Endpoint => { + form.endpoint = value + } + frontend::view_model::SettingsProviderField::Model => { + form.model = value + } + frontend::view_model::SettingsProviderField::ResourceId => { + form.resource_id = value + } + frontend::view_model::SettingsProviderField::AuthMode => { + form.auth_mode = value + } + frontend::view_model::SettingsProviderField::PrimarySecret => { + form.primary_secret = value + } + frontend::view_model::SettingsProviderField::SecondarySecret => { + form.secondary_secret = value + } } } - if ui.button("清除 Secret").clicked() { + } + frontend::view_model::FrontendAction::SettingsProviderSave => { + if let Some(editor) = self.editor_from_form() { if let Some(backend) = self.backend() { - let cleared = editor.clone(); - self.spawn_provider_mutation(async move { - clear_provider_secrets(backend, &cleared).await?; - Ok("渠道 Secret 已清除".to_string()) + let lang = self.lang; + self.spawn(async move { + save_provider_editor(backend, editor).await?; + Ok(tr_l10n(lang, "status.channel_saved").to_string()) }); + // Secrets are write-only: drop the drafts once Core + // has them so they are not kept in egui state. + if let Some(form) = self.provider_editor_form.as_mut() { + form.primary_secret.clear(); + form.secondary_secret.clear(); + } + self.load_settings_channels(); + self.load_service_configured(); } } - if ui.button("验证连接").clicked() { + } + frontend::view_model::FrontendAction::SettingsProviderClearSecrets => { + if let Some(editor) = self.editor_from_form() { if let Some(backend) = self.backend() { - let kind = editor.kind; - let channel_id = editor.channel.id.clone(); - self.spawn_provider_mutation(async move { - validate_provider_channel(backend, kind, channel_id).await + let lang = self.lang; + self.spawn(async move { + clear_provider_secrets(Arc::clone(&backend), &editor).await?; + Ok(tr_l10n(lang, "status.secret_cleared").to_string()) }); + if let Some(form) = self.provider_editor_form.as_mut() { + form.primary_secret.clear(); + form.secondary_secret.clear(); + } + self.load_service_configured(); } } - if let Some(url) = editor - .descriptor - .endpoint_presets - .iter() - .find(|preset| { - openless_core::provider_rules::matches_endpoint_preset( - &editor.endpoint, - &preset.endpoint, - ) - }) - .and_then(|preset| preset.models_url.as_deref()) + } + frontend::view_model::FrontendAction::SettingsProviderModels => { + let kind = self.settings_channel_kind; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if let (Some(form), Some(channel_id)) = + (self.provider_editor_form.as_mut(), channel_id) { - self.provider_models.clear(); - ui.hyperlink_to("查看支持的模型", url); - } else if ui.button("列出模型").clicked() { - self.provider_models.clear(); - self.request_provider_models(editor.kind, editor.channel.id.clone()); - } - }); - if !self.provider_models.is_empty() { - ui.label("模型列表(点击填入):"); - for model in self.provider_models.clone() { - if ui.button(&model).clicked() { - editor.model = model; - } + form.models_loading = true; + self.request_provider_models(kind, channel_id); } } - self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); - } - } - } - - fn services_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("AI 服务"); - ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"); - if let Some(snapshot) = &self.snapshot { - let credentials = &snapshot.credentials; - ui.label(format!( - "ASR:{}({})", - credentials.active_asr_provider, - if credentials.asr_configured { - "已配置" - } else { - "未配置" + frontend::view_model::FrontendAction::SettingsProviderClose => { + self.close_provider_editor(); } - )); - ui.label(format!( - "LLM:{}({})", - credentials.active_llm_provider, - if credentials.llm_configured { - "已配置" - } else { - "未配置" + frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { + let kind = self.settings_channel_kind; + let target = self + .settings_channels + .get(index) + .map(|channel| (channel.id.clone(), channel.enabled)); + if let (Some(backend), Some((id, enabled))) = (self.backend(), target) { + let lang = self.lang; + self.spawn(async move { + backend.set_channel_enabled(kind, id, !enabled).await?; + Ok(tr_l10n(lang, "status.channel_enabled").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + } } - )); - } - self.provider_management_ui(ui); - } - - fn save_preferences(&mut self) { - let (Some(native), Some(snapshot), Some(preferences)) = - (&self.native, &self.snapshot, &self.preferences) - else { - return; - }; - match native - .host() - .save_settings(preferences.clone(), snapshot.preferences_revision) - { - Ok(_) => { - self.status = "设置已保存".to_string(); - let config = openless_core::RemoteInputConfig { - enabled: preferences.remote_input_enabled, - port: preferences.remote_input_port, - }; - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().remote_input.configure(config).await?; - Ok("远程输入状态已更新".to_string()) - }); + frontend::view_model::FrontendAction::SettingsChannelValidate(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + validate_provider_channel(lang, backend, kind, id).await + }); + self.load_settings_channels(); + self.load_service_configured(); + } } - } - Err(error) => self.status = error.to_string(), - } - } - - fn settings_actions_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - if ui.button("保存设置").clicked() { - self.save_preferences(); - } - if ui.button("放弃修改并重新读取").clicked() { - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); - self.snapshot = Some(backend.snapshot()); - self.status = "已重新读取设置".to_string(); + frontend::view_model::FrontendAction::SettingsChannelDelete(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + backend.delete_channel(kind, id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + } } - } - }); - ui.small( - "环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。", - ); - } - - fn settings_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("环境与设置"); - ui.strong("现有功能设置"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.streaming_insert, "流式插入"); - ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"); - ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer"); - ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"); - self.settings_actions_ui(ui); - } - ui.horizontal_wrapped(|ui| { - if ui.button("配置 AI 服务").clicked() { - self.navigation.open(Page::Services); - } - if ui.button("设置手机输入").clicked() { - self.navigation.open(Page::Remote); - } - }); - ui.small( - "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", - ); - ui.separator(); - self.environment_ui(ui); - } - - fn remote_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("手机输入"); - ui.label( - "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", - ); - ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入"); - ui.add( - egui::DragValue::new(&mut preferences.remote_input_port) - .range(1..=u16::MAX) - .prefix("端口 "), - ); - self.settings_actions_ui(ui); - } - ui.separator(); - if ui.button("刷新连接状态").clicked() { - self.load_remote_status(); - } - if let Some(error) = &self.remote_error { - ui.colored_label( - egui::Color32::YELLOW, - format!("暂时无法读取连接状态:{error}"), - ); - ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。"); - } else if self.remote_access.is_none() { - ui.label("尚未取得手机输入状态。"); - } - if let Some((remote, pin)) = &self.remote_access { - ui.label(if remote.running { - "远程输入服务:运行中" - } else if remote.starting { - "远程输入服务:启动中" - } else { - "远程输入服务:已停止" - }); - ui.label(format!("当前连接数:{}", remote.connection_count)); - if remote.active_session_id.is_some() { - ui.label("手机语音会话进行中,可使用顶部的语音取消。"); - } - if remote.urls_stale { - ui.colored_label( - egui::Color32::YELLOW, - "网络地址已过期,请检查网络后刷新状态。", - ); - } - if remote.enabled && remote.running && !remote.urls_stale { - // 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。 - ui.label("本机根证书 SHA-256"); - match remote.ca_fingerprint_sha256.as_ref().filter(|value| { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - Some(fingerprint) => { - let display = fingerprint - .as_bytes() - .chunks(2) - .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) - .collect::>() - .join(" "); - ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap()); - if ui.button("复制完整指纹").clicked() { - ui.ctx().copy_text(display); + frontend::view_model::FrontendAction::MarketplaceDetail(index) => { + self.frontend_vm.marketplace_selected = Some(index); + // Load real detail from backend, not just index. + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .detail(id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceDetail(result)); + }); } } - None => { - ui.label("完整指纹不可用。请勿安装或信任下载的证书。"); - } - } - ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); - ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); - ui.monospace(format!("PIN:{pin}")); - for url in &remote.urls { - ui.monospace(url); - } - if remote.urls.is_empty() { - ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); - } - } - if remote.enabled && ui.button("重置配对码").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .remote_input - .regenerate_pairing_pin() - .await?; - Ok("远程输入配对码已重置".to_string()) - }); } } } } + } - fn environment_ui(&mut self, ui: &mut egui::Ui) { - ui.strong("Linux 环境准备"); - ui.label(if self.native.is_some() { - "Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。" - } else { - "Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" - }); - if let Some(environment) = &self.environment { - ui.label(match environment.session { - LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境", - LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境", - LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY", - }); - ui.label(if environment.fcitx5_ready { - "fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。" - } else { - "fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。" - }); - ui.label(match environment.permissions.microphone { - openless_core::PermissionState::Unsupported => { - "麦克风:当前探测环境不支持;请进入图形桌面会话。" - } - _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", - }); - } else { - ui.label("尚未取得桌面环境探测结果。"); - } - ui.label(match &self.plugin_check { - Some(Ok(FcitxPluginStatus::Ready)) => { - "本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。" - } - Some(Ok(FcitxPluginStatus::Updated)) => { - "本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。" - } - Some(Ok(FcitxPluginStatus::Missing)) => { - "本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。" - } - Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。", - None => "本次启动插件检查:未执行。", - }); - if let Some(Err(error)) = &self.plugin_check { - ui.colored_label(egui::Color32::YELLOW, error); - } - if ui - .add_enabled( - !self.environment_refreshing, - egui::Button::new(if self.environment_refreshing { - "正在检测…" - } else { - "重新检测会话与 D-Bus" - }), - ) - .clicked() - { - self.environment_refreshing = true; - let tx = self.tx.clone(); - self.tokio.spawn_blocking(move || { - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); - let _ = tx.send(UiResult::Environment(environment)); - }); - } - ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。"); - egui::CollapsingHeader::new("准备步骤与官方指南") - .default_open(self.native.is_none()) - .show(ui, |ui| { - ui.separator(); - ui.strong("1 · 准备输入法与桌面会话"); - ui.label("在当前图形桌面安装并启用 fcitx5,再安装含 OpenLess 插件的当前软件包。先在普通编辑器中确认输入法可以输入。"); - ui.label("在终端运行以下诊断,查看输入法环境与插件加载信息:"); - command_ui(ui, "fcitx5-diagnose"); - ui.label("插件安装或更新后可先重载配置;若插件仍未加载,退出并重新登录桌面,再启动 OpenLess:"); - command_ui(ui, "fcitx5-remote -r"); - ui.horizontal_wrapped(|ui| { - ui.hyperlink_to( - "Fcitx 5 官方设置指南", - "https://fcitx-im.org/wiki/Setup_Fcitx_5", - ); - ui.hyperlink_to( - "Wayland 桌面配置差异", - "https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland", - ); - }); - ui.small("Wayland 的输入法配置取决于桌面和应用工具包,请按官方对应章节配置;检测到 Wayland 不代表所有目标应用都支持替换。X11 的 overlay 能力标记也不代表本应用已接入录音浮层。"); - ui.separator(); - ui.strong("2 · 准备密钥环与识别服务"); - ui.label("Secret Service:当前没有独立的服务连接或解锁状态检测;渠道显示“已配置”也不能证明密钥环现在可读写。"); - ui.label("打开桌面的密码/密钥环管理器,确认当前登录会话的密钥环已解锁。然后到 AI 服务选择渠道,填写所需凭据、保存并校验;若返回锁定或访问失败,解锁后重试。"); - ui.hyperlink_to( - "Secret Service 官方规范", - "https://specifications.freedesktop.org/secret-service/latest/", - ); - ui.small("API 密钥输入只用于写入,不回显已有密钥。本地识别可在本地模型页下载并激活 Generic Qwen。"); - ui.separator(); - ui.strong("3 · 做一次短听写"); - ui.label("在系统声音设置确认输入设备有电平。配置识别服务后,在目标编辑器聚焦输入框,用已有听写快捷键录制一句话并结束,检查转写和落字结果。问答、选区润色与 Agent 分别从导航进入。"); - ui.small("请分别验证你使用的 X11/Wayland、GTK/Qt/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。"); - }); - } + /// UI 窗口进程的地址开关。 + const UI_CLIENT_FLAG: &str = "--ui-client"; + const UI_SOCKET_FLAG: &str = "--ui-socket"; - fn start_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("从一次听写开始"); - ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"); - if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}")); - } - if let Some(snapshot) = &self.snapshot { - ui.label(if snapshot.running { - "Core:运行中" - } else { - "Core:未运行" - }); - let credentials = &snapshot.credentials; - match credentials.pipeline_mode { - openless_core::shared_types::PipelineMode::Multimodal => { - ui.label("当前管线:多模态(Omni)"); - ui.label(if credentials.omni_configured { - "Omni:已配置。" - } else { - "Omni:尚未配置,请到 AI 服务配置 Omni。" - }); - } - openless_core::shared_types::PipelineMode::Traditional => { - ui.label("当前管线:传统(ASR + LLM)"); - ui.label(if credentials.asr_configured { - "ASR 语音识别:已配置。" - } else { - "ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。" - }); - ui.label(if credentials.llm_configured { - "LLM 润色:已配置。" - } else { - "LLM 润色:尚未配置。" - }); - } - } - ui.small("已配置不代表校验通过;请到 AI 服务验证连接。"); - } - ui.horizontal_wrapped(|ui| { - for (page, label) in [ - (Page::Settings, "1. 准备环境"), - (Page::Services, "2. 配置 AI 服务"), - (Page::Models, "使用本地模型"), - (Page::Dictation, "3. 打开听写"), - ] { - if ui - .add_enabled( - self.native.is_some() || page == Page::Settings, - egui::Button::new(label), - ) - .clicked() - { - self.navigation.open(page); - } - } - }); - ui.separator(); - if self.native.is_none() { - self.environment_ui(ui); - } else { - ui.strong("继续其他任务"); - ui.horizontal_wrapped(|ui| { - for page in [ - Page::Qa, - Page::Selection, - Page::Agent, - Page::Remote, - Page::History, - ] { - if ui.button(page.label()).clicked() { - self.navigation.open(page); - } - } - }); - ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); - ui.small( - "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", - ); - } + /// 解析 `--ui-client --ui-socket `。普通启动(宿主)返回 `None`。 + fn ui_client_socket(args: &[String]) -> Option { + if !args.iter().any(|arg| arg == UI_CLIENT_FLAG) { + return None; } + let index = args.iter().position(|arg| arg == UI_SOCKET_FLAG)?; + args.get(index + 1).map(std::path::PathBuf::from) + } - fn page_activity(&self, page: Page) -> Option<&'static str> { - match page { - Page::Dictation - if self.snapshot.as_ref().is_some_and(|snapshot| { - matches!( - snapshot.dictation.phase, - DictationPhase::Starting - | DictationPhase::Recording - | DictationPhase::Transcribing - | DictationPhase::Polishing - | DictationPhase::Inserting - ) - }) => - { - Some("进行中") - } - Page::Qa if self.qa_visible => Some("会话"), - Page::Selection - if self.selection_preview_visible - && self.selection.as_ref().is_some_and(|selection| { - selection.phase == SelectionPhase::Preview - }) => - { - Some("待确认") - } - Page::Agent if self.pending_approval.is_some() => Some("待审批"), - Page::Agent if self.less_computer_running => Some("进行中"), - _ if self.navigation.has_update(page) => Some("有更新"), - _ => None, - } - } + /// 单实例锁的获取结果。 + enum BrokerAcquisition { + Primary(SingleInstanceBroker), + /// 已有实例接管了本次启动意图,本进程应当直接退出。 + Forwarded, + } - fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) { - let label = match self.page_activity(page) { - Some(activity) => format!("{} · {activity}", page.label()), - None => page.label().to_string(), - }; - if ui - .selectable_label(self.navigation.page == page, label) - .clicked() - { - self.navigation.open(page); - } + /// 常规启动抢锁(抢不到就把意图转发给已有实例 —— 由它把主窗口推出来)。 + fn acquire_broker( + runtime_dir: &std::path::Path, + args: &[String], + ) -> Result { + let lock = runtime_dir.join("openless.lock"); + let socket = runtime_dir.join("openless.sock"); + match SingleInstanceBroker::acquire_or_forward( + &lock, + &socket, + LinuxLaunchIntent::from_args(args), + ) + .map_err(|error| error.to_string())? + { + SingleInstanceRole::Primary(broker) => Ok(BrokerAcquisition::Primary(broker)), + SingleInstanceRole::Forwarded => Ok(BrokerAcquisition::Forwarded), } + } - fn activity_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - for page in Page::ALL { - if let Some(activity) = self.page_activity(page) { - if ui - .link(format!("{} · {activity} →", page.label())) - .clicked() - { - self.navigation.open(page); - } - } - } - if self.native.is_some() && ui.button("取消当前语音 · Esc").clicked() { - self.cancel_voice(); - } - if self.qa_visible && ui.button("取消问答").clicked() { - if let Some(backend) = self.backend() { - let session_id = self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - .and_then(|id| uuid::Uuid::parse_str(id).ok()) - .map(openless_core::SessionId::from_uuid); - self.spawn(async move { - backend.services().qa.cancel(session_id).await?; - Ok("问答本轮已取消".to_string()) - }); - } - } - if let Some(session_id) = self - .selection - .as_ref() - .filter(|selection| selection.phase == SelectionPhase::Preview) - .and_then(|selection| selection.session_id) - { - if ui.button("取消选区预览").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) - }); - } - } - } - if (self.less_computer_running || self.pending_approval.is_some()) - && ui.button("取消 Agent").clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_less_computer(None).await?; - Ok("Less Computer 已取消".to_string()) - }); - } - } - }); + /// 主窗口的最小内尺寸(UI 进程创建窗口时用它,和 `with_min_inner_size` 同源)。 + const MAIN_WINDOW_MIN_INNER_SIZE: egui::Vec2 = egui::vec2(960.0, 640.0); + + /// 主窗口的初始尺寸。 + const MAIN_WINDOW_INNER_SIZE: [f32; 2] = [1240.0, 800.0]; + + /// 视图模型载荷指纹(FNV-1a 64)。够快,用来判断「要不要重发快照」: + /// 内容没变就不发,UI 慢的时候也不会被无意义的帧糊住。 + fn snapshot_fingerprint(payload: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in payload { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } + hash + } - fn cancel_voice(&self) { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_active_voice_session(None).await?; - Ok("语音会话已取消".to_string()) - }); + impl OpenLessEguiApp { + /// 与渲染无关的宿主心跳:原生事件、托盘命令、自动更新检查、泵心跳日志。 + /// + /// 宿主循环(`run_host`)每 50ms 调它一次。窗口是独立进程,所以热键消费 + /// 与弹窗拉起完全不依赖「窗口是否在绘制」——窗口关掉、最小化、压根没开, + /// 后台照样收键、照样把弹窗进程拉起来。 + fn tick(&mut self, ctx: &egui::Context) { + self.poll(ctx); + self.drain_tray(ctx); + let auto_check = self + .preferences + .as_ref() + .is_some_and(|preferences| preferences.auto_update_check); + if auto_check + && !self.update_busy + && self.update_manifest.is_none() + && self + .update_schedule + .poll(self.update_started.elapsed(), false) + .is_some() + { + let channel = self + .preferences + .as_ref() + .map(|preferences| preferences.update_channel) + .unwrap_or_default(); + self.request_update_check(channel); } + self.log_pump_heartbeat(ctx); + } + + /// 「显示主窗口」:宿主只记意图,由 `run_host` 拉起/抬起 UI 窗口进程。 + fn request_main_window(&mut self) { + self.window_should_be_open = true; } - fn history_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("历史"); - ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); - let Some(backend) = self.backend() else { - return; + /// UI 窗口进程是否还活着(顺带回收已经退出的子进程)。 + fn ui_window_alive(&mut self) -> bool { + let Some(child) = self.ui_window.as_mut() else { + return false; }; - match backend.list_history() { - Ok(history) if history.is_empty() => { - ui.label("暂无历史记录"); - } - Ok(history) => { - for item in history.into_iter().rev().take(20) { - let delivery = match item.insert_status { - HistoryInsertStatus::Inserted => "已插入", - HistoryInsertStatus::CopiedFallback => "已复制", - HistoryInsertStatus::PasteSent => "已发送粘贴", - HistoryInsertStatus::Failed => "失败", - HistoryInsertStatus::NotRequested => "未请求插入", - }; - ui.label(format!( - "{} · {} · {}", - item.created_at, delivery, item.final_text - )); - } + match child.try_wait() { + Ok(None) => true, + Ok(Some(status)) => { + log::info!("[ui-host] UI window process exited ({status}); host keeps running"); + self.ui_window = None; + false } Err(error) => { - ui.label(error.to_string()); + log::warn!("[ui-host] UI window process wait failed: {error}"); + self.ui_window = None; + false } } } - } - impl eframe::App for OpenLessEguiApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Poll every frame before routing pages. Hidden pages retain their - // drafts, session ownership, event replay and async completion paths. - self.poll(ctx); - if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - self.cancel_voice(); - } - egui::TopBottomPanel::top("status").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.strong("OpenLess 2.0"); - ui.separator(); - ui.add(egui::Label::new(&self.status).truncate()) - .on_hover_text(&self.status); - }); - self.activity_ui(ui); - if self.pending_approval.is_some() { - ui.strong("Less Computer 等待审批"); - self.agent_approval_ui(ui); + /// 拉起 UI 窗口进程。 + /// + /// 时序:调用方保证宿主已经 bind 好桥 socket(UI 进程连不上就直接报错退出, + /// 不会自己抢单实例锁或打开数据目录)。 + fn spawn_ui_window(&mut self, socket: &std::path::Path) -> Result<(), String> { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut command = std::process::Command::new(executable); + command + .arg(UI_CLIENT_FLAG) + .arg(UI_SOCKET_FLAG) + .arg(socket) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + // 让 panic / winit 警告走宿主自己的 stderr(终端或 journal), + // 否则「窗口没起来」会变成一条无声的失败。 + .stderr(std::process::Stdio::inherit()); + let child = command.spawn().map_err(|error| error.to_string())?; + log::info!( + "[ui-host] spawned UI window process pid={} socket={}", + child.id(), + socket.display() + ); + self.ui_window = Some(child); + self.ui_window_spawned_at = Some(std::time::Instant::now()); + Ok(()) + } + + /// 窗口当前是否需要一个 UI 进程:用户想开着,而且现在没有活着的窗口。 + /// 刚拉起的 800ms 内不重复拉起,避免连点托盘菜单拉出两个窗口。 + fn should_spawn_ui_window(&mut self) -> bool { + if !self.window_should_be_open { + return false; + } + if self.ui_window_alive() { + return false; + } + if let Some(spawned_at) = self.ui_window_spawned_at { + if spawned_at.elapsed() < Duration::from_millis(800) { + return false; } - }); - if ctx.screen_rect().width() < 760.0 { - egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| { - ui.horizontal_wrapped(|ui| { - for page in Page::ALL { - self.navigation_button(ui, page); - } - }); - }); - } else { - egui::SidePanel::left("navigation") - .resizable(false) - .default_width(176.0) - .show(ctx, |ui| { - egui::ScrollArea::vertical() - .id_salt("navigation_scroll") - .show(ui, |ui| { - ui.strong("工作空间"); - ui.add_space(8.0); - for page in Page::ALL { - if page == Page::Services { - ui.separator(); - ui.strong("准备与管理"); - } - self.navigation_button(ui, page); - } - }); - }); } - egui::CentralPanel::default().show(ctx, |ui| { - let page = self.navigation.page; - egui::ScrollArea::vertical() - .id_salt(("page", page)) - .show(ui, |ui| { - if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) { - ui.heading(page.label()); - ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); - if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, error); - } - if ui.button("查看环境准备步骤").clicked() { - self.navigation.open(Page::Settings); - } - return; - } - match page { - Page::Start => self.start_ui(ui), - Page::Dictation => self.dictation_ui(ui), - Page::Qa => self.qa_ui(ui), - Page::Selection => self.selection_ui(ui), - Page::Agent => self.less_computer_ui(ui), - Page::Services => self.services_ui(ui), - Page::Models => self.models_ui(ui), - Page::Remote => self.remote_ui(ui), - Page::History => self.history_ui(ui), - Page::Settings => self.settings_ui(ui), + true + } + + /// 处理 UI 进程发来的消息(含断连语义)。 + /// + /// 时序:`Bye`/断开只把窗口标记为关闭,**不动**后端、会话与弹窗; + /// 没有托盘时则连宿主一起退出 —— 否则用户再也找不到这个进程。 + fn apply_window_messages(&mut self, messages: Vec, tray_available: bool) { + for message in messages { + match message { + WindowToHost::Hello { version } => { + if version != UI_BRIDGE_VERSION { + log::warn!( + "[ui-host] UI window speaks protocol {version}, host speaks {UI_BRIDGE_VERSION}" + ); + } else { + log::info!("[ui-host] UI window handshake ok (protocol {version})"); } - }); - }); - ctx.request_repaint_after(Duration::from_millis(50)); + } + WindowToHost::Action { sequence, action } => { + log::debug!("[ui-host] UI action #{sequence}: {action:?}"); + self.pending_ui_actions.push(action); + } + WindowToHost::Ping { sequence } => { + self.pending_ui_pongs.push(sequence); + } + WindowToHost::Bye => { + log::info!("[ui-host] UI window said goodbye; host keeps running"); + self.window_should_be_open = false; + } + } + } + if !self.window_should_be_open && !tray_available { + // 没有托盘就没有重新打开的入口,窗口退出等于应用退出。 + log::info!("[ui-host] no tray to reopen the window; exiting with it"); + self.exit_requested = true; + } } - } - fn command_ui(ui: &mut egui::Ui, command: &str) { - ui.horizontal_wrapped(|ui| { - ui.monospace(command); - if ui.button("复制命令").clicked() { - ui.ctx().copy_text(command.to_string()); + /// 把当前视图模型发给 UI 进程:内容变过、或距上次超过 2s(保活)才发。 + fn publish_view_model(&mut self, bridge: &mut UiBridgeHost) { + if !bridge.is_connected() { + // UI 不在:清掉指纹,等它回来时无条件发一份完整快照。 + self.last_snapshot_fingerprint = None; + return; } - }); + let payload = match serde_json::to_vec(&self.frontend_vm) { + Ok(payload) => payload, + Err(error) => { + log::warn!("[ui-host] view model serialization failed: {error}"); + return; + } + }; + let fingerprint = snapshot_fingerprint(&payload); + let keepalive = self.last_snapshot_at.elapsed() >= Duration::from_secs(2); + if Some(fingerprint) == self.last_snapshot_fingerprint && !keepalive { + return; + } + self.last_snapshot_fingerprint = Some(fingerprint); + self.last_snapshot_at = std::time::Instant::now(); + bridge.send_snapshot_encoded(&payload); + } } impl Drop for OpenLessEguiApp { @@ -2116,6 +5256,44 @@ mod linux_app { } } + /// Map a concrete UI language to its display-name catalog key, shown in + /// that language's own native script regardless of the current UI language. + fn overview_activity_day(day: DailyActivity) -> frontend::view_model::OverviewActivityDay { + frontend::view_model::OverviewActivityDay { + date: day.date, + count: day.count, + chars: day.chars, + duration_ms: day.duration_ms, + } + } + + fn overview_heatmap_day(day: DailyActivity) -> frontend::view_model::OverviewHeatmapDay { + frontend::view_model::OverviewHeatmapDay { + date: day.date, + count: day.count, + } + } + + /// Core polish mode -> frontend display enum. + fn overview_mode(mode: openless_core::PolishMode) -> frontend::view_model::OverviewMode { + match mode { + openless_core::PolishMode::Raw => frontend::view_model::OverviewMode::Raw, + openless_core::PolishMode::Light => frontend::view_model::OverviewMode::Light, + openless_core::PolishMode::Structured => frontend::view_model::OverviewMode::Structured, + openless_core::PolishMode::Formal => frontend::view_model::OverviewMode::Formal, + } + } + + /// Localized label for a polish mode (used as the history pill fallback). + fn polish_mode_label(lang: Lang, mode: openless_core::PolishMode) -> &'static str { + match mode { + openless_core::PolishMode::Raw => tr_l10n(lang, "overview.mode_raw"), + openless_core::PolishMode::Light => tr_l10n(lang, "overview.mode_light"), + openless_core::PolishMode::Structured => tr_l10n(lang, "overview.mode_structured"), + openless_core::PolishMode::Formal => tr_l10n(lang, "overview.mode_formal"), + } + } + fn provider_kind(kind: openless_core::ChannelKind) -> openless_core::ProviderKind { match kind { openless_core::ChannelKind::Asr => openless_core::ProviderKind::Asr, @@ -2144,6 +5322,31 @@ mod linux_app { } } + /// Localized provider name from `settings.providers.presets.`. + /// Falls back to the raw label id when the catalog has no entry, so a + /// missing translation never leaks an i18n key into the UI. + fn localized_provider_label( + lang: Lang, + kind: openless_core::ChannelKind, + provider_type: &str, + ) -> String { + let label_key = provider_label_key(kind, provider_type); + let key = format!("settings.providers.presets.{label_key}"); + let text = fmt_l10n(lang, &key, &[]); + if text == key { + label_key + } else { + text + } + } + + /// i18n lookup id for a provider type (falls back to the raw type id). + fn provider_label_key(kind: openless_core::ChannelKind, provider_type: &str) -> String { + openless_core::provider_rules::provider_descriptor(provider_kind(kind), provider_type) + .map(|descriptor| descriptor.label_key) + .unwrap_or_else(|| provider_type.to_string()) + } + fn model_account(kind: openless_core::ChannelKind) -> &'static str { match kind { openless_core::ChannelKind::Asr => openless_core::credentials::ASR_MODEL_ACCOUNT, @@ -2158,6 +5361,25 @@ mod linux_app { } } + /// Core's `AuthRequirement` decides which inputs the editor renders. The + /// host maps it to a render hint and keeps validating through Core. + fn settings_provider_auth( + requirement: openless_core::AuthRequirement, + ) -> frontend::view_model::SettingsProviderAuth { + use frontend::view_model::SettingsProviderAuth as Ui; + use openless_core::AuthRequirement as Core; + match requirement { + Core::None => Ui::None, + Core::Volcengine => Ui::Volcengine, + Core::Xfyun => Ui::Xfyun, + Core::OAuth => Ui::OAuth, + Core::TencentCloud => Ui::Other, + Core::ApiKey | Core::EndpointModelOptionalApiKey | Core::ApiKeyUnlessCustomEndpoint => { + Ui::ApiKey + } + } + } + fn provider_credential_key( kind: openless_core::ChannelKind, channel_id: &str, @@ -2170,67 +5392,237 @@ mod linux_app { ) } - fn provider_descriptor_label(descriptor: &openless_core::ProviderDescriptor) -> String { - format!( - "{} ({})", - descriptor.label_key, - descriptor.provider_type.as_str() - ) + fn provider_channel_descriptor( + panel: &ProviderPanel, + channel_id: &str, + ) -> Option<( + openless_core::ChannelSummary, + openless_core::ProviderDescriptor, + )> { + let channel = panel + .channels + .iter() + .find(|channel| channel.id == channel_id)? + .clone(); + let descriptor = panel + .descriptors + .iter() + .find(|descriptor| descriptor.provider_type.as_str() == channel.provider_type) + .cloned() + .or_else(|| { + openless_core::provider_rules::provider_descriptor( + provider_kind(panel.kind), + &channel.provider_type, + ) + })?; + Some((channel, descriptor)) } - fn auth_requirement_label(requirement: openless_core::AuthRequirement) -> &'static str { - match requirement { - openless_core::AuthRequirement::None => "无需 Secret", - openless_core::AuthRequirement::ApiKey => "API Key", - openless_core::AuthRequirement::EndpointModelOptionalApiKey => { - "Endpoint + Model,API Key 可选" + async fn read_provider_value( + backend: &openless_core::OpenLessBackend, + kind: openless_core::ChannelKind, + channel_id: &str, + account: &str, + ) -> Result, BackendError> { + backend + .read_credential(provider_credential_key(kind, channel_id, account)?) + .await + .map(|value| value.map(openless_core::SecretValue::into_exposed)) + } + + /// Write a non-secret value (endpoint/model/resource id/auth mode), or drop + /// it when the field was cleared: an empty string must not be stored as a + /// credential that then reads back as "configured". + async fn write_or_remove_provider_value( + backend: &openless_core::OpenLessBackend, + kind: openless_core::ChannelKind, + channel_id: &str, + account: &str, + value: &str, + ) -> Result<(), BackendError> { + let key = provider_credential_key(kind, channel_id, account)?; + if value.trim().is_empty() { + backend.remove_credential(key).await?; + } else { + backend + .set_credential(key, openless_core::SecretValue::new(value.trim())) + .await?; + } + Ok(()) + } + + /// Secrets are write-only: an empty input means "keep the stored key", not + /// "erase it" — erasing has its own explicit action. + async fn write_secret_if_entered( + backend: &openless_core::OpenLessBackend, + kind: openless_core::ChannelKind, + channel_id: &str, + account: &str, + value: &str, + ) -> Result<(), BackendError> { + let value = value.trim(); + if value.is_empty() { + return Ok(()); + } + backend + .set_credential( + provider_credential_key(kind, channel_id, account)?, + openless_core::SecretValue::new(value), + ) + .await?; + Ok(()) + } + + /// Persist the editor through Core: rename, then the credential schema that + /// matches the selected `ProviderDescriptor`. Account names are Core's wire + /// schema; which of them is required stays in Core, never in this form. + async fn save_provider_editor( + backend: Arc, + editor: ProviderEditor, + ) -> Result<(), BackendError> { + let channel_id = editor.channel.id.as_str(); + backend + .rename_channel(editor.kind, channel_id.to_string(), editor.name) + .await?; + match editor.descriptor.auth_requirement { + openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => {} + openless_core::AuthRequirement::Volcengine => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, + &editor.auth_mode, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, + &editor.resource_id, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + &editor.volcengine_service, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + model_account(editor.kind), + &editor.model, + ) + .await?; + if editor.auth_mode == "api_key" { + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT, + &editor.primary_secret, + ) + .await?; + } else { + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, + &editor.primary_secret, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, + &editor.secondary_secret, + ) + .await?; + } + } + openless_core::AuthRequirement::Xfyun => { + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::XFYUN_APP_ID_ACCOUNT, + &editor.primary_secret, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::XFYUN_API_KEY_ACCOUNT, + &editor.secondary_secret, + ) + .await?; } - openless_core::AuthRequirement::ApiKeyUnlessCustomEndpoint => { - "公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key" + _ => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + endpoint_account(editor.kind), + &editor.endpoint, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + model_account(editor.kind), + &editor.model, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + api_key_account(editor.kind), + &editor.primary_secret, + ) + .await?; } - openless_core::AuthRequirement::Volcengine => "火山引擎凭据", - openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key", - openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey", - openless_core::AuthRequirement::OAuth => "OAuth", } + Ok(()) } - fn provider_channel_descriptor( - panel: &ProviderPanel, - channel_id: &str, - ) -> Option<( - openless_core::ChannelSummary, - openless_core::ProviderDescriptor, - )> { - let channel = panel - .channels - .iter() - .find(|channel| channel.id == channel_id)? - .clone(); - let descriptor = panel - .descriptors - .iter() - .find(|descriptor| descriptor.provider_type.as_str() == channel.provider_type) - .cloned() - .or_else(|| { - openless_core::provider_rules::provider_descriptor( - provider_kind(panel.kind), - &channel.provider_type, - ) - })?; - Some((channel, descriptor)) - } - - async fn read_provider_value( - backend: &openless_core::OpenLessBackend, - kind: openless_core::ChannelKind, - channel_id: &str, - account: &str, - ) -> Result, BackendError> { - backend - .read_credential(provider_credential_key(kind, channel_id, account)?) - .await - .map(|value| value.map(openless_core::SecretValue::into_exposed)) + /// Drop every credential of the selected descriptor shape for one channel. + async fn clear_provider_secrets( + backend: Arc, + editor: &ProviderEditor, + ) -> Result<(), BackendError> { + let accounts: &[&str] = match editor.descriptor.auth_requirement { + openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[], + openless_core::AuthRequirement::Volcengine => &[ + openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, + openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, + openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT, + ], + openless_core::AuthRequirement::Xfyun => &[ + openless_core::credentials::XFYUN_APP_ID_ACCOUNT, + openless_core::credentials::XFYUN_API_KEY_ACCOUNT, + ], + _ => &[api_key_account(editor.kind)], + }; + for account in accounts { + backend + .remove_credential(provider_credential_key( + editor.kind, + &editor.channel.id, + account, + )?) + .await?; + } + Ok(()) } async fn load_provider_editor( @@ -2283,19 +5675,6 @@ mod linux_app { } else { (String::new(), String::new()) }; - let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud - { - read_provider_value( - &backend, - kind, - &channel.id, - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - ) - .await? - .unwrap_or_default() - } else { - String::new() - }; Ok(ProviderEditor { kind, name: channel.name.clone(), @@ -2306,517 +5685,1483 @@ mod linux_app { volcengine_service, auth_mode, resource_id, - app_id, primary_secret: String::new(), secondary_secret: String::new(), }) } - fn secret_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { - ui.horizontal(|ui| { - ui.label(label); - ui.add(egui::TextEdit::singleline(value).password(true)); + async fn validate_provider_channel( + lang: Lang, + backend: Arc, + kind: openless_core::ChannelKind, + channel_id: String, + ) -> Result { + let started = std::time::Instant::now(); + let result = backend + .services() + .provider + .validate(openless_core::ProviderRequest { + kind: provider_kind(kind), + thinking_enabled: false, + channel_id: Some(channel_id.clone()), + }) + .await; + let latency_ms = started.elapsed().as_millis().min(u128::from(u32::MAX)) as u32; + match result { + Ok(_) => { + backend + .record_channel_test(kind, channel_id, true, Some(latency_ms), None) + .await?; + Ok(fmt_l10n(lang, "status.provider_validated", &[&latency_ms])) + } + Err(error) => { + let _ = backend + .record_channel_test( + kind, + channel_id, + false, + Some(latency_ms), + Some(error.message.clone()), + ) + .await; + Err(error) + } + } + } + + fn package_kind() -> LinuxPackageKind { + if std::env::var_os("APPDIR").is_some() { + LinuxPackageKind::AppImage + } else if cfg!(debug_assertions) { + LinuxPackageKind::Development + } else { + LinuxPackageKind::SystemPackage + } + } + + /// OpenLess 数据目录。宿主写数据,UI 进程只用它定位日志文件。 + fn openless_data_dir() -> Result { + std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| std::path::PathBuf::from(home).join(".local/share")) + }) + .map(|base| base.join("OpenLess")) + .ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string()) + } + + fn backend_config( + tray_available: bool, + updater_available: bool, + ) -> Result { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let data_dir = openless_data_dir()?; + let cache_dir = std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".cache"))) + .ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())? + .join("OpenLess"); + std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; + std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?; + let kind = package_kind(); + let capabilities = + LinuxCapabilitySnapshot::detect(tray_available, kind, updater_available).capabilities; + Ok(BackendConfig { + data_dir, + cache_dir, + home_dir: home, + resource_dir: std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)), + platform: capabilities, + locale: std::env::var("LANG").unwrap_or_else(|_| "en-US".to_string()), + }) + } + + fn ensure_fcitx5_ready(config: &BackendConfig) -> Result<(), String> { + let home = config + .home_dir + .as_deref() + .ok_or_else(|| "HOME is unavailable for the fcitx5 plugin".to_string())?; + let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; + let plan = + FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; + let status = ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string())?; + // 安装包升级会替换 libopenless.so,但运行中的 fcitx5 仍持有旧映像 —— + // 不重启它,新的热键匹配规则就不会生效。只在插件确实更新过时重启, + // 并且**绝不放在启动关键路径上**:`fcitx5 -r` 会变成常驻的守护进程, + // 早先在这里等它直接导致主窗口出不来。现在丢到后台线程,启动只做纯计算。 + let reload_plan = plan.clone(); + let reload_data_dir = config.data_dir.clone(); + std::thread::spawn(move || { + openless_linux_egui::reload_fcitx5_if_plugin_updated(&reload_plan, &reload_data_dir); }); + reconcile_fcitx5_install(status) } - fn provider_fields_ui(ui: &mut egui::Ui, editor: &mut ProviderEditor) { - // This match chooses which input controls to render; it does not decide - // whether credentials are sufficient. ProviderService validates the - // descriptor's AuthRequirement again before any protocol request. - match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None => { - ui.label("此 Provider 不使用云凭据;模型由本地模型面板管理。"); + /// Map an fcitx5 addon install result onto startup. + /// + /// A ready addon lets startup continue down the normal fcitx5 DBus path — + /// never a global-hotkey fallback — and only a genuinely missing plugin + /// aborts startup. + /// 插件缺失/未就绪 **绝不是** 启动失败:主窗口必须照常出现,只是全局热键 + /// 暂时不可用。早先这里 `Err(...)?` 会把整个启动打断,表现就是「主窗口不显示」。 + fn reconcile_fcitx5_install(status: FcitxPluginStatus) -> Result<(), String> { + match status { + FcitxPluginStatus::Ready => Ok(()), + FcitxPluginStatus::Missing => { + log::warn!( + "[fcitx] no OpenLess fcitx5 addon found in the package paths; \ + global hotkeys stay unavailable until the package is reinstalled" + ); + Ok(()) } - openless_core::AuthRequirement::OAuth => { - ui.label("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。"); + } + } + + /// 划词追问头像:登录名变化时后台取 `github.com/{login}.png`,解码后上传成 + /// egui 贴图(Tauri `UserAvatar`)。取图失败保持 GitHub 图标兜底。 + #[derive(Default)] + struct QaAvatar { + login: String, + texture: Option, + pending: Option>>, + } + + impl QaAvatar { + fn sync(&mut self, ctx: &egui::Context, login: &str) { + if login != self.login { + self.login = login.to_string(); + self.texture = None; + self.pending = None; + if !login.trim().is_empty() { + self.pending = Some(spawn_github_avatar_fetch(login.trim().to_string())); + } } - openless_core::AuthRequirement::Volcengine => { - let previous_api_key = - editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; - egui::ComboBox::from_id_salt("volcengine-service") - .selected_text(if editor.volcengine_service == "agent_plan" { - "Agent Plan" - } else { - "普通服务" - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut editor.volcengine_service, - "standard".to_string(), - "普通服务", + let Some(receiver) = self.pending.as_ref() else { + return; + }; + match receiver.try_recv() { + Ok(Ok(image)) => { + self.texture = Some(ctx.load_texture( + "openless-qa-user-avatar", + image, + egui::TextureOptions::LINEAR, + )); + self.pending = None; + } + Ok(Err(error)) => { + log::debug!("avatar unavailable: {error}"); + self.pending = None; + } + Err(mpsc::TryRecvError::Empty) => { + // 取图在别的线程:保持重绘直到结果回来。 + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + } + Err(mpsc::TryRecvError::Disconnected) => self.pending = None, + } + } + } + + fn spawn_github_avatar_fetch( + login: String, + ) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("openless-avatar".into()) + .spawn(move || { + let _ = tx.send(fetch_github_avatar(&login)); + }) + .ok(); + rx + } + + /// GitHub 公开头像接口(无需登录;Tauri 用的是同一个 URL 形状)。 + fn fetch_github_avatar(login: &str) -> Result { + let encoded: String = login + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character.to_string() + } else { + let mut buffer = [0u8; 4]; + character + .encode_utf8(&mut buffer) + .bytes() + .map(|byte| format!("%{byte:02X}")) + .collect() + } + }) + .collect(); + let url = format!("https://github.com/{encoded}.png?size=64"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let bytes = runtime.block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build() + .map_err(|error| error.to_string())?; + let response = client + .get(&url) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("avatar http {}", response.status())); + } + response.bytes().await.map_err(|error| error.to_string()) + })?; + let decoded = image::load_from_memory(&bytes).map_err(|error| error.to_string())?; + let rgba = decoded.to_rgba8(); + Ok(egui::ColorImage::from_rgba_unmultiplied( + [rgba.width() as usize, rgba.height() as usize], + rgba.as_raw(), + )) + } + + /// X11 overlay placement for the capsule popup. + /// + /// The capsule must land at the bottom centre of the work area and must + /// never take the keyboard: on macOS Tauri gets the same guarantee from + /// `orderFrontRegardless` ("visible but not the key window"). Under XWayland + /// the equivalent is `WM_HINTS.input = False`, which is why the capsule is + /// launched with the Wayland backend removed (see + /// [`openless_linux_egui::popup_command`]). + #[cfg(all(target_os = "linux", feature = "x11-overlay"))] + mod popup_overlay { + use super::*; + use openless_linux_egui::{ + place_overlay, popup_position, OverlayEnvironment, OverlayPlacement, X11Overlay, + }; + + pub struct PopupOverlay { + kind: PopupKind, + connection: X11Overlay, + environment: OverlayEnvironment, + /// The pre-map pass (hints + geometry) ran. + placed: bool, + /// The post-map pass (EWMH states, once the window is managed). + reasserted: bool, + attempts: u8, + } + + impl PopupOverlay { + pub fn probe(kind: PopupKind) -> Option { + // 纯 Wayland(没有 XWayland)时不做任何 X11 处理,按原行为跑。 + if !openless_linux_egui::x11_available(std::env::var("DISPLAY").ok().as_deref()) { + log::debug!("popup x11: no DISPLAY, keeping the compositor placement"); + return None; + } + let connection = match X11Overlay::connect() { + Ok(connection) => connection, + Err(error) => { + log::warn!( + "capsule x11: connect failed, staying with the compositor: {error}" + ); + return None; + } + }; + let environment = match connection.probe() { + Ok(environment) => environment, + Err(error) => { + log::warn!("capsule x11: geometry probe failed: {error}"); + OverlayEnvironment::default() + } + }; + log::info!( + "capsule x11: work_area={:?} monitors={} cursor={:?} active_window={:?}", + environment.work_area, + environment.monitors.len(), + environment.cursor, + environment.active_window + ); + Some(Self { + kind, + connection, + environment, + placed: false, + reasserted: false, + attempts: 0, + }) + } + + /// Position handed to `ViewportBuilder::with_position`, so the pill + /// is already in place the first time it is shown. + pub fn initial_position(&self) -> Option<(i32, i32)> { + popup_position(&self.environment, self.kind) + } + + fn apply(&mut self, reason: &str) -> OverlayPlacement { + let placement = place_overlay( + &mut self.connection, + std::process::id(), + &self.environment, + self.kind, + ); + if placement.applied() { + log::info!( + "capsule x11 ({reason}): window={:?} matched={} moved_to={:?} focus_was_stolen={} focus_restored={} warnings={:?}", + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.focus_was_stolen, + placement.focus_restored, + placement.warnings + ); + } + // Milestone line for real-machine verification: the popup + // process installs no logger, but it inherits stderr from the + // host, so this is the one place the fallback is observable + // (`journalctl --user -f | grep 'OpenLess capsule'`). + eprintln!( + "OpenLess capsule: x11 {reason} window={:?} matched={} moved_to={:?} \ +focus_was_stolen={} focus_restored={} warnings={:?}", + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.focus_was_stolen, + placement.focus_restored, + placement.warnings + ); + placement + } + + pub fn place(&mut self, ctx: &egui::Context, visible: bool) { + // 只有胶囊需要「永不聚焦 + 置顶 + 不进任务栏」;两个面板要键盘输入, + // 位置已经由 `with_position` 在创建时给过,X11 变更一概不做。 + if self.kind != PopupKind::Capsule { + self.placed = true; + return; + } + if !self.placed { + self.attempts = self.attempts.saturating_add(1); + if self.apply("pre-map").applied() { + self.placed = true; + } else if self.attempts >= 100 { + // The window never showed up in the tree: stop asking but + // keep the pill working with the compositor's placement. + log::warn!( + "capsule x11: own window not found, keeping the compositor placement" ); - ui.selectable_value( - &mut editor.volcengine_service, - "agent_plan".to_string(), - "Agent Plan", + self.placed = true; + } else { + // The window is created a frame or two after the app + // starts; try again on the next tick. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + } + if visible && !self.reasserted { + // Now that the window is managed, (re)assert above + + // skip-taskbar and the geometry the manager may have moved. + self.reasserted = true; + self.apply("post-map"); + } + } + } + } + + /// Pure Wayland build: the capsule keeps the compositor's placement. + #[cfg(not(all(target_os = "linux", feature = "x11-overlay")))] + mod popup_overlay { + use super::*; + + pub struct PopupOverlay; + + impl PopupOverlay { + pub fn probe(_kind: PopupKind) -> Option { + None + } + + pub fn initial_position(&self) -> Option<(i32, i32)> { + None + } + + pub fn place(&mut self, _ctx: &egui::Context, _visible: bool) {} + } + } + + use popup_overlay::PopupOverlay; + + struct NativePopupApp { + kind: PopupKind, + state: PopupState, + incoming: mpsc::Receiver, + outgoing: mpsc::Sender, + qa_input: String, + /// Less Computer 面板的输入框(与 QA 的 composer 各自独立)。 + less_computer_input: String, + outgoing_sequence: u64, + ready_sent: bool, + preview_focus_requested: bool, + avatar: QaAvatar, + lang: Lang, + /// X11 overlay placement for the capsule (bottom-centre, never focus). + overlay: Option, + } + + impl NativePopupApp { + fn send(&mut self, message: PopupToHost) { + if self.outgoing.send(message).is_err() { + eprintln!("OpenLess popup output channel closed"); + } + } + + fn next_sequence(&mut self) -> u64 { + self.outgoing_sequence = self.outgoing_sequence.saturating_add(1); + self.outgoing_sequence + } + + fn session_id(&self) -> Option { + self.state.session_id.clone() + } + + fn dismiss(&mut self, ctx: &egui::Context) { + let Some(session_id) = self.session_id() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + let version = POPUP_PROTOCOL_VERSION; + let sequence = self.next_sequence(); + let message = match self.kind { + PopupKind::Qa => PopupToHost::DismissQa { + version, + session_id, + sequence, + }, + PopupKind::Preview => PopupToHost::CancelPreview { + version, + session_id, + sequence, + }, + PopupKind::Capsule => PopupToHost::DismissCapsule { + version, + session_id, + sequence, + }, + PopupKind::LessComputer => PopupToHost::DismissLessComputer { + version, + session_id, + sequence, + }, + }; + self.send(message); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + impl NativePopupApp { + /// Drain every host message queued since the last frame. `ctx` is + /// `None` on the layer-shell path, which has no viewport to command: + /// visibility and shutdown are handled by the runner instead. + /// + /// Returns true when the process should exit. + fn pump(&mut self, ctx: Option<&egui::Context>) -> bool { + loop { + let message = match self.incoming.try_recv() { + Ok(message) => message, + Err(std::sync::mpsc::TryRecvError::Empty) => return false, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // 宿主进程没了(stdin 到 EOF → 读线程结束 → 发送端析构)。 + // 之前这里把 Empty 和 Disconnected 一起当成「没有消息」, + // 于是胶囊会在宿主崩溃/被杀后永久贴在屏幕上(layer surface + // 不能隐藏,只能随进程销毁)。宿主不在了就该自己退场。 + log::warn!( + "openless popup ({:?}): host pipe closed — closing the popup", + self.kind ); - }); - if editor.volcengine_service != "agent_plan" { - egui::ComboBox::from_id_salt("volcengine-auth-mode") - .selected_text(&editor.auth_mode) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut editor.auth_mode, - "app_id_token".to_string(), - "APP ID + Access Token", - ); - ui.selectable_value( - &mut editor.auth_mode, - "api_key".to_string(), - "API Key", - ); - }); + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + return true; + } + }; + if message + .content_kind() + .is_some_and(|message_kind| message_kind != self.kind) + { + continue; } - let api_key = - editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; - if api_key != previous_api_key { - // Input buffers change meaning; persisted credential slots remain untouched. - editor.primary_secret.clear(); - editor.secondary_secret.clear(); + if matches!(message, HostToPopup::Preview { .. }) { + self.preview_focus_requested = false; } - if editor.volcengine_service == "agent_plan" { - ui.label("使用 Agent Plan 专属 API Key;普通服务请使用单独渠道。"); + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + let outcome = self.state.apply(message); + if outcome == openless_linux_egui::PopupApplyOutcome::Applied { + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(self.state.visible)); + } } - if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { - secret_edit(ui, "API Key", &mut editor.primary_secret); - } else { - secret_edit(ui, "APP ID", &mut editor.primary_secret); - secret_edit(ui, "Access Token", &mut editor.secondary_secret); + if shutdown || self.state.shutdown_requested { + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + return true; } - ui.horizontal(|ui| { - ui.label("Resource ID"); - ui.text_edit_singleline(&mut editor.resource_id); - }); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); - }); } - openless_core::AuthRequirement::Xfyun => { - secret_edit(ui, "AppID", &mut editor.primary_secret); - secret_edit(ui, "API Key", &mut editor.secondary_secret); + } + + /// Tell the host which session this window is serving. The host drops + /// every message that carries another session id, so this must happen + /// before the first content arrives. + fn send_ready_if_needed(&mut self) { + if self.ready_sent { + return; } - openless_core::AuthRequirement::TencentCloud => { - ui.horizontal(|ui| { - ui.label("腾讯云 AppID"); - ui.text_edit_singleline(&mut editor.app_id); - }); - secret_edit(ui, "SecretID", &mut editor.primary_secret); - secret_edit(ui, "SecretKey", &mut editor.secondary_secret); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::Ready { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + kind: self.kind, }); + self.ready_sent = true; } - _ => { - secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); - let mut endpoint_read_only = false; - if editor.kind == openless_core::ChannelKind::Llm - && editor.channel.provider_type == "ark" - { - let presets = editor - .descriptor - .default_endpoint - .as_deref() - .map(|endpoint| ("火山方舟", endpoint)) - .into_iter() - .chain( - editor - .descriptor - .endpoint_presets - .iter() - .map(|preset| (preset.name.as_str(), preset.endpoint.as_str())), - ) - .collect::>(); - let selected = presets - .iter() - .find(|(_, endpoint)| { - openless_core::provider_rules::matches_endpoint_preset( - &editor.endpoint, - endpoint, - ) - }) - .map(|(label, _)| *label); - endpoint_read_only = selected.is_some(); - egui::ComboBox::from_id_salt("ark-service") - .selected_text(selected.unwrap_or("自定义")) - .show_ui(ui, |ui| { - for (label, endpoint) in presets { - if ui - .selectable_label(selected == Some(label), label) - .clicked() - { - editor.endpoint = endpoint.to_string(); - endpoint_read_only = true; - } + } + + /// One capsule frame on a layer surface: same view, same protocol and + /// same send / exit rules as the eframe window, minus viewport + /// commands (a layer surface is sized by the compositor). + fn layer_frame( + &mut self, + ctx: &egui::Context, + raw: egui::RawInput, + first: bool, + ) -> openless_linux_egui::LayerFrame { + if first { + theme::install(ctx); + } + let mut exit = self.pump(None); + self.send_ready_if_needed(); + let animated = matches!( + self.state.capsule.phase.to_ascii_lowercase().as_str(), + "starting" | "recording" | "transcribing" | "polishing" | "inserting" + ); + let capsule = self.state.capsule.clone(); + let lang = self.lang; + let mut action = frontend::popups::CapsuleAction::None; + let output = ctx.run(raw, |ctx| { + action = frontend::popups::dictation_capsule(ctx, &capsule, lang); + }); + match action { + frontend::popups::CapsuleAction::None => {} + frontend::popups::CapsuleAction::Cancel + | frontend::popups::CapsuleAction::Confirm => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + let message = if matches!(action, frontend::popups::CapsuleAction::Cancel) { + PopupToHost::CancelDictation { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, } - }); + } else { + PopupToHost::StopDictation { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + } + }; + self.send(message); + } + exit = true; } - ui.horizontal(|ui| { - ui.label("Endpoint"); - ui.add( - egui::TextEdit::singleline(&mut editor.endpoint) - .interactive(!endpoint_read_only), - ); - }); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); - }); + } + openless_linux_egui::LayerFrame { + output, + exit, + // Same cadence as the windowed popup: animate fast, idle slowly. + repaint_after: Duration::from_millis(if self.state.visible && animated { + 33 + } else { + 100 + }), } } } - async fn write_or_remove_provider_value( - backend: &openless_core::OpenLessBackend, - kind: openless_core::ChannelKind, - channel_id: &str, - account: &str, - value: &str, - ) -> Result<(), BackendError> { - let key = provider_credential_key(kind, channel_id, account)?; - if value.trim().is_empty() { - backend.remove_credential(key).await?; - } else { - backend - .set_credential(key, openless_core::SecretValue::new(value.trim())) - .await?; - } - Ok(()) - } - - async fn write_secret_if_entered( - backend: &openless_core::OpenLessBackend, - kind: openless_core::ChannelKind, - channel_id: &str, - account: &str, - value: &str, - ) -> Result<(), BackendError> { - let value = value.trim(); - if value.is_empty() { - return Ok(()); - } - backend - .set_credential( - provider_credential_key(kind, channel_id, account)?, - openless_core::SecretValue::new(value), - ) - .await?; - Ok(()) - } - - async fn save_provider_editor( - backend: Arc, - editor: ProviderEditor, - ) -> Result<(), BackendError> { - // Account names are the stable credential wire schema exported by - // Core. Defaults and required/optional semantics stay in the selected - // ProviderDescriptor and ProviderService, never in this Host form. - let channel_id = editor.channel.id.as_str(); - backend - .rename_channel(editor.kind, channel_id.to_string(), editor.name) - .await?; - match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => {} - openless_core::AuthRequirement::Volcengine => { - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, - &editor.volcengine_service, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, - &editor.auth_mode, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, - &editor.resource_id, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - model_account(editor.kind), - &editor.model, - ) - .await?; - if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT, - &editor.primary_secret, - ) - .await?; - } else { - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, - &editor.primary_secret, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, - &editor.secondary_secret, - ) - .await?; - } + impl eframe::App for NativePopupApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Overlay placement first: it must run before the window is shown so + // the compositor never gives the capsule the keyboard. + if let Some(overlay) = self.overlay.as_mut() { + overlay.place(ctx, self.state.visible); } - openless_core::AuthRequirement::Xfyun => { - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::XFYUN_APP_ID_ACCOUNT, - &editor.primary_secret, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::XFYUN_API_KEY_ACCOUNT, - &editor.secondary_secret, - ) - .await?; + if self.pump(Some(ctx)) { + return; } - openless_core::AuthRequirement::TencentCloud => { - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - &editor.app_id, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, - &editor.primary_secret, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, - &editor.secondary_secret, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - model_account(editor.kind), - &editor.model, - ) - .await?; + self.send_ready_if_needed(); + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + self.dismiss(ctx); + return; + } + let lang = self.lang; + // 只有「有动画」的状态需要 30fps 连续重绘:录音音量条、思考光环/光点、 + // 头像取图中。静止或隐藏时降到 10fps(stdin 轮询延迟 ≤100ms,肉眼无感), + // 避免透明置顶窗口长期白跑帧。 + let mut animated = false; + match self.kind { + PopupKind::Preview => { + let first_frame = !self.preview_focus_requested; + let action = frontend::popups::selection_preview( + ctx, + &mut self.state.preview, + first_frame, + lang, + ); + if first_frame { + self.preview_focus_requested = true; + } + match action { + frontend::popups::PreviewAction::Cancel => self.dismiss(ctx), + frontend::popups::PreviewAction::Confirm(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ConfirmPreview { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + frontend::popups::PreviewAction::None => {} + } + } + PopupKind::Qa => { + self.avatar.sync(ctx, &self.state.qa.viewer_login); + let qa_phase = self.state.qa.phase.to_ascii_lowercase(); + animated = self.avatar.pending.is_some() + || matches!( + qa_phase.as_str(), + "loading" | "thinking" | "recording" | "answerdelta" + ); + let action = frontend::popups::selection_ask( + ctx, + &self.state.qa, + &mut self.qa_input, + lang, + self.avatar.texture.as_ref(), + ); + match action { + frontend::popups::QaAction::Dismiss => self.dismiss(ctx), + frontend::popups::QaAction::ToggleRecording => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ToggleQaRecording { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + frontend::popups::QaAction::SetPinned(pinned) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SetPinned { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + pinned, + }); + } + } + frontend::popups::QaAction::SetEditInstructionMode(enabled) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SetEditInstructionMode { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + enabled, + }); + } + } + frontend::popups::QaAction::ApplyEdit => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ApplyEdit { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + frontend::popups::QaAction::RevertEdit => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::RevertEdit { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + frontend::popups::QaAction::Submit(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + } + } + frontend::popups::QaAction::None => {} + } + } + PopupKind::LessComputer => { + // 运行中的一轮需要连续重绘(「执行中…」标记 + 滚动到底)。 + animated = self.state.less_computer.working; + let action = frontend::popups::less_computer( + ctx, + &self.state.less_computer, + &mut self.less_computer_input, + lang, + ); + match action { + frontend::popups::LessComputerAction::None => {} + frontend::popups::LessComputerAction::Dismiss => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::DismissLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + frontend::popups::LessComputerAction::Cancel => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::CancelLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + frontend::popups::LessComputerAction::Submit(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SubmitLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + } + } + frontend::popups::LessComputerAction::Approve { token, approved } => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ApproveLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + token, + approved, + }); + } + } + } + } + PopupKind::Capsule => { + let capsule_phase = self.state.capsule.phase.to_ascii_lowercase(); + animated = matches!( + capsule_phase.as_str(), + "starting" | "recording" | "transcribing" | "polishing" | "inserting" + ); + let action = + frontend::popups::dictation_capsule(ctx, &self.state.capsule, lang); + let message = match action { + frontend::popups::CapsuleAction::Cancel => { + Some(PopupToHost::CancelDictation { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + }) + } + frontend::popups::CapsuleAction::Confirm => { + Some(PopupToHost::StopDictation { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + }) + } + frontend::popups::CapsuleAction::None => None, + }; + if let Some(mut message) = message { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + match &mut message { + PopupToHost::CancelDictation { + session_id: id, + sequence: seq, + .. + } + | PopupToHost::StopDictation { + session_id: id, + sequence: seq, + .. + } => { + *id = session_id; + *seq = sequence; + } + _ => {} + } + self.send(message); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } } - _ => { - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - endpoint_account(editor.kind), - &editor.endpoint, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - model_account(editor.kind), - &editor.model, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - api_key_account(editor.kind), - &editor.primary_secret, - ) - .await?; + ctx.request_repaint_after(Duration::from_millis(if self.state.visible && animated { + 33 + } else { + 100 + })); + } + } + + fn popup_kind(args: &[String]) -> Option { + if !args.iter().any(|arg| arg == "--openless-egui-popup") { + return None; + } + if args.iter().any(|arg| arg == "--qa") { + Some(PopupKind::Qa) + } else if args.iter().any(|arg| arg == "--preview") { + Some(PopupKind::Preview) + } else if args.iter().any(|arg| arg == "--capsule") { + Some(PopupKind::Capsule) + } else if args.iter().any(|arg| arg == "--less-computer") { + Some(PopupKind::LessComputer) + } else { + None + } + } + + /// Append a streaming delta to the trailing assistant entry, creating it on + /// the first delta of a turn. Keeps one assistant bubble per turn instead of + /// one per delta, matching the Tauri panel's message list. + fn append_assistant_entry( + entries: &mut Vec, + delta: &str, + ) { + match entries.last_mut() { + Some(entry) if entry.kind == "assistant" => entry.text.push_str(delta), + _ => entries.push(openless_linux_egui::LessComputerEntry { + kind: "assistant".to_string(), + text: delta.to_string(), + }), + } + } + + /// stdin/stdout JSONL plumbing shared by the eframe popup window and the + /// layer-shell capsule: both talk to the host through the same protocol. + fn popup_stdio() -> Result<(mpsc::Receiver, mpsc::Sender), String> { + let (tx, rx) = mpsc::sync_channel(256); + std::thread::Builder::new() + .name("openless-popup-input".into()) + .spawn(move || { + let stdin = std::io::stdin(); + let mut reader = std::io::BufReader::new(stdin.lock()); + if let Err(error) = openless_linux_egui::run_popup(&mut reader, |message| { + let _ = tx.send(message); + }) { + eprintln!("OpenLess popup input failed: {error}"); + } + }) + .map_err(|error| error.to_string())?; + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("openless-popup-output".into()) + .spawn(move || { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + while let Ok(message) = outgoing_rx.recv() { + if let Err(error) = write_jsonl(&mut writer, &message) { + eprintln!("OpenLess popup output failed: {error}"); + break; + } + } + }) + .map_err(|error| error.to_string())?; + Ok((rx, outgoing_tx)) + } + + /// 胶囊在实现了 `zwlr_layer_shell_v1` 的合成器上跑原生 layer surface + /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、EGL 起不来或 + /// configure 超时都会返回 Err,由调用方回退到 XWayland 叠加层。 + fn run_capsule_layer_process() -> Result<(), LayerCapsuleFailure> { + let geometry = openless_linux_egui::capsule_geometry( + openless_linux_egui::CAPSULE_WINDOW_SIZE.0, + openless_linux_egui::CAPSULE_WINDOW_SIZE.1, + openless_linux_egui::CAPSULE_BOTTOM_GAP, + ); + // The host pipe is opened lazily, on the first frame the runner asks + // for: the runner only calls back once the layer surface is configured + // and EGL is live, so a preflight failure leaves stdin untouched for the + // XWayland fallback. `started` records that the pipe is in use, which + // makes a late failure fatal instead of a (broken) second attempt. + let mut app: Option = None; + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started_in_frame = Arc::clone(&started); + let result = openless_linux_egui::run_layer_capsule(geometry, move |ctx, raw, first| { + if app.is_none() { + match popup_stdio() { + Ok((incoming, outgoing)) => { + started_in_frame.store(true, std::sync::atomic::Ordering::SeqCst); + app = Some(NativePopupApp { + kind: PopupKind::Capsule, + state: PopupState::default(), + incoming, + outgoing, + qa_input: String::new(), + less_computer_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + preview_focus_requested: false, + avatar: QaAvatar::default(), + lang: load_locale_pref().resolve(), + overlay: None, + }); + } + Err(error) => { + log::error!("popup pipe unavailable: {error}"); + let output = ctx.run(raw, |_| {}); + return openless_linux_egui::LayerFrame { + output, + exit: true, + repaint_after: std::time::Duration::from_millis(0), + }; + } + } + } + let app = app.as_mut().expect("popup app is created above"); + app.layer_frame(ctx, raw, first) + }); + match result { + Ok(()) => Ok(()), + Err(error) if started.load(std::sync::atomic::Ordering::SeqCst) => { + // The capsule was already on screen: the host pipe is in use, so + // there is nothing to fall back to (a second window would fight + // this process for the same stdin). Report and let it die. + Err(LayerCapsuleFailure { + message: format!("layer-shell capsule stopped after startup: {error}"), + started: true, + }) } + Err(error) => Err(LayerCapsuleFailure { + message: error, + started: false, + }), } - Ok(()) } - async fn clear_provider_secrets( - backend: Arc, - editor: &ProviderEditor, - ) -> Result<(), BackendError> { - let accounts: &[&str] = match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[], - openless_core::AuthRequirement::Volcengine => &[ - openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, - openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, - openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT, + /// Why the layer-shell capsule gave up, plus whether it had already taken + /// over the host pipe — a live capsule cannot fall back to a second window. + struct LayerCapsuleFailure { + message: String, + started: bool, + } + + fn run_popup_process(kind: PopupKind) -> Result<(), String> { + // 胶囊优先走原生 layer surface;不可用时(无该协议 / EGL 失败 / + // configure 超时)安静回退到下面那条 XWayland 叠加层路径。 + if kind == PopupKind::Capsule + && openless_linux_egui::detect_capsule_path() + == openless_linux_egui::CapsulePath::LayerShell + { + match run_capsule_layer_process() { + Ok(()) => return Ok(()), + Err(failure) if failure.started => return Err(failure.message), + Err(failure) => { + eprintln!( + "OpenLess capsule: layer-shell unavailable ({}); using the XWayland overlay", + failure.message + ); + log::warn!( + "layer-shell capsule unavailable ({}); falling back to the XWayland overlay", + failure.message + ); + } + } + } + let (rx, outgoing_tx) = popup_stdio()?; + // 胶囊窗口贴着药丸尺寸(Tauri 经典药丸 176×42),并用透明背景让圆角 + // 真正透出桌面;QA / 预览是实心卡片窗口。 + let size = match kind { + // 面板尺寸一律取自共享常量(Tauri:qa/less-computer 420×540)。 + PopupKind::Qa => [ + openless_linux_egui::QA_WINDOW_SIZE.0 as f32, + openless_linux_egui::QA_WINDOW_SIZE.1 as f32, ], - openless_core::AuthRequirement::Xfyun => &[ - openless_core::credentials::XFYUN_APP_ID_ACCOUNT, - openless_core::credentials::XFYUN_API_KEY_ACCOUNT, + PopupKind::LessComputer => [ + openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.0 as f32, + openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.1 as f32, ], - openless_core::AuthRequirement::TencentCloud => &[ - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, - openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + PopupKind::Preview => [ + openless_linux_egui::PREVIEW_WINDOW_SIZE.0 as f32, + openless_linux_egui::PREVIEW_WINDOW_SIZE.1 as f32, + ], + // 经典药丸 176×42 + 16px 下边距 + 8px 间距 + 「正在翻译」徽章 + // (Tauri `getCapsuleHostMetrics(.., 'classic')` 的 100 高度)。 + PopupKind::Capsule => [ + openless_linux_egui::CAPSULE_WINDOW_SIZE.0 as f32, + openless_linux_egui::CAPSULE_WINDOW_SIZE.1 as f32, ], - _ => &[api_key_account(editor.kind)], }; - for account in accounts { - backend - .remove_credential(provider_credential_key( - editor.kind, - &editor.channel.id, - account, - )?) - .await?; + let transparent = matches!(kind, PopupKind::Capsule); + // 三条路径的优先级(`popup_layer::choose_capsule_path`): + // 1. 合成器有 zwlr_layer_shell_v1 → 走原生 layer surface(已在上面 return) + // 2. 否且有 X 服务器 → XWayland + 下面的 X11 叠加层(本分支) + // 3. 两者都没有 → 普通无边框窗口,位置/焦点交给合成器 + // 胶囊只在第 2 条路径里做 X11 处理:先读一次几何,让窗口在**创建时**就落 + // 在工作区底部居中,并在映射前把 WM_HINTS.input 关掉(kwin 不会再给它焦点)。 + // 面板窗(QA/预览)需要键盘输入,只借 `with_position` 定位。 + let capsule_on_x11 = kind == PopupKind::Capsule + && openless_linux_egui::detect_capsule_path() + == openless_linux_egui::CapsulePath::X11Overlay; + let overlay = if kind == PopupKind::Capsule && !capsule_on_x11 { + None + } else { + PopupOverlay::probe(kind) + }; + let initial_position = overlay + .as_ref() + .and_then(|overlay| overlay.initial_position()); + let mut viewport = egui::ViewportBuilder::default() + .with_title("OpenLess") + .with_inner_size(size) + .with_decorations(false) + .with_always_on_top() + .with_transparent(transparent) + .with_visible(false); + if let Some(position) = initial_position { + viewport = viewport.with_position([position.0 as f32, position.1 as f32]); } - Ok(()) + if kind == PopupKind::Capsule { + // 不主动要激活:Wayland 下由合成器决定,X11 下就是「可见但不是 key + // window」,与 Tauri 的 `orderFrontRegardless` 同语义。 + viewport = viewport.with_active(false); + } + if kind == PopupKind::Preview { + // Tauri `selection-polish-preview`:可缩放 + 显式抢焦点,因为用户要就地 + // 编辑润色后的文本(键盘输入必须落在本窗口)。 + viewport = viewport + .with_resizable(true) + .with_min_inner_size([ + openless_linux_egui::PREVIEW_MIN_SIZE.0 as f32, + openless_linux_egui::PREVIEW_MIN_SIZE.1 as f32, + ]) + .with_active(true); + } + let options = eframe::NativeOptions { + viewport, + ..Default::default() + }; + eframe::run_native( + "OpenLess Popup", + options, + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(NativePopupApp { + kind, + state: PopupState::default(), + incoming: rx, + outgoing: outgoing_tx, + qa_input: String::new(), + less_computer_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + preview_focus_requested: false, + avatar: QaAvatar::default(), + // The popup is a separate process, so it re-reads the + // persisted UI-locale preference rather than sharing state. + lang: load_locale_pref().resolve(), + overlay, + })) + }), + ) + .map_err(|error| error.to_string()) } - async fn validate_provider_channel( - backend: Arc, - kind: openless_core::ChannelKind, - channel_id: String, - ) -> Result { - let started = std::time::Instant::now(); - let result = backend - .services() - .provider - .validate(openless_core::ProviderRequest { - thinking_enabled: backend.get_preferences().llm_thinking_enabled, - kind: provider_kind(kind), - channel_id: Some(channel_id.clone()), - }) - .await; - let latency_ms = started.elapsed().as_millis().min(u128::from(u32::MAX)) as u32; - match result { - Ok(_) => { - backend - .record_channel_test(kind, channel_id, true, Some(latency_ms), None) - .await?; - Ok(format!("Provider 验证通过({latency_ms} ms)")) + /// 无窗口宿主:进程里没有 eframe 窗口,事件泵、托盘与弹窗由这个循环驱动。 + /// + /// 关掉主窗口后进程会切到这个形态:窗口(以及它在任务栏/窗口列表里的条目) + /// 因此真的消失(Wayland 下 winit 无法隐藏窗口,只有真退出窗口进程才算数), + /// 而热键、弹窗、托盘继续工作。托盘「显示主窗口」时再拉起带窗口的进程, + /// 本进程退出,把单实例锁让出去。 + /// 宿主主循环的节拍。50ms:UI 动作最坏等一个节拍再进行,加上 UI 侧 30ms + /// 的重绘间隔,端到端仍在 100ms 预算内。 + const HOST_TICK_INTERVAL: Duration = Duration::from_millis(50); + + /// 常驻宿主的运行循环:**本进程没有窗口**。 + /// + /// 它持有后端 / 数据目录 / 单实例锁 / 热键监听 / 托盘 / 弹窗监督器,并通过 + /// `UiBridgeHost` 与独立的 UI 窗口进程通信。时序: + /// 1. 单实例锁(`run()` 里已拿到)→ 托盘与热键(native)→ **bind 桥 socket**; + /// 2. socket 就绪后才拉 UI 进程,UI 连不上宿主就直接报错退出; + /// 3. 每轮先收 UI 消息、再跑宿主心跳、再按需拉窗口、最后推视图模型; + /// 4. 退出前先给 UI 发 `Shutdown`,再由 `run()` 的 drop 释放单实例锁。 + fn run_host( + runtime_dir: &std::path::Path, + tokio: Arc, + native: Result, + tray: Option, + update_support: LinuxUpdateSupport, + start_minimized: bool, + ) -> Result<(), String> { + let socket = bridge::ui_socket_path(runtime_dir); + let mut ui_bridge = UiBridgeHost::bind(socket.clone()) + .map_err(|error| format!("UI bridge bind failed: {error}"))?; + let tray_available = tray.is_some(); + // 没有托盘时必须开窗,否则关掉就再也找不回来。 + let window_should_be_open = !start_minimized || !tray_available; + let ctx = egui::Context::default(); + let mut app = + OpenLessEguiApp::new(tokio, native, tray, update_support, window_should_be_open); + log::info!( + "[ui-host] host started (no window in this process); bridge={} window_should_be_open={window_should_be_open} tray={tray_available}", + ui_bridge.path().display() + ); + loop { + ui_bridge.accept_pending(); + let messages = ui_bridge.drain(); + if !messages.is_empty() { + app.apply_window_messages(messages, tray_available); } - Err(error) => { - let _ = backend - .record_channel_test( - kind, - channel_id, - false, - Some(latency_ms), - Some(error.message.clone()), - ) - .await; - Err(error) + app.tick(&ctx); + if app.should_spawn_ui_window() { + if let Err(error) = app.spawn_ui_window(&socket) { + log::warn!("[ui-host] cannot start the UI window: {error}"); + } + } + let actions = std::mem::take(&mut app.pending_ui_actions); + if !actions.is_empty() { + app.apply_frontend_actions(actions, &ctx); + } + for sequence in std::mem::take(&mut app.pending_ui_pongs) { + ui_bridge.send(HostToWindow::Pong { sequence }); + } + app.sync_view_model(); + app.publish_view_model(&mut ui_bridge); + if app.exit_requested { + break; } + std::thread::sleep(HOST_TICK_INTERVAL); } + log::info!("[ui-host] host exiting; asking the UI window to close"); + ui_bridge.shutdown(); + Ok(()) } - fn package_kind() -> LinuxPackageKind { - if std::env::var_os("APPDIR").is_some() { - LinuxPackageKind::AppImage - } else if cfg!(debug_assertions) { - LinuxPackageKind::Development - } else { - LinuxPackageKind::SystemPackage + /// UI 窗口进程入口:只渲染。 + /// + /// 它不构造 Core 后端、不打开数据目录、不抢单实例锁、不注册托盘与热键 —— + /// 关掉它等于「关掉一个窗口」,宿主与所有后台能力原地不动。 + fn run_ui_client(socket: std::path::PathBuf) -> Result<(), String> { + // UI 进程不复用宿主的日志器对象,但写到同一个文件里, + // 排查「窗口进程怎么没了」时两端日志在同一处。 + if let Ok(data_dir) = openless_data_dir() { + if let Err(error) = openless_linux_egui::init_file_logger(&data_dir) { + eprintln!("OpenLess UI window logger unavailable: {error}"); + } } + let client = UiBridgeClient::connect(&socket)?; + log::info!( + "[ui-client] connected to the host bridge at {}", + socket.display() + ); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("OpenLess") + .with_inner_size(MAIN_WINDOW_INNER_SIZE) + .with_min_inner_size(MAIN_WINDOW_MIN_INNER_SIZE) + .with_decorations(false) + .with_transparent(true) + .with_resizable(true) + .with_visible(true), + ..Default::default() + }; + eframe::run_native( + "OpenLess", + options, + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(UiClientApp::new(client))) + }), + ) + .map_err(|error| error.to_string()) } - fn backend_config() -> Result { - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - let data_dir = std::env::var_os("XDG_DATA_HOME") - .map(std::path::PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".local/share"))) - .ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string())? - .join("OpenLess"); - let cache_dir = std::env::var_os("XDG_CACHE_HOME") - .map(std::path::PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".cache"))) - .ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())? - .join("OpenLess"); - std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; - std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?; - let kind = package_kind(); - let capabilities = LinuxCapabilitySnapshot::detect(false, kind).capabilities; - Ok(BackendConfig { - data_dir, - cache_dir, - home_dir: home, - resource_dir: std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(std::path::Path::to_path_buf)), - platform: capabilities, - locale: std::env::var("LANG").unwrap_or_else(|_| "en-US".to_string()), - }) + /// 诊断开关:`OPENLESS_UI_DEBUG=1` 时 UI 进程把指针点击与动作记进日志。 + /// 用来分辨「按钮没反应」是没收到指针事件,还是动作没能送到宿主。 + fn ui_debug_enabled() -> bool { + std::env::var("OPENLESS_UI_DEBUG").is_ok_and(|value| value == "1") } - fn ensure_fcitx5_ready(config: &BackendConfig) -> Result { - let home = config - .home_dir - .as_deref() - .ok_or_else(|| "HOME is unavailable for the fcitx5 plugin".to_string())?; - let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; - let plan = - FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; - ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string()) + /// 是否采纳这一份快照:序号必须**严格递增**(重复、乱序、回退统统丢弃), + /// 否则 UI 会把新状态画成旧状态。 + fn snapshot_supersedes(last_sequence: u64, sequence: u64) -> bool { + sequence > last_sequence + } + + /// UI 进程侧的 eframe 应用:收快照 → 渲染 → 把动作发回宿主。 + struct UiClientApp { + client: UiBridgeClient, + view_model: FrontendViewModel, + /// 已采纳的最大快照序号。 + last_sequence: u64, + /// 已发出的动作序号(宿主可据此看出重复或丢失)。 + action_sequence: u64, + ping_sequence: u64, + ping_sent_at: Option, + last_ping_at: std::time::Instant, + latency_samples: Vec, + exited: bool, + } + + impl UiClientApp { + fn new(client: UiBridgeClient) -> Self { + Self { + client, + view_model: FrontendViewModel::default(), + last_sequence: 0, + action_sequence: 0, + ping_sequence: 0, + ping_sent_at: None, + last_ping_at: std::time::Instant::now(), + latency_samples: Vec::new(), + exited: false, + } + } + + /// 收宿主的帧。快照按序号采纳;`Shutdown` 与断连都表示「宿主走了」, + /// 此时 UI 必须自己退出(没有宿主就没有数据可渲染)。 + fn drain_host(&mut self, ctx: &egui::Context) { + loop { + match self.client.try_recv() { + Ok(HostToWindow::Ready { version }) => { + log::info!("[ui-client] host ready (protocol {version})"); + } + Ok(HostToWindow::Snapshot { + sequence, + view_model, + }) => { + if snapshot_supersedes(self.last_sequence, sequence) { + self.last_sequence = sequence; + self.view_model = *view_model; + } else { + log::debug!("[ui-client] dropped stale snapshot #{sequence}"); + } + } + Ok(HostToWindow::Pong { sequence }) => { + if let Some(sent_at) = self.ping_sent_at.take() { + let rtt = sent_at.elapsed().as_millis(); + self.latency_samples.push(rtt); + if self.latency_samples.len() >= 20 { + let count = self.latency_samples.len(); + let max = self.latency_samples.iter().copied().max().unwrap_or(0); + let sum: u128 = self.latency_samples.iter().sum(); + log::info!( + "[ui-client] ipc round-trip avg={}ms max={max}ms over {count} probes (last #{sequence})", + sum / count as u128 + ); + self.latency_samples.clear(); + } + } + } + Ok(HostToWindow::Shutdown) => { + log::info!("[ui-client] host asked to shut down; closing the window"); + self.exited = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + log::warn!("[ui-client] host connection lost; closing the window"); + self.exited = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + break; + } + } + } + } + + fn ping_if_due(&mut self) { + if self.last_ping_at.elapsed() < Duration::from_secs(2) { + return; + } + self.last_ping_at = std::time::Instant::now(); + self.ping_sequence += 1; + self.ping_sent_at = Some(std::time::Instant::now()); + let _ = self.client.send(WindowToHost::Ping { + sequence: self.ping_sequence, + }); + } + + /// 关窗 = 本进程退出。宿主仍在,会话、录音、弹窗都不受影响。 + fn request_exit(&mut self, ctx: &egui::Context, reason: &str) { + if self.exited { + return; + } + log::info!("[ui-client] {reason}: exiting the window process (host keeps running)"); + self.exited = true; + let _ = self.client.send(WindowToHost::Bye); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + /// 把渲染层产生的动作分发出去:窗口控制就地处理,其余发给宿主。 + fn dispatch( + &mut self, + actions: Vec, + ctx: &egui::Context, + ) { + for action in actions { + match action { + frontend::view_model::FrontendAction::WindowClose => { + self.request_exit(ctx, "close button"); + } + frontend::view_model::FrontendAction::WindowMinimize => { + // 最小化在 Wayland 上是单向门(winit 明确拒绝取消最小化), + // 而宿主已经接管热键与弹窗,所以按「关窗回托盘」处理: + // 窗口进程退出,任务栏条目消失,托盘随时能再开一个。 + self.request_exit(ctx, "minimize button"); + } + frontend::view_model::FrontendAction::WindowMaximize => { + let maximized = + ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + other => { + self.action_sequence += 1; + if let Err(error) = self.client.send(WindowToHost::Action { + sequence: self.action_sequence, + action: other, + }) { + log::warn!("[ui-client] cannot forward action to the host: {error}"); + } + } + } + } + } + } + + impl Drop for UiClientApp { + fn drop(&mut self) { + // 关窗即退出:告别帧让宿主立刻作废窗口句柄,收尾读写线程 + // 以免宿主一直等到 EOF。 + let _ = self.client.send(WindowToHost::Bye); + self.client.shutdown(); + } + } + + impl eframe::App for UiClientApp { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::TRANSPARENT.to_normalized_gamma_f32() + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.drain_host(ctx); + theme::apply_visuals(ctx, self.view_model.theme_mode); + if ctx.input(|input| input.viewport().close_requested()) { + self.request_exit(ctx, "window manager close request"); + } + if self.exited { + return; + } + if ui_debug_enabled() { + let (pointer, clicked) = + ctx.input(|input| (input.pointer.interact_pos(), input.pointer.any_click())); + if clicked { + log::info!("[ui-client] pointer click at {pointer:?}"); + } + } + let mut actions = Vec::new(); + frontend::render(ctx, &mut self.view_model, &mut actions); + if ui_debug_enabled() && !actions.is_empty() { + log::info!("[ui-client] actions from the renderer: {actions:?}"); + } + self.dispatch(actions, ctx); + self.ping_if_due(); + ctx.request_repaint_after(Duration::from_millis(30)); + } } pub fn run() -> Result<(), String> { - let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?); - let config = backend_config()?; - let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| config.cache_dir.join("runtime")); let args = std::env::args().collect::>(); - let broker = match SingleInstanceBroker::acquire_or_forward( - &runtime_dir.join("openless.lock"), - &runtime_dir.join("openless.sock"), - LinuxLaunchIntent::from_args(&args), - ) - .map_err(|error| error.to_string())? - { - SingleInstanceRole::Primary(broker) => broker, - SingleInstanceRole::Forwarded => return Ok(()), + if let Some(kind) = popup_kind(&args) { + return run_popup_process(kind); + } + if let Some(socket) = ui_client_socket(&args) { + // UI 窗口进程:只渲染,不碰后端、数据目录、单实例锁与热键。 + return run_ui_client(socket); + } + let start_minimized = args.iter().any(|arg| arg == "--minimized"); + let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?); + let kind = package_kind(); + let update_support = LinuxUpdateSupport::initialize(kind); + let updater_available = update_support.supports_auto_update(); + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| std::path::PathBuf::from(home).join(".cache")) + }) + .map(|cache| cache.join("OpenLess/runtime")) + }) + .ok_or_else(|| "HOME/XDG_RUNTIME_DIR is unavailable".to_string())?; + // 常态启动时托盘先于能力快照存在;形态切换(--takeover)时旧进程可能还 + // 占着托盘名,所以接管到单实例锁之后再重试一次。 + let mut tray = openless_linux_egui::LinuxTray::start().ok(); + let broker = match acquire_broker(&runtime_dir, &args)? { + BrokerAcquisition::Primary(broker) => Some(broker), + BrokerAcquisition::Forwarded => return Ok(()), }; - let plugin_check = ensure_fcitx5_ready(&config); - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); + if tray.is_none() { + tray = openless_linux_egui::LinuxTray::start().ok(); + } + let tray_available = tray.is_some(); + let config = backend_config(tray_available, updater_available)?; + if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { + eprintln!("OpenLess file logger unavailable: {error}"); + } let native = (|| { // AppImage may need to materialize its bundled plugin into the // per-user fcitx5 search path. Do that before opening the DBus // listener: otherwise the first run can wait forever for signals // from a plugin fcitx5 has never loaded. - match &plugin_check { - Ok(FcitxPluginStatus::Ready) => {} - Ok(FcitxPluginStatus::Updated) => return Err( - "fcitx5 插件已安装或更新;请重载配置(fcitx5-remote -r),重新启动 fcitx5 或重新登录桌面,再启动 OpenLess".to_string() - ), - Ok(FcitxPluginStatus::Missing) => return Err( - "未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string() - ), - Err(error) => return Err(error.clone()), + // fcitx5 只是「全局热键」这一条能力:它缺席、插件过旧、DBus 不通 + // 都**不能**拖死后端与主窗口(否则用户看到的就是「跟后端完全没连上」)。 + if let Err(error) = ensure_fcitx5_ready(&config) { + log::warn!("[fcitx] addon readiness check failed, continuing without it: {error}"); } - let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; + let hotkeys = match Fcitx5HotkeyListener::start() { + Ok(listener) => Some(listener), + Err(error) => { + // fcitx5 没在跑 / DBus 不通:热键暂时不可用,其余功能照常。 + log::warn!("[fcitx] hotkey listener unavailable, continuing: {error}"); + None + } + }; let backend = { // Construction captures the existing executor for cpal/native // callbacks. The GUI thread leaves its context before block_on; @@ -2828,340 +7173,332 @@ mod linux_app { .map_err(|error| error.to_string())? }; tokio - .block_on(LinuxNativeRuntime::start( - backend, - Some(broker), - Some(hotkeys), - )) + .block_on(LinuxNativeRuntime::start(backend, broker, hotkeys)) .map_err(|error| error.to_string()) })(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1040.0, 760.0]) - .with_min_inner_size([420.0, 400.0]), - ..Default::default() - }; - eframe::run_native( - "OpenLess", - options, - Box::new(move |_| { - let mut app = OpenLessEguiApp::new(tokio, native); - app.environment = Some(environment); - app.plugin_check = Some(plugin_check); - Ok(Box::new(app)) - }), - ) - .map_err(|error| error.to_string()) + // 常驻宿主:本进程不再创建窗口,窗口交给独立的 UI 进程。 + run_host( + &runtime_dir, + tokio, + native, + tray, + update_support, + start_minimized, + )?; + Ok(()) + } + + fn set_style_pack_hotkey( + preferences: &mut UserPreferences, + pack_id: &str, + binding: Option, + ) { + preferences + .style_pack_hotkeys + .retain(|hotkey| hotkey.pack_id != pack_id); + if let Some(binding) = binding { + preferences + .style_pack_hotkeys + .push(openless_core::shared_types::StylePackHotkey { + pack_id: pack_id.to_string(), + binding, + }); + } + } + + fn shortcut_editor( + ui: &mut egui::Ui, + label: &str, + binding: &mut openless_core::shared_types::ShortcutBinding, + ) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + changed |= ui.text_edit_singleline(&mut binding.primary).changed(); + for (modifier, caption) in [ + ("ctrl", "Ctrl"), + ("alt", "Alt"), + ("shift", "Shift"), + ("super", "Super"), + ] { + let mut enabled = binding + .modifiers + .iter() + .any(|value| value.eq_ignore_ascii_case(modifier)); + if ui.checkbox(&mut enabled, caption).changed() { + changed = true; + binding + .modifiers + .retain(|value| !value.eq_ignore_ascii_case(modifier)); + if enabled { + binding.modifiers.push(modifier.to_string()); + } + } + } + }); + changed + } + + fn optional_shortcut_editor( + ui: &mut egui::Ui, + lang: Lang, + label: &str, + binding: &mut Option, + default_primary: &str, + ) -> bool { + let mut enabled = binding.is_some(); + let mut changed = ui + .checkbox(&mut enabled, fmt_l10n(lang, "hotkey.enable", &[&label])) + .changed(); + if enabled && binding.is_none() { + *binding = Some(openless_core::shared_types::ShortcutBinding { + primary: default_primary.to_string(), + modifiers: vec!["ctrl".into(), "shift".into()], + }); + } else if !enabled && binding.is_some() { + *binding = None; + } + if let Some(binding) = binding { + changed |= shortcut_editor(ui, label, binding); + } + changed } #[cfg(test)] mod tests { use super::*; - fn disconnected_app() -> OpenLessEguiApp { - OpenLessEguiApp::new( - Arc::new(tokio::runtime::Runtime::new().unwrap()), - Err("fixture: plugin unavailable".into()), - ) + #[test] + fn the_ui_client_flag_carries_the_bridge_socket() { + let args = vec![ + "openless".to_string(), + UI_CLIENT_FLAG.to_string(), + UI_SOCKET_FLAG.to_string(), + "/run/user/1000/openless-ui.sock".to_string(), + ]; + assert_eq!( + ui_client_socket(&args), + Some(std::path::PathBuf::from("/run/user/1000/openless-ui.sock")) + ); + // 普通启动(宿主)不是 UI 进程。 + assert_eq!(ui_client_socket(&["openless".to_string()]), None); + // 带了开关却没给路径:当作普通启动,不要装作是 UI 进程。 + assert_eq!(ui_client_socket(&[UI_CLIENT_FLAG.to_string()]), None); } - fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String { - let ctx = egui::Context::default(); - let output = ctx.run( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(720.0, 1800.0), - )), - ..Default::default() - }, - |ctx| { - egui::CentralPanel::default().show(ctx, |ui| { - draw(ui); - }); - }, + #[test] + fn stale_snapshots_never_replace_newer_state() { + // 严格递增才采纳:重复、乱序、回退的快照都必须丢掉, + // 否则 UI 会把新状态画成旧状态。 + assert!(snapshot_supersedes(0, 1)); + assert!(snapshot_supersedes(7, 8)); + assert!(!snapshot_supersedes(7, 7), "duplicate must be dropped"); + assert!(!snapshot_supersedes(7, 3), "out-of-order must be dropped"); + } + + #[test] + fn the_snapshot_fingerprint_notices_any_change() { + let a = br#"{"active_page":"Overview"}"#.to_vec(); + let b = br#"{"active_page":"History"}"#.to_vec(); + assert_eq!(snapshot_fingerprint(&a), snapshot_fingerprint(&a)); + assert_ne!(snapshot_fingerprint(&a), snapshot_fingerprint(&b)); + } + + #[test] + fn the_host_keeps_running_when_the_window_says_goodbye() { + let mut app = fixture_app(true); + app.apply_window_messages(vec![WindowToHost::Bye], true); + // 关窗只关窗口:宿主不退出、后端与会话不动。 + assert!(!app.window_should_be_open); + assert!(!app.exit_requested); + } + + #[test] + fn a_window_is_reopened_only_when_the_user_asks_for_it() { + let mut app = fixture_app(false); + // 用户已经关窗:宿主不会自己把窗口拉回来。 + app.pending_ui_actions + .push(frontend::view_model::FrontendAction::Navigate( + frontend::view_model::Page::History, + )); + assert!(!app.should_spawn_ui_window()); + // 托盘「显示主窗口」是显式意图,必须重新拉起一个窗口进程。 + app.request_main_window(); + assert!(app.window_should_be_open); + assert!(app.should_spawn_ui_window()); + } + + #[test] + fn a_freshly_spawned_window_is_not_spawned_twice() { + let mut app = fixture_app(false); + app.request_main_window(); + // 模拟「刚拉起过」:防抖窗口内不得再拉第二个窗口进程。 + app.ui_window_spawned_at = Some(std::time::Instant::now()); + assert!(!app.should_spawn_ui_window()); + // 防抖过期且没有活着的子进程时,允许重拉。 + app.ui_window_spawned_at = Some(std::time::Instant::now() - Duration::from_secs(5)); + assert!(app.should_spawn_ui_window()); + } + + #[test] + fn a_host_without_a_tray_exits_with_its_only_window() { + // 没有托盘就没有重新打开的入口:窗口退出后宿主必须跟着退出, + // 否则用户留下一个看得见进程、点不开窗口的僵尸。 + let mut app = fixture_app(false); + app.apply_window_messages(vec![WindowToHost::Bye], false); + assert!(app.exit_requested); + } + + #[test] + fn the_host_actions_that_used_to_raise_a_window_no_longer_do() { + // Core 的 ShowMain/FocusMain 在弹窗流程里也会发,宿主若照做就会 + // 「弹一次面板冒出一个主窗口」,所以它们必须不改变窗口意图。 + let mut app = fixture_app(true); + app.apply_window_messages( + vec![WindowToHost::Action { + sequence: 1, + action: frontend::view_model::FrontendAction::WindowClose, + }], + true, ); - output - .shapes - .into_iter() - .filter_map(|shape| match shape.shape { - egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()), - _ => None, - }) - .collect::>() - .join("\n") + // 窗口控制由 UI 进程处理;即便漏到宿主,也只是入队后由 + // apply_frontend_actions 记一条日志,不改变窗口意图。 + assert!(app.window_should_be_open); } #[test] - fn start_page_pipeline_multimodal_uses_only_omni_configuration() { - use openless_core::shared_types::PipelineMode; - - let mut app = disconnected_app(); - // A pending settings draft must not override Core's effective mode. - app.preferences = Some(UserPreferences { - multimodal_pipeline_enabled: false, - pipeline_mode: PipelineMode::Traditional, - ..Default::default() - }); - for omni_configured in [true, false] { - for (asr_configured, llm_configured) in - [(false, false), (true, false), (false, true), (true, true)] - { - app.snapshot = Some(BackendSnapshot { - credentials: openless_core::shared_types::CredentialsStatus { - pipeline_mode: PipelineMode::Multimodal, - omni_configured, - asr_configured, - llm_configured, - ..Default::default() - }, - ..Default::default() - }); - let text = rendered_text(|ui| app.start_ui(ui)); - let expected = if omni_configured { - "Omni:已配置" - } else { - "Omni:尚未配置" - }; - assert!(text.contains(expected), "missing {expected}: {text}"); - assert!(!text.contains("语音识别:尚未配置"), "{text}"); - assert!(!text.contains("ASR 语音识别:"), "{text}"); - assert!(!text.contains("LLM 润色:"), "{text}"); - assert!(text.contains("已配置不代表校验通过"), "{text}"); - } - } + fn the_host_heartbeat_runs_without_any_window() { + // tick() 不依赖 eframe 的帧循环:用一个没有窗口的 egui Context + // 连续跑两次也不会 panic(热键消费/弹窗拉起就在这条路径上)。 + let ctx = egui::Context::default(); + let mut app = fixture_app(true); + app.tick(&ctx); + app.tick(&ctx); + } + + fn fixture_app(window_should_be_open: bool) -> OpenLessEguiApp { + OpenLessEguiApp::new( + Arc::new(tokio::runtime::Runtime::new().unwrap()), + Err("fixture".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, + window_should_be_open, + ) } #[test] - fn start_page_pipeline_traditional_reports_asr_and_llm_independently() { - use openless_core::shared_types::PipelineMode; - - let mut app = disconnected_app(); - // Conversely, a multimodal draft must not hide the effective - // traditional pipeline's missing ASR or LLM configuration. - app.preferences = Some(UserPreferences { - multimodal_pipeline_enabled: true, - pipeline_mode: PipelineMode::Multimodal, - ..Default::default() + fn assistant_deltas_accumulate_into_one_entry_per_turn() { + // 一个轮次里流式增量只应形成一条助手条目;工具标记之后的新增量属于 + // 新一轮正文,要另起一条(否则工具行会被并进正文里)。 + let mut entries = Vec::new(); + append_assistant_entry(&mut entries, "he"); + append_assistant_entry(&mut entries, "llo"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, "assistant"); + assert_eq!(entries[0].text, "hello"); + entries.push(openless_linux_egui::LessComputerEntry { + kind: "tool".to_string(), + text: "Used bash".to_string(), }); - for omni_configured in [true, false] { - for (asr_configured, llm_configured) in - [(false, false), (true, false), (false, true), (true, true)] - { - app.snapshot = Some(BackendSnapshot { - credentials: openless_core::shared_types::CredentialsStatus { - pipeline_mode: PipelineMode::Traditional, - omni_configured, - asr_configured, - llm_configured, - ..Default::default() - }, - ..Default::default() - }); - let text = rendered_text(|ui| app.start_ui(ui)); - for expected in [ - if asr_configured { - "ASR 语音识别:已配置" - } else { - "ASR 语音识别:尚未配置" - }, - if llm_configured { - "LLM 润色:已配置" - } else { - "LLM 润色:尚未配置" - }, - ] { - assert!(text.contains(expected), "missing {expected}: {text}"); - } - assert!(!text.contains("Omni:"), "{text}"); - assert!(text.contains("已配置不代表校验通过"), "{text}"); - } + append_assistant_entry(&mut entries, "done"); + assert_eq!(entries.len(), 3); + assert_eq!(entries[2].text, "done"); + } + + /// 最小可用的弹窗实例:只为了驱动 `pump` 这条退出链路。 + fn popup_app(kind: PopupKind, incoming: mpsc::Receiver) -> NativePopupApp { + let (outgoing, _outgoing_rx) = mpsc::channel(); + NativePopupApp { + kind, + state: PopupState::default(), + incoming, + outgoing, + qa_input: String::new(), + less_computer_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + preview_focus_requested: false, + avatar: QaAvatar::default(), + lang: Lang::ZhCn, + overlay: None, } } #[test] - fn background_approval_survives_navigation_and_stale_terminals() { - let mut app = disconnected_app(); - let session = openless_core::SessionId::new(); - for (sequence, kind) in [ - ( - 1, - LessComputerEventKind::User { - text: "task".into(), - fresh: true, - }, - ), - ( - 2, - LessComputerEventKind::Approval { - token: "approval".into(), - command: "echo test".into(), - reason: "fixture".into(), - }, - ), - ] { - app.apply_event(BackendEvent { - sequence, - session_id: Some(session), - kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { - seq: None, - kind, - }), - }); - } - for page in Page::ALL { - app.navigation.open(page); - assert_eq!(app.page_activity(Page::Agent), Some("待审批")); - let text = rendered_text(|ui| { - app.activity_ui(ui); - app.agent_approval_ui(ui); - }); - for control in ["允许", "拒绝", "取消 Agent"] { - assert!( - text.contains(control), - "missing {control} on {page:?}: {text}" - ); - } - } - app.apply_event(BackendEvent { - sequence: 3, - session_id: Some(openless_core::SessionId::new()), - kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { - seq: None, - kind: LessComputerEventKind::Cancelled, - }), - }); - assert_eq!( - app.pending_approval, - Some(("approval".into(), "echo test".into())) - ); - assert!(app.less_computer_running); + fn a_closed_host_pipe_exits_the_popup() { + // 宿主进程崩溃/被杀时 stdin 到 EOF、发送端析构。以前 Empty 与 + // Disconnected 被一起当成「没有消息」,胶囊就会永久贴在屏幕上 + // (真机验证过:layer surface 不会自己消失,只能随进程销毁)。 + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + drop(tx); + assert!(app.pump(None), "a closed host pipe must end the popup"); } #[test] - fn qa_and_selection_events_on_settings_keep_drafts_and_action_notices() { - let mut app = disconnected_app(); - app.navigation.open(Page::Settings); - app.qa_input = "unsent question".into(); - let qa_session = openless_core::SessionId::new(); - let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); - thinking.session_id = Some(qa_session.to_string()); - app.apply_event(BackendEvent { + fn a_live_host_pipe_keeps_the_popup_running() { + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + tx.send(HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: "s1".into(), sequence: 1, - session_id: Some(qa_session), - kind: BackendEventKind::QaState(thinking), - }); - let selection_session = openless_core::SessionId::new(); - app.apply_event(BackendEvent { - sequence: 2, - session_id: Some(selection_session), - kind: BackendEventKind::SelectionStateChanged(SelectionSnapshot { - phase: SelectionPhase::Preview, - session_id: Some(selection_session), - preview_text: Some("editable preview".into()), - ..Default::default() - }), - }); - assert_eq!(app.navigation.page, Page::Settings); - assert!(app.navigation.has_update(Page::Qa)); - assert_eq!(app.page_activity(Page::Selection), Some("待确认")); - app.selection_draft = "user edited preview".into(); - app.navigation.open(Page::Qa); - app.navigation.open(Page::Selection); - app.navigation.open(Page::Models); - assert_eq!(app.qa_input, "unsent question"); - assert_eq!(app.selection_draft, "user edited preview"); - assert_eq!( - app.selection.as_ref().unwrap().session_id, - Some(selection_session) - ); - let text = rendered_text(|ui| app.activity_ui(ui)); - assert!(text.contains("取消选区预览"), "{text}"); + phase: "Recording".into(), + text: String::new(), + audio_level: Some(0.2), + translation_active: false, + }) + .expect("channel is open"); + assert!(!app.pump(None), "a progress frame must not exit"); + // 宿主仍然活着(发送端还在)→ 不能因为消息读空就退出。 + assert!(!app.pump(None)); + drop(tx); + assert!(app.pump(None), "losing the host must exit"); } #[test] - fn startup_failure_keeps_preparation_steps_without_claiming_connection() { - let mut app = disconnected_app(); - app.environment = Some(LinuxCapabilitySnapshot::from_environment( - Some("wayland-0"), - None, - false, - false, - LinuxPackageKind::AppImage, - )); - app.plugin_check = Some(Ok(FcitxPluginStatus::Updated)); - let text = rendered_text(|ui| app.start_ui(ui)); - for expected in [ - "Core 未连接", - "Wayland", - "D-Bus 探测未通过", - "尚未验证录音", - "fcitx5-diagnose", - "fcitx5-remote -r", - "Secret Service", - ] { - assert!(text.contains(expected), "missing {expected}: {text}"); - } - assert!(!text.contains("Core 已连接")); - assert!(!text.contains("Core:运行中")); + fn a_shutdown_frame_exits_the_popup() { + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + tx.send(HostToPopup::Shutdown { + version: POPUP_PROTOCOL_VERSION, + session_id: "s1".into(), + sequence: 2, + }) + .expect("channel is open"); + assert!(app.pump(None), "the host shutdown must end the popup"); } #[test] - fn stopped_or_stale_remote_status_never_shows_pairing_secrets_or_old_urls() { - let mut app = disconnected_app(); - for (running, urls_stale) in [(false, false), (true, true)] { - app.remote_access = Some(( - openless_core::RemoteInputStatus { - enabled: true, - running, - starting: false, - port: 8443, - urls: vec!["https://old.example.invalid".into()], - urls_stale, - ca_fingerprint_sha256: None, - locale: "en".into(), - connection_count: 0, - active_session_id: None, - }, - "fixture-pin".into(), - )); - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(!text.contains("fixture-pin"), "{text}"); - assert!(!text.contains("https://old.example.invalid"), "{text}"); - assert!(text.contains("当前连接数:0"), "{text}"); - } + fn pinned_qa_ignores_the_automatic_hide_action() { + assert!(qa_hides_on_host_action(false)); + assert!(!qa_hides_on_host_action(true)); } #[test] - fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() { - let mut app = disconnected_app(); - let fingerprint = "ab".repeat(32); - app.remote_access = Some(( - openless_core::RemoteInputStatus { - enabled: true, - running: true, - starting: false, - port: 8443, - urls: vec!["https://phone.example.invalid".into()], - urls_stale: false, - ca_fingerprint_sha256: Some(fingerprint.clone()), - locale: "zh-CN".into(), - connection_count: 0, - active_session_id: None, - }, - "fixture-pin".into(), - )); - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(text.contains("本机根证书 SHA-256"), "{text}"); - assert!( - text.contains("AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB"), - "{text}" - ); - assert!(!text.contains("完整指纹不可用"), "{text}"); + fn qa_edit_flags_merge_partial_core_updates() { + let mut flags = QaEditFlags::default(); + // Core 只在变化时下发 Some(..):未下发的字段必须保持原值。 + let mut event = openless_core::QaStateEvent::simple(openless_core::QaStateKind::Idle); + event.edit_apply_available = Some(true); + flags.merge(&event); + assert!(flags.apply_available); + assert!(!flags.instruction_mode); + assert!(!flags.revert_available); - app.remote_access.as_mut().unwrap().0.ca_fingerprint_sha256 = None; - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(text.contains("完整指纹不可用。请勿安装或信任下载的证书。"), "{text}"); + event.edit_instruction_mode = Some(true); + event.edit_revert_available = Some(true); + flags.merge(&event); + assert!(flags.instruction_mode); + assert!(flags.revert_available); + assert!(flags.apply_available); + + event.edit_apply_available = Some(false); + flags.merge(&event); + assert!(!flags.apply_available); + assert!(flags.revert_available); } #[test] @@ -3169,6 +7506,11 @@ mod linux_app { let mut app = OpenLessEguiApp::new( Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, + false, ); let first = openless_core::SessionId::new(); let second = openless_core::SessionId::new(); @@ -3243,6 +7585,11 @@ mod linux_app { let mut app = OpenLessEguiApp::new( Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, + false, ); let session = openless_core::SessionId::new(); let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); @@ -3271,6 +7618,276 @@ mod linux_app { assert_eq!(state.chunk.as_deref(), Some("Hello world")); assert_eq!(state.messages.as_ref().unwrap()[0].content, "question"); } + + #[test] + fn settings_conflict_merge_preserves_only_dirty_draft_fields() { + let latest = UserPreferences { + remote_input_port: 9443, + streaming_insert: false, + ..Default::default() + }; + let draft = UserPreferences { + remote_input_port: 7777, + streaming_insert: true, + ..Default::default() + }; + let dirty = SettingsDirty { + streaming_insert: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert!(merged.streaming_insert); + assert_eq!(merged.remote_input_port, 9443); + } + + #[test] + fn style_pack_hotkey_update_preserves_other_pack_bindings() { + let mut preferences = UserPreferences::default(); + let first = openless_core::shared_types::ShortcutBinding { + primary: "1".into(), + modifiers: vec!["ctrl".into()], + }; + let second = openless_core::shared_types::ShortcutBinding { + primary: "2".into(), + modifiers: vec!["alt".into()], + }; + set_style_pack_hotkey(&mut preferences, "first", Some(first.clone())); + set_style_pack_hotkey(&mut preferences, "second", Some(second.clone())); + set_style_pack_hotkey(&mut preferences, "first", None); + + assert_eq!(preferences.style_pack_hotkeys.len(), 1); + assert_eq!(preferences.style_pack_hotkeys[0].pack_id, "second"); + assert_eq!(preferences.style_pack_hotkeys[0].binding, second); + } + + #[test] + fn settings_conflict_merge_preserves_hotkey_drafts_as_one_domain() { + let latest = UserPreferences::default(); + let mut draft = latest.clone(); + draft.open_app_hotkey = Some(openless_core::shared_types::ShortcutBinding { + primary: "O".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }); + let dirty = SettingsDirty { + hotkeys: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert_eq!(merged.open_app_hotkey, draft.open_app_hotkey); + } + + #[test] + fn settings_conflict_merge_preserves_recording_device_and_appearance_domains() { + let latest = UserPreferences { + remote_input_port: 9443, + ..Default::default() + }; + let mut draft = latest.clone(); + draft.hotkey.mode = openless_core::shared_types::HotkeyMode::Auto; + draft.silence_auto_stop_enabled = true; + draft.silence_auto_stop_seconds = 1.5; + draft.mute_during_recording = true; + draft.audio_cue_on_record = false; + draft.microphone_device_name = "USB microphone".into(); + draft.theme_mode = openless_core::shared_types::ThemeMode::Dark; + draft.show_overview_activity_heatmap = false; + draft.remote_input_port = 7777; + let dirty = SettingsDirty { + recording: true, + microphone: true, + appearance: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert_eq!(merged.hotkey.mode, draft.hotkey.mode); + assert!(merged.silence_auto_stop_enabled); + assert_eq!(merged.silence_auto_stop_seconds, 1.5); + assert!(merged.mute_during_recording); + assert!(!merged.audio_cue_on_record); + assert_eq!(merged.microphone_device_name, "USB microphone"); + assert_eq!( + merged.theme_mode, + openless_core::shared_types::ThemeMode::Dark + ); + assert!(!merged.show_overview_activity_heatmap); + assert_eq!(merged.remote_input_port, 9443); + } + + #[test] + fn ready_install_needs_no_reload_but_continues() { + assert!(reconcile_fcitx5_install(FcitxPluginStatus::Ready).is_ok()); + } + + /// 插件缺失只影响全局热键,**绝不能** 让启动失败 —— 早先这里返回 Err 并 + /// 用 `?` 中断启动,用户看到的就是「主窗口不显示」。 + #[test] + fn missing_install_still_starts_the_window() { + assert!( + reconcile_fcitx5_install(FcitxPluginStatus::Missing).is_ok(), + "a missing fcitx5 addon must never abort startup" + ); + } + + // ---- Overview summary (Tauri parity) ----------------------------- + + fn session_entry( + created_at: &str, + final_text: &str, + duration_ms: Option, + ) -> openless_core::DictationSession { + openless_core::DictationSession { + id: String::new(), + created_at: created_at.to_string(), + source: openless_core::HistorySource::Voice, + raw_transcript: String::new(), + asr_transcript: None, + final_text: final_text.to_string(), + mode: openless_core::PolishMode::Raw, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: openless_core::HistoryInsertStatus::Inserted, + error_code: None, + duration_ms, + dictionary_entry_count: None, + has_audio_recording: None, + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + pipeline_mode: None, + asr_ms: None, + polish_ms: None, + } + } + + fn activity_day(date: &str, count: u32) -> openless_core::ActivityDay { + openless_core::ActivityDay { + date: date.to_string(), + count, + chars: 0, + duration_ms: 0, + } + } + + #[test] + fn overview_metrics_aggregate_only_today_from_history() { + let now = chrono::Local::now(); + let today = now.date_naive(); + let history = vec![ + session_entry(&now.to_rfc3339(), "今天第一句", Some(2000)), + session_entry( + &(now - chrono::Duration::days(1)).to_rfc3339(), + "昨天", + Some(999), + ), + session_entry( + &(now - chrono::Duration::days(2)).to_rfc3339(), + "前天", + None, + ), + ]; + let credentials = openless_core::CredentialsStatus { + active_asr_provider: "volcengine".to_string(), + active_llm_provider: "ark".to_string(), + asr_configured: true, + ..Default::default() + }; + + let summary = overview_summary( + &OverviewData { + credentials, + history, + activity: Vec::new(), + }, + today, + ); + + assert_eq!(summary.segments_today, 1, "only today's entry counts"); + assert_eq!(summary.chars_today, 5, "今日第一句 has 5 chars"); + assert_eq!(summary.duration_ms_today, 2000); + assert_eq!(summary.avg_latency_ms, 2000); + assert_eq!(summary.history_total, 3); + assert_eq!(summary.asr_provider, "volcengine"); + assert!(summary.asr_configured); + assert!(!summary.llm_configured); + assert_eq!(summary.recent.len(), 3, "newest three retained"); + assert_eq!( + summary.recent[0].final_text, "今天第一句", + "recent list is newest-first" + ); + assert_eq!(summary.recent[0].duration_ms, Some(2000)); + } + + #[test] + fn overview_activity_windows_and_heatmap_are_windowed_by_date() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); + let credentials = openless_core::CredentialsStatus::default(); + let activity = vec![ + activity_day("2026-01-15", 5), + activity_day("2026-01-08", 2), + activity_day("2026-01-01", 3), + activity_day("2025-06-01", 9), + ]; + + let summary = overview_summary( + &OverviewData { + credentials, + history: Vec::new(), + activity, + }, + today, + ); + + // Last-7 window covers only Jan 15. + assert_eq!(summary.last_7.active_days, 1); + assert_eq!(summary.last_7.segments, 5); + // Last-30 window covers Jan 15, Jan 8 and Jan 1. + assert_eq!(summary.last_30.active_days, 3); + assert_eq!(summary.last_30.segments, 10); + + // The daily series is the trailing 30 days ending today. + assert_eq!(summary.activity_daily.len(), 30); + assert_eq!(summary.activity_daily.last().unwrap().date, "2026-01-15"); + assert_eq!(summary.activity_daily.last().unwrap().count, 5); + + // The heatmap now covers the whole calendar year (Jan 1 – Dec 31), + // so every 2026 day counts and the 2025 day drops out. + assert_eq!(summary.heatmap_year, 2026); + assert_eq!(summary.heatmap.len(), 365); + let heat_total: u32 = summary.heatmap.iter().map(|day| day.count).sum(); + assert_eq!(heat_total, 10); + } + + #[test] + fn overview_heatmap_excludes_days_outside_the_calendar_year() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); + let far = (today - chrono::Duration::days(400)) + .format("%Y-%m-%d") + .to_string(); + let summary = overview_summary( + &OverviewData { + credentials: openless_core::CredentialsStatus::default(), + history: Vec::new(), + activity: vec![activity_day("2026-01-15", 3), activity_day(&far, 7)], + }, + today, + ); + + let heat_total: u32 = summary.heatmap.iter().map(|day| day.count).sum(); + assert_eq!( + heat_total, 3, + "days outside the calendar year must not appear in the heatmap" + ); + } } } diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs new file mode 100644 index 000000000..ad6c9eceb --- /dev/null +++ b/openless-all/app/linux-egui/src/popup.rs @@ -0,0 +1,1536 @@ +//! Native popup process protocol and lifecycle management. +//! +//! The egui frame must never own or wait for a child process. [`PopupSupervisor`] +//! moves the child, its pipes and all waiting into Tokio tasks and exposes only +//! non-blocking `try_*` methods to the UI thread. + +use std::collections::HashSet; +use std::fmt; +use std::io::{BufRead, Write}; +use std::path::Path; +use std::process::Stdio; +use std::sync::mpsc::{self, Receiver}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::runtime::Handle; +use tokio::sync::mpsc as tokio_mpsc; + +pub const POPUP_PROTOCOL_VERSION: u16 = 4; +pub const MAX_JSONL_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PopupKind { + Qa, + Preview, + Capsule, + LessComputer, +} + +impl PopupKind { + pub fn argument(self) -> &'static str { + match self { + Self::Qa => "--qa", + Self::Preview => "--preview", + Self::Capsule => "--capsule", + Self::LessComputer => "--less-computer", + } + } +} + +/// One rendered Less Computer turn entry. +/// +/// Mirrors Core's `LessComputerEventKind` presentation: the panel prints the +/// entries in order and never re-derives product intent, so a new Core event +/// variant only needs a host-side translation into `kind` + display text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LessComputerEntry { + /// `user` / `assistant` / `tool` / `compaction` / `error` / `note`. + pub kind: String, + #[serde(default)] + pub text: String, +} + +/// A blocked command waiting for the user's decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LessComputerApproval { + pub token: String, + pub command: String, + #[serde(default)] + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PopupChatMessage { + pub role: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection_text: Option, +} + +/// Messages written by the Linux host to a popup's stdin. +/// +/// Every variant is independently versioned and ordered. This deliberately +/// avoids an unversioned outer envelope that can accidentally be discarded by +/// a future enum deserializer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HostToPopup { + Preview { + version: u16, + session_id: String, + sequence: u64, + text: String, + source: String, + }, + QaSnapshot { + version: u16, + session_id: String, + sequence: u64, + phase: String, + messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + selection_preview: Option, + #[serde(default)] + streaming_answer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + /// 「编辑指令」勾选框状态(Core `QaSnapshot.edit_instruction_mode`)。 + #[serde(default)] + edit_instruction_mode: bool, + /// 预览可用:底部出现「预览并确认插入」。 + #[serde(default)] + edit_apply_available: bool, + /// 可一键回退:额外出现「保留上一版本」。 + #[serde(default)] + edit_revert_available: bool, + /// 固定(不自动关闭)。Tauri `qa.pinTooltip` / `qa.unpinTooltip`。 + #[serde(default)] + pinned: bool, + /// GitHub 登录名,用于 `https://github.com/{login}.png` 头像。 + #[serde(default)] + viewer_login: String, + }, + Capsule { + version: u16, + session_id: String, + sequence: u64, + phase: String, + #[serde(default)] + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + audio_level: Option, + /// 正在翻译:药丸上方显示「正在翻译」徽章(Tauri `capsule.translating`)。 + #[serde(default)] + translation_active: bool, + }, + Hide { + version: u16, + session_id: String, + sequence: u64, + }, + /// Less Computer 面板状态(Tauri `LessComputerPanel.tsx`)。 + /// + /// `entries` 是已发生的事件序列(用户指令 / 工具 / 压缩 / 助手正文 / 错误), + /// `working` 表示本轮尚未终结,`approval` 是等待用户批准的阻塞命令。 + LessComputer { + version: u16, + session_id: String, + sequence: u64, + entries: Vec, + #[serde(default)] + working: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + approval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + Shutdown { + version: u16, + session_id: String, + sequence: u64, + }, +} + +impl HostToPopup { + pub fn version(&self) -> u16 { + match self { + Self::Preview { version, .. } + | Self::QaSnapshot { version, .. } + | Self::Capsule { version, .. } + | Self::LessComputer { version, .. } + | Self::Hide { version, .. } + | Self::Shutdown { version, .. } => *version, + } + } + + pub fn session_id(&self) -> &str { + match self { + Self::Preview { session_id, .. } + | Self::QaSnapshot { session_id, .. } + | Self::Capsule { session_id, .. } + | Self::LessComputer { session_id, .. } + | Self::Hide { session_id, .. } + | Self::Shutdown { session_id, .. } => session_id, + } + } + + pub fn sequence(&self) -> u64 { + match self { + Self::Preview { sequence, .. } + | Self::QaSnapshot { sequence, .. } + | Self::Capsule { sequence, .. } + | Self::LessComputer { sequence, .. } + | Self::Hide { sequence, .. } + | Self::Shutdown { sequence, .. } => *sequence, + } + } + + pub fn content_kind(&self) -> Option { + match self { + Self::Preview { .. } => Some(PopupKind::Preview), + Self::QaSnapshot { .. } => Some(PopupKind::Qa), + Self::Capsule { .. } => Some(PopupKind::Capsule), + Self::LessComputer { .. } => Some(PopupKind::LessComputer), + Self::Hide { .. } | Self::Shutdown { .. } => None, + } + } +} + +/// Actions written by a popup to the host's stdout. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PopupToHost { + Ready { + version: u16, + session_id: String, + sequence: u64, + kind: PopupKind, + }, + ConfirmPreview { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + CancelPreview { + version: u16, + session_id: String, + sequence: u64, + }, + SubmitQa { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + ToggleQaRecording { + version: u16, + session_id: String, + sequence: u64, + }, + DismissQa { + version: u16, + session_id: String, + sequence: u64, + }, + DismissCapsule { + version: u16, + session_id: String, + sequence: u64, + }, + /// 胶囊上的 ✕:放弃这次听写(Tauri `cancelDictation`)。 + CancelDictation { + version: u16, + session_id: String, + sequence: u64, + }, + /// 胶囊上的 ✓:结束录音并落字(Tauri `stopDictation`)。 + StopDictation { + version: u16, + session_id: String, + sequence: u64, + }, + /// 划词追问头部图钉:固定后宿主不再自动收起(Tauri `qa.pinTooltip`)。 + SetPinned { + version: u16, + session_id: String, + sequence: u64, + pinned: bool, + }, + /// 输入组左下角「编辑指令」勾选框。 + SetEditInstructionMode { + version: u16, + session_id: String, + sequence: u64, + enabled: bool, + }, + /// 「预览并确认插入」:把编辑结果写回选区(Tauri `qa.editApplyReplace`)。 + ApplyEdit { + version: u16, + session_id: String, + sequence: u64, + }, + /// 「保留上一版本」:回退这一轮的编辑预览(Tauri `qa.editRevertPrevious`)。 + RevertEdit { + version: u16, + session_id: String, + sequence: u64, + }, + /// Less Computer 输入框:提交一条指令(Tauri `lessComputerSubmitText`)。 + SubmitLessComputer { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + /// 批准/拒绝被阻塞的命令(Tauri `lessComputerApprove`)。 + ApproveLessComputer { + version: u16, + session_id: String, + sequence: u64, + token: String, + approved: bool, + }, + /// 停止当前这一轮(Esc / 关闭时的收尾,Tauri `less_computer_window_dismiss`)。 + CancelLessComputer { + version: u16, + session_id: String, + sequence: u64, + }, + /// ✕:只收起面板,不动已完成的对话(Tauri `cancel()` / `minimize()` 语义)。 + DismissLessComputer { + version: u16, + session_id: String, + sequence: u64, + }, +} + +impl PopupToHost { + pub fn version(&self) -> u16 { + match self { + Self::Ready { version, .. } + | Self::ConfirmPreview { version, .. } + | Self::CancelPreview { version, .. } + | Self::SubmitQa { version, .. } + | Self::ToggleQaRecording { version, .. } + | Self::DismissQa { version, .. } + | Self::DismissCapsule { version, .. } + | Self::CancelDictation { version, .. } + | Self::StopDictation { version, .. } + | Self::SetPinned { version, .. } + | Self::SetEditInstructionMode { version, .. } + | Self::ApplyEdit { version, .. } + | Self::RevertEdit { version, .. } + | Self::SubmitLessComputer { version, .. } + | Self::ApproveLessComputer { version, .. } + | Self::CancelLessComputer { version, .. } + | Self::DismissLessComputer { version, .. } => *version, + } + } + + pub fn session_id(&self) -> &str { + match self { + Self::Ready { session_id, .. } + | Self::ConfirmPreview { session_id, .. } + | Self::CancelPreview { session_id, .. } + | Self::SubmitQa { session_id, .. } + | Self::ToggleQaRecording { session_id, .. } + | Self::DismissQa { session_id, .. } + | Self::DismissCapsule { session_id, .. } + | Self::CancelDictation { session_id, .. } + | Self::StopDictation { session_id, .. } + | Self::SetPinned { session_id, .. } + | Self::SetEditInstructionMode { session_id, .. } + | Self::ApplyEdit { session_id, .. } + | Self::RevertEdit { session_id, .. } + | Self::SubmitLessComputer { session_id, .. } + | Self::ApproveLessComputer { session_id, .. } + | Self::CancelLessComputer { session_id, .. } + | Self::DismissLessComputer { session_id, .. } => session_id, + } + } + + pub fn sequence(&self) -> u64 { + match self { + Self::Ready { sequence, .. } + | Self::ConfirmPreview { sequence, .. } + | Self::CancelPreview { sequence, .. } + | Self::SubmitQa { sequence, .. } + | Self::ToggleQaRecording { sequence, .. } + | Self::DismissQa { sequence, .. } + | Self::DismissCapsule { sequence, .. } + | Self::CancelDictation { sequence, .. } + | Self::StopDictation { sequence, .. } + | Self::SetPinned { sequence, .. } + | Self::SetEditInstructionMode { sequence, .. } + | Self::ApplyEdit { sequence, .. } + | Self::RevertEdit { sequence, .. } + | Self::SubmitLessComputer { sequence, .. } + | Self::ApproveLessComputer { sequence, .. } + | Self::CancelLessComputer { sequence, .. } + | Self::DismissLessComputer { sequence, .. } => *sequence, + } + } + + pub fn kind(&self) -> PopupKind { + match self { + Self::Ready { kind, .. } => *kind, + Self::ConfirmPreview { .. } | Self::CancelPreview { .. } => PopupKind::Preview, + Self::SubmitQa { .. } + | Self::ToggleQaRecording { .. } + | Self::DismissQa { .. } + | Self::SetPinned { .. } + | Self::SetEditInstructionMode { .. } + | Self::ApplyEdit { .. } + | Self::RevertEdit { .. } => PopupKind::Qa, + Self::DismissCapsule { .. } + | Self::CancelDictation { .. } + | Self::StopDictation { .. } => PopupKind::Capsule, + Self::SubmitLessComputer { .. } + | Self::ApproveLessComputer { .. } + | Self::CancelLessComputer { .. } + | Self::DismissLessComputer { .. } => PopupKind::LessComputer, + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct PopupActionSlot { + session_id: Option, + sequence: u64, +} + +/// Rejects stale, cross-session and cross-kind actions received from popup +/// children. Each newly spawned process resets only its own sequence domain. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PopupActionGuard { + qa: PopupActionSlot, + preview: PopupActionSlot, + capsule: PopupActionSlot, + less_computer: PopupActionSlot, +} + +impl PopupActionGuard { + fn slot_mut(&mut self, kind: PopupKind) -> &mut PopupActionSlot { + match kind { + PopupKind::Qa => &mut self.qa, + PopupKind::Preview => &mut self.preview, + PopupKind::Capsule => &mut self.capsule, + PopupKind::LessComputer => &mut self.less_computer, + } + } + + pub fn reset(&mut self, kind: PopupKind) { + *self.slot_mut(kind) = PopupActionSlot::default(); + } + + pub fn accept( + &mut self, + process_kind: PopupKind, + message: &PopupToHost, + expected_session_id: &str, + ) -> bool { + if message.version() != POPUP_PROTOCOL_VERSION + || message.kind() != process_kind + || message.session_id() != expected_session_id + { + return false; + } + let slot = self.slot_mut(process_kind); + if slot.session_id.as_deref() != Some(expected_session_id) { + slot.session_id = Some(expected_session_id.to_owned()); + slot.sequence = 0; + } + if message.sequence() <= slot.sequence { + return false; + } + slot.sequence = message.sequence(); + true + } +} + +pub trait VersionedMessage { + fn protocol_version(&self) -> u16; +} + +impl VersionedMessage for HostToPopup { + fn protocol_version(&self) -> u16 { + self.version() + } +} + +impl VersionedMessage for PopupToHost { + fn protocol_version(&self) -> u16 { + self.version() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolErrorKind { + Io, + Eof, + Truncated, + Oversize, + Malformed, + UnsupportedVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError { + pub kind: ProtocolErrorKind, + pub message: String, +} + +impl ProtocolError { + fn new(kind: ProtocolErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for ProtocolError {} + +/// Write one complete JSONL frame. Serialization is used for all escaping. +pub fn write_jsonl(writer: &mut impl Write, value: &T) -> Result<(), ProtocolError> { + let encoded = serde_json::to_vec(value) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()))?; + if encoded.len() > MAX_JSONL_LINE_BYTES { + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + format!("popup JSONL frame is {} bytes", encoded.len()), + )); + } + writer + .write_all(&encoded) + .and_then(|_| writer.write_all(b"\n")) + .and_then(|_| writer.flush()) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string())) +} + +/// Read one complete, bounded JSONL frame. +pub fn read_jsonl(reader: &mut impl BufRead) -> Result +where + T: DeserializeOwned + VersionedMessage, +{ + let bytes = read_bounded_line(reader)?; + decode_jsonl(&bytes) +} + +fn read_bounded_line(reader: &mut impl BufRead) -> Result, ProtocolError> { + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return if bytes.is_empty() { + Err(ProtocolError::new( + ProtocolErrorKind::Eof, + "popup stream closed", + )) + } else { + Err(ProtocolError::new( + ProtocolErrorKind::Truncated, + "popup stream ended in the middle of a JSONL frame", + )) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content_len = bytes + .len() + .saturating_add(newline.unwrap_or(available.len())); + let take = newline.map_or(available.len(), |index| index + 1); + if content_len > MAX_JSONL_LINE_BYTES { + reader.consume(take); + if newline.is_none() { + discard_through_newline(reader)?; + } + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + "popup JSONL frame exceeds the 1 MiB limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return Ok(bytes); + } + } +} + +fn discard_through_newline(reader: &mut impl BufRead) -> Result<(), ProtocolError> { + loop { + let available = reader + .fill_buf() + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return Ok(()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + reader.consume(take); + if newline.is_some() { + return Ok(()); + } + } +} + +fn decode_jsonl(bytes: &[u8]) -> Result +where + T: DeserializeOwned + VersionedMessage, +{ + let message: T = serde_json::from_slice(bytes) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()))?; + if message.protocol_version() != POPUP_PROTOCOL_VERSION { + return Err(ProtocolError::new( + ProtocolErrorKind::UnsupportedVersion, + format!( + "unsupported popup protocol version {} (expected {})", + message.protocol_version(), + POPUP_PROTOCOL_VERSION + ), + )); + } + Ok(message) +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PreviewPopupState { + pub text: String, + pub source: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct QaPopupState { + pub phase: String, + pub messages: Vec, + pub selection_preview: Option, + pub streaming_answer: String, + pub error: Option, + pub edit_instruction_mode: bool, + pub edit_apply_available: bool, + pub edit_revert_available: bool, + pub pinned: bool, + pub viewer_login: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CapsulePopupState { + pub phase: String, + pub text: String, + pub audio_level: Option, + pub translation_active: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LessComputerPopupState { + pub entries: Vec, + pub working: bool, + pub approval: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PopupState { + pub session_id: Option, + pub last_sequence: u64, + pub visible: bool, + pub shutdown_requested: bool, + pub preview: PreviewPopupState, + pub qa: QaPopupState, + pub capsule: CapsulePopupState, + pub less_computer: LessComputerPopupState, + retired_sessions: HashSet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyOutcome { + Applied, + Stale, + Shutdown, +} + +impl PopupState { + /// Apply a host event while rejecting late messages from an old session or + /// duplicate/out-of-order sequence numbers. + pub fn apply(&mut self, message: HostToPopup) -> ApplyOutcome { + let session_id = message.session_id().to_owned(); + let sequence = message.sequence(); + if let Some(current) = self.session_id.as_deref() { + if current == session_id { + if sequence <= self.last_sequence { + return ApplyOutcome::Stale; + } + } else { + let starts_session = matches!( + message, + HostToPopup::Preview { .. } + | HostToPopup::QaSnapshot { .. } + | HostToPopup::Capsule { .. } + | HostToPopup::LessComputer { .. } + ); + if !starts_session || self.retired_sessions.contains(&session_id) { + return ApplyOutcome::Stale; + } + self.retired_sessions.insert(current.to_owned()); + self.last_sequence = 0; + } + } + if sequence <= self.last_sequence { + return ApplyOutcome::Stale; + } + self.session_id = Some(session_id); + self.last_sequence = sequence; + match message { + HostToPopup::Preview { text, source, .. } => { + self.preview = PreviewPopupState { text, source }; + self.visible = true; + } + HostToPopup::QaSnapshot { + phase, + messages, + selection_preview, + streaming_answer, + error, + edit_instruction_mode, + edit_apply_available, + edit_revert_available, + pinned, + viewer_login, + .. + } => { + self.qa = QaPopupState { + phase, + messages, + selection_preview, + streaming_answer, + error, + edit_instruction_mode, + edit_apply_available, + edit_revert_available, + pinned, + viewer_login, + }; + self.visible = true; + } + HostToPopup::Capsule { + phase, + text, + audio_level, + translation_active, + .. + } => { + self.capsule = CapsulePopupState { + phase, + text, + audio_level, + translation_active, + }; + self.visible = true; + } + HostToPopup::LessComputer { + entries, + working, + approval, + error, + .. + } => { + self.less_computer = LessComputerPopupState { + entries, + working, + approval, + error, + }; + self.visible = true; + } + HostToPopup::Hide { .. } => self.visible = false, + HostToPopup::Shutdown { .. } => { + self.visible = false; + self.shutdown_requested = true; + return ApplyOutcome::Shutdown; + } + } + ApplyOutcome::Applied + } +} + +/// Blocking popup-side protocol driver, intended to run on the popup's stdin +/// reader thread. UI mutation must be forwarded by `on_message` to the popup +/// frame through a channel. +pub fn run_popup( + reader: &mut impl BufRead, + mut on_message: impl FnMut(HostToPopup), +) -> Result<(), ProtocolError> { + loop { + match read_jsonl::(reader) { + Ok(message) => { + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + on_message(message); + if shutdown { + return Ok(()); + } + } + Err(error) if error.kind == ProtocolErrorKind::Eof => return Ok(()), + Err(error) => return Err(error), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PopupSupervisorEvent { + Message(PopupToHost), + ProtocolError(ProtocolError), + Exited { code: Option, crashed: bool }, + SpawnFailed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PopupSendError { + Full, + Closed, +} + +enum SupervisorCommand { + Send(HostToPopup), + Shutdown, +} + +/// Whether this popup must be pushed onto X11 (XWayland counts). +/// +/// The recording capsule is a pure overlay: it must sit at the bottom centre of +/// the work area and must never take the keyboard away from the app the user is +/// dictating into. A compositor that offers `zwlr_layer_shell_v1` grants both on +/// a native surface, so our own Wayland window stays the better host; only when +/// that protocol is missing is the capsule launched with `WAYLAND_DISPLAY` +/// removed so winit falls back to X11, where the overlay can place itself and +/// set `WM_HINTS.input = FALSE`. The selection-ask panel and the polish preview +/// keep their Wayland windows either way because they do take typing. +pub fn force_x11_for(kind: PopupKind, display: Option<&str>, layer_shell: bool) -> bool { + kind == PopupKind::Capsule + && !layer_shell + && display.is_some_and(|value| !value.trim().is_empty()) +} + +/// Build the popup child command, including the backend choice above. +pub fn popup_command( + executable: impl AsRef, + kind: PopupKind, + display: Option<&str>, + layer_shell: bool, +) -> Command { + let mut command = Command::new(executable.as_ref()); + command.arg("--openless-egui-popup").arg(kind.argument()); + if force_x11_for(kind, display, layer_shell) { + // winit prefers Wayland whenever `WAYLAND_DISPLAY` is set. Dropping it + // also pins the child's `detect_capsule_path` to the X11 overlay. + command.env_remove("WAYLAND_DISPLAY"); + command.env_remove("WAYLAND_SOCKET"); + } + command +} + +/// Non-blocking handle held by the main egui application. +pub struct PopupSupervisor { + commands: tokio_mpsc::Sender, + events: Receiver, +} + +impl PopupSupervisor { + pub fn spawn(runtime: &Handle, executable: impl AsRef, kind: PopupKind) -> Self { + let display = std::env::var("DISPLAY").ok(); + // The parent owns the backend choice: winning the layer-shell protocol + // keeps the capsule on Wayland, anything else pushes it onto X11. The + // child repeats the same decision (and sees the same env override), so + // the two never disagree about which window to build. + let layer_shell = crate::popup_layer::layer_shell_available(); + log::debug!( + "popup spawn: kind={kind:?} x11={} layer_shell={layer_shell}", + display.as_deref().unwrap_or("none") + ); + Self::spawn_command( + runtime, + popup_command(executable, kind, display.as_deref(), layer_shell), + ) + } + + /// Low-level construction seam used by tests and alternative launchers. + pub fn spawn_command(runtime: &Handle, mut command: Command) -> Self { + let (command_tx, command_rx) = tokio_mpsc::channel(64); + let (event_tx, event_rx) = mpsc::sync_channel(256); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + runtime.spawn(supervise(command, command_rx, event_tx)); + Self { + commands: command_tx, + events: event_rx, + } + } + + /// Queue a message without ever waiting in an egui frame. + pub fn try_send(&self, message: HostToPopup) -> Result<(), PopupSendError> { + match self.commands.try_send(SupervisorCommand::Send(message)) { + Ok(()) => Ok(()), + Err(tokio_mpsc::error::TrySendError::Full(_)) => Err(PopupSendError::Full), + Err(tokio_mpsc::error::TrySendError::Closed(_)) => Err(PopupSendError::Closed), + } + } + + /// Poll one child event without blocking the egui frame. + pub fn try_recv(&self) -> Result { + self.events.try_recv() + } + + pub fn request_shutdown(&self) -> Result<(), PopupSendError> { + match self.commands.try_send(SupervisorCommand::Shutdown) { + Ok(()) => Ok(()), + Err(tokio_mpsc::error::TrySendError::Full(_)) => Err(PopupSendError::Full), + Err(tokio_mpsc::error::TrySendError::Closed(_)) => Err(PopupSendError::Closed), + } + } +} + +impl Drop for PopupSupervisor { + fn drop(&mut self) { + let _ = self.commands.try_send(SupervisorCommand::Shutdown); + } +} + +async fn supervise( + mut command: Command, + mut commands: tokio_mpsc::Receiver, + events: mpsc::SyncSender, +) { + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed(error.to_string())); + return; + } + }; + let Some(mut stdin) = child.stdin.take() else { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed( + "popup stdin pipe was not created".to_owned(), + )); + let _ = child.kill().await; + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed( + "popup stdout pipe was not created".to_owned(), + )); + let _ = child.kill().await; + return; + }; + + let (reader_tx, mut reader_rx) = tokio_mpsc::channel(64); + tokio::spawn(read_child_output(BufReader::new(stdout), reader_tx)); + + let mut reader_open = true; + let mut shutdown_requested = false; + loop { + tokio::select! { + status = child.wait() => { + match status { + Ok(status) => { + let code = status.code(); + let _ = events.try_send(PopupSupervisorEvent::Exited { + code, + crashed: !status.success() && !shutdown_requested, + }); + } + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } + } + return; + } + output = reader_rx.recv(), if reader_open => { + match output { + Some(event) => { let _ = events.try_send(event); } + None => reader_open = false, + } + } + command = commands.recv() => { + match command { + Some(SupervisorCommand::Send(message)) => { + match serde_json::to_vec(&message) { + Ok(encoded) if encoded.len() <= MAX_JSONL_LINE_BYTES => { + if let Err(error) = stdin.write_all(&encoded).await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } else if let Err(error) = stdin.write_all(b"\n").await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } else if let Err(error) = stdin.flush().await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } + } + Ok(encoded) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new( + ProtocolErrorKind::Oversize, + format!("popup JSONL frame is {} bytes", encoded.len()), + ), + )); + } + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()), + )); + } + } + } + Some(SupervisorCommand::Shutdown) | None => { + shutdown_requested = true; + let _ = child.start_kill(); + } + } + } + } + } +} + +async fn read_child_output(mut reader: R, events: tokio_mpsc::Sender) +where + R: AsyncBufRead + Unpin, +{ + loop { + match read_async_bounded_line(&mut reader).await { + Ok(bytes) => { + let event = match decode_jsonl::(&bytes) { + Ok(message) => PopupSupervisorEvent::Message(message), + Err(error) => PopupSupervisorEvent::ProtocolError(error), + }; + if events.send(event).await.is_err() { + return; + } + } + Err(error) if error.kind == ProtocolErrorKind::Eof => return, + Err(error) => { + if events + .send(PopupSupervisorEvent::ProtocolError(error)) + .await + .is_err() + { + return; + } + } + } + } +} + +async fn read_async_bounded_line(reader: &mut R) -> Result, ProtocolError> +where + R: AsyncBufRead + Unpin, +{ + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return if bytes.is_empty() { + Err(ProtocolError::new( + ProtocolErrorKind::Eof, + "popup stream closed", + )) + } else { + Err(ProtocolError::new( + ProtocolErrorKind::Truncated, + "popup stream ended in the middle of a JSONL frame", + )) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content_len = bytes + .len() + .saturating_add(newline.unwrap_or(available.len())); + let take = newline.map_or(available.len(), |index| index + 1); + if content_len > MAX_JSONL_LINE_BYTES { + reader.consume(take); + if newline.is_none() { + discard_async_through_newline(reader).await?; + } + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + "popup JSONL frame exceeds the 1 MiB limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return Ok(bytes); + } + } +} + +async fn discard_async_through_newline(reader: &mut R) -> Result<(), ProtocolError> +where + R: AsyncBufRead + Unpin, +{ + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return Ok(()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + reader.consume(take); + if newline.is_some() { + return Ok(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn less_computer_snapshot(sequence: u64, text: &str) -> HostToPopup { + HostToPopup::LessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence, + entries: vec![LessComputerEntry { + kind: "assistant".to_string(), + text: text.to_string(), + }], + working: true, + approval: None, + error: None, + } + } + + #[test] + fn less_computer_snapshots_drive_the_panel_state() { + // 面板只呈现宿主序列:应用快照后要能看到条目、working 与可见性。 + let mut state = PopupState::default(); + assert_eq!( + state.apply(less_computer_snapshot(1, "first")), + ApplyOutcome::Applied + ); + assert!(state.visible); + assert!(state.less_computer.working); + assert_eq!(state.less_computer.entries[0].text, "first"); + + // 单调序号:迟到的旧帧不得覆盖新正文。 + assert_eq!( + state.apply(less_computer_snapshot(2, "first+second")), + ApplyOutcome::Applied + ); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + assert_eq!( + state.apply(less_computer_snapshot(1, "stale")), + ApplyOutcome::Stale + ); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + + // Hide 只收起面板,不动对话内容(✕ 的语义)。 + assert_eq!( + state.apply(HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 3, + }), + ApplyOutcome::Applied + ); + assert!(!state.visible); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + } + + #[test] + fn less_computer_actions_are_routed_to_their_kind() { + let submit = PopupToHost::SubmitLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 1, + text: "open the editor".to_string(), + }; + assert_eq!(submit.kind(), PopupKind::LessComputer); + assert_eq!(PopupKind::LessComputer.argument(), "--less-computer"); + let approve = PopupToHost::ApproveLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 2, + token: "token".to_string(), + approved: false, + }; + assert_eq!(approve.kind(), PopupKind::LessComputer); + } + + #[test] + fn only_the_capsule_without_layer_shell_is_pushed_onto_xwayland() { + assert!(force_x11_for(PopupKind::Capsule, Some(":0"), false)); + assert!(!force_x11_for(PopupKind::Capsule, None, false)); + assert!(!force_x11_for(PopupKind::Capsule, Some(" "), false)); + // A compositor with zwlr_layer_shell_v1 keeps the capsule on Wayland: + // the layer surface already gives bottom-centre placement and no focus. + assert!(!force_x11_for(PopupKind::Capsule, Some(":0"), true)); + // The panels take keyboard input, so they keep their Wayland windows. + assert!(!force_x11_for(PopupKind::Qa, Some(":0"), false)); + assert!(!force_x11_for(PopupKind::Preview, Some(":0"), false)); + } + + #[test] + fn capsule_command_drops_the_wayland_backend_without_layer_shell() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + Some(":0"), + false, + ); + let envs: Vec<(String, Option)> = command + .as_std() + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect(); + assert!(envs.contains(&("WAYLAND_DISPLAY".to_string(), None))); + assert!(envs.contains(&("WAYLAND_SOCKET".to_string(), None))); + let args: Vec = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec!["--openless-egui-popup", "--capsule"]); + } + + /// The layer-shell capsule needs its Wayland connection: touching the + /// backend would pin the child to the X11 overlay instead. + #[test] + fn capsule_command_keeps_wayland_when_layer_shell_is_available() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + Some(":0"), + true, + ); + assert_eq!(command.as_std().get_envs().count(), 0); + } + + #[test] + fn qa_command_keeps_the_wayland_backend() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Qa, + Some(":0"), + false, + ); + assert_eq!(command.as_std().get_envs().count(), 0); + } + + #[test] + fn capsule_command_keeps_wayland_without_an_x_server() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + None, + false, + ); + assert_eq!(command.as_std().get_envs().count(), 0); + } + use super::*; + use std::io::Cursor; + use std::time::{Duration, Instant}; + + fn preview(text: String) -> HostToPopup { + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: "session-一".to_owned(), + sequence: 7, + text, + source: "原文 \\\\ source".to_owned(), + } + } + + #[test] + fn qa_snapshot_carries_pin_and_edit_state() { + let message = HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 11, + phase: "IDLE".to_owned(), + messages: Vec::new(), + selection_preview: None, + streaming_answer: String::new(), + error: None, + edit_instruction_mode: true, + edit_apply_available: true, + edit_revert_available: false, + pinned: true, + viewer_login: "octocat".to_owned(), + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message.clone()), ApplyOutcome::Applied); + assert!(state.qa.edit_instruction_mode); + assert!(state.qa.edit_apply_available); + assert!(!state.qa.edit_revert_available); + assert!(state.qa.pinned); + assert_eq!(state.qa.viewer_login, "octocat"); + + // 老宿主(协议 v2)没有这些字段时保持默认值,而不是解析失败。 + let legacy = r#"{"type":"qa_snapshot","version":2,"session_id":"qa","sequence":12,"phase":"IDLE","messages":[],"streaming_answer":""}"#; + let legacy: HostToPopup = serde_json::from_str(legacy).expect("legacy snapshot"); + let mut state = PopupState::default(); + assert_eq!(state.apply(legacy), ApplyOutcome::Applied); + assert!(!state.qa.pinned); + assert!(state.qa.viewer_login.is_empty()); + } + + #[test] + fn capsule_carries_translation_active() { + let message = HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: "dictation".to_owned(), + sequence: 3, + phase: "Recording".to_owned(), + text: String::new(), + audio_level: Some(0.5), + translation_active: true, + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert!(state.capsule.translation_active); + } + + #[test] + fn qa_actions_are_scoped_to_the_qa_popup_and_accepted_once() { + for message in [ + PopupToHost::SetPinned { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 1, + pinned: true, + }, + PopupToHost::SetEditInstructionMode { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 2, + enabled: true, + }, + PopupToHost::ApplyEdit { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 3, + }, + PopupToHost::RevertEdit { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 4, + }, + ] { + assert_eq!(message.kind(), PopupKind::Qa); + let mut guard = PopupActionGuard::default(); + assert!(guard.accept(PopupKind::Qa, &message, "qa")); + // 同一个 sequence 不能重复执行。 + assert!(!guard.accept(PopupKind::Qa, &message, "qa")); + // 其它弹窗进程的同一 sequence 不受影响(各自独立)。 + let mut capsule = PopupActionGuard::default(); + assert!(!capsule.accept(PopupKind::Capsule, &message, "qa")); + } + } + + #[test] + fn jsonl_round_trip_escapes_quotes_backslashes_and_unicode() { + let expected = preview("他说:\"你好\" C:\\\\tmp\\\\文件".to_owned()); + let mut bytes = Vec::new(); + write_jsonl(&mut bytes, &expected).unwrap(); + assert_eq!(bytes.last(), Some(&b'\n')); + let actual: HostToPopup = read_jsonl(&mut Cursor::new(bytes)).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn malformed_oversize_eof_and_truncation_are_classified() { + let malformed = read_jsonl::(&mut Cursor::new(b"not json\n")); + assert_eq!(malformed.unwrap_err().kind, ProtocolErrorKind::Malformed); + + let oversized = vec![b'x'; MAX_JSONL_LINE_BYTES + 2]; + let oversized = read_jsonl::(&mut Cursor::new(oversized)); + assert_eq!(oversized.unwrap_err().kind, ProtocolErrorKind::Oversize); + + let eof = read_jsonl::(&mut Cursor::new(Vec::::new())); + assert_eq!(eof.unwrap_err().kind, ProtocolErrorKind::Eof); + + let truncated = read_jsonl::(&mut Cursor::new(b"{\"type\":")); + assert_eq!(truncated.unwrap_err().kind, ProtocolErrorKind::Truncated); + } + + #[test] + fn popup_state_rejects_late_and_cross_session_messages() { + let mut state = PopupState::default(); + assert_eq!(state.apply(preview("new".into())), ApplyOutcome::Applied); + let mut late = preview("late".into()); + if let HostToPopup::Preview { sequence, .. } = &mut late { + *sequence = 6; + } + assert_eq!(state.apply(late), ApplyOutcome::Stale); + let other = HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "other".into(), + sequence: 8, + }; + assert_eq!(state.apply(other), ApplyOutcome::Stale); + assert_eq!(state.preview.text, "new"); + + let mut next_session = preview("next".into()); + if let HostToPopup::Preview { + session_id, + sequence, + .. + } = &mut next_session + { + *session_id = "session-二".into(); + *sequence = 1; + } + assert_eq!(state.apply(next_session), ApplyOutcome::Applied); + let mut retired = preview("retired".into()); + if let HostToPopup::Preview { sequence, .. } = &mut retired { + *sequence = 99; + } + assert_eq!(state.apply(retired), ApplyOutcome::Stale); + assert_eq!(state.preview.text, "next"); + } + + #[test] + fn popup_action_guard_rejects_replay_cross_session_and_cross_kind() { + let mut guard = PopupActionGuard::default(); + let submit = PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa-session".into(), + sequence: 2, + text: "question".into(), + }; + assert!(guard.accept(PopupKind::Qa, &submit, "qa-session")); + assert!(!guard.accept(PopupKind::Qa, &submit, "qa-session")); + + let stale = PopupToHost::DismissQa { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa-session".into(), + sequence: 1, + }; + assert!(!guard.accept(PopupKind::Qa, &stale, "qa-session")); + assert!(!guard.accept(PopupKind::Preview, &submit, "qa-session")); + assert!(!guard.accept(PopupKind::Qa, &submit, "new-session")); + } + + #[test] + fn popup_action_guard_reset_starts_a_new_child_sequence_domain() { + let mut guard = PopupActionGuard::default(); + let ready = PopupToHost::Ready { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".into(), + sequence: 1, + kind: PopupKind::Preview, + }; + assert!(guard.accept(PopupKind::Preview, &ready, "session")); + assert!(!guard.accept(PopupKind::Preview, &ready, "session")); + guard.reset(PopupKind::Preview); + assert!(guard.accept(PopupKind::Preview, &ready, "session")); + } + + #[test] + fn popup_messages_are_bound_to_their_process_kind() { + assert_eq!( + preview("text".into()).content_kind(), + Some(PopupKind::Preview) + ); + let hide = HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".into(), + sequence: 8, + }; + assert_eq!(hide.content_kind(), None); + + let wrong_version = PopupToHost::DismissCapsule { + version: POPUP_PROTOCOL_VERSION + 1, + session_id: "session".into(), + sequence: 1, + }; + let mut guard = PopupActionGuard::default(); + assert!(!guard.accept(PopupKind::Capsule, &wrong_version, "session")); + } + + #[tokio::test] + async fn shutdown_ends_the_popup_process_so_nothing_is_left_on_screen() { + // 自动收起(听写终态 2 秒/取消立即)靠的是结束弹窗进程:胶囊的 + // layer surface 只能随进程销毁,进程留着就会有一颗药丸永远贴屏。 + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("sleep 30"); + let supervisor = PopupSupervisor::spawn_command(&Handle::current(), command); + supervisor + .request_shutdown() + .expect("a fresh supervisor accepts shutdown"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match supervisor.try_recv() { + Ok(PopupSupervisorEvent::Exited { crashed, .. }) => { + assert!(!crashed, "a requested shutdown must not look like a crash"); + break; + } + Ok(_) | Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + result => panic!("popup process survived shutdown: {result:?}"), + } + } + } + + #[tokio::test] + async fn supervisor_reaps_a_crashed_child() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exit 17"); + let supervisor = PopupSupervisor::spawn_command(&Handle::current(), command); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match supervisor.try_recv() { + Ok(PopupSupervisorEvent::Exited { code, crashed }) => { + assert_eq!(code, Some(17)); + assert!(crashed); + break; + } + Ok(_) | Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + result => panic!("did not observe crashed child: {result:?}"), + } + } + } +} diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs new file mode 100644 index 000000000..57accf2ce --- /dev/null +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -0,0 +1,919 @@ +//! Native Wayland overlay for the recording capsule: `zwlr_layer_shell_v1`. +//! +//! The capsule must sit at the bottom centre of the screen and must **never** +//! take the keyboard — the user is dictating into another window, and a focus +//! steal would send the insert to the wrong place. `xdg-shell` offers neither an +//! absolute position nor a focus opt-out, so the capsule used to run under +//! XWayland (see [`crate::popup_window`], which owns that fallback). Compositors +//! that implement `zwlr_layer_shell_v1` — KWin 6.7+ (verified against +//! `zwlr_layer_shell_v1` version 5), sway, Hyprland, labwc, … — can host the +//! capsule natively instead: +//! +//! * `Anchor::Bottom` + `margin.bottom` places the pill bottom-centre without +//! the client ever knowing the screen size (a surface with only one +//! horizontal anchor is centred by the compositor, and the input region stays +//! the pill's own box instead of the whole bottom strip), +//! * `KeyboardInteractivity::None` makes keyboard focus impossible, +//! * `exclusive_zone(-1)` keeps the compositor from reserving space, so the +//! capsule never reflows other windows. +//! +//! Rendering reuses the popup's existing egui view ([`crate::ui::frontend::popups::dictation_capsule`]): +//! the runner below owns the EGL context (glutin), the `egui_glow` painter and +//! the wayland event loop, and asks the caller for one egui frame at a time. +//! +//! Everything that can be decided without a compositor lives in pure functions +//! ([`has_layer_shell`], [`choose_capsule_path`], [`capsule_geometry`], +//! [`pointer_events`]) so the policy is unit-testable; the I/O half is a thin +//! shell around them and reports failures as `Err`, letting the caller fall back +//! to the X11 overlay. + +use std::ffi::c_void; +use std::num::NonZeroU32; +use std::ptr::NonNull; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use wayland_client::protocol::{wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface}; +use wayland_client::{ + globals::{registry_queue_init, GlobalListContents}, + Connection, Dispatch, Proxy, QueueHandle, WEnum, +}; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; + +/// The layer-shell global this module needs. +pub const LAYER_SHELL_GLOBAL: &str = "zwlr_layer_shell_v1"; +/// Wayland namespace of the capsule surface (shows up in compositor logs and +/// `swaymsg -t get_tree`-style tooling). +pub const LAYER_NAMESPACE: &str = "openless-capsule"; +/// How long to wait for the first `configure` event before giving up and +/// falling back to the X11 overlay. +pub const CONFIGURE_TIMEOUT: Duration = Duration::from_secs(3); +/// Upper bound for one sleep between frames, so protocol events are never +/// starved even when the view asks for a long pause. +pub const MAX_FRAME_PAUSE: Duration = Duration::from_millis(100); + +// ── pure decision core ────────────────────────────────────────────────────── + +/// Whether the compositor advertised `zwlr_layer_shell_v1`. +pub fn has_layer_shell(globals: &[String]) -> bool { + globals.iter().any(|global| global == LAYER_SHELL_GLOBAL) +} + +/// Whether a Wayland session is present (`WAYLAND_DISPLAY` non-empty). +pub fn wayland_display_available(display: Option<&str>) -> bool { + display.is_some_and(|value| !value.trim().is_empty()) +} + +/// How the capsule window should be hosted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapsulePath { + /// Native layer surface (bottom-centre, keyboard-impossible). + LayerShell, + /// The existing X11/XWayland overlay in [`crate::popup_window`]. + X11Overlay, + /// Neither is available: keep the plain borderless window and let the + /// compositor decide. + PlainWindow, +} + +/// Pick the capsule host. Layer-shell wins when the compositor offers it; +/// otherwise the X11 overlay (XWayland counts) is the only way to get a +/// guaranteed position and focus opt-out. +pub fn choose_capsule_path( + wayland_display: Option<&str>, + x11_display: Option<&str>, + globals: &[String], +) -> CapsulePath { + if wayland_display_available(wayland_display) && has_layer_shell(globals) { + return CapsulePath::LayerShell; + } + if crate::popup_window::x11_available(x11_display) { + return CapsulePath::X11Overlay; + } + CapsulePath::PlainWindow +} + +/// Environment variable that pins the capsule host, for verification on a +/// machine whose compositor would otherwise win the choice (e.g. a KWin session +/// where only the X11 fallback is under test). +pub const CAPSULE_PATH_ENV: &str = "OPENLESS_CAPSULE_PATH"; + +/// Parse [`CAPSULE_PATH_ENV`]. Unknown or empty values are ignored so a typo +/// cannot leave the capsule without a window. +pub fn capsule_path_override(value: Option<&str>) -> Option { + match value?.trim().to_ascii_lowercase().as_str() { + "layer" | "layer-shell" | "layer_shell" => Some(CapsulePath::LayerShell), + "x11" | "xwayland" => Some(CapsulePath::X11Overlay), + "plain" | "none" => Some(CapsulePath::PlainWindow), + _ => None, + } +} + +/// Whether the compositor offers `zwlr_layer_shell_v1`, memoised: the capsule is +/// launched once per dictation and probing opens a Wayland connection. +/// +/// [`CAPSULE_PATH_ENV`] wins over the probe, and both this and +/// [`detect_capsule_path`] read it, so the parent (which decides the child's +/// backend) and the child (which decides how to host the window) always agree. +pub fn layer_shell_available() -> bool { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + if let Some(forced) = capsule_path_override(std::env::var(CAPSULE_PATH_ENV).ok().as_deref()) + { + return forced == CapsulePath::LayerShell; + } + probe_layer_shell(std::env::var("WAYLAND_DISPLAY").ok().as_deref()) + }) +} + +/// Decide how this process should host the capsule: an explicit override wins, +/// otherwise probe the live session and apply [`choose_capsule_path`]. Never +/// fails; a missing session, a failed connection or an absent global all end up +/// on the fallback path. +pub fn detect_capsule_path() -> CapsulePath { + if let Some(forced) = capsule_path_override(std::env::var(CAPSULE_PATH_ENV).ok().as_deref()) { + log::info!("capsule path forced by {CAPSULE_PATH_ENV}: {forced:?}"); + return forced; + } + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + let x11 = std::env::var("DISPLAY").ok(); + if !wayland_display_available(wayland.as_deref()) { + return choose_capsule_path(None, x11.as_deref(), &[]); + } + let globals = match probe_globals() { + Ok(globals) => globals, + Err(error) => { + log::warn!("layer-shell probe failed ({error}); using the fallback overlay"); + Vec::new() + } + }; + choose_capsule_path(wayland.as_deref(), x11.as_deref(), &globals) +} + +/// Interface names the compositor advertises, or an error when there is no +/// usable Wayland connection. +fn probe_globals() -> Result, String> { + let connection = Connection::connect_to_env().map_err(|error| format!("wayland: {error}"))?; + // `registry_queue_init` already round-trips the registry and hands back the + // `GlobalList` it collected. Dispatching a second time into our own state + // collects nothing (the global events are already consumed), which would + // make every compositor look like it lacks layer-shell. + let (globals, _queue) = registry_queue_init::(&connection) + .map_err(|error| format!("wayland registry: {error}"))?; + Ok(globals + .contents() + .with_list(|list| list.iter().map(|global| global.interface.clone()).collect())) +} + +/// Layer-surface geometry: the surface is a fixed-size child of the compositor, +/// so only the size and the bottom margin matter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CapsuleGeometry { + pub width: u32, + pub height: u32, + pub bottom_gap: i32, +} + +impl CapsuleGeometry { + /// The buffer size the compositor is asked for. Zero would be rejected by + /// the protocol, so both axes clamp to at least one pixel. + pub fn buffer_size(self) -> (u32, u32) { + (self.width.max(1), self.height.max(1)) + } + + /// egui viewport for one frame, matching the requested buffer size. + pub fn rect(self) -> egui::Rect { + let (width, height) = self.buffer_size(); + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32)) + } + + /// Ignore a compositor-suggested size of zero (the protocol allows it while + /// the surface is still unconfigured) and otherwise follow the compositor. + pub fn rect_for_configure(self, configure: (u32, u32)) -> egui::Rect { + let (width, height) = match configure { + (0, _) | (_, 0) => self.buffer_size(), + (width, height) => (width, height), + }; + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32)) + } +} + +/// Capsule geometry from the shared window-size constants. +pub fn capsule_geometry(width: u32, height: u32, bottom_gap: i32) -> CapsuleGeometry { + CapsuleGeometry { + width: width.max(1), + height: height.max(1), + bottom_gap: bottom_gap.max(0), + } +} + +/// Translate pointer samples into egui events. Pure so the mapping is testable +/// without a compositor; `pressed` carries the button state at that point. +pub fn pointer_events( + position: Option, + buttons: &[(egui::Pos2, egui::PointerButton, bool)], + left: bool, +) -> Vec { + let mut events = Vec::new(); + if let Some(position) = position { + events.push(egui::Event::PointerMoved(position)); + } + for (position, button, pressed) in buttons { + events.push(egui::Event::PointerButton { + pos: *position, + button: *button, + pressed: *pressed, + modifiers: egui::Modifiers::default(), + }); + } + if left { + events.push(egui::Event::PointerGone); + } + events +} + +/// One frame produced by the caller: the egui output plus the loop's control +/// flow. +pub struct LayerFrame { + /// Result of `egui::Context::run` for this frame. + pub output: egui::FullOutput, + /// Ask the runner to stop (host requested shutdown, or the user pressed + /// cancel / confirm). + pub exit: bool, + /// When to draw the next frame. + pub repaint_after: Duration, +} + +// ── compositor probe ──────────────────────────────────────────────────────── + +/// Ask the running compositor whether it implements `zwlr_layer_shell_v1`. +/// +/// Returns `false` (never an error) when there is no Wayland session, when the +/// connection fails, or when the global is missing — the caller then falls back. +pub fn probe_layer_shell(wayland_display: Option<&str>) -> bool { + if !wayland_display_available(wayland_display) { + return false; + } + probe_globals().is_ok_and(|globals| has_layer_shell(&globals)) +} + +// ── wayland state ─────────────────────────────────────────────────────────── + +/// Everything the capsule surface needs from the compositor, plus the pointer +/// events collected between frames. +#[derive(Default)] +struct LayerState { + compositor: Option, + layer_shell: Option, + seat: Option, + pointer: Option, + pointer_position: Option, + /// Set when the pointer actually moved (or entered) since the last frame, so + /// a stationary pointer does not emit a motion event every frame. + pointer_moved: bool, + pressed: Vec<(egui::Pos2, egui::PointerButton, bool)>, + pointer_left: bool, + configure: Option<(u32, u32)>, + closed: bool, +} + +impl LayerState { + /// Take the pointer events collected since the last frame. + fn take_input(&mut self, rect: egui::Rect) -> egui::RawInput { + let events = pointer_events( + None, + &std::mem::take(&mut self.pressed), + std::mem::take(&mut self.pointer_left), + ); + // PointerMoved must lead so egui's interaction position is current + // before the button events are applied. + let mut all = Vec::with_capacity(events.len() + 1); + if std::mem::take(&mut self.pointer_moved) { + if let Some(position) = self.pointer_position { + all.push(egui::Event::PointerMoved(position)); + } + } + all.extend(events); + egui::RawInput { + screen_rect: Some(rect), + events: all, + focused: true, + ..Default::default() + } + } +} + +fn button_from_code(button: u32) -> Option { + match button { + 0x110 => Some(egui::PointerButton::Primary), + 0x111 => Some(egui::PointerButton::Secondary), + 0x112 => Some(egui::PointerButton::Middle), + _ => None, + } +} + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(4), qh, ())); + } + // Layer shell is at version 4 in the widest-deployed compositors and + // 5 in KWin 6.7; everything this module sets exists since version 1. + LAYER_SHELL_GLOBAL => { + state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + _ => {} + } + } +} + +macro_rules! ignore_events { + ($interface:ty) => { + impl Dispatch<$interface, ()> for LayerState { + fn event( + _state: &mut Self, + _proxy: &$interface, + _event: <$interface as Proxy>::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + } + } + }; +} + +ignore_events!(wl_compositor::WlCompositor); +ignore_events!(wl_surface::WlSurface); + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + seat: &wl_seat::WlSeat, + event: wl_seat::Event, + _data: &(), + _connection: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { capabilities } = event { + if let WEnum::Value(capabilities) = capabilities { + if capabilities.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() { + state.pointer = Some(seat.get_pointer(qh, ())); + } + } + } + } +} + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + _pointer: &wl_pointer::WlPointer, + event: wl_pointer::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + match event { + wl_pointer::Event::Enter { + surface_x, + surface_y, + .. + } + | wl_pointer::Event::Motion { + surface_x, + surface_y, + .. + } => { + state.pointer_position = Some(egui::pos2(surface_x as f32, surface_y as f32)); + state.pointer_moved = true; + } + wl_pointer::Event::Leave { .. } => { + state.pointer_position = None; + state.pointer_left = true; + } + wl_pointer::Event::Button { + button, + state: button_state, + .. + } => { + let WEnum::Value(button_state) = button_state else { + return; + }; + let Some(position) = state.pointer_position else { + return; + }; + let Some(button) = button_from_code(button) else { + return; + }; + let pressed = button_state == wl_pointer::ButtonState::Pressed; + state.pressed.push((position, button, pressed)); + } + _ => {} + } + } +} + +ignore_events!(zwlr_layer_shell_v1::ZwlrLayerShellV1); + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + layer_surface.ack_configure(serial); + state.configure = Some((width, height)); + } + zwlr_layer_surface_v1::Event::Closed => state.closed = true, + _ => {} + } + } +} + +// ── EGL + egui_glow plumbing ──────────────────────────────────────────────── + +struct GlSurface { + _display: glutin::display::Display, + surface: glutin::surface::Surface, + context: glutin::context::PossiblyCurrentContext, + gl: Arc, +} + +impl GlSurface { + fn new( + connection: &Connection, + wl_surface: &wl_surface::WlSurface, + size: (u32, u32), + ) -> Result { + let size = (size.0.max(1), size.1.max(1)); + use glutin::config::{Api, ConfigTemplateBuilder}; + use glutin::context::{ContextApi, ContextAttributesBuilder}; + use glutin::display::{Display, DisplayApiPreference, GlDisplay}; + use glutin::prelude::*; + use glutin::surface::{SurfaceAttributesBuilder, WindowSurface}; + use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, + }; + + let display_ptr = connection.backend().display_ptr().cast::(); + // `ObjectId::as_ptr` hands back the underlying `wl_proxy`, which is the + // same C object as the `wl_surface` glutin wants. + let surface_ptr = wl_surface.id().as_ptr().cast::(); + + let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + NonNull::new(display_ptr).ok_or("null wayland display")?, + )); + let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new( + NonNull::new(surface_ptr).ok_or("null wayland surface")?, + )); + + let display = unsafe { Display::new(raw_display, DisplayApiPreference::Egl) } + .map_err(|error| format!("egl display: {error}"))?; + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true) + .with_api(Api::OPENGL | Api::GLES2) + .build(); + let config = unsafe { display.find_configs(template) } + .map_err(|error| format!("egl configs: {error}"))? + .reduce(|best, candidate| { + let best_alpha = best.alpha_size(); + let candidate_alpha = candidate.alpha_size(); + if candidate_alpha > best_alpha { + candidate + } else { + best + } + }) + .ok_or("no suitable EGL config")?; + let attributes = SurfaceAttributesBuilder::::new().build( + raw_window, + NonZeroU32::new(size.0.max(1)).ok_or("zero width")?, + NonZeroU32::new(size.1.max(1)).ok_or("zero height")?, + ); + let surface = unsafe { display.create_window_surface(&config, &attributes) } + .map_err(|error| format!("egl window surface: {error}"))?; + let context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::OpenGl(None)) + .build(Some(raw_window)); + let context = unsafe { display.create_context(&config, &context_attributes) } + .map_err(|error| format!("egl context: {error}"))? + .make_current(&surface) + .map_err(|error| format!("egl make current: {error}"))?; + let gl = unsafe { + glow::Context::from_loader_function(|symbol| { + let symbol = std::ffi::CString::new(symbol) + .map_err(|_| ()) + .unwrap_or_default(); + display.get_proc_address(symbol.as_c_str()) as *const c_void + }) + }; + Ok(Self { + _display: display, + surface, + context, + gl: Arc::new(gl), + }) + } + + fn paint( + &self, + painter: &mut egui_glow::Painter, + size: (u32, u32), + primitives: &[egui::ClippedPrimitive], + textures_delta: &egui::TexturesDelta, + scale: f32, + ) { + // The capsule is a transparent overlay: the painter clears the buffer + // itself, so no opaque clear colour is needed. + painter.paint_and_update_textures( + [size.0.max(1), size.1.max(1)], + scale, + primitives, + textures_delta, + ); + } +} + +// ── runner ────────────────────────────────────────────────────────────────── + +/// Host the capsule on a `wlr-layer-shell` surface until `frame` asks to exit or +/// the compositor closes it. +/// +/// Fails (with a human-readable reason) when there is no Wayland session, the +/// compositor lacks the protocol, the configure never arrives, or EGL could not +/// be initialised — the caller then uses the X11 overlay instead. The context is +/// created here; the caller installs fonts/visuals on the first frame (egui's +/// built-in fonts are enough for the pill, but a host font setup should run +/// once). +/// Bind the registry objects this module needs from the list +/// `registry_queue_init` collected. +/// +/// The global events are consumed during `registry_queue_init`, so a later +/// `roundtrip` into our own state never sees them: binding through the returned +/// `GlobalList` is the only way the compositor, layer-shell and seat objects +/// exist at all. +fn bind_globals( + globals: &wayland_client::globals::GlobalList, + qh: &QueueHandle, +) -> LayerState { + let registry = globals.registry(); + let mut state = LayerState::default(); + for global in globals.contents().clone_list() { + match global.interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(global.name, global.version.min(4), qh, ())); + } + // Layer shell is at version 4 in the widest-deployed compositors and + // 5 in KWin 6.7; everything this module sets exists since version 1. + LAYER_SHELL_GLOBAL => { + state.layer_shell = Some(registry.bind(global.name, global.version.min(4), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(global.name, global.version.min(7), qh, ())); + } + _ => {} + } + } + state +} + +pub fn run_layer_capsule(geometry: CapsuleGeometry, mut frame: F) -> Result<(), String> +where + F: FnMut(&egui::Context, egui::RawInput, bool) -> LayerFrame, +{ + let connection = Connection::connect_to_env().map_err(|error| format!("wayland: {error}"))?; + let (globals, mut queue) = registry_queue_init::(&connection) + .map_err(|error| format!("wayland registry: {error}"))?; + let qh = queue.handle(); + let mut state = bind_globals(&globals, &qh); + // One roundtrip so the seat capabilities arrive and the pointer exists. + queue + .roundtrip(&mut state) + .map_err(|error| format!("wayland roundtrip: {error}"))?; + let compositor = state + .compositor + .clone() + .ok_or("no wl_compositor on this compositor")?; + let layer_shell = state + .layer_shell + .clone() + .ok_or("compositor has no zwlr_layer_shell_v1")?; + + let wl_surface = compositor.create_surface(&qh, ()); + let layer_surface = layer_shell.get_layer_surface( + &wl_surface, + None, + zwlr_layer_shell_v1::Layer::Overlay, + LAYER_NAMESPACE.to_string(), + &qh, + (), + ); + let (width, height) = geometry.buffer_size(); + layer_surface.set_size(width, height); + let _ = (width, height); + // Bottom only: a single horizontal anchor leaves the surface centred while + // keeping the input region at the pill instead of the whole bottom strip. + layer_surface.set_anchor(zwlr_layer_surface_v1::Anchor::Bottom); + layer_surface.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None); + layer_surface.set_exclusive_zone(-1); + layer_surface.set_margin(0, 0, geometry.bottom_gap, 0); + wl_surface.commit(); + + let deadline = Instant::now() + CONFIGURE_TIMEOUT; + while state.configure.is_none() { + if state.closed { + return Err("layer surface closed before configure".to_string()); + } + if Instant::now() >= deadline { + return Err("layer surface configure timed out".to_string()); + } + queue + .blocking_dispatch(&mut state) + .map_err(|error| format!("wayland dispatch: {error}"))?; + } + + // Milestone lines for real-machine verification: they land on the popup + // process' inherited stderr (terminal or journal), since the popup installs + // no logger of its own. + let (configured_width, configured_height) = state.configure.unwrap_or((width, height)); + eprintln!( + "OpenLess capsule: layer surface configured {configured_width}x{configured_height} \ + (anchor=bottom, margin.bottom={}, keyboard-interactivity=none, exclusive-zone=-1)", + geometry.bottom_gap + ); + let gl = GlSurface::new(&connection, &wl_surface, geometry.buffer_size())?; + use glutin::surface::GlSurface as _; + let mut painter = egui_glow::Painter::new(gl.gl.clone(), "", None, false) + .map_err(|error| format!("egui_glow painter: {error}"))?; + eprintln!("OpenLess capsule: EGL ready on the layer surface"); + let context = egui::Context::default(); + let scale = 1.0; + let mut first = true; + + loop { + queue + .dispatch_pending(&mut state) + .map_err(|error| format!("wayland dispatch: {error}"))?; + if state.closed { + return Ok(()); + } + let _ = connection.flush(); + let rect = geometry.rect_for_configure(state.configure.unwrap_or((width, height))); + let input = state.take_input(rect); + let frame = frame(&context, input, first); + first = false; + let primitives = context.tessellate(frame.output.shapes.clone(), scale); + gl.paint( + &mut painter, + (rect.width().max(1.0) as u32, rect.height().max(1.0) as u32), + &primitives, + &frame.output.textures_delta, + scale, + ); + gl.surface + .swap_buffers(&gl.context) + .map_err(|error| format!("egl swap: {error}"))?; + if frame.exit { + return Ok(()); + } + std::thread::sleep(frame.repaint_after.min(MAX_FRAME_PAUSE)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn globals(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_string()).collect() + } + + #[test] + fn detects_the_layer_shell_global() { + assert!(has_layer_shell(&globals(&[ + "wl_compositor", + LAYER_SHELL_GLOBAL, + "wl_seat", + ]))); + assert!(!has_layer_shell(&globals(&["wl_compositor", "wl_seat"]))); + assert!(!has_layer_shell(&[])); + } + + #[test] + fn layer_shell_wins_over_the_x11_overlay() { + let advertised = globals(&[LAYER_SHELL_GLOBAL]); + assert_eq!( + choose_capsule_path(Some("wayland-0"), Some(":0"), &advertised), + CapsulePath::LayerShell + ); + // No protocol: XWayland is the only way to pin position and focus. + assert_eq!( + choose_capsule_path(Some("wayland-0"), Some(":0"), &globals(&["wl_compositor"])), + CapsulePath::X11Overlay + ); + // Wayland session without XWayland and without layer-shell. + assert_eq!( + choose_capsule_path(Some("wayland-0"), None, &globals(&["wl_compositor"])), + CapsulePath::PlainWindow + ); + // Pure X11 session. + assert_eq!( + choose_capsule_path(None, Some(":0"), &globals(&[LAYER_SHELL_GLOBAL])), + CapsulePath::X11Overlay + ); + } + + #[test] + fn a_blank_display_variable_is_not_a_wayland_session() { + assert!(!wayland_display_available(Some(""))); + assert!(!wayland_display_available(Some(" "))); + assert!(!wayland_display_available(None)); + assert!(wayland_display_available(Some("wayland-0"))); + } + + /// 真机验证用:在一台真的连着合成器的机器上跑 + /// `cargo test -p openless-linux-egui -- --ignored --nocapture capsule_decision` + /// 就能看到这台机器实际会走哪条路径。 + #[test] + #[ignore = "requires a live Wayland / X11 session"] + fn capsule_decision_on_this_machine() { + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + let x11 = std::env::var("DISPLAY").ok(); + let globals = probe_globals(); + println!("WAYLAND_DISPLAY={wayland:?} DISPLAY={x11:?}"); + match &globals { + Ok(globals) => println!( + "globals with layer-shell: {}", + globals + .iter() + .filter(|g| g.as_str() == LAYER_SHELL_GLOBAL) + .count() + ), + Err(error) => println!("globals unavailable: {error}"), + } + println!( + "probe_layer_shell={} layer_shell_available={} detect_capsule_path={:?}", + probe_layer_shell(wayland.as_deref()), + layer_shell_available(), + detect_capsule_path() + ); + } + + #[test] + fn capsule_path_override_parses_the_documented_values() { + assert_eq!( + capsule_path_override(Some("layer")), + Some(CapsulePath::LayerShell) + ); + assert_eq!( + capsule_path_override(Some(" Layer-Shell ")), + Some(CapsulePath::LayerShell) + ); + assert_eq!( + capsule_path_override(Some("x11")), + Some(CapsulePath::X11Overlay) + ); + assert_eq!( + capsule_path_override(Some("XWayland")), + Some(CapsulePath::X11Overlay) + ); + assert_eq!( + capsule_path_override(Some("plain")), + Some(CapsulePath::PlainWindow) + ); + // A typo must never take the capsule off every path. + assert_eq!(capsule_path_override(Some("")), None); + assert_eq!(capsule_path_override(Some("layerish")), None); + assert_eq!(capsule_path_override(None), None); + } + + /// The parent picks the child's backend from the same override the child + /// uses to pick its window, so the two can never disagree: an override of + /// `layer` is the only one that must keep the Wayland connection alive. + #[test] + fn the_override_keeps_the_parent_and_child_in_agreement() { + for (value, layer_shell, path) in [ + ("layer", true, CapsulePath::LayerShell), + ("x11", false, CapsulePath::X11Overlay), + ("plain", false, CapsulePath::PlainWindow), + ] { + let forced = capsule_path_override(Some(value)); + assert_eq!(forced, Some(path), "override {value}"); + // What `layer_shell_available` returns for that override… + assert_eq!(forced == Some(CapsulePath::LayerShell), layer_shell); + // …and what the child derives from the same value. + assert_eq!(forced, Some(path)); + } + } + + #[test] + fn capsule_geometry_clamps_to_a_paintable_surface() { + let geometry = capsule_geometry(200, 100, 12); + assert_eq!(geometry.buffer_size(), (200, 100)); + assert_eq!(geometry.bottom_gap, 12); + let degenerate = capsule_geometry(0, 0, -5); + assert_eq!(degenerate.buffer_size(), (1, 1)); + assert_eq!(degenerate.bottom_gap, 0); + assert_eq!(degenerate.rect().size(), egui::vec2(1.0, 1.0)); + } + + #[test] + fn a_zero_configure_keeps_the_requested_size() { + let geometry = capsule_geometry(200, 100, 12); + assert_eq!( + geometry.rect_for_configure((0, 0)).size(), + egui::vec2(200.0, 100.0) + ); + // A compositor that resizes the surface wins once it reports pixels. + assert_eq!( + geometry.rect_for_configure((240, 120)).size(), + egui::vec2(240.0, 120.0) + ); + } + + #[test] + fn pointer_samples_become_egui_events() { + let position = Some(egui::pos2(4.0, 5.0)); + let events = pointer_events( + position, + &[(egui::pos2(4.0, 5.0), egui::PointerButton::Primary, true)], + false, + ); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], egui::Event::PointerMoved(_))); + match &events[1] { + egui::Event::PointerButton { + button, pressed, .. + } => { + assert_eq!(*button, egui::PointerButton::Primary); + assert!(pressed); + } + other => panic!("expected a button event, got {other:?}"), + } + } + + #[test] + fn leaving_the_surface_reports_a_gone_pointer() { + let events = pointer_events(None, &[], true); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], egui::Event::PointerGone)); + } + + #[test] + fn mouse_buttons_map_to_egui_buttons() { + assert_eq!(button_from_code(0x110), Some(egui::PointerButton::Primary)); + assert_eq!( + button_from_code(0x111), + Some(egui::PointerButton::Secondary) + ); + assert_eq!(button_from_code(0x112), Some(egui::PointerButton::Middle)); + assert_eq!(button_from_code(0x113), None); + } + + #[test] + fn input_carries_the_viewport_and_pending_clicks() { + let mut state = LayerState { + pointer_position: Some(egui::pos2(1.0, 2.0)), + pointer_moved: true, + pressed: vec![(egui::pos2(1.0, 2.0), egui::PointerButton::Primary, true)], + ..Default::default() + }; + let rect = capsule_geometry(200, 100, 12).rect(); + let input = state.take_input(rect); + assert_eq!(input.screen_rect, Some(rect)); + assert_eq!(input.events.len(), 2); + // Drained: the next frame starts clean. + let next = state.take_input(rect); + assert!(next.events.is_empty()); + } +} diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs new file mode 100644 index 000000000..330bea9c7 --- /dev/null +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -0,0 +1,1249 @@ +//! Overlay placement for the popup processes: bottom-centre, never take focus. +//! +//! The recording capsule is a pure overlay: it shows the recording state and +//! offers cancel / confirm, but it must **never** take the keyboard — the user +//! is dictating into *another* window, and stealing focus there would send the +//! insert to the wrong place. Tauri gets exactly this guarantee on macOS from +//! `NSWindow::orderFrontRegardless` ("visible but not the key window", see +//! the Tauri host's `show_qa_window`). Wayland's xdg-shell offers +//! neither an absolute position nor a focus opt-out, so the capsule runs under +//! XWayland, where: +//! +//! * `WM_HINTS.input = False` makes the window manager never assign focus +//! (pointer clicks still reach the pill's ✕ / ✓ buttons), +//! * `_NET_WM_WINDOW_TYPE_UTILITY` keeps KWin from applying its OSD-style +//! placement to the pill (the notification look is asked for explicitly with +//! `_NET_WM_STATE_ABOVE` + `SKIP_TASKBAR` instead), and +//! * an explicit `ConfigureWindow` places it at the bottom centre of the work +//! area, with the ICCCM `USPosition` hint so the manager keeps those +//! coordinates, mirroring the Tauri host's +//! `position_capsule_bottom_center_with_style`. +//! +//! The focus is only taken back when `_NET_ACTIVE_WINDOW` really is the pill: +//! re-reading it after the move keeps the overlay from yanking the keyboard +//! away from whatever window the user moved on to. +//! +//! The maths lives in [`OverlayEnvironment`] so it is unit-testable, and every +//! X11 mutation goes through the [`OverlayX11`] trait so the request sequence +//! can be asserted without an X server. + +/// Capsule window size (the 176×42 pill plus room for the translate badge). +pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (200, 100); +/// Gap between the capsule pill and the bottom of the work area — Tauri's +/// `EDGE_GAP` for the classic / siri capsule styles. +pub const CAPSULE_BOTTOM_GAP: i32 = 12; +/// Selection-ask panel size (the chat panel) — Tauri `qa` window is 420×540. +pub const QA_WINDOW_SIZE: (u32, u32) = (420, 540); +/// Less Computer panel size — Tauri `less-computer` is 420×540, the same +/// footprint as the selection-ask panel. +pub const LESS_COMPUTER_WINDOW_SIZE: (u32, u32) = (420, 540); +/// Polish-preview panel size — Tauri `selection-polish-preview` is 640×440. +pub const PREVIEW_WINDOW_SIZE: (u32, u32) = (640, 440); +/// Smallest the user may resize the polish preview to — Tauri `minWidth/minHeight`. +pub const PREVIEW_MIN_SIZE: (u32, u32) = (480, 320); + +/// Window size for one popup kind, in X11 pixels. +pub fn popup_size(kind: crate::popup::PopupKind) -> (u32, u32) { + use crate::popup::PopupKind; + match kind { + PopupKind::Capsule => CAPSULE_WINDOW_SIZE, + PopupKind::Qa => QA_WINDOW_SIZE, + PopupKind::LessComputer => LESS_COMPUTER_WINDOW_SIZE, + PopupKind::Preview => PREVIEW_WINDOW_SIZE, + } +} + +/// Where one popup is placed inside the work area. +/// +/// The capsule hugs the bottom edge, centred: it is the transient overlay the +/// eyes track while dictating, and Tauri's `position_capsule_bottom_center_*` +/// puts it there too. The selection-ask panel and the polish preview are +/// **centred** instead of stacked above the pill — Tauri shows both as centred +/// cards, and stacking would overlap: a 520px panel sitting 50px above the +/// work-area bottom runs into the 100px capsule strip that starts 112px above +/// it. +pub fn popup_position( + environment: &OverlayEnvironment, + kind: crate::popup::PopupKind, +) -> Option<(i32, i32)> { + use crate::popup::PopupKind; + match kind { + PopupKind::Capsule => environment.position_for(CAPSULE_WINDOW_SIZE, CAPSULE_BOTTOM_GAP), + PopupKind::Qa => environment.centred_for(QA_WINDOW_SIZE), + PopupKind::LessComputer => environment.centred_for(LESS_COMPUTER_WINDOW_SIZE), + PopupKind::Preview => environment.centred_for(PREVIEW_WINDOW_SIZE), + } +} + +/// A rectangle in root-window coordinates (pixels). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct X11Rect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +impl X11Rect { + pub fn contains(self, point: (i32, i32)) -> bool { + point.0 >= self.x + && point.0 < self.x + self.width as i32 + && point.1 >= self.y + && point.1 < self.y + self.height as i32 + } +} + +/// Tauri `bottom_center_position`: centre horizontally, sit `bottom_gap` above +/// the bottom edge, then pull the window back inside `area` if it overflows. +pub fn bottom_center(area: X11Rect, window: (u32, u32), bottom_gap: i32) -> (i32, i32) { + let x = area.x + (area.width.saturating_sub(window.0) / 2) as i32; + let y = area.y + (area.height as i32 - bottom_gap - window.1 as i32).max(0); + clamp_to_area(x, y, window, area) +} + +/// Tauri `clamp_to_monitor`: keep the whole window inside `area`, tolerating an +/// area that is smaller than the window. +pub fn clamp_to_area(x: i32, y: i32, window: (u32, u32), area: X11Rect) -> (i32, i32) { + let max_x = (area.x + area.width as i32 - window.0 as i32).max(area.x); + let max_y = (area.y + area.height as i32 - window.1 as i32).max(area.y); + (x.clamp(area.x, max_x), y.clamp(area.y, max_y)) +} + +/// Centre the window in `area` (both axes), then pull it back inside. +pub fn centered(area: X11Rect, window: (u32, u32)) -> (i32, i32) { + let x = area.x + (area.width.saturating_sub(window.0) / 2) as i32; + let y = area.y + (area.height.saturating_sub(window.1) / 2) as i32; + clamp_to_area(x, y, window, area) +} + +/// Search the monitor list for the one holding `point` (Tauri follows the +/// pointer on macOS and the foreground window on Windows; on X11 the pointer is +/// what we can read before we map our own window). +pub fn monitor_containing(monitors: &[X11Rect], point: Option<(i32, i32)>) -> Option { + let point = point?; + monitors + .iter() + .copied() + .find(|monitor| monitor.contains(point)) +} + +/// Everything read from X11 *before* the popup maps its own window: the work +/// area (taskbar excluded), the monitor list, the pointer position and whoever +/// held the focus at the time. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OverlayEnvironment { + pub work_area: Option, + pub monitors: Vec, + pub cursor: Option<(i32, i32)>, + /// `_NET_ACTIVE_WINDOW` before the popup appeared, used to put the focus + /// back if the compositor handed it to us anyway. + pub active_window: Option, +} + +impl OverlayEnvironment { + /// The rectangle to centre in: the work area of the monitor under the + /// pointer, else that monitor, else the first monitor. + pub fn placement_area(&self) -> Option { + let monitor = monitor_containing(&self.monitors, self.cursor) + .or_else(|| self.work_area) + .or_else(|| self.monitors.first().copied())?; + // `_NET_WORKAREA` is a single rectangle for the whole virtual desktop; + // intersect it with the chosen monitor so the pill lands on the screen + // the user is looking at, still above the taskbar. + match self.work_area { + Some(work) => Some(intersect(work, monitor).unwrap_or(monitor)), + None => Some(monitor), + } + } + + pub fn position_for(&self, window: (u32, u32), bottom_gap: i32) -> Option<(i32, i32)> { + self.placement_area() + .map(|area| bottom_center(area, window, bottom_gap)) + } + + /// Centre `window` in the same area `position_for` uses. + pub fn centred_for(&self, window: (u32, u32)) -> Option<(i32, i32)> { + self.placement_area().map(|area| centered(area, window)) + } +} + +/// Intersection of two rectangles, `None` when they do not overlap. +pub fn intersect(a: X11Rect, b: X11Rect) -> Option { + let left = a.x.max(b.x); + let top = a.y.max(b.y); + let right = (a.x + a.width as i32).min(b.x + b.width as i32); + let bottom = (a.y + a.height as i32).min(b.y + b.height as i32); + if right <= left || bottom <= top { + return None; + } + Some(X11Rect { + x: left, + y: top, + width: (right - left) as u32, + height: (bottom - top) as u32, + }) +} + +/// How the overlay window was identified in the X11 tree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowMatch { + /// `_NET_WM_PID` matched our own pid — the reliable path. + Pid, + /// Fallback: a window publishing no `_NET_WM_PID` whose `WM_CLASS` + /// mentions OpenLess. + Class, + /// Fallback: same, matched on `_NET_WM_NAME` / `WM_NAME`. + Name, +} + +impl WindowMatch { + pub fn as_str(self) -> &'static str { + match self { + Self::Pid => "pid", + Self::Class => "wm_class", + Self::Name => "wm_name", + } + } +} + +/// One window found in the X11 tree, with the properties the selector needs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WindowCandidate { + pub window: u32, + pub pid: Option, + pub wm_class: Option, + pub name: Option, +} + +/// Pick our own popup window out of the tree. +/// +/// `_NET_WM_PID` is authoritative, but the window manager is free to keep it +/// off the client window (or the client may not have published it yet when the +/// pre-map pass runs), so `WM_CLASS` / `_NET_WM_NAME` are the documented +/// fallbacks. +/// +/// The fallback deliberately only looks at windows that carry **no** +/// `_NET_WM_PID` at all: in an X11 session the *main* OpenLess window is also +/// called "OpenLess" and does publish a pid, so restricting the fallback keeps +/// the overlay from ever grabbing the main window. +pub fn select_overlay_window( + candidates: &[WindowCandidate], + pid: u32, +) -> Option<(u32, WindowMatch)> { + if let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.pid == Some(pid)) + { + return Some((candidate.window, WindowMatch::Pid)); + } + let unowned = || { + candidates + .iter() + .filter(|candidate| candidate.pid.is_none()) + }; + if let Some(candidate) = unowned() + .filter(|candidate| { + candidate + .wm_class + .as_deref() + .is_some_and(|class| class.to_ascii_lowercase().contains("openless")) + }) + // The popup is created after every other OpenLess window, and the tree + // lists children in creation order, so the last match is ours. + .last() + { + return Some((candidate.window, WindowMatch::Class)); + } + if let Some(candidate) = unowned() + .filter(|candidate| { + candidate + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("openless")) + }) + .last() + { + return Some((candidate.window, WindowMatch::Name)); + } + None +} + +/// The X11 mutations the overlay needs. Split out so the whole placement +/// sequence can be driven by a recording fake in tests. +pub trait OverlayX11 { + /// Find our own window: `_NET_WM_PID` first, then the class / name + /// fallbacks, reporting which strategy matched. + fn find_own_window(&mut self, pid: u32) -> Result, String>; + /// `WM_HINTS.input = False`: the window manager must never assign focus. + fn set_never_focus(&mut self, window: u32) -> Result<(), String>; + /// `_NET_WM_WINDOW_TYPE = _NET_WM_WINDOW_TYPE_UTILITY`. + fn set_window_type(&mut self, window: u32) -> Result<(), String>; + /// ICCCM `WM_NORMAL_HINTS` with `USPosition`: the position was chosen by the + /// program, so the window manager must not re-place the window. + fn mark_self_placed(&mut self, window: u32) -> Result<(), String>; + /// `_NET_WM_STATE_ABOVE` + `_NET_WM_STATE_SKIP_TASKBAR`. + fn set_overlay_states(&mut self, window: u32) -> Result<(), String>; + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String>; + /// `_NET_ACTIVE_WINDOW` right now; `None` when the root has no value. + fn active_window(&mut self) -> Result, String>; + /// Hand the focus back to `window` (the one that had it before we mapped). + fn restore_focus(&mut self, window: u32) -> Result<(), String>; +} + +/// What [`place_overlay`] managed to do; the caller logs it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OverlayPlacement { + pub window: Option, + /// How the window was identified (`pid` / `wm_class` / `wm_name`). + pub matched: Option, + pub moved_to: Option<(i32, i32)>, + /// The compositor had handed us the keyboard and we took it back. + pub focus_was_stolen: bool, + pub focus_restored: bool, + /// Non-fatal problems, in the order they happened. + pub warnings: Vec, +} + +impl OverlayPlacement { + pub fn applied(&self) -> bool { + self.window.is_some() + } +} + +/// Point the popup's own X11 window at the bottom centre of the work area and +/// make sure it never holds the keyboard. +/// +/// Best effort by design: the pill must still appear if any single step fails, +/// so every failure is collected into [`OverlayPlacement::warnings`] instead of +/// aborting. +pub fn place_overlay( + x11: &mut dyn OverlayX11, + pid: u32, + environment: &OverlayEnvironment, + kind: crate::popup::PopupKind, +) -> OverlayPlacement { + let mut placement = OverlayPlacement::default(); + let (window, matched) = match x11.find_own_window(pid) { + Ok(Some(found)) => found, + Ok(None) => { + placement + .warnings + .push("own X11 window not found yet".to_string()); + return placement; + } + Err(error) => { + placement + .warnings + .push(format!("window lookup failed: {error}")); + return placement; + } + }; + placement.window = Some(window); + placement.matched = Some(matched); + + if let Err(error) = x11.set_never_focus(window) { + placement + .warnings + .push(format!("input hint failed: {error}")); + } + if let Err(error) = x11.set_window_type(window) { + placement + .warnings + .push(format!("window type failed: {error}")); + } + if let Err(error) = x11.mark_self_placed(window) { + placement + .warnings + .push(format!("position hint failed: {error}")); + } + if let Err(error) = x11.set_overlay_states(window) { + placement + .warnings + .push(format!("overlay states failed: {error}")); + } + if let Some(position) = popup_position(environment, kind) { + match x11.move_window(window, position) { + Ok(()) => placement.moved_to = Some(position), + Err(error) => placement.warnings.push(format!("move failed: {error}")), + } + } else { + placement + .warnings + .push("no usable work area or monitor".to_string()); + } + + // Only fight the compositor when it really handed us the keyboard: read + // `_NET_ACTIVE_WINDOW` again and check it is *our* window before taking the + // focus away from whatever the user is actually looking at now. + if let Some(previous) = environment.active_window { + if previous != window { + match x11.active_window() { + Ok(Some(current)) if current == window => match x11.restore_focus(previous) { + Ok(()) => { + placement.focus_was_stolen = true; + placement.focus_restored = true; + } + Err(error) => { + placement.focus_was_stolen = true; + placement + .warnings + .push(format!("focus restore failed: {error}")); + } + }, + // We never took the focus (the `input = False` hint did its job, + // or the user already moved on): leave the focus alone. + Ok(_) => {} + Err(error) => placement + .warnings + .push(format!("focus check failed: {error}")), + } + } + } + placement +} + +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +mod x11 { + //! The real connection. Kept behind a feature so the default build never + //! links X11 (the same binary also runs under pure Wayland). + use super::{OverlayEnvironment, OverlayX11, X11Rect}; + use x11rb::connection::Connection; + use x11rb::protocol::randr::ConnectionExt as _; + use x11rb::protocol::xproto::{ + AtomEnum, ClientMessageEvent, ConnectionExt as _, EventMask, PropMode, + }; + use x11rb::rust_connection::RustConnection; + use x11rb::wrapper::ConnectionExt as _; + + pub struct X11Overlay { + connection: RustConnection, + root: u32, + } + + impl X11Overlay { + pub fn connect() -> Result { + let (connection, screen) = RustConnection::connect(None).map_err(|e| e.to_string())?; + let root = connection.setup().roots[screen].root; + Ok(Self { connection, root }) + } + + pub fn probe(&self) -> Result { + Ok(OverlayEnvironment { + work_area: self.work_area()?, + monitors: self.monitors()?, + cursor: self.cursor()?, + active_window: self.active_window_property()?, + }) + } + + fn atom(&self, name: &[u8]) -> Result { + Ok(self + .connection + .intern_atom(false, name) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())? + .atom) + } + + fn work_area(&self) -> Result, String> { + let atom = self.atom(b"_NET_WORKAREA")?; + let reply = self + .connection + .get_property(false, self.root, atom, AtomEnum::CARDINAL, 0, 4) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let values: Vec = reply.value32().map(|it| it.collect()).unwrap_or_default(); + if values.len() < 4 { + return Ok(None); + } + Ok(Some(X11Rect { + x: values[0] as i32, + y: values[1] as i32, + width: values[2], + height: values[3], + })) + } + + fn monitors(&self) -> Result, String> { + if let Ok(reply) = self + .connection + .randr_get_monitors(self.root, true) + .map_err(|e| e.to_string())? + .reply() + { + let rects: Vec = reply + .monitors + .iter() + .map(|monitor| X11Rect { + x: monitor.x as i32, + y: monitor.y as i32, + width: u32::from(monitor.width), + height: u32::from(monitor.height), + }) + .collect(); + if !rects.is_empty() { + return Ok(rects); + } + } + let screen = self + .connection + .setup() + .roots + .iter() + .find(|root| root.root == self.root) + .ok_or_else(|| "root screen missing".to_string())?; + Ok(vec![X11Rect { + x: 0, + y: 0, + width: u32::from(screen.width_in_pixels), + height: u32::from(screen.height_in_pixels), + }]) + } + + fn cursor(&self) -> Result, String> { + let reply = self + .connection + .query_pointer(self.root) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(Some((i32::from(reply.root_x), i32::from(reply.root_y)))) + } + + fn active_window_property(&self) -> Result, String> { + let atom = self.atom(b"_NET_ACTIVE_WINDOW")?; + let reply = self + .connection + .get_property(false, self.root, atom, AtomEnum::WINDOW, 0, 1) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(reply + .value32() + .and_then(|mut it| it.next()) + .filter(|window| *window != 0)) + } + + fn pid_of(&self, window: u32) -> Result, String> { + let atom = self.atom(b"_NET_WM_PID")?; + let reply = self + .connection + .get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(reply.value32().and_then(|mut it| it.next())) + } + + fn send_state(&self, window: u32, state: u32) -> Result<(), String> { + let atom = self.atom(b"_NET_WM_STATE")?; + let event = ClientMessageEvent::new( + 32, + window, + atom, + [ + 1, /* _NET_WM_STATE_ADD */ + state, 0, 1, /* application */ + 0, + ], + ); + self.connection + .send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + } + + impl X11Overlay { + /// Every window the overlay could plausibly be: the root's children and + /// one level deeper, because a window manager may have reparented the + /// client into a frame window. + fn own_candidates(&self) -> Result, String> { + let tree = self + .connection + .query_tree(self.root) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut windows = Vec::with_capacity(tree.children.len()); + for &child in &tree.children { + windows.push(child); + if let Ok(inner) = self + .connection + .query_tree(child) + .map_err(|e| e.to_string())? + .reply() + { + windows.extend(inner.children.iter().copied()); + } + } + let mut candidates = Vec::with_capacity(windows.len()); + for window in windows { + candidates.push(super::WindowCandidate { + window, + pid: self.pid_of(window)?, + // Class / name are only read for the fallback path (see + // `own_candidate_details`), so they stay empty here. + wm_class: None, + name: None, + }); + } + Ok(candidates) + } + + /// `WM_CLASS` instance + class, or `_NET_WM_NAME` / `WM_NAME`. + fn own_candidate_details( + &self, + candidates: &mut [super::WindowCandidate], + ) -> Result<(), String> { + for candidate in candidates.iter_mut() { + if candidate.pid.is_some() { + continue; + } + candidate.wm_class = self.string_property(candidate.window, AtomEnum::WM_CLASS)?; + candidate.name = self + .utf8_property(candidate.window, b"_NET_WM_NAME")? + .or(self.string_property(candidate.window, AtomEnum::WM_NAME)?); + } + Ok(()) + } + + fn string_property( + &self, + window: u32, + property: impl Into, + ) -> Result, String> { + let reply = self + .connection + .get_property(false, window, property, AtomEnum::STRING, 0, 1024) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(decode_strings(&reply.value)) + } + + fn utf8_property(&self, window: u32, name: &[u8]) -> Result, String> { + let atom = self.atom(name)?; + let reply = self + .connection + .get_property(false, window, atom, AtomEnum::ANY, 0, 1024) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + if reply.value.is_empty() { + return Ok(None); + } + Ok(Some(String::from_utf8_lossy(&reply.value).to_string())) + } + } + + /// `WM_CLASS` holds two NUL separated strings (instance, class); join them + /// so the selector can look for "openless" in either one. + pub(super) fn decode_strings(bytes: &[u8]) -> Option { + let joined = bytes + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).to_string()) + .collect::>() + .join(" "); + (!joined.is_empty()).then_some(joined) + } + + impl OverlayX11 for X11Overlay { + fn find_own_window( + &mut self, + pid: u32, + ) -> Result, String> { + let mut candidates = self.own_candidates()?; + // Fast path: the pid is published, so no class / name round trips. + if let Some((window, matched)) = super::select_overlay_window(&candidates, pid) { + log::debug!( + "capsule x11: window {window:#x} matched by {}", + matched.as_str() + ); + return Ok(Some((window, matched))); + } + // Slow path: only the pid-less windows are of interest. + self.own_candidate_details(&mut candidates)?; + Ok(super::select_overlay_window(&candidates, pid)) + } + + fn set_never_focus(&mut self, window: u32) -> Result<(), String> { + let reply = self + .connection + .get_property(false, window, AtomEnum::WM_HINTS, AtomEnum::WM_HINTS, 0, 9) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut hints: Vec = reply + .value32() + .map(|it| it.collect::>()) + .unwrap_or_default(); + hints.resize(9, 0); + const INPUT_HINT: u32 = 1 << 0; + hints[0] |= INPUT_HINT; // flags + hints[1] = 0; // input = False + self.connection + .change_property32( + PropMode::REPLACE, + window, + AtomEnum::WM_HINTS, + AtomEnum::WM_HINTS, + &hints, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + /// `_NET_WM_WINDOW_TYPE_UTILITY`. + /// + /// UTILITY rather than NOTIFICATION: KWin treats NOTIFICATION as a + /// special OSD-style window and applies its own placement / stacking + /// policy to it, which would fight the explicit geometry we ask for. + /// A utility window is an ordinary window as far as placement goes (it + /// honours the client position), while the two properties we *do* want + /// to inherit from the notification look — never focus, never in the + /// taskbar, always above — are set explicitly through `WM_HINTS` and + /// `_NET_WM_STATE` instead of relying on the window type. + fn set_window_type(&mut self, window: u32) -> Result<(), String> { + let property = self.atom(b"_NET_WM_WINDOW_TYPE")?; + let utility = self.atom(b"_NET_WM_WINDOW_TYPE_UTILITY")?; + self.connection + .change_property32( + PropMode::REPLACE, + window, + property, + AtomEnum::ATOM, + &[utility], + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + /// ICCCM `WM_NORMAL_HINTS`: flag the position as program-specified + /// (`USPosition` | `PPosition`) so the window manager keeps the + /// coordinates instead of running its own placement. + fn mark_self_placed(&mut self, window: u32) -> Result<(), String> { + let reply = self + .connection + .get_property( + false, + window, + AtomEnum::WM_NORMAL_HINTS, + AtomEnum::WM_SIZE_HINTS, + 0, + 18, + ) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut hints: Vec = reply + .value32() + .map(|it| it.collect::>()) + .unwrap_or_default(); + hints.resize(18, 0); + const US_POSITION: u32 = 1 << 0; + const P_POSITION: u32 = 1 << 2; + hints[0] |= US_POSITION | P_POSITION; + self.connection + .change_property32( + PropMode::REPLACE, + window, + AtomEnum::WM_NORMAL_HINTS, + AtomEnum::WM_SIZE_HINTS, + &hints, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + fn active_window(&mut self) -> Result, String> { + self.active_window_property() + } + + fn set_overlay_states(&mut self, window: u32) -> Result<(), String> { + let above = self.atom(b"_NET_WM_STATE_ABOVE")?; + let skip = self.atom(b"_NET_WM_STATE_SKIP_TASKBAR")?; + self.send_state(window, above)?; + self.send_state(window, skip) + } + + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String> { + use x11rb::protocol::xproto::ConfigureWindowAux; + self.connection + .configure_window( + window, + &ConfigureWindowAux::new().x(position.0).y(position.1), + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + fn restore_focus(&mut self, window: u32) -> Result<(), String> { + let atom = self.atom(b"_NET_ACTIVE_WINDOW")?; + let event = ClientMessageEvent::new( + 32, + window, + atom, + // source indication 2 = pager: the compositor may refuse to let + // a normal application move the focus around, but a pager + // request is the documented way to hand it back. + [2, 0, 0, 0, 0], + ); + self.connection + .send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + } +} + +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +pub use x11::X11Overlay; + +/// Whether the host has an X server (XWayland counts) for the capsule to use. +pub fn x11_available(display: Option<&str>) -> bool { + display.is_some_and(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::popup::PopupKind; + + fn monitor(x: i32, y: i32, width: u32, height: u32) -> X11Rect { + X11Rect { + x, + y, + width, + height, + } + } + + #[test] + fn bottom_center_centres_on_a_1080p_screen() { + let area = monitor(0, 0, 1920, 1080); + // 1920 - 200 = 1720 / 2 = 860; 1080 - 12 - 100 = 968 + assert_eq!(bottom_center(area, (200, 100), 12), (860, 968)); + } + + #[test] + fn bottom_center_uses_the_monitor_offset_on_a_second_display() { + let area = monitor(1920, 0, 2560, 1440); + assert_eq!( + bottom_center(area, (200, 100), 12), + (1920 + 1180, 1440 - 112) + ); + } + + #[test] + fn bottom_center_keeps_a_window_wider_than_the_area_inside() { + let area = monitor(0, 0, 160, 120); + assert_eq!(bottom_center(area, (200, 100), 12), (0, 8)); + } + + #[test] + fn bottom_center_never_goes_negative_when_the_area_is_tiny() { + let area = monitor(100, 50, 40, 30); + assert_eq!(bottom_center(area, (200, 100), 12), (100, 50)); + } + + #[test] + fn placement_area_prefers_the_monitor_under_the_cursor() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 0, 3840, 1080)), + monitors: vec![monitor(0, 0, 1920, 1080), monitor(1920, 0, 1920, 1080)], + cursor: Some((2500, 500)), + ..Default::default() + }; + // The work area is the whole desktop; intersecting it with the right + // hand monitor keeps the pill on that screen. + assert_eq!( + environment.placement_area(), + Some(monitor(1920, 0, 1920, 1080)) + ); + assert_eq!( + environment.position_for((200, 100), 12), + Some((1920 + 860, 968)) + ); + } + + #[test] + fn placement_area_falls_back_to_the_first_monitor_without_a_cursor() { + let environment = OverlayEnvironment { + monitors: vec![monitor(0, 0, 1920, 1080), monitor(1920, 0, 1920, 1080)], + ..Default::default() + }; + assert_eq!( + environment.placement_area(), + Some(monitor(0, 0, 1920, 1080)) + ); + } + + #[test] + fn placement_area_uses_the_work_area_when_there_are_no_monitors() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 40, 1920, 1040)), + ..Default::default() + }; + assert_eq!( + environment.placement_area(), + Some(monitor(0, 40, 1920, 1040)) + ); + // A 40px taskbar at the top moves the work area's *top* edge only, so + // the pill keeps hugging the same bottom edge (1080 - 112). + assert_eq!(environment.position_for((200, 100), 12), Some((860, 968))); + } + + #[test] + fn placement_area_is_none_without_any_x11_geometry() { + assert_eq!(OverlayEnvironment::default().placement_area(), None); + assert_eq!( + OverlayEnvironment::default().position_for((200, 100), 12), + None + ); + } + + #[derive(Default)] + struct FakeX11 { + window: Option, + matched: Option, + fail_input: bool, + /// `_NET_ACTIVE_WINDOW` when the placement re-reads it after the move. + current_active: Option, + calls: Vec, + } + + impl OverlayX11 for FakeX11 { + fn find_own_window(&mut self, pid: u32) -> Result, String> { + self.calls.push(format!("find({pid})")); + Ok(self + .window + .map(|window| (window, self.matched.unwrap_or(WindowMatch::Pid)))) + } + fn set_never_focus(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("never_focus({window})")); + if self.fail_input { + return Err("nope".to_string()); + } + Ok(()) + } + fn set_window_type(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("window_type({window})")); + Ok(()) + } + fn mark_self_placed(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("self_placed({window})")); + Ok(()) + } + fn set_overlay_states(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("states({window})")); + Ok(()) + } + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String> { + self.calls + .push(format!("move({window},{},{})", position.0, position.1)); + Ok(()) + } + fn active_window(&mut self) -> Result, String> { + self.calls.push("active_window".to_string()); + Ok(self.current_active) + } + fn restore_focus(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("focus({window})")); + Ok(()) + } + } + + fn environment() -> OverlayEnvironment { + OverlayEnvironment { + work_area: Some(monitor(0, 0, 1920, 1080)), + monitors: vec![monitor(0, 0, 1920, 1080)], + cursor: Some((10, 10)), + active_window: Some(0x40), + } + } + + #[test] + fn popup_size_maps_every_kind() { + assert_eq!(popup_size(PopupKind::Capsule), CAPSULE_WINDOW_SIZE); + assert_eq!(popup_size(PopupKind::Qa), QA_WINDOW_SIZE); + assert_eq!(popup_size(PopupKind::Preview), PREVIEW_WINDOW_SIZE); + // Tauri 的 `less-computer` 窗口与 qa 同为 420×540。 + assert_eq!( + popup_size(PopupKind::LessComputer), + LESS_COMPUTER_WINDOW_SIZE + ); + assert_eq!(LESS_COMPUTER_WINDOW_SIZE, (420, 540)); + } + + /// The capsule hugs the bottom edge of the work area, centred. + #[test] + fn popup_position_puts_the_capsule_at_the_bottom_centre() { + assert_eq!( + popup_position(&environment(), PopupKind::Capsule), + Some((860, 968)) + ); + } + + /// The panels are centred dialogs, and centring is what keeps them from + /// running into the capsule strip: a 540px panel 50px above the bottom edge + /// would start at y=490 and end at 1030, i.e. inside the pill's 968..1068. + #[test] + fn popup_position_centres_the_panels_clear_of_the_capsule() { + // 尺寸取自 Tauri:qa 420×540、selection-polish-preview 640×440, + // 居中于 1920×1080 工作区。 + assert_eq!( + popup_position(&environment(), PopupKind::Qa), + Some((750, 270)) + ); + assert_eq!( + popup_position(&environment(), PopupKind::Preview), + Some((640, 320)) + ); + let area = monitor(0, 0, 1920, 1080); + let (_, capsule_y) = bottom_center(area, CAPSULE_WINDOW_SIZE, CAPSULE_BOTTOM_GAP); + for kind in [PopupKind::Qa, PopupKind::Preview] { + let (_, y) = popup_position(&environment(), kind).expect("centred"); + let height = popup_size(kind).1 as i32; + assert!( + y + height <= capsule_y, + "{kind:?} overlaps the capsule strip: {}..{} vs {}", + y, + y + height, + capsule_y + ); + } + } + + /// A work area smaller than the window keeps the window at its origin + /// instead of producing a negative position. + #[test] + fn popup_position_keeps_an_oversized_panel_inside_a_tiny_area() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 0, 400, 300)), + monitors: vec![monitor(0, 0, 400, 300)], + cursor: None, + active_window: None, + }; + assert_eq!(popup_position(&environment, PopupKind::Qa), Some((0, 0)),); + assert_eq!( + popup_position(&environment, PopupKind::Capsule), + Some((100, 188)), + ); + } + + /// A capsule that did take the keyboard: everything is asserted on one + /// request sequence, including the order (input hint + window type + + /// position hint before the EWMH states and the move). + #[test] + fn place_overlay_never_focuses_moves_and_restores_the_previous_window() { + let mut x11 = FakeX11 { + window: Some(0x2a), + current_active: Some(0x2a), + ..Default::default() + }; + let placement = place_overlay(&mut x11, 4242, &environment(), PopupKind::Capsule); + assert_eq!( + x11.calls, + vec![ + "find(4242)", + "never_focus(42)", // 0x2a + "window_type(42)", + "self_placed(42)", + "states(42)", + "move(42,860,968)", + "active_window", + "focus(64)", // 0x40 + ] + ); + assert_eq!(placement.window, Some(0x2a)); + assert_eq!(placement.matched, Some(WindowMatch::Pid)); + assert_eq!(placement.moved_to, Some((860, 968))); + assert!(placement.focus_was_stolen); + assert!(placement.focus_restored); + assert!(placement.warnings.is_empty()); + assert!(placement.applied()); + } + + #[test] + fn place_overlay_keeps_going_when_the_input_hint_fails() { + let mut x11 = FakeX11 { + window: Some(0x2a), + fail_input: true, + ..Default::default() + }; + let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); + assert!(placement.applied()); + assert_eq!(placement.moved_to, Some((860, 968))); + assert_eq!(placement.warnings, vec!["input hint failed: nope"]); + } + + #[test] + fn place_overlay_reports_a_missing_window() { + let mut x11 = FakeX11::default(); + let placement = place_overlay(&mut x11, 7, &environment(), PopupKind::Capsule); + assert!(!placement.applied()); + assert_eq!(placement.warnings, vec!["own X11 window not found yet"]); + assert_eq!(x11.calls, vec!["find(7)"]); + } + + #[test] + fn place_overlay_does_not_restore_focus_when_we_already_had_it() { + let mut x11 = FakeX11 { + window: Some(0x40), + ..Default::default() + }; + let mut environment = environment(); + environment.active_window = Some(0x40); + let placement = place_overlay(&mut x11, 1, &environment, PopupKind::Capsule); + assert!(!placement.focus_was_stolen); + assert!(!placement.focus_restored); + assert!(!x11.calls.iter().any(|call| call.starts_with("focus("))); + } + + /// The `WM_HINTS.input = False` hint (or the user moving on) means the + /// focus never landed on us: the overlay must not yank it to a stale window. + #[test] + fn place_overlay_leaves_the_focus_alone_when_it_was_never_stolen() { + let mut x11 = FakeX11 { + window: Some(0x2a), + current_active: Some(0x99), + ..Default::default() + }; + let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); + assert!(placement.applied()); + assert!(placement.focus_was_stolen == false); + assert!(placement.focus_restored == false); + assert!(x11.calls.contains(&"active_window".to_string())); + assert!(!x11.calls.iter().any(|call| call.starts_with("focus("))); + assert!(placement.warnings.is_empty()); + } + + fn candidate(window: u32, pid: Option) -> WindowCandidate { + WindowCandidate { + window, + pid, + wm_class: None, + name: None, + } + } + + #[test] + fn select_overlay_window_prefers_the_pid() { + let candidates = vec![candidate(0x1, Some(7)), candidate(0x2, Some(4242))]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Pid)) + ); + } + + #[test] + fn select_overlay_window_falls_back_to_the_wm_class() { + let candidates = vec![ + candidate(0x1, None), + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Class)) + ); + } + + #[test] + fn select_overlay_window_falls_back_to_the_window_name() { + let candidates = vec![WindowCandidate { + name: Some("OpenLess".to_string()), + ..candidate(0x5, None) + }]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x5, WindowMatch::Name)) + ); + } + + /// The main window is also called "OpenLess" but publishes a pid, so the + /// class / name fallback must never claim it. + #[test] + fn select_overlay_window_ignores_windows_owned_by_another_process() { + let candidates = vec![ + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x1, Some(99)) + }, + WindowCandidate { + name: Some("OpenLess".to_string()), + ..candidate(0x2, Some(99)) + }, + ]; + assert_eq!(select_overlay_window(&candidates, 4242), None); + } + + #[test] + fn select_overlay_window_takes_the_newest_class_match() { + let candidates = vec![ + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x1, None) + }, + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Class)) + ); + } + + #[test] + fn select_overlay_window_reports_nothing_without_a_match() { + let candidates = vec![ + candidate(0x1, Some(7)), + WindowCandidate { + wm_class: Some("firefox Firefox".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!(select_overlay_window(&candidates, 4242), None); + } + + #[cfg(all(target_os = "linux", feature = "x11-overlay"))] + #[test] + fn wm_class_is_decoded_into_a_searchable_string() { + // `WM_CLASS` is two NUL separated strings: instance + class. + assert_eq!( + super::x11::decode_strings(b"openless\0OpenLess\0").as_deref(), + Some("openless OpenLess") + ); + // Some clients send only the class (or nothing at all). + assert_eq!( + super::x11::decode_strings(b"OpenLess\0").as_deref(), + Some("OpenLess") + ); + assert_eq!(super::x11::decode_strings(b""), None); + } + + #[test] + fn x11_available_follows_the_display_variable() { + assert!(x11_available(Some(":0"))); + assert!(!x11_available(Some(" "))); + assert!(!x11_available(None)); + } +} diff --git a/openless-all/app/linux-egui/src/recordings.rs b/openless-all/app/linux-egui/src/recordings.rs new file mode 100644 index 000000000..f4c90f8fe --- /dev/null +++ b/openless-all/app/linux-egui/src/recordings.rs @@ -0,0 +1,97 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +pub const MAX_RECORDING_BYTES: u64 = 1024 * 1024 * 1024; + +#[derive(Debug)] +pub enum RecordingError { + InvalidSession, + NotFound, + TooLarge, + InvalidWav, + Io(std::io::Error), +} + +impl fmt::Display for RecordingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSession => f.write_str("invalid recording session id"), + Self::NotFound => f.write_str("recording not found"), + Self::TooLarge => f.write_str("recording exceeds the size limit"), + Self::InvalidWav => f.write_str("recording is not canonical PCM WAV"), + Self::Io(error) => write!(f, "recording I/O failed: {error}"), + } + } +} + +impl std::error::Error for RecordingError {} + +pub fn recording_path(data_dir: &Path, session_id: &str) -> Result { + let parsed = uuid::Uuid::parse_str(session_id).map_err(|_| RecordingError::InvalidSession)?; + if parsed.to_string() != session_id { + return Err(RecordingError::InvalidSession); + } + Ok(data_dir + .join("recordings") + .join(format!("{session_id}.wav"))) +} + +pub fn read_recording_wav(data_dir: &Path, session_id: &str) -> Result, RecordingError> { + let path = recording_path(data_dir, session_id)?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + RecordingError::NotFound + } else { + RecordingError::Io(error) + } + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(RecordingError::InvalidWav); + } + if metadata.len() > MAX_RECORDING_BYTES { + return Err(RecordingError::TooLarge); + } + let wav = std::fs::read(path).map_err(RecordingError::Io)?; + recording_pcm(&wav)?; + Ok(wav) +} + +pub fn recording_pcm(wav: &[u8]) -> Result<&[u8], RecordingError> { + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || &wav[12..16] != b"fmt " + || u16::from_le_bytes([wav[20], wav[21]]) != 1 + || u16::from_le_bytes([wav[22], wav[23]]) != 1 + || u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]) != 16_000 + || u16::from_le_bytes([wav[34], wav[35]]) != 16 + || &wav[36..40] != b"data" + || !(wav.len() - 44).is_multiple_of(2) + { + return Err(RecordingError::InvalidWav); + } + let declared = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]) as usize; + if declared != wav.len() - 44 { + return Err(RecordingError::InvalidWav); + } + Ok(&wav[44..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_path_rejects_traversal_and_wav_contract_is_strict() { + assert!(recording_path(Path::new("/tmp/openless"), "../escape").is_err()); + let id = uuid::Uuid::new_v4().to_string(); + assert!(recording_path(Path::new("/tmp/openless"), &id) + .unwrap() + .ends_with(format!("{id}.wav"))); + let mut wav = crate::audio::wav_header(2).to_vec(); + wav.extend_from_slice(&[1, 0]); + assert_eq!(recording_pcm(&wav).unwrap(), [1, 0]); + wav[24] = 0; + assert!(recording_pcm(&wav).is_err()); + } +} diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index 8dced6273..8b980a234 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -209,13 +209,12 @@ use tokio_rustls::TlsAcceptor; #[cfg(target_os = "linux")] mod assets { - pub const INDEX_HTML: &str = - include_str!("../../src-tauri/src/remote_server/assets/index.html"); - pub const APP_JS: &str = include_str!("../../src-tauri/src/remote_server/assets/app.js"); - pub const STYLE_CSS: &str = include_str!("../../src-tauri/src/remote_server/assets/style.css"); - pub const ICON_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/icon.png"); - pub const MIC_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/mic.png"); - pub const DONE_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/done.png"); + pub const INDEX_HTML: &str = include_str!("../../assets/remote-input/index.html"); + pub const APP_JS: &str = include_str!("../../assets/remote-input/app.js"); + pub const STYLE_CSS: &str = include_str!("../../assets/remote-input/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("../../assets/remote-input/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("../../assets/remote-input/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("../../assets/remote-input/done.png"); } #[cfg(target_os = "linux")] diff --git a/openless-all/app/linux-egui/src/resources.rs b/openless-all/app/linux-egui/src/resources.rs index 7cb9c4bb1..ca14606af 100644 --- a/openless-all/app/linux-egui/src/resources.rs +++ b/openless-all/app/linux-egui/src/resources.rs @@ -4,7 +4,6 @@ use openless_core::{BackendError, BackendErrorCode, DirectoryResourceResolver, R pub const FCITX_PLUGIN_LIBRARY: &str = "linux-fcitx5-plugin/libopenless.so"; pub const FCITX_PLUGIN_CONFIG: &str = "linux-fcitx5-plugin/openless.conf"; -pub(crate) const QWEN_ASR_RUNTIME: &str = "qwen-asr/qwen_asr"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxPackageKind { @@ -89,29 +88,6 @@ impl ResourceResolver for LinuxResourceResolver { } } -pub(crate) fn qwen_runtime_path( - layout: &LinuxResourceLayout, - explicit: Option, -) -> Result { - if let Some(path) = explicit { - if !path.is_absolute() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "OPENLESS_QWEN_ASR_BIN must be an absolute path", - )); - } - return Ok(path); - } - layout.resolver()?.resolve(Path::new(QWEN_ASR_RUNTIME)) -} - -pub(crate) fn detect_qwen_runtime_path() -> Result { - qwen_runtime_path( - &LinuxResourceLayout::detect(None)?, - std::env::var_os("OPENLESS_QWEN_ASR_BIN").map(PathBuf::from), - ) -} - #[cfg(test)] mod tests { use super::*; @@ -142,28 +118,4 @@ mod tests { .resource_root .ends_with("bin/../lib/openless/resources")); } - - #[test] - fn qwen_runtime_uses_the_packaged_resource_or_an_absolute_dev_override() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::SystemPackage, - resource_root: PathBuf::from("/usr/lib/openless/resources"), - }; - - assert_eq!( - qwen_runtime_path(&layout, None).unwrap(), - PathBuf::from("/usr/lib/openless/resources/qwen-asr/qwen_asr") - ); - let override_path = std::env::temp_dir().join("qwen_asr"); - assert_eq!( - qwen_runtime_path(&layout, Some(override_path.clone())).unwrap(), - override_path - ); - assert_eq!( - qwen_runtime_path(&layout, Some(PathBuf::from("qwen_asr"))) - .unwrap_err() - .code, - BackendErrorCode::InvalidArgument - ); - } } diff --git a/openless-all/app/linux-egui/src/runtime.rs b/openless-all/app/linux-egui/src/runtime.rs index 164d09a03..01e8b0eb4 100644 --- a/openless-all/app/linux-egui/src/runtime.rs +++ b/openless-all/app/linux-egui/src/runtime.rs @@ -98,6 +98,15 @@ impl LinuxNativeRuntime { &self.host_actions } + /// Whether the fcitx5 global-hotkey listener came up. + /// + /// Tauri hides the 「快捷键」 settings section when the platform has no + /// desktop hotkey (`visibleSettingsSections(supportsDesktopHotkey)`); the + /// egui rail needs the same honest answer instead of always showing it. + pub fn hotkeys_available(&self) -> bool { + self.hotkeys.is_some() + } + pub fn drain_native_events( &self, ) -> ( diff --git a/openless-all/app/linux-egui/src/selection.rs b/openless-all/app/linux-egui/src/selection.rs index a564bd205..f7deb043f 100644 --- a/openless-all/app/linux-egui/src/selection.rs +++ b/openless-all/app/linux-egui/src/selection.rs @@ -117,6 +117,17 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { source_text: text.clone(), replacement_text: None, }); + // Honest `source_app`: fcitx5 exposes the surrounding text via + // DBus but gives the host no reliable foreground-application + // identity that holds across both X11 and Wayland clients, and + // the PRIMARY/clipboard cannot prove the original control is + // still the current focus. There is no IBus/global-hotkey + // fallback by design (Linux supports fcitx5 only), so we report + // the app as unknown instead of faking an identity. Post-insertion + // edit observation for streamed dictation is likewise unsupported + // on Linux: Core's Noop HostContextAdapter/EditObservationAdapter + // (the factory does not inject real ones) keep those use-cases on + // the explicit `Unsupported` path rather than simulating edits. Ok(SelectionCapture { text, source_app: None, diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 8081afd00..5172b4219 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -17,6 +17,13 @@ pub trait LinuxSettingsEffects: Send + Sync { fn apply_hotkeys(&self, target: &HotkeyRuntimeTarget) -> Result<(), BackendError>; fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError>; + + fn set_launch_at_login(&self, _enabled: bool) -> Result<(), BackendError> { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "launch-at-login is unavailable", + )) + } } /// Linux implementation of the shared settings transaction runtime. @@ -29,6 +36,7 @@ impl LinuxSettingsRuntime { pub fn new(credentials: LinuxCredentialStore) -> Self { Self::with_effects(Arc::new(Fcitx5SettingsEffects { credentials: Some(credentials), + autostart: production_autostart(), })) } @@ -38,46 +46,15 @@ impl LinuxSettingsRuntime { /// also injecting a matching `SettingsRuntime`. Active-provider changes then /// fail explicitly with `Unsupported` instead of silently diverging. pub fn hotkeys_only() -> Self { - Self::with_effects(Arc::new(Fcitx5SettingsEffects { credentials: None })) + Self::with_effects(Arc::new(Fcitx5SettingsEffects { + credentials: None, + autostart: production_autostart(), + })) } pub fn with_effects(effects: Arc) -> Self { Self { effects } } - - fn reject_unsupported_hotkey_changes(plan: &SettingsEffectPlan) -> Result<(), BackendError> { - let Some(change) = &plan.hotkeys else { - return Ok(()); - }; - let previous = &change.previous; - let next = &change.next; - let unsupported = [ - ( - previous.switch_style != next.switch_style, - "switch-style hotkey", - ), - (previous.open_app != next.open_app, "open-app hotkey"), - ( - previous.style_packs != next.style_packs, - "style-pack hotkeys", - ), - ]; - let names = unsupported - .into_iter() - .filter_map(|(changed, name)| changed.then_some(name)) - .collect::>(); - if names.is_empty() { - Ok(()) - } else { - Err(BackendError::new( - BackendErrorCode::Unsupported, - format!( - "Linux fcitx5 settings adapter does not support changing {}", - names.join(", ") - ), - )) - } - } } impl SettingsRuntime for LinuxSettingsRuntime { @@ -95,6 +72,12 @@ impl SettingsRuntime for LinuxSettingsRuntime { } let mut receipt = SettingsEffectReceipt::default(); + if let Some(change) = &plan.launch_at_login { + if let Err(error) = self.effects.set_launch_at_login(change.next) { + return Err(SettingsEffectFailure::after_side_effect(error, receipt)); + } + receipt.applied.push(SettingsEffectKind::LaunchAtLogin); + } if let Some(change) = &plan.active_asr_provider { if let Err(error) = self.effects.set_active_asr_provider(&change.next) { return Err(SettingsEffectFailure::after_side_effect(error, receipt)); @@ -109,8 +92,6 @@ impl SettingsRuntime for LinuxSettingsRuntime { plan: &SettingsEffectPlan, receipt: &mut SettingsEffectReceipt, ) -> Result<(), SettingsEffectFailure> { - Self::reject_unsupported_hotkey_changes(plan) - .map_err(SettingsEffectFailure::before_side_effect)?; let Some(change) = &plan.hotkeys else { return Ok(()); }; @@ -130,6 +111,11 @@ impl SettingsRuntime for LinuxSettingsRuntime { let mut failures = Vec::new(); for effect in receipt.applied.iter().rev() { let result = match effect { + SettingsEffectKind::LaunchAtLogin => plan + .launch_at_login + .as_ref() + .map(|change| self.effects.set_launch_at_login(change.previous)) + .unwrap_or(Ok(())), SettingsEffectKind::Hotkeys => plan .hotkeys .as_ref() @@ -162,27 +148,79 @@ impl SettingsRuntime for LinuxSettingsRuntime { struct Fcitx5SettingsEffects { credentials: Option, + autostart: Result, +} + +fn production_autostart() -> Result { + std::env::current_exe() + .map_err(|error| format!("resolve current executable for autostart: {error}")) + .and_then(|executable| { + crate::AutostartManager::detect(executable) + .map_err(|error| format!("initialize XDG autostart manager: {error}")) + }) } impl LinuxSettingsEffects for Fcitx5SettingsEffects { fn apply_hotkeys(&self, target: &HotkeyRuntimeTarget) -> Result<(), BackendError> { + // 一行摘要,用户可据此确认「到底注册了哪些键」;修饰键触发显示为 + // modifier(LeftControl) 这样的形态。 + log::info!( + "[fcitx] hotkey registration: {}", + registration_summary(target) + ); apply_dictation_hotkey(&target.dictation)?; + // 宿主只把热键注册给插件,**从不改动输入法自己的配置**。曾短暂加过 + // 「检测到引擎占用主键就去清空拼音的快速短语触发键」,实测证明那是假象: + // fcitx 的 `Key::check` 要求修饰位精确相等,分号与 `Ctrl+Shift+;` 并不 + // 冲突(见 tests/fcitx5_config_contract.rs 锁死的不变量)。 apply_action_hotkey("SetQaHotkeyRaw", target.qa.as_ref())?; apply_action_hotkey( "SetSelectionPolishHotkeyRaw", target.selection_polish.as_ref(), )?; apply_action_hotkey("SetTranslationHotkeyRaw", Some(&target.translation))?; + tolerate_optional_fcitx_method(apply_action_hotkey( + "SetSwitchStyleHotkeyRaw", + target.switch_style.as_ref(), + ))?; + tolerate_optional_fcitx_method(apply_action_hotkey( + "SetOpenAppHotkeyRaw", + target.open_app.as_ref(), + ))?; + let mut style_pack_hotkeys = Vec::with_capacity(target.style_packs.len()); + for hotkey in &target.style_packs { + let (symbol, states) = registerable_raw(&hotkey.binding)?; + // symbol 0 = 没有可注册的键(空绑定),不报给插件。 + if symbol == 0 { + continue; + } + style_pack_hotkeys.push((hotkey.pack_id.clone(), symbol, states)); + } + if style_pack_hotkeys.is_empty() { + log::info!("[fcitx] registered SetStylePackHotkeys 0 entries"); + } else { + for (pack_id, symbol, states) in &style_pack_hotkeys { + log::info!( + "[fcitx] registered SetStylePackHotkeys pack={} sym=0x{:x} states=0x{:x}", + pack_id, + symbol, + states + ); + } + } + tolerate_optional_fcitx_method(crate::fcitx5::set_style_pack_hotkeys(style_pack_hotkeys))?; let (symbol, states) = target .coding_agent_voice .as_ref() // The configured binding survives a disabled feature, but the // native hook must be removed until the user enables it again. .filter(|_| target.coding_agent_enabled) - .map(shortcut_to_raw) + .map(registerable_raw) .transpose()? .unwrap_or((0, 0)); - crate::fcitx5::set_less_computer_hotkey_raw(symbol, states) + let result = crate::fcitx5::set_less_computer_hotkey_raw(symbol, states); + log_registration_result("SetLessComputerHotkeyRaw", (symbol, states), &result); + tolerate_optional_fcitx_method(result) } fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError> { @@ -194,33 +232,192 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { }; credentials.set_active_provider_immediate(ProviderSlot::Asr, provider_id) } + + fn set_launch_at_login(&self, enabled: bool) -> Result<(), BackendError> { + let manager = self + .autostart + .as_ref() + .map_err(|message| BackendError::new(BackendErrorCode::Platform, message.clone()))?; + manager.set_enabled(enabled).map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("update XDG launch-at-login entry: {error}"), + ) + }) + } +} + +fn tolerate_optional_fcitx_method(result: Result<(), BackendError>) -> Result<(), BackendError> { + match result { + Err(error) + if error.message.contains("Unknown method") + || error.message.contains("UnknownMethod") => + { + log::warn!( + "[fcitx] running addon lacks an optional extended hotkey method; continuing with the legacy interface: {}", + error.message + ); + Ok(()) + } + result => result, + } } fn apply_dictation_hotkey(binding: &ShortcutBinding) -> Result<(), BackendError> { + // 「按住某个修饰键说话」是 Core 允许的形态(macOS 默认就是它)。 + // Linux 侧的限制不在注册,而在**吞键**:插件只对非修饰键 + // `filterAndAccept()`,按住期间若又按下别的键则判定为组合键并放弃触发 + // (见 hotkey_match.h 的 shouldConsume)。所以这里照常注册。 if let Some(trigger) = legacy_modifier_trigger(binding) { let symbol = modifier_trigger_keysym(trigger)?; - return crate::fcitx5::set_raw_hotkey("SetHotkeyRaw", symbol, 0); + let result = crate::fcitx5::set_raw_hotkey("SetHotkeyRaw", symbol, 0); + log_registration_result("SetHotkeyRaw", (symbol, 0), &result); + return result; } - crate::fcitx5::set_custom_dictation_trigger(&binding_to_fcitx_key(binding)) + let key = binding_to_fcitx_key(binding); + log::info!( + "[fcitx] registered SetCustomDictationTrigger key={} sym=0x{:x} states=0x{:x}", + key, + shortcut_to_raw(binding).map(|raw| raw.0).unwrap_or(0), + shortcut_to_raw(binding).map(|raw| raw.1).unwrap_or(0) + ); + crate::fcitx5::set_custom_dictation_trigger(&key) } fn apply_action_hotkey( method: &str, binding: Option<&ShortcutBinding>, ) -> Result<(), BackendError> { - let (symbol, states) = binding.map(shortcut_to_raw).transpose()?.unwrap_or((0, 0)); - crate::fcitx5::set_raw_hotkey(method, symbol, states) + let raw = binding.map(registerable_raw).transpose()?.unwrap_or((0, 0)); + let result = crate::fcitx5::set_raw_hotkey(method, raw.0, raw.1); + log_registration_result(method, raw, &result); + result +} + +/// Record the exact `(sym, states)` pair handed to the addon and whether the +/// call landed. A registration that silently failed (unknown method on an older +/// addon, bad arguments) used to be swallowed by +/// [`tolerate_optional_fcitx_method`], so "the hotkey does nothing" had no +/// visible cause anywhere — this line is that cause. +fn log_registration_result(method: &str, raw: (u32, u32), result: &Result<(), BackendError>) { + match result { + Ok(()) => log::info!( + "[fcitx] registered {} sym=0x{:x} states=0x{:x}", + method, + raw.0, + raw.1 + ), + Err(error) => log::warn!( + "[fcitx] registration FAILED {} sym=0x{:x} states=0x{:x}: {}", + method, + raw.0, + raw.1, + error.message + ), + } +} + +/// Convert a binding into the `(keysym, states)` pair fcitx5 should grab. +/// +/// Modifier-only bindings (`LeftControl`, `Shift`, …) are registered as such: +/// the plugin never consumes a modifier key, so "hold this key to talk" works +/// without taking the modifier away from every other application. +pub(crate) fn registerable_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { + shortcut_to_raw(binding) +} + +/// True for `primary` = a bare modifier with no other modifier held. Used by the +/// registration summary so a modifier trigger is visible as such in the log. +pub fn is_bare_modifier_binding(binding: &ShortcutBinding) -> bool { + if !binding.modifiers.is_empty() { + return false; + } + if legacy_modifier_trigger(binding).is_some() { + return true; + } + matches!( + binding.primary.trim().to_ascii_lowercase().as_str(), + "shift" + | "control" + | "ctrl" + | "alt" + | "option" + | "opt" + | "super" + | "meta" + | "win" + | "cmd" + | "command" + | "fn" + | "function" + | "mediaplaypause" + | "mediaplay" + | "playpause" + ) +} + +/// One-line summary of what the next [`apply_hotkeys`] will register. +pub(crate) fn registration_summary(target: &HotkeyRuntimeTarget) -> String { + fn describe(binding: Option<&ShortcutBinding>) -> String { + match binding { + None => "-".to_string(), + Some(binding) if is_bare_modifier_binding(binding) => { + // 修饰键触发:注册的是修饰键本身,插件按住期间不吞键。 + format!("modifier({})", binding.primary) + } + Some(binding) => { + let mut parts: Vec = binding + .modifiers + .iter() + .map(|modifier| normalize_modifier_tag(modifier)) + .filter(|tag| !tag.is_empty()) + .collect(); + parts.push(binding.primary.clone()); + parts.join("+") + } + } + } + format!( + "dictation={} qa={} selection_polish={} translation={} switch_style={} open_app={} style_packs={} less_computer={}", + describe(Some(&target.dictation)), + describe(target.qa.as_ref()), + describe(target.selection_polish.as_ref()), + describe(Some(&target.translation)), + describe(target.switch_style.as_ref()), + describe(target.open_app.as_ref()), + target.style_packs.len(), + if target.coding_agent_enabled { + describe(target.coding_agent_voice.as_ref()) + } else { + "disabled".to_string() + }, + ) +} + +/// Canonical lowercase tag for a modifier; unknown tags become empty so the +/// caller can drop them instead of producing a meaningless combination. +pub(crate) fn normalize_modifier_tag(modifier: &str) -> String { + match modifier.trim().to_ascii_lowercase().as_str() { + "ctrl" | "control" => "ctrl".to_string(), + "alt" | "option" | "opt" => "alt".to_string(), + "shift" => "shift".to_string(), + "cmd" | "command" | "super" | "meta" | "win" => "super".to_string(), + other => { + log::warn!("[fcitx] dropping unknown hotkey modifier '{other}'"); + String::new() + } + } } fn binding_to_fcitx_key(binding: &ShortcutBinding) -> String { let mut parts = Vec::new(); for modifier in &binding.modifiers { - let normalized = match modifier.trim().to_ascii_lowercase().as_str() { - "ctrl" | "control" => "Control".to_string(), - "alt" | "option" | "opt" => "Alt".to_string(), + let normalized = match normalize_modifier_tag(modifier).as_str() { + "ctrl" => "Control".to_string(), + "alt" => "Alt".to_string(), "shift" => "Shift".to_string(), - "cmd" | "command" | "super" | "meta" | "win" => "Super".to_string(), - other => other.to_string(), + "super" => "Super".to_string(), + _ => continue, }; if !parts.contains(&normalized) { parts.push(normalized); @@ -239,7 +436,7 @@ fn normalize_fcitx_primary(primary: &str) -> String { } } -fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { +pub(crate) fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { if let Some(trigger) = legacy_modifier_trigger(binding) { return Ok((modifier_trigger_keysym(trigger)?, 0)); } @@ -249,17 +446,14 @@ fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError let mut states = 0_u32; for modifier in &binding.modifiers { - states |= match modifier.trim().to_ascii_lowercase().as_str() { + // 未知修饰键只丢弃并告警:若直接返回 Err,整轮 apply_hotkeys 会中止, + // 后面的热键(含 QA)就全都注册不上——一个脏修饰键不该有这个后果。 + states |= match normalize_modifier_tag(modifier).as_str() { "shift" => 1, - "ctrl" | "control" => 4, - "alt" | "option" | "opt" => 8, - "cmd" | "command" | "super" | "meta" | "win" => 64, - other => { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - format!("fcitx5 does not support modifier {other}"), - )); - } + "ctrl" => 4, + "alt" => 8, + "super" => 64, + _ => 0, }; } let (symbol, implied_shift) = primary_keysym(&binding.primary)?; @@ -373,6 +567,102 @@ mod tests { ); } + #[test] + fn modifier_only_bindings_register_the_modifier_keysym() { + // 「按住某个修饰键说话」是 Core 允许的形态(macOS 默认就是它)。Linux + // 侧的限制从注册挪到了插件:只观察不吞键(hotkey_match.h::shouldConsume)。 + for (primary, keysym) in [ + ("LeftControl", 0xffe3_u32), + ("RightControl", 0xffe4), + ("LeftShift", 0xffe1), + ("RightShift", 0xffe2), + ("LeftAlt", 0xffe9), + ("LeftSuper", 0xffeb), + ] { + let binding = ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + }; + assert!( + is_bare_modifier_binding(&binding), + "{primary} must be recognised as modifier-only" + ); + assert_eq!( + registerable_raw(&binding).unwrap(), + (keysym, 0), + "{primary} must register its own keysym with no modifier bits" + ); + } + // Core 里 primary 就是 "shift" 的形态也照常注册。 + let shift = ShortcutBinding { + primary: "Shift".into(), + modifiers: Vec::new(), + }; + assert_eq!(registerable_raw(&shift).unwrap(), (0xffe1, 0)); + // 真实组合键照常注册。 + let qa = ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + assert!(!is_bare_modifier_binding(&qa)); + assert_eq!(registerable_raw(&qa).unwrap(), (b';' as u32, 5)); + // 有修饰位时 primary 是字母,绝不算裸修饰键。 + let dictation = ShortcutBinding { + primary: "A".into(), + modifiers: vec!["alt".into()], + }; + assert!(!is_bare_modifier_binding(&dictation)); + } + + #[test] + fn unknown_modifiers_are_dropped_instead_of_aborting() { + // "cmd"/"Command" 是 macOS 写法;"banana" 是彻底未知的脏值。 + let binding = ShortcutBinding { + primary: "Enter".into(), + modifiers: vec!["cmd".into(), "shift".into(), "banana".into()], + }; + assert_eq!(shortcut_to_raw(&binding).unwrap(), (0xff0d, 64 | 1)); + assert_eq!(binding_to_fcitx_key(&binding), "Super+Shift+enter"); + } + + #[test] + fn registration_summary_shows_modifier_triggers() { + let target = HotkeyRuntimeTarget { + dictation: ShortcutBinding { + primary: "A".into(), + modifiers: vec!["alt".into()], + }, + dictation_mode: openless_core::shared_types::HotkeyMode::Toggle, + qa: Some(ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }), + translation: ShortcutBinding { + primary: "Z".into(), + modifiers: vec!["alt".into()], + }, + switch_style: None, + open_app: None, + selection_polish: Some(ShortcutBinding { + primary: "X".into(), + modifiers: vec!["alt".into()], + }), + coding_agent_enabled: true, + coding_agent_voice: Some(ShortcutBinding { + primary: "LeftControl".into(), + modifiers: Vec::new(), + }), + style_packs: Vec::new(), + }; + let summary = registration_summary(&target); + assert!(summary.contains("dictation=alt+A"), "{summary}"); + assert!(summary.contains("qa=ctrl+shift+:"), "{summary}"); + assert!( + summary.contains("less_computer=modifier(LeftControl)"), + "{summary}" + ); + } + #[test] fn shifted_printable_uses_base_keysym_and_shift_state() { let shortcut = ShortcutBinding { @@ -381,4 +671,40 @@ mod tests { }; assert_eq!(shortcut_to_raw(&shortcut).unwrap(), (b'/' as u32, 5)); } + + #[test] + fn qa_default_binding_registers_the_base_key_with_ctrl_shift() { + // Core 的默认 QA 绑定写的是 ":",宿主必须把它折算成**物理键** `;`(0x3b) + // + Ctrl|Shift(0x5):插件侧按下时收到的是 level-applied 的 ':'(0x3a), + // 靠 hotkey_match.h 的 base/shifted 折叠才算命中。真机取证: + // registered SetQaHotkeyRaw sym=0x3b states=0x5 + let qa = ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + assert_eq!(shortcut_to_raw(&qa).unwrap(), (b';' as u32, 0x5)); + } + + #[test] + fn legacy_addon_may_omit_optional_extended_hotkey_methods() { + for message in [ + "Unknown method SetSwitchStyleHotkeyRaw", + "org.freedesktop.DBus.Error.UnknownMethod", + ] { + assert!(tolerate_optional_fcitx_method(Err(BackendError::new( + BackendErrorCode::Platform, + message, + ))) + .is_ok()); + } + } + + #[test] + fn optional_hotkey_compatibility_does_not_hide_other_failures() { + let error = BackendError::new(BackendErrorCode::Platform, "session bus unavailable"); + assert_eq!( + tolerate_optional_fcitx_method(Err(error.clone())).unwrap_err(), + error + ); + } } diff --git a/openless-all/app/linux-egui/src/tray.rs b/openless-all/app/linux-egui/src/tray.rs new file mode 100644 index 000000000..40ec96f8f --- /dev/null +++ b/openless-all/app/linux-egui/src/tray.rs @@ -0,0 +1,579 @@ +//! Freedesktop StatusNotifierItem tray integration without GTK or Tauri. +//! +//! The D-Bus worker owns no Core state. It emits typed commands that the egui +//! thread drains, and accepts menu snapshots for microphone checkmarks. A tray +//! is considered available only after the session bus name is owned and the +//! desktop watcher has acknowledged registration. + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use crate::{tr_l10n, Lang}; + +const ITEM_PATH: &str = "/StatusNotifierItem"; +const MENU_PATH: &str = "/MenuBar"; +const ITEM_INTERFACE: &str = "org.kde.StatusNotifierItem"; +const MENU_INTERFACE: &str = "com.canonical.dbusmenu"; +const WATCHER_NAME: &str = "org.kde.StatusNotifierWatcher"; +const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +const WATCHER_INTERFACE: &str = "org.kde.StatusNotifierWatcher"; +const DBUS_PROPERTIES: &str = "org.freedesktop.DBus.Properties"; +const DBUS_INTROSPECTABLE: &str = "org.freedesktop.DBus.Introspectable"; +const PROCESS_INTERVAL: Duration = Duration::from_millis(100); +const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(3); + +const SHOW_ID: i32 = 1; +const PREVIOUS_STYLE_ID: i32 = 2; +const MICROPHONES_ID: i32 = 3; +const SEPARATOR_ID: i32 = 4; +const QUIT_ID: i32 = 5; +const FIRST_MICROPHONE_ID: i32 = 100; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrayCommand { + ShowMain, + ActivatePreviousStyle, + SelectMicrophone(String), + Quit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrayMicrophone { + pub name: String, + pub is_default: bool, + pub selected: bool, +} + +#[derive(Debug)] +pub enum TrayError { + Dbus(String), + Worker(String), +} + +impl fmt::Display for TrayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dbus(message) => write!(f, "tray D-Bus initialization failed: {message}"), + Self::Worker(message) => write!(f, "tray worker failed: {message}"), + } + } +} + +impl std::error::Error for TrayError {} + +enum TrayControl { + SetMicrophones(Vec), + SetLang(Lang), + Shutdown, +} + +#[derive(Default)] +struct TrayMenuState { + revision: u32, + microphones: Vec, + lang: Option, +} + +impl TrayMenuState { + fn command_for_id(&self, id: i32) -> Option { + match id { + SHOW_ID => Some(TrayCommand::ShowMain), + PREVIOUS_STYLE_ID => Some(TrayCommand::ActivatePreviousStyle), + QUIT_ID => Some(TrayCommand::Quit), + FIRST_MICROPHONE_ID => Some(TrayCommand::SelectMicrophone(String::new())), + id if id > FIRST_MICROPHONE_ID => self + .microphones + .get((id - FIRST_MICROPHONE_ID - 1) as usize) + .map(|device| TrayCommand::SelectMicrophone(device.name.clone())), + _ => None, + } + } +} + +pub struct LinuxTray { + commands: mpsc::Receiver, + control: mpsc::Sender, + last_error: Arc>>, + shutdown: Arc, + worker: Option>, +} + +impl LinuxTray { + /// Start and register a StatusNotifierItem. `Ok` means the desktop watcher + /// accepted it; callers may then truthfully expose `supports_tray=true`. + pub fn start() -> Result { + #[cfg(target_os = "linux")] + { + let (command_tx, command_rx) = mpsc::channel(); + let (control_tx, control_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let last_error = Arc::new(Mutex::new(None)); + let shutdown = Arc::new(AtomicBool::new(false)); + let worker_error = Arc::clone(&last_error); + let worker_shutdown = Arc::clone(&shutdown); + let worker = std::thread::Builder::new() + .name("openless-tray".into()) + .spawn(move || { + let result = run_dbus_worker(command_tx, control_rx, worker_shutdown, ready_tx); + if let Err(error) = result { + *worker_error.lock().expect("tray error lock poisoned") = + Some(error.to_string()); + } + }) + .map_err(|error| TrayError::Worker(error.to_string()))?; + + match ready_rx.recv_timeout(REGISTRATION_TIMEOUT) { + Ok(Ok(())) => Ok(Self { + commands: command_rx, + control: control_tx, + last_error, + shutdown, + worker: Some(worker), + }), + Ok(Err(error)) => { + shutdown.store(true, Ordering::Release); + let _ = worker.join(); + Err(error) + } + Err(error) => { + shutdown.store(true, Ordering::Release); + let _ = worker.join(); + Err(TrayError::Worker(format!( + "timed out waiting for tray registration: {error}" + ))) + } + } + } + #[cfg(not(target_os = "linux"))] + { + Err(TrayError::Worker( + "StatusNotifierItem is available only on Linux".into(), + )) + } + } + + pub fn drain(&self, mut apply: impl FnMut(TrayCommand)) -> usize { + let mut count = 0; + while let Ok(command) = self.commands.try_recv() { + count += 1; + apply(command); + } + count + } + + pub fn set_microphones(&self, microphones: Vec) -> Result<(), TrayError> { + self.control + .send(TrayControl::SetMicrophones(microphones)) + .map_err(|_| TrayError::Worker("tray worker has stopped".into())) + } + + /// Set the UI language used for the tray menu labels. Callers should keep + /// this in sync with the persisted Linux-UI locale preference whenever it + /// changes so a system tray re-layout reads the right language. + pub fn set_lang(&self, lang: Lang) -> Result<(), TrayError> { + self.control + .send(TrayControl::SetLang(lang)) + .map_err(|_| TrayError::Worker("tray worker has stopped".into())) + } + + pub fn take_error(&self) -> Option { + self.last_error + .lock() + .expect("tray error lock poisoned") + .take() + } +} + +impl Drop for LinuxTray { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + let _ = self.control.send(TrayControl::Shutdown); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(target_os = "linux")] +fn run_dbus_worker( + command_tx: mpsc::Sender, + control_rx: mpsc::Receiver, + shutdown: Arc, + ready: mpsc::SyncSender>, +) -> Result<(), TrayError> { + use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply; + use dbus::blocking::Connection; + use dbus::channel::{MatchingReceiver, Sender}; + use dbus::message::MatchRule; + + let connection = Connection::new_session().map_err(dbus_error)?; + let service_name = format!("org.kde.StatusNotifierItem-{}-1", std::process::id()); + let ownership = connection + .request_name(&service_name, false, true, true) + .map_err(dbus_error)?; + if ownership != RequestNameReply::PrimaryOwner { + let error = TrayError::Dbus(format!("D-Bus name {service_name} is already owned")); + let _ = ready.send(Err(TrayError::Dbus(error.to_string()))); + return Err(error); + } + + let menu = Arc::new(Mutex::new(TrayMenuState::default())); + let callback_menu = Arc::clone(&menu); + let callback_commands = command_tx; + connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |message, connection| { + if let Some(reply) = handle_method_call(&message, &callback_menu, &callback_commands) { + let _ = connection.send(reply); + } + true + }), + ); + + let watcher = connection.with_proxy(WATCHER_NAME, WATCHER_PATH, REGISTRATION_TIMEOUT); + let registration: Result<(), dbus::Error> = watcher.method_call( + WATCHER_INTERFACE, + "RegisterStatusNotifierItem", + (service_name.as_str(),), + ); + if let Err(error) = registration { + let error = TrayError::Dbus(format!( + "StatusNotifierWatcher rejected registration: {error}" + )); + let _ = ready.send(Err(TrayError::Dbus(error.to_string()))); + return Err(error); + } + let _ = ready.send(Ok(())); + + while !shutdown.load(Ordering::Acquire) { + while let Ok(control) = control_rx.try_recv() { + match control { + TrayControl::SetMicrophones(microphones) => { + let revision = { + let mut state = menu.lock().expect("tray menu lock poisoned"); + state.microphones = microphones; + state.revision = state.revision.wrapping_add(1).max(1); + state.revision + }; + let signal = + dbus::Message::new_signal(MENU_PATH, MENU_INTERFACE, "LayoutUpdated") + .map_err(TrayError::Dbus)? + .append2(revision, 0i32); + connection.send(signal).map_err(|_| { + TrayError::Dbus("failed to publish tray menu update".into()) + })?; + } + TrayControl::SetLang(lang) => { + let revision = { + let mut state = menu.lock().expect("tray menu lock poisoned"); + state.lang = Some(lang); + state.revision = state.revision.wrapping_add(1).max(1); + state.revision + }; + let signal = + dbus::Message::new_signal(MENU_PATH, MENU_INTERFACE, "LayoutUpdated") + .map_err(TrayError::Dbus)? + .append2(revision, 0i32); + connection.send(signal).map_err(|_| { + TrayError::Dbus("failed to publish tray menu update".into()) + })?; + } + TrayControl::Shutdown => return Ok(()), + } + } + connection.process(PROCESS_INTERVAL).map_err(dbus_error)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn dbus_error(error: dbus::Error) -> TrayError { + TrayError::Dbus(error.to_string()) +} + +#[cfg(target_os = "linux")] +type Properties = HashMap>>; + +#[cfg(target_os = "linux")] +type Children = Vec>>; + +#[cfg(target_os = "linux")] +fn property( + value: T, +) -> dbus::arg::Variant> { + dbus::arg::Variant(Box::new(value)) +} + +#[cfg(target_os = "linux")] +fn menu_properties(label: &str) -> Properties { + HashMap::from([ + ("label".into(), property(label.to_string())), + ("enabled".into(), property(true)), + ("visible".into(), property(true)), + ]) +} + +#[cfg(target_os = "linux")] +fn menu_item( + id: i32, + properties: Properties, + children: Children, +) -> dbus::arg::Variant> { + property((id, properties, children)) +} + +#[cfg(target_os = "linux")] +fn menu_layout(state: &TrayMenuState) -> (i32, Properties, Children) { + let lang = state.lang.unwrap_or(Lang::ZhCn); + let mut microphone_children = Vec::new(); + let default_selected = state.microphones.iter().all(|device| !device.selected); + let mut default_props = menu_properties(tr_l10n(lang, "settings.system_default")); + default_props.insert("toggle-type".into(), property("checkmark".to_string())); + default_props.insert("toggle-state".into(), property(i32::from(default_selected))); + microphone_children.push(menu_item(FIRST_MICROPHONE_ID, default_props, Vec::new())); + for (index, device) in state.microphones.iter().enumerate() { + let mut props = menu_properties(&device.name); + props.insert("toggle-type".into(), property("checkmark".to_string())); + props.insert("toggle-state".into(), property(i32::from(device.selected))); + if device.is_default { + props.insert("x-openless-default".into(), property(true)); + } + microphone_children.push(menu_item( + FIRST_MICROPHONE_ID + index as i32 + 1, + props, + Vec::new(), + )); + } + let mut microphone_props = menu_properties(tr_l10n(lang, "settings.microphone")); + microphone_props.insert("children-display".into(), property("submenu".to_string())); + let separator = HashMap::from([("type".into(), property("separator".to_string()))]); + ( + 0, + HashMap::new(), + vec![ + menu_item( + SHOW_ID, + menu_properties(tr_l10n(lang, "tray.show")), + Vec::new(), + ), + menu_item( + PREVIOUS_STYLE_ID, + menu_properties(tr_l10n(lang, "tray.previous_style")), + Vec::new(), + ), + menu_item(MICROPHONES_ID, microphone_props, microphone_children), + menu_item(SEPARATOR_ID, separator, Vec::new()), + menu_item( + QUIT_ID, + menu_properties(tr_l10n(lang, "tray.quit")), + Vec::new(), + ), + ], + ) +} + +#[cfg(target_os = "linux")] +fn handle_method_call( + message: &dbus::Message, + menu: &Arc>, + commands: &mpsc::Sender, +) -> Option { + use dbus::arg::Variant; + + let path = message.path()?.to_string(); + let interface = message.interface()?.to_string(); + let member = message.member()?.to_string(); + + if interface == DBUS_INTROSPECTABLE && member == "Introspect" { + return Some(message.method_return().append1(INTROSPECTION_XML)); + } + if path == ITEM_PATH && interface == ITEM_INTERFACE { + if member == "Activate" || member == "SecondaryActivate" { + let _ = commands.send(TrayCommand::ShowMain); + } + return Some(message.method_return()); + } + if path == MENU_PATH && interface == MENU_INTERFACE { + match member.as_str() { + "GetLayout" => { + let state = menu.lock().expect("tray menu lock poisoned"); + return Some( + message + .method_return() + .append2(state.revision, menu_layout(&state)), + ); + } + "GetGroupProperties" => { + let entries: Vec<(i32, Properties)> = Vec::new(); + return Some(message.method_return().append1(entries)); + } + "Event" => { + if let Ok((id, event, _data, _timestamp)) = + message.read4::>, u32>() + { + if event == "clicked" { + if let Some(command) = menu + .lock() + .expect("tray menu lock poisoned") + .command_for_id(id) + { + let _ = commands.send(command); + } + } + } + return Some(message.method_return()); + } + "AboutToShow" => return Some(message.method_return().append1(false)), + _ => return Some(message.method_return()), + } + } + if interface == DBUS_PROPERTIES && member == "Get" { + if let Ok((requested_interface, name)) = message.read2::() { + let value = item_property(&requested_interface, &name) + .or_else(|| menu_property(&requested_interface, &name)); + if let Some(value) = value { + return Some(message.method_return().append1(value)); + } + } + } + if interface == DBUS_PROPERTIES && member == "GetAll" { + let requested_interface = message.read1::().unwrap_or_default(); + let properties = if requested_interface == ITEM_INTERFACE { + item_properties() + } else if requested_interface == MENU_INTERFACE { + menu_properties_all() + } else { + HashMap::new() + }; + return Some(message.method_return().append1(properties)); + } + dbus::channel::default_reply(message) +} + +#[cfg(target_os = "linux")] +fn item_property( + interface: &str, + name: &str, +) -> Option>> { + if interface != ITEM_INTERFACE { + return None; + } + match name { + "Category" => Some(property("ApplicationStatus".to_string())), + "Id" => Some(property("openless".to_string())), + "Title" => Some(property("OpenLess".to_string())), + "Status" => Some(property("Active".to_string())), + "IconName" => Some(property("openless".to_string())), + "Menu" => Some(property( + dbus::Path::new(MENU_PATH).expect("static D-Bus path"), + )), + "ItemIsMenu" => Some(property(false)), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn item_properties() -> Properties { + [ + "Category", + "Id", + "Title", + "Status", + "IconName", + "Menu", + "ItemIsMenu", + ] + .into_iter() + .filter_map(|name| item_property(ITEM_INTERFACE, name).map(|value| (name.into(), value))) + .collect() +} + +#[cfg(target_os = "linux")] +fn menu_property( + interface: &str, + name: &str, +) -> Option>> { + if interface != MENU_INTERFACE { + return None; + } + match name { + "Version" => Some(property(3u32)), + "TextDirection" => Some(property("ltr".to_string())), + "Status" => Some(property("normal".to_string())), + "IconThemePath" => Some(property(Vec::::new())), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn menu_properties_all() -> Properties { + ["Version", "TextDirection", "Status", "IconThemePath"] + .into_iter() + .filter_map(|name| menu_property(MENU_INTERFACE, name).map(|value| (name.into(), value))) + .collect() +} + +#[cfg(target_os = "linux")] +const INTROSPECTION_XML: &str = r#" + + + + + + + + + + + +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn menu_ids_map_only_to_supported_commands() { + let state = TrayMenuState { + revision: 1, + microphones: vec![TrayMicrophone { + name: "Studio Mic".into(), + is_default: true, + selected: true, + }], + lang: None, + }; + assert_eq!(state.command_for_id(SHOW_ID), Some(TrayCommand::ShowMain)); + assert_eq!( + state.command_for_id(PREVIOUS_STYLE_ID), + Some(TrayCommand::ActivatePreviousStyle) + ); + assert_eq!(state.command_for_id(QUIT_ID), Some(TrayCommand::Quit)); + assert_eq!( + state.command_for_id(FIRST_MICROPHONE_ID + 1), + Some(TrayCommand::SelectMicrophone("Studio Mic".into())) + ); + assert_eq!(state.command_for_id(MICROPHONES_ID), None); + assert_eq!( + state.command_for_id(FIRST_MICROPHONE_ID), + Some(TrayCommand::SelectMicrophone(String::new())) + ); + assert_eq!(state.command_for_id(999), None); + } + + #[test] + fn tray_capability_is_not_inferred_from_the_desktop_environment() { + let snapshot = crate::LinuxCapabilitySnapshot::from_environment( + None, + Some(":0"), + true, + false, + crate::LinuxPackageKind::Development, + ); + assert!(!snapshot.capabilities.supports_tray); + } +} diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs new file mode 100644 index 000000000..8cc9c5aa5 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -0,0 +1,610 @@ +//! 宿主进程 ⇄ UI 窗口进程之间的 IPC。 +//! +//! 形态:**常驻无窗口宿主**(后端 / 数据 / 单实例锁 / 热键 / 托盘 / 弹窗)与 +//! **可随时开关的 UI 窗口进程**(纯渲染,绝不碰后端与数据目录)。关窗只是窗口 +//! 进程退出(任务栏条目随之消失),宿主与后端状态原地不动。 +//! +//! 时序契约(两端都必须守): +//! 1. 宿主先抢到单实例锁、起好托盘与热键,**然后**才 bind 本 socket; +//! 2. socket 就绪后才拉 UI 进程 —— UI 必须连上宿主才渲染任何数据; +//! 3. 快照带单调递增 `sequence`,UI 只接受更大的序号,旧包直接丢,绝不回退; +//! 4. UI 断连(EOF)后宿主立即作废窗口句柄,但会话/录音/弹窗不受影响; +//! 5. 宿主退出时先给 UI 发 `Shutdown`,再释放单实例锁。 +//! +//! 帧格式与弹窗协议一致:一行一个 JSON 对象,便于用既有工具排查。 + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread::JoinHandle; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::frontend::view_model::{FrontendAction, FrontendViewModel}; + +/// 协议版本:宿主与 UI 进程对不上就直接拒绝启动 UI(避免半懂不懂地渲染)。 +pub const UI_BRIDGE_VERSION: u32 = 1; + +/// 单帧上限。视图模型快照含历史列表,比弹窗协议大得多。 +const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// UI 进程等待宿主 socket 出现的上限(宿主 bind 后才拉它,正常是毫秒级)。 +pub const UI_CLIENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HostToWindow { + /// 握手确认:UI 收到它才算真的接上,此前不渲染任何业务数据。 + Ready { version: u32 }, + /// 完整视图模型快照;`sequence` 单调递增。 + Snapshot { + sequence: u64, + view_model: Box, + }, + /// 延迟探针回包。 + Pong { sequence: u64 }, + /// 宿主退出,UI 自行关窗退出。 + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WindowToHost { + /// UI 已连上,报告自己的协议版本。 + Hello { version: u32 }, + /// 用户动作,按发送顺序处理。 + Action { + sequence: u64, + action: FrontendAction, + }, + /// 延迟探针。 + Ping { sequence: u64 }, + /// UI 正常退出前的告别(宿主据此立即作废句柄,不必等 EOF)。 + Bye, +} + +/// 宿主与 UI 进程约定的 socket 路径(放在 XDG_RUNTIME_DIR 下)。 +pub fn ui_socket_path(runtime_dir: &Path) -> PathBuf { + runtime_dir.join("openless-ui.sock") +} + +/// 写一帧 JSONL。 +pub fn write_frame(writer: &mut impl Write, frame: &T) -> std::io::Result<()> { + let mut encoded = serde_json::to_vec(frame) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("UI bridge frame is {} bytes", encoded.len()), + )); + } + encoded.push(b'\n'); + writer.write_all(&encoded)?; + writer.flush() +} + +/// 读一帧 JSONL;EOF 时返回 `Ok(None)`,便于把「对方退出」与「帧损坏」分开。 +pub fn read_frame Deserialize<'de>>( + reader: &mut impl BufRead, +) -> std::io::Result> { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if read > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "UI bridge frame too large", + )); + } + let frame = serde_json::from_str(line.trim_end()) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + Ok(Some(frame)) +} + +/// 一个已连上的 UI 窗口。读线程负责 `incoming`,写线程负责 `outgoing`。 +struct UiConnection { + incoming: Receiver, + outgoing: Sender, + reader: Option>, + writer: Option>, + /// socket 的一份副本:收尾时 `shutdown(Both)` 才能把阻塞在 read 上的 + /// 读线程叫醒,否则 join 会一直等下去。 + control: UnixStream, +} + +enum Outgoing { + /// 快照会合并:排在后面的覆盖前面还没写出去的,避免 UI 卡顿时堆一堆过期状态。 + Frame(HostToWindow), + /// UI 进程发往宿主的帧(动作、探针、告别)。 + ClientFrame(WindowToHost), + /// 已编码的快照帧(宿主为了算指纹已经序列化过,避免重复序列化)。 + Encoded(Vec), + Stop, +} + +impl Outgoing { + fn is_snapshot(&self) -> bool { + matches!( + self, + Outgoing::Frame(HostToWindow::Snapshot { .. }) | Outgoing::Encoded(_) + ) + } + + fn is_stop(&self) -> bool { + matches!(self, Outgoing::Stop) + } + + fn write_to(&self, stream: &mut UnixStream) -> std::io::Result<()> { + match self { + Outgoing::Stop => Ok(()), + Outgoing::Frame(frame) => write_frame(stream, frame), + Outgoing::ClientFrame(frame) => write_frame(stream, frame), + Outgoing::Encoded(bytes) => { + stream.write_all(bytes)?; + stream.write_all(b"\n")?; + stream.flush() + } + } + } +} + +/// 宿主侧桥:监听 ≤1 个 UI 窗口连接,并暴露「发快照 / 收动作」的最小接口。 +pub struct UiBridgeHost { + listener: UnixListener, + path: PathBuf, + connection: Option, + /// 下一个要发出的快照序号(单调递增;UI 侧拒收更小的序号)。 + next_sequence: u64, + /// 上一个 UI 连接的整体标识,用于日志。 + connection_generation: u64, +} + +impl UiBridgeHost { + /// bind 监听 socket。必须在抢到单实例锁、起好托盘/热键之后、拉 UI 进程之前调用。 + pub fn bind(path: PathBuf) -> std::io::Result { + // 上一次异常退出可能留下 socket 文件;监听前先清掉,否则 bind 会 EADDRINUSE。 + match std::fs::remove_file(&path) { + Ok(()) => log::info!("[ui-host] removed stale UI bridge socket"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!("[ui-host] stale socket not removable: {error}"), + } + let listener = UnixListener::bind(&path)?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + path, + connection: None, + next_sequence: 1, + connection_generation: 0, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// 收下新连接(若有)。同一时刻只服务一个窗口;重复连接时保留先到的那个。 + pub fn accept_pending(&mut self) { + if self.connection.is_some() { + return; + } + match self.listener.accept() { + Ok((stream, _)) => { + self.connection_generation += 1; + let generation = self.connection_generation; + log::info!("[ui-host] UI window connected (generation {generation})"); + match spawn_connection(stream) { + Ok(connection) => self.connection = Some(connection), + Err(error) => { + log::warn!("[ui-host] UI window connection setup failed: {error}") + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => log::warn!("[ui-host] accept failed: {error}"), + } + } + + pub fn is_connected(&self) -> bool { + self.connection.is_some() + } + + /// 取走 UI 发来的消息(非阻塞,保持到达顺序)。 + pub fn drain(&mut self) -> Vec { + let mut messages = Vec::new(); + let mut disconnected = false; + if let Some(connection) = self.connection.as_ref() { + loop { + match connection.incoming.try_recv() { + Ok(message) => messages.push(message), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + if disconnected { + log::info!("[ui-host] UI window disconnected"); + self.connection = None; + } + messages + } + + /// 发送快照,`payload` 必须是 `view_model` 的 JSON 编码。 + /// + /// 宿主为了判断「视图模型变没变」已经序列化过一次,这里直接拼帧、不再二次 + /// 序列化;`sequence` 由桥推进,保证单调递增。 + pub fn send_snapshot_encoded(&mut self, payload: &[u8]) { + let sequence = self.next_sequence; + self.next_sequence += 1; + let mut frame = Vec::with_capacity(payload.len() + 64); + frame.extend_from_slice(b"{\"Snapshot\":{\"sequence\":"); + frame.extend_from_slice(sequence.to_string().as_bytes()); + frame.extend_from_slice(b",\"view_model\":"); + frame.extend_from_slice(payload); + frame.extend_from_slice(b"}}"); + if let Some(connection) = self.connection.as_ref() { + if connection.outgoing.send(Outgoing::Encoded(frame)).is_err() { + self.connection = None; + } + } + } + + pub fn send(&mut self, frame: HostToWindow) { + if let Some(connection) = self.connection.as_ref() { + if connection.outgoing.send(Outgoing::Frame(frame)).is_err() { + self.connection = None; + } + } + } + + /// 宿主退出前的收尾:通知 UI 关窗,然后断开。 + pub fn shutdown(&mut self) { + if let Some(connection) = self.connection.take() { + let _ = connection + .outgoing + .send(Outgoing::Frame(HostToWindow::Shutdown)); + // 给写线程一点时间把 Shutdown 交给 socket,再让它收尾。 + std::thread::sleep(Duration::from_millis(50)); + let _ = connection.outgoing.send(Outgoing::Stop); + // 先把 socket 关掉:读线程阻塞在 read 上,只有关闭才能让它退出。 + let _ = connection.control.shutdown(std::net::Shutdown::Both); + if let Some(writer) = connection.writer { + let _ = writer.join(); + } + if let Some(reader) = connection.reader { + let _ = reader.join(); + } + } + let _ = std::fs::remove_file(&self.path); + } +} + +fn spawn_connection(stream: UnixStream) -> std::io::Result { + let reader_stream = stream.try_clone()?; + let control = stream.try_clone()?; + let (incoming_tx, incoming_rx) = mpsc::channel(); + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + + let reader = std::thread::Builder::new() + .name("openless-ui-host-reader".into()) + .spawn(move || { + let mut reader = BufReader::new(reader_stream); + loop { + match read_frame::(&mut reader) { + Ok(Some(frame)) => { + if incoming_tx.send(frame).is_err() { + break; + } + } + // EOF:UI 进程退出(正常关闭或被杀)。 + Ok(None) => break, + Err(error) => { + log::warn!("[ui-host] UI bridge frame error: {error}"); + break; + } + } + } + })?; + + let writer = std::thread::Builder::new() + .name("openless-ui-host-writer".into()) + .spawn(move || { + let mut stream = stream; + // 待发快照只保留最新一份:UI 慢的时候宁可跳帧,也不能画过期状态。 + let mut pending: Option = None; + loop { + let received = outgoing_rx.recv_timeout(Duration::from_millis(20)); + match received { + Ok(stop) if stop.is_stop() => { + if let Some(stale) = pending.take() { + let _ = stale.write_to(&mut stream); + } + return; + } + Ok(outgoing) if outgoing.is_snapshot() => { + // 快照可合并:只留最新一份,UI 慢时宁可跳帧也不画过期状态。 + pending = Some(outgoing); + } + Ok(outgoing) => { + // 控制帧(Ready/Pong/Shutdown)必须先于任何待发快照落盘。 + if let Some(stale) = pending.take() { + if stale.write_to(&mut stream).is_err() { + return; + } + } + if outgoing.write_to(&mut stream).is_err() { + return; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Some(stale) = pending.take() { + if stale.write_to(&mut stream).is_err() { + return; + } + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } + })?; + + Ok(UiConnection { + incoming: incoming_rx, + outgoing: outgoing_tx, + reader: Some(reader), + writer: Some(writer), + control, + }) +} + +/// UI 进程侧连接:连上宿主、收快照、发动作。 +pub struct UiBridgeClient { + incoming: Receiver, + outgoing: Sender, + reader: Option>, + writer: Option>, + /// 见 `UiConnection::control`:收尾要用它叫醒读线程。 + control: UnixStream, + consecutive_failures: u32, +} + +impl UiBridgeClient { + /// 连宿主的 socket。宿主 bind 之后才拉 UI,所以这里通常一次就成; + /// 仍做重试以覆盖「宿主刚 bind 就被调度器换出」的竞态。 + pub fn connect(path: &Path) -> Result { + let deadline = std::time::Instant::now() + UI_CLIENT_CONNECT_TIMEOUT; + let stream = loop { + match UnixStream::connect(path) { + Ok(stream) => break stream, + Err(error) => { + if std::time::Instant::now() >= deadline { + return Err(format!("UI bridge connect failed: {error}")); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + }; + let reader_stream = stream + .try_clone() + .map_err(|error| format!("UI bridge clone failed: {error}"))?; + let control = stream + .try_clone() + .map_err(|error| format!("UI bridge clone failed: {error}"))?; + let (incoming_tx, incoming_rx) = mpsc::channel(); + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + let reader = std::thread::Builder::new() + .name("openless-ui-client-reader".into()) + .spawn(move || { + let mut reader = BufReader::new(reader_stream); + loop { + match read_frame::(&mut reader) { + Ok(Some(frame)) => { + if incoming_tx.send(frame).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + log::warn!("[ui-client] host frame error: {error}"); + break; + } + } + } + }) + .map_err(|error| format!("UI bridge reader thread failed: {error}"))?; + let writer = std::thread::Builder::new() + .name("openless-ui-client-writer".into()) + .spawn(move || { + let mut stream = stream; + while let Ok(outgoing) = outgoing_rx.recv() { + if outgoing.is_snapshot() { + continue; + } + match outgoing { + Outgoing::Stop => return, + other => { + if other.write_to(&mut stream).is_err() { + return; + } + } + } + } + }) + .map_err(|error| format!("UI bridge writer thread failed: {error}"))?; + Ok(Self { + incoming: incoming_rx, + outgoing: outgoing_tx, + reader: Some(reader), + writer: Some(writer), + control, + consecutive_failures: 0, + }) + } + + pub fn try_recv(&self) -> Result { + self.incoming.try_recv() + } + + /// 发一帧;失败累计到阈值就报错,让 UI 进程知道宿主已经走了。 + pub fn send(&mut self, frame: WindowToHost) -> Result<(), String> { + match self.outgoing.send(Outgoing::ClientFrame(frame)) { + Ok(()) => { + self.consecutive_failures = 0; + Ok(()) + } + Err(_) => { + self.consecutive_failures += 1; + Err("UI bridge is closed".to_string()) + } + } + } + + /// 退出前收尾:停掉读写线程。 + pub fn shutdown(&mut self) { + let _ = self.outgoing.send(Outgoing::Stop); + std::thread::sleep(Duration::from_millis(20)); + // 关 socket 才能让阻塞在 read 上的读线程退出。 + let _ = self.control.shutdown(std::net::Shutdown::Both); + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::Page; + + /// 轮询等待一个非空结果(跨线程的帧到达有延迟,不能在测试里假设「立刻」)。 + fn wait_for(mut poll: impl FnMut() -> Option) -> T { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if let Some(value) = poll() { + return value; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for a frame" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("openless-ui-bridge-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn frames_round_trip_over_the_socket() { + let dir = temp_dir("roundtrip"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + assert!(host.is_connected()); + + client + .send(WindowToHost::Hello { + version: UI_BRIDGE_VERSION, + }) + .unwrap(); + // 帧要经写线程落到 socket、再经读线程回到宿主,所以轮询等一小会儿。 + let received = wait_for(|| { + let messages = host.drain(); + if messages.is_empty() { + None + } else { + Some(messages) + } + }); + assert!(matches!(received.as_slice(), [WindowToHost::Hello { .. }])); + + let mut view_model = FrontendViewModel::default(); + view_model.active_page = Page::History; + let payload = serde_json::to_vec(&view_model).unwrap(); + host.send_snapshot_encoded(&payload); + let frame = loop { + match client.try_recv() { + Ok(frame) => break frame, + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), + Err(TryRecvError::Disconnected) => panic!("client disconnected early"), + } + }; + match frame { + HostToWindow::Snapshot { + sequence, + view_model, + } => { + assert_eq!(sequence, 1, "first snapshot is sequence 1"); + assert_eq!(view_model.active_page, Page::History); + } + other => panic!("unexpected frame {other:?}"), + } + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn snapshots_are_monotonic_and_stale_frames_are_dropped() { + let dir = temp_dir("monotonic"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + for _ in 0..3 { + let payload = serde_json::to_vec(&FrontendViewModel::default()).unwrap(); + host.send_snapshot_encoded(&payload); + } + let mut sequences = Vec::new(); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + // 连发三份快照:写线程可能合并掉中间那些(UI 卡顿时宁可跳帧), + // 但**到达 UI 的序号必须严格递增**,而且最后一份必然是最新的。 + while std::time::Instant::now() < deadline { + match client.try_recv() { + Ok(HostToWindow::Snapshot { sequence, .. }) => sequences.push(sequence), + Ok(_) => {} + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), + Err(TryRecvError::Disconnected) => break, + } + if sequences.last().copied() == Some(3) { + break; + } + } + assert!( + !sequences.is_empty(), + "at least the newest snapshot arrives" + ); + assert_eq!(sequences.last().copied(), Some(3)); + assert!( + sequences.windows(2).all(|pair| pair[0] < pair[1]), + "snapshot sequences must never go backwards: {sequences:?}" + ); + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_dead_bridge_socket_is_replaced_on_bind() { + let dir = temp_dir("stale"); + let path = ui_socket_path(&dir); + std::fs::write(&path, b"junk").unwrap(); + let host = UiBridgeHost::bind(path.clone()).unwrap(); + assert_eq!(host.path(), path.as_path()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs new file mode 100644 index 000000000..d18e40616 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs @@ -0,0 +1,224 @@ +//! Correction rules (纠错规则) page — port of the Tauri `pages/Corrections.tsx`. +//! +//! Fixes common ASR misrecognitions: `pattern → replacement`, with one `{num}` +//! digit wildcard. Rules collected automatically from user edits are badged and +//! can be reviewed or removed like any other rule. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::layout; +use super::pages::correction_chip; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const GAP: f32 = 14.0; +const CARD_PADDING: f32 = 20.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.vocab_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "nav.corrections")); + return; + } + + layout::page_header( + ui, + width, + tr_l10n(lang, "nav.corrections"), + tr_l10n(lang, "vocab.corrections_title"), + Some(tr_l10n(lang, "vocab.corrections_tip")), + ); + ui.add_space(GAP); + + // ── Add a rule ────────────────────────────────────────────────────────── + card(ui, width, |ui| { + ui.horizontal(|ui| { + let add_width = 72.0; + let arrow_width = 24.0; + let spacing = ui.spacing().item_spacing.x; + let input_width = + ((ui.available_width() - add_width - arrow_width - spacing * 3.0) / 2.0).max(60.0); + input( + ui, + input_width, + &mut vm.vocab_pattern, + tr_l10n(lang, "vocab.corrections_pattern_placeholder"), + ); + ui.add_sized( + [arrow_width, 32.0], + egui::Label::new(egui::RichText::new("→").color(theme::INK_4)) + .wrap_mode(egui::TextWrapMode::Extend), + ); + input( + ui, + input_width, + &mut vm.vocab_replacement, + tr_l10n(lang, "vocab.corrections_replacement_placeholder"), + ); + let pattern = vm.vocab_pattern.trim().to_string(); + let replacement = vm.vocab_replacement.trim().to_string(); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "btn.add")) + .color(theme::SURFACE) + .size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(add_width, 32.0)), + ) + .clicked() + && !pattern.is_empty() + { + actions.push(FrontendAction::VocabAddRule { + pattern, + replacement, + }); + vm.vocab_pattern.clear(); + vm.vocab_replacement.clear(); + } + }); + if let Some(error) = vm.vocab_error.clone() { + ui.add_space(8.0); + ui.label(egui::RichText::new(error).size(11.5).color(theme::ERR)); + } + }); + + ui.add_space(GAP); + + // ── Rules ─────────────────────────────────────────────────────────────── + let learned = vm.vocab_rules.iter().filter(|rule| rule.learned).count(); + card(ui, width, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.corrections_title")) + .size(13.0) + .strong(), + ); + if learned > 0 { + ui.add_space(8.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(fmt_l10n( + lang, + "vocab.corrections_only_learned", + &[&learned], + )) + .size(10.5) + .color(theme::INK_3), + ); + }); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if learned > 0 + && ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "vocab.corrections_remove_all_learned", + )) + .size(11.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + let indices: Vec = vm + .vocab_rules + .iter() + .enumerate() + .filter(|(_, rule)| rule.learned) + .map(|(index, _)| index) + .rev() + .collect(); + for index in indices { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + } + }); + }); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + let mut remove_index = None; + for (index, rule) in vm.vocab_rules.iter().enumerate() { + let label = if rule.learned { + format!( + "{} → {} {}", + rule.pattern, + rule.replacement, + tr_l10n(lang, "vocab.corrections_learned_badge") + ) + } else { + format!("{} → {}", rule.pattern, rule.replacement) + }; + let (toggle, remove) = correction_chip(ui, &label, rule.enabled); + if remove { + remove_index = Some(index); + break; + } + if toggle { + actions.push(FrontendAction::VocabToggleRule(index)); + } + } + if let Some(index) = remove_index { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + if vm.vocab_rules.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.corrections_empty")) + .size(12.0) + .color(theme::INK_4), + ); + } + }); + }); +} + +/// A single-line input with the shared rounded surface style. +fn input(ui: &mut egui::Ui, width: f32, value: &mut String, hint: &str) { + let content_width = (width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(content_width); + ui.add_sized( + [content_width, 20.0], + egui::TextEdit::singleline(value) + .desired_width(content_width) + .hint_text(hint) + .frame(false), + ); + }); +} + +/// Full-width card that sizes itself to its contents. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/format.rs b/openless-all/app/linux-egui/src/ui/frontend/format.rs new file mode 100644 index 000000000..070cea9eb --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/format.rs @@ -0,0 +1,73 @@ +//! Locale-aware value formatting shared by the frontend pages. +//! +//! Kept separate from the pages so the same numbers render identically in the +//! overview dashboard and the history detail panel. + +use chrono::{Datelike, Timelike}; +use openless_linux_egui::{fmt_l10n, Lang}; + +/// Recording length as shown on history rows and the `录音 …` label. +/// `—` for unknown/zero, seconds below a minute, minutes above. +pub fn history_duration(ms: Option, lang: Lang) -> String { + let Some(ms) = ms else { + return "—".to_string(); + }; + if ms == 0 { + return "—".to_string(); + } + let seconds = ms as f64 / 1000.0; + if seconds < 60.0 { + return fmt_l10n(lang, "dur.sec", &[&format!("{seconds:.1}")]); + } + fmt_l10n( + lang, + "common.duration_minutes", + &[&format!("{:.1}", seconds / 60.0)], + ) +} + +/// One pipeline step's duration. Sub-second steps stay in integer milliseconds +/// (streaming ASR tails are tens of ms, so 0.1s rounding would hide differences). +pub fn step_duration(ms: u64, lang: Lang) -> String { + if ms < 1000 { + return fmt_l10n(lang, "dur.ms", &[&ms]); + } + history_duration(Some(ms), lang) +} + +/// RFC3339 history timestamp → compact local label. +/// +/// Today renders as `HH:MM`, the current year as `M/D HH:MM`, older entries as +/// `Y/M/D HH:MM`, mirroring the Tauri `formatHistoryTime`. +pub fn time_label(created_at: &str) -> String { + let Ok(instant) = chrono::DateTime::parse_from_rfc3339(created_at) else { + return created_at.to_string(); + }; + let local = instant.with_timezone(&chrono::Local); + let now = chrono::Local::now(); + if local.date_naive() == now.date_naive() { + format!("{:02}:{:02}", local.hour(), local.minute()) + } else if local.year() == now.year() { + format!( + "{}/{} {:02}:{:02}", + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } else { + format!( + "{}/{}/{} {:02}:{:02}", + local.year(), + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } +} + +/// Count of Unicode code points, matching the backend's `chars().count()`. +pub fn code_points(text: &str) -> usize { + text.chars().count() +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/history.rs b/openless-all/app/linux-egui/src/ui/frontend/history.rs new file mode 100644 index 000000000..741c6a95c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/history.rs @@ -0,0 +1,1027 @@ +//! History page — list + detail, ported from the Tauri `pages/History.tsx`. +//! +//! Layout: +//! +//! ```text +//! ┌ 历史记录 (kicker/title/desc) [刷新] [清空] ┐ +//! │ ┌ list ───────────┐ ┌ detail ─────────────────────┐ │ +//! │ │ 🔍 search │ │ time pill 录音 3.1 秒 ⋯ │ │ +//! │ │ row / row / … │ │ [播放录音] │ │ +//! │ │ │ │ 识别 provider · model 465ms│ │ +//! │ │ │ │ 插入 App · 0 字 插入失败│ │ +//! │ │ │ │ [原文] [样式] [复制] │ │ +//! │ └─────────────────┘ └─────────────────────────────┘ │ +//! └──────────────────────────────────────────────────────┘ +//! ``` +//! +//! The page is a single-screen layout: the list and detail cards split the +//! height the shell gives them, and each scrolls independently. Below +//! `STACK_WIDTH` the two cards stack vertically. All strings come from the +//! localization catalog; nothing is hardcoded. + +use std::sync::Arc; + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::format; +use super::icons::{self, IconName}; +use super::layout::{self, ButtonKind, PillTone}; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, HistoryConfirm, HistoryEntry, HistoryInsertStatus, + OverviewMode, +}; + +const GAP: f32 = 14.0; +const LIST_WIDTH: f32 = 300.0; +const STACK_WIDTH: f32 = 760.0; +const CARD_PADDING: f32 = 20.0; +const DETAIL_PADDING: f32 = 12.0; +const LINE_SOFT: egui::Color32 = theme::LINE_SOFT; +const MONO_SMALL: f32 = 11.0; + +/// Faint hover wash for unselected rows. +fn hover_fill() -> egui::Color32 { + theme::SURFACE_2 +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + header(ui, width, lang, actions); + ui.add_space(GAP); + + let body_height = ui.available_height().max(260.0); + let (body, _) = ui.allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover()); + + let (list_rect, detail_rect) = if width < STACK_WIDTH { + let list_height = (body_height * 0.45).clamp(150.0, 300.0); + ( + egui::Rect::from_min_size(body.min, egui::vec2(width, list_height)), + egui::Rect::from_min_size( + egui::pos2(body.left(), body.top() + list_height + GAP), + egui::vec2(width, (body_height - list_height - GAP).max(140.0)), + ), + ) + } else { + ( + egui::Rect::from_min_size(body.min, egui::vec2(LIST_WIDTH, body_height)), + egui::Rect::from_min_size( + egui::pos2(body.left() + LIST_WIDTH + GAP, body.top()), + egui::vec2((width - LIST_WIDTH - GAP).max(280.0), body_height), + ), + ) + }; + + let filtered = filtered_indices(vm); + list_card(ui, list_rect, vm, &filtered, lang, actions); + detail_card(ui, detail_rect, vm, &filtered, lang, actions); + + if vm.history_confirm.is_some() { + confirm_overlay(ui.ctx(), body, vm, lang, actions); + } +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header(ui: &mut egui::Ui, width: f32, lang: Lang, actions: &mut Vec) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 84.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top() + 2.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "history.kicker"), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + painter.text( + egui::pos2(rect.left(), rect.top() + 18.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "history.title"), + egui::FontId::proportional(26.0), + theme::INK, + ); + painter.text( + egui::pos2(rect.left(), rect.top() + 56.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "history.desc"), + egui::FontId::proportional(13.0), + theme::INK_3, + ); + + let clear = tr_l10n(lang, "common.clear"); + let refresh = tr_l10n(lang, "common.refresh"); + let clear_width = layout::text_width(ui, clear, 12.5) + 40.0; + let refresh_width = layout::text_width(ui, refresh, 12.5) + 40.0; + let top = rect.top() + 22.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - clear_width - 8.0 - refresh_width, top), + egui::vec2(refresh_width, 30.0), + ); + let clear_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - clear_width, top), + egui::vec2(clear_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(IconName::Refresh), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRefresh); + } + if layout::action_button( + ui, + clear_rect, + clear, + Some(IconName::Trash), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRequestClear); + } +} + +// ── List ──────────────────────────────────────────────────────────────────── + +/// Indices into `history_entries` that match the current search query. +fn filtered_indices(vm: &FrontendViewModel) -> Vec { + let query = vm.history_query.trim().to_lowercase(); + vm.history_entries + .iter() + .enumerate() + .filter(|(_, entry)| { + query.is_empty() + || entry.raw_transcript.to_lowercase().contains(&query) + || entry.final_text.to_lowercase().contains(&query) + }) + .map(|(index, _)| index) + .collect() +} + +fn selected_index(vm: &FrontendViewModel, filtered: &[usize]) -> Option { + if filtered.contains(&vm.history_selected) { + Some(vm.history_selected) + } else { + filtered.first().copied() + } +} + +fn list_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &mut FrontendViewModel, + filtered: &[usize], + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + layout::fixed_ui(ui, rect, ("openless-history-list-card",), |ui| { + // Sticky search box. + let padding = 14.0; + let search_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + padding, rect.top() + 12.0), + egui::vec2((rect.width() - padding * 2.0).max(1.0), 34.0), + ); + let painter = ui.painter().with_clip_rect(search_rect); + painter.rect_filled(search_rect, egui::CornerRadius::same(8), theme::SURFACE_2); + painter.rect_stroke( + search_rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let search_id = egui::Id::new("openless-history-search"); + if ui.input(|input| input.modifiers.command && input.key_pressed(egui::Key::K)) { + ui.memory_mut(|memory| memory.request_focus(search_id)); + } + let inner = search_rect.shrink2(egui::vec2(10.0, 5.0)); + layout::fixed_ui(ui, inner, ("openless-history-search-inner",), |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(14.0, 24.0), egui::Sense::hover()); + icons::draw_icon(ui, icon_rect.center(), IconName::Search, theme::INK_3); + ui.add_space(6.0); + let hint = fmt_l10n(lang, "history.search_placeholder", &[&"Ctrl+K"]); + ui.add_sized( + [ui.available_width(), 24.0], + egui::TextEdit::singleline(&mut vm.history_query) + .id(search_id) + .hint_text(hint) + .text_color(theme::INK) + .font(egui::FontId::proportional(12.5)) + .vertical_align(egui::Align::Center) + .frame(false), + ); + }); + }); + + // Independently scrolling list below the search box. + let list_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + 6.0, search_rect.bottom() + 6.0), + egui::pos2(rect.right() - 6.0, rect.bottom() - 6.0), + ); + layout::fixed_ui(ui, list_rect, ("openless-history-list-scroll",), |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-history-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if vm.history_loading { + hint(ui, width, tr_l10n(lang, "common.loading")); + return; + } + if let Some(error) = vm.history_error.as_deref() { + let message = fmt_l10n(lang, "history.load_failed", &[&error]); + if hint_with_action(ui, width, &message, tr_l10n(lang, "common.retry")) { + actions.push(FrontendAction::HistoryRefresh); + } + return; + } + if filtered.is_empty() { + let query = vm.history_query.trim(); + let message = if query.is_empty() { + tr_l10n(lang, "history.empty").to_string() + } else { + fmt_l10n(lang, "history.search_no_match", &[&query]) + }; + hint(ui, width, &message); + return; + } + for &index in filtered { + let selected = Some(index) == selected_index(vm, filtered); + row( + ui, + &vm.history_entries[index], + index, + selected, + lang, + actions, + ); + } + }); + }); + }); +} + +fn hint(ui: &mut egui::Ui, width: f32, text: &str) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 56.0), egui::Sense::hover()); + ui.painter().with_clip_rect(rect).text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(12.0), + theme::INK_4, + ); +} + +/// Hint with a trailing action button; returns whether the button was clicked. +fn hint_with_action(ui: &mut egui::Ui, width: f32, text: &str, action: &str) -> bool { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 96.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + let galley = layout_text(ui, text, theme::INK_4, 12.0, (width - 12.0).max(1.0), 4); + painter.galley( + egui::pos2(rect.left() + 6.0, rect.top() + 12.0), + galley.clone(), + theme::INK_4, + ); + let button_width = layout::text_width(ui, action, 12.5) + 30.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2( + rect.left() + 6.0, + rect.top() + 12.0 + galley.size().y + 10.0, + ), + egui::vec2(button_width, 28.0), + ); + layout::action_button(ui, button_rect, action, None, ButtonKind::Ghost).clicked() +} + +fn row( + ui: &mut egui::Ui, + entry: &HistoryEntry, + index: usize, + selected: bool, + lang: Lang, + actions: &mut Vec, +) { + let width = ui.available_width(); + let preview_text = entry.final_text.split('\n').next().unwrap_or(""); + let preview_text = if preview_text.trim().is_empty() { + entry.raw_transcript.split('\n').next().unwrap_or("") + } else { + preview_text + }; + let preview = if preview_text.is_empty() { + None + } else { + Some(layout_text( + ui, + preview_text, + theme::INK_2, + 12.0, + (width - 24.0).max(1.0), + 2, + )) + }; + let preview_height = preview.as_ref().map(|g| g.size().y).unwrap_or(0.0); + let row_height = 10.0 + + 15.0 + + if preview_height > 0.0 { + 4.0 + preview_height + } else { + 0.0 + } + + 6.0 + + 18.0 + + 10.0; + + let (rect, response) = + ui.allocate_exact_size(egui::vec2(width, row_height), egui::Sense::click()); + let painter = ui.painter().with_clip_rect(rect); + if selected { + painter.rect_filled(rect, egui::CornerRadius::same(10), theme::SURFACE_2); + painter.rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + } else if response.hovered() { + painter.rect_filled(rect, egui::CornerRadius::same(10), hover_fill()); + } + + let header_y = rect.top() + 10.0 + 7.5; + painter.text( + egui::pos2(rect.left() + 12.0, header_y), + egui::Align2::LEFT_CENTER, + format::time_label(&entry.created_at), + egui::FontId::monospace(MONO_SMALL), + theme::INK_3, + ); + painter.text( + egui::pos2(rect.right() - 12.0, header_y - 0.5), + egui::Align2::RIGHT_CENTER, + format::history_duration(entry.duration_ms, lang), + egui::FontId::monospace(10.0), + theme::INK_4, + ); + let mut y = rect.top() + 10.0 + 15.0; + if let Some(preview) = preview { + y += 4.0; + painter.galley(egui::pos2(rect.left() + 12.0, y), preview, theme::INK_2); + y += preview_height + 6.0; + } else { + y += 6.0; + } + let pill = layout::pill_size(ui, &entry.style_label); + let pill_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + 12.0, y), pill); + layout::paint_pill( + &painter, + pill_rect, + &entry.style_label, + if entry.mode == OverviewMode::Raw { + PillTone::Outline + } else { + PillTone::Gray + }, + ); + + if response.clicked() { + actions.push(FrontendAction::HistorySelect(index)); + } + ui.add_space(4.0); +} + +// ── Detail ────────────────────────────────────────────────────────────────── + +fn detail_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &FrontendViewModel, + filtered: &[usize], + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + let selected = selected_index(vm, filtered); + layout::fixed_ui( + ui, + rect.shrink(CARD_PADDING), + ("openless-history-detail",), + |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-history-detail-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if vm.history_loading && vm.history_entries.is_empty() { + hint(ui, width, tr_l10n(lang, "common.loading")); + return; + } + let Some(index) = selected else { + let message = if let Some(error) = vm.history_error.as_deref() { + fmt_l10n(lang, "history.load_failed", &[&error]) + } else { + tr_l10n(lang, "history.select_hint").to_string() + }; + hint(ui, width, &message); + return; + }; + detail_body( + ui, + &vm.history_entries[index], + index, + vm.history_playback.as_ref(), + lang, + actions, + ); + }); + }, + ); +} + +fn detail_body( + ui: &mut egui::Ui, + entry: &HistoryEntry, + index: usize, + playback: Option<&super::view_model::HistoryPlayback>, + lang: Lang, + actions: &mut Vec, +) { + let width = ui.available_width(); + + // Header: time · style pill · recording length, actions on the right. + let (top, _) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(top); + let time = format::time_label(&entry.created_at); + let mut x = top.left(); + painter.text( + egui::pos2(x, top.center().y), + egui::Align2::LEFT_CENTER, + &time, + egui::FontId::monospace(13.0), + theme::INK_3, + ); + x += layout::text_width(ui, &time, 13.0) + 10.0; + let pill = layout::pill_size(ui, &entry.style_label); + let pill_rect = egui::Rect::from_min_size(egui::pos2(x, top.center().y - pill.y / 2.0), pill); + layout::paint_pill(&painter, pill_rect, &entry.style_label, PillTone::Gray); + x += pill.x + 10.0; + let recorded = fmt_l10n( + lang, + "history.recorded", + &[&format::history_duration(entry.duration_ms, lang)], + ); + painter.text( + egui::pos2(x, top.center().y), + egui::Align2::LEFT_CENTER, + &recorded, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + + // Right-aligned actions: 删除 / 重新转写 / 导出录音 (audio-gated). + let mut right = top.right(); + let delete_label = tr_l10n(lang, "common.delete"); + let delete_width = layout::text_width(ui, delete_label, 12.5) + 38.0; + let delete_rect = egui::Rect::from_min_size( + egui::pos2(right - delete_width, top.center().y - 15.0), + egui::vec2(delete_width, 30.0), + ); + right = delete_rect.left() - 6.0; + if layout::action_button( + ui, + delete_rect, + delete_label, + Some(IconName::Trash), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRequestDelete(index)); + } + if entry.has_audio { + let retranscribe = tr_l10n(lang, "history.retranscribe"); + let retranscribe_width = layout::text_width(ui, retranscribe, 12.5) + 38.0; + let retranscribe_rect = egui::Rect::from_min_size( + egui::pos2(right - retranscribe_width, top.center().y - 15.0), + egui::vec2(retranscribe_width, 30.0), + ); + right = retranscribe_rect.left() - 6.0; + if layout::action_button( + ui, + retranscribe_rect, + retranscribe, + Some(IconName::Refresh), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRetranscribe(index)); + } + let export = tr_l10n(lang, "history.export"); + let export_width = layout::text_width(ui, export, 12.5) + 38.0; + let export_rect = egui::Rect::from_min_size( + egui::pos2(right - export_width, top.center().y - 15.0), + egui::vec2(export_width, 30.0), + ); + if layout::action_button( + ui, + export_rect, + export, + Some(IconName::Download), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryExport(index)); + } + } + + // In-app playback: a player bar with the elapsed time and a progress track. + if entry.has_audio { + ui.add_space(10.0); + let (play_row, _) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::hover()); + let playing = playback.filter(|playback| playback.id == entry.id); + let label = if playing.is_some() { + tr_l10n(lang, "history.stop_playback") + } else { + tr_l10n(lang, "history.play") + }; + let icon = if playing.is_some() { + IconName::Stop + } else { + IconName::Play + }; + let button_width = layout::text_width(ui, label, 12.5) + 42.0; + let button_rect = egui::Rect::from_min_size(play_row.min, egui::vec2(button_width, 32.0)); + if layout::action_button(ui, button_rect, label, Some(icon), ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::HistoryPlay(index)); + } + if let Some(playback) = playing { + // Progress track to the right of the button. + let track = egui::Rect::from_min_max( + egui::pos2(button_rect.right() + 12.0, play_row.center().y - 3.0), + egui::pos2(play_row.right() - 96.0, play_row.center().y + 3.0), + ); + if track.width() > 20.0 { + let ratio = if playback.total_ms == 0 { + 0.0 + } else { + (playback.position_ms as f32 / playback.total_ms as f32).clamp(0.0, 1.0) + }; + ui.painter() + .rect_filled(track, egui::CornerRadius::same(3), theme::SURFACE_2); + let filled = egui::Rect::from_min_max( + track.min, + egui::pos2(track.left() + track.width() * ratio, track.bottom()), + ); + ui.painter() + .rect_filled(filled, egui::CornerRadius::same(3), theme::BLUE); + ui.painter().text( + egui::pos2(play_row.right(), play_row.center().y), + egui::Align2::RIGHT_CENTER, + format!( + "{} / {}", + playback_clock(playback.position_ms), + playback_clock(playback.total_ms) + ), + egui::FontId::monospace(11.0), + theme::INK_4, + ); + } + } + } + + ui.add_space(if entry.has_audio { 12.0 } else { 4.0 }); + separator(ui, width); + ui.add_space(12.0); + + // Pipeline rows: 识别 / 润色 / 插入. + let step_labels = [ + tr_l10n(lang, "history.step_asr"), + tr_l10n(lang, "history.step_polish"), + tr_l10n(lang, "history.step_insert"), + ]; + let label_column = step_labels + .iter() + .map(|label| layout::text_width(ui, label, 11.0)) + .fold(0.0_f32, f32::max) + + 14.0; + + let asr_detail = join_provider(&entry.asr_provider, &entry.asr_model); + if !asr_detail.is_empty() || entry.asr_ms.is_some() { + pipeline_row( + ui, + width, + label_column, + step_labels[0], + &asr_detail, + entry.asr_ms.map(|ms| format::step_duration(ms, lang)), + ); + } + let llm_detail = join_provider(&entry.llm_provider, &entry.llm_model); + if !llm_detail.is_empty() || entry.polish_ms.is_some() { + pipeline_row( + ui, + width, + label_column, + step_labels[1], + &llm_detail, + entry.polish_ms.map(|ms| format::step_duration(ms, lang)), + ); + } + let mut insert_detail = match &entry.app_name { + Some(app) if !app.trim().is_empty() => format!("{app} · "), + _ => String::new(), + }; + insert_detail.push_str(&fmt_l10n( + lang, + "history.chars", + &[&format::code_points(&entry.final_text)], + )); + if let Some(count) = entry.dictionary_count.filter(|count| *count > 0) { + insert_detail.push_str(" · "); + insert_detail.push_str(&fmt_l10n(lang, "history.vocab_hits", &[&count])); + } + pipeline_row( + ui, + width, + label_column, + step_labels[2], + &insert_detail, + Some(insert_status_label(lang, entry.insert_status)), + ); + + // 原文 / 润色结果 cards. + ui.add_space(16.0); + let remaining = ui.available_height().max(120.0); + let raw_text = entry.raw_transcript.as_str(); + let styled_text = entry.final_text.as_str(); + let raw_empty = tr_l10n(lang, "history.raw_empty"); + let raw_body = if raw_text.trim().is_empty() { + raw_empty + } else { + raw_text + }; + let raw_galley = layout_text( + ui, + raw_body, + theme::INK_2, + 13.0, + (width / 2.0 - 60.0).max(60.0), + 60, + ); + let styled_galley = layout_text( + ui, + styled_text, + theme::INK, + 13.0, + (width / 2.0 - 60.0).max(60.0), + 60, + ); + let content_height = raw_galley.size().y.max(styled_galley.size().y); + let card_height = remaining.max(52.0 + content_height); + + let (cards, _) = ui.allocate_exact_size(egui::vec2(width, card_height), egui::Sense::hover()); + let raw_label = tr_l10n(lang, "history.raw_label"); + let raw_is_empty = raw_text.trim().is_empty(); + let raw_override = if raw_is_empty { + Some(tr_l10n(lang, "history.raw_empty").to_string()) + } else { + None + }; + let raw_copy = (!raw_is_empty).then(|| ("openless-history-copy-raw", raw_text.to_string())); + let styled_is_empty = styled_text.trim().is_empty(); + let styled_copy = + (!styled_is_empty).then(|| ("openless-history-copy-styled", styled_text.to_string())); + + if width >= 560.0 { + let column_width = (width - 12.0) / 2.0; + let left_rect = egui::Rect::from_min_size(cards.min, egui::vec2(column_width, card_height)); + let right_rect = egui::Rect::from_min_size( + egui::pos2(cards.left() + column_width + 12.0, cards.top()), + egui::vec2(column_width, card_height), + ); + text_card( + ui, + left_rect, + raw_label, + PillTone::Outline, + raw_text, + raw_override, + raw_copy, + lang, + ); + text_card( + ui, + right_rect, + &entry.style_label, + PillTone::Blue, + styled_text, + None, + styled_copy, + lang, + ); + } else { + text_card( + ui, + cards, + raw_label, + PillTone::Outline, + raw_text, + raw_override, + raw_copy, + lang, + ); + ui.add_space(12.0); + let (second, _) = + ui.allocate_exact_size(egui::vec2(width, card_height), egui::Sense::hover()); + text_card( + ui, + second, + &entry.style_label, + PillTone::Blue, + styled_text, + None, + styled_copy, + lang, + ); + } +} + +fn join_provider(provider: &Option, model: &Option) -> String { + [provider.as_deref(), model.as_deref()] + .into_iter() + .flatten() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" · ") +} + +fn insert_status_label(lang: Lang, status: HistoryInsertStatus) -> String { + match status { + HistoryInsertStatus::Inserted => tr_l10n(lang, "history.inserted").to_string(), + HistoryInsertStatus::PasteSent => tr_l10n(lang, "history.paste_sent").to_string(), + HistoryInsertStatus::CopiedFallback => { + fmt_l10n(lang, "history.copied_fallback", &[&"Ctrl+V"]) + } + HistoryInsertStatus::Failed => tr_l10n(lang, "history.insert_failed").to_string(), + HistoryInsertStatus::NotRequested => tr_l10n(lang, "history.not_requested").to_string(), + } +} + +/// One `label | detail | status` row of the pipeline breakdown. +fn pipeline_row( + ui: &mut egui::Ui, + width: f32, + label_column: f32, + label: &str, + detail: &str, + status: Option, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 20.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + painter.text( + egui::pos2(rect.left() + label_column, rect.center().y), + egui::Align2::LEFT_CENTER, + detail, + egui::FontId::monospace(11.0), + theme::INK_2, + ); + if let Some(status) = status { + painter.text( + egui::pos2(rect.right(), rect.center().y), + egui::Align2::RIGHT_CENTER, + status, + egui::FontId::monospace(11.0), + theme::INK_4, + ); + } + ui.add_space(4.0); +} + +/// A `原文` / polished text card with an optional pill and copy button. +#[allow(clippy::too_many_arguments)] +fn text_card( + ui: &mut egui::Ui, + rect: egui::Rect, + pill_text: &str, + pill_tone: PillTone, + body: &str, + empty_override: Option, + copy: Option<(&'static str, String)>, + lang: Lang, +) { + if rect == egui::Rect::NOTHING { + return; + } + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(10), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + let painter = ui.painter().with_clip_rect(rect); + let pill = layout::pill_size(ui, pill_text); + let pill_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + DETAIL_PADDING, rect.top() + DETAIL_PADDING), + pill, + ); + layout::paint_pill(&painter, pill_rect, pill_text, pill_tone); + + if let Some((salt, text)) = copy { + let id = egui::Id::new(salt); + let now = ui.input(|input| input.time); + let copied = ui + .ctx() + .data(|data| data.get_temp::(id)) + .is_some_and(|at| now - at < 1.5); + let label = if copied { + tr_l10n(lang, "common.copied") + } else { + tr_l10n(lang, "common.copy") + }; + let button_width = layout::text_width(ui, label, 12.5) + 34.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2( + rect.right() - DETAIL_PADDING - button_width, + rect.top() + DETAIL_PADDING - 3.0, + ), + egui::vec2(button_width, 26.0), + ); + if layout::action_button( + ui, + button_rect, + label, + Some(IconName::Copy), + ButtonKind::Ghost, + ) + .clicked() + { + ui.ctx().copy_text(text); + ui.ctx().data_mut(|data| data.insert_temp(id, now)); + } + } + + let text = empty_override.as_deref().unwrap_or(body); + let color = if empty_override.is_some() { + theme::INK_4 + } else { + theme::INK_2 + }; + let galley = layout_text( + ui, + text, + color, + 13.0, + (rect.width() - DETAIL_PADDING * 2.0).max(1.0), + 60, + ); + painter.galley( + egui::pos2(rect.left() + DETAIL_PADDING, pill_rect.bottom() + 10.0), + galley, + color, + ); +} + +// ── Confirmation dialog ───────────────────────────────────────────────────── + +fn confirm_overlay( + ctx: &egui::Context, + body: egui::Rect, + vm: &FrontendViewModel, + lang: Lang, + actions: &mut Vec, +) { + egui::Area::new(egui::Id::new("openless-history-confirm")) + .order(egui::Order::Foreground) + .sense(egui::Sense::hover()) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + ui.set_clip_rect(body); + ui.painter().rect_filled( + body, + egui::CornerRadius::ZERO, + egui::Color32::from_black_alpha(36), + ); + + let dialog = egui::Rect::from_center_size( + body.center(), + egui::vec2(body.width().min(400.0), 152.0), + ); + paint_card(ui.painter(), dialog); + let painter = ui.painter().with_clip_rect(dialog); + let message = match vm.history_confirm { + Some(HistoryConfirm::Clear) => { + fmt_l10n(lang, "history.confirm_clear", &[&vm.history_entries.len()]) + } + Some(HistoryConfirm::Delete(_)) => { + tr_l10n(lang, "history.confirm_delete").to_string() + } + None => return, + }; + let galley = layout_text( + ui, + &message, + theme::INK_2, + 13.0, + (dialog.width() - 40.0).max(1.0), + 4, + ); + painter.galley( + egui::pos2(dialog.left() + 20.0, dialog.top() + 22.0), + galley, + theme::INK_2, + ); + + let confirm = tr_l10n(lang, "common.confirm"); + let cancel = tr_l10n(lang, "common.cancel"); + let confirm_width = layout::text_width(ui, confirm, 12.5) + 34.0; + let cancel_width = layout::text_width(ui, cancel, 12.5) + 34.0; + let button_y = dialog.bottom() - 20.0 - 30.0; + let confirm_rect = egui::Rect::from_min_size( + egui::pos2(dialog.right() - 20.0 - confirm_width, button_y), + egui::vec2(confirm_width, 30.0), + ); + let cancel_rect = egui::Rect::from_min_size( + egui::pos2(confirm_rect.left() - 8.0 - cancel_width, button_y), + egui::vec2(cancel_width, 30.0), + ); + if layout::action_button(ui, cancel_rect, cancel, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::HistoryCancelConfirm); + } + if layout::action_button(ui, confirm_rect, confirm, None, ButtonKind::Blue).clicked() { + actions.push(FrontendAction::HistoryConfirmAction); + } + }); +} + +// ── Painting helpers ──────────────────────────────────────────────────────── + +/// `m:ss` clock used by the in-app player bar. +fn playback_clock(ms: u64) -> String { + let seconds = ms / 1000; + format!("{}:{:02}", seconds / 60, seconds % 60) +} + +fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +fn separator(ui: &mut egui::Ui, width: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 1.0), egui::Sense::hover()); + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, LINE_SOFT), + ); +} + +fn layout_text( + ui: &egui::Ui, + text: &str, + color: egui::Color32, + size: f32, + max_width: f32, + max_rows: usize, +) -> Arc { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = max_width.max(1.0); + job.wrap.max_rows = max_rows; + job.append( + text, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(size), + color, + ..Default::default() + }, + ); + ui.fonts_mut(|fonts| fonts.layout_job(job)) +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs new file mode 100644 index 000000000..bde305a40 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -0,0 +1,514 @@ +use eframe::egui; + +use super::theme; + +#[derive(Clone, Copy)] +pub enum IconName { + Overview, + History, + Vocab, + Style, + SelectionAsk, + Settings, + Mic, + Sparkle, + Hash, + Clock, + Bolt, + Copy, + Search, + Trash, + Refresh, + Download, + Play, + Stop, + /// 浮窗用:关闭 ✕、确认 ✓、发送 ↑、空状态对话气泡、用户头像占位。 + Close, + Check, + Send, + /// 划词追问头部的图钉(固定 / 取消固定)。 + Pin, + Chat, + Github, +} + +/// Draw an icon centred at `center` with the given `color`. +pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { + let p = ui.painter(); + let stroke = egui::Stroke::new(1.25, color); + // 与中心点的相对坐标(各 arm 若需要不同缩放会在内部自行 shadow)。 + let point = |x: f32, y: f32| center + egui::vec2(x, y); + match icon { + IconName::Overview => { + let s = 2.0 / 3.0; + p.add(egui::Shape::line( + [ + center + egui::vec2(-9.0 * s, -9.0 * s), + center + egui::vec2(-9.0 * s, 9.0 * s), + center + egui::vec2(9.0 * s, 9.0 * s), + ] + .to_vec(), + stroke, + )); + for (x, top) in [(6.0, 9.0), (1.0, 5.0), (-4.0, 14.0)] { + p.line_segment( + [ + center + egui::vec2(x * s, 5.0 * s), + center + egui::vec2(x * s, (top - 12.0) * s), + ], + stroke, + ); + } + } + IconName::History | IconName::Clock => { + p.circle_stroke(center, 6.0, stroke); + p.line_segment([center, center + egui::vec2(0.0, -3.5)], stroke); + p.line_segment([center, center + egui::vec2(3.0, 2.0)], stroke); + } + IconName::Search => { + let s = 0.5; + let stroke = egui::Stroke::new(1.0, color); + p.circle_stroke(center + egui::vec2(-0.5, -0.5), 4.0, stroke); + p.line_segment( + [ + center + egui::vec2(4.65 * s, 4.65 * s), + center + egui::vec2(9.0 * s, 9.0 * s), + ], + stroke, + ); + } + IconName::Trash => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.line_segment([pt(3.0, 6.0), pt(21.0, 6.0)], stroke); + p.line_segment([pt(19.0, 6.0), pt(19.0, 20.0)], stroke); + p.line_segment([pt(19.0, 20.0), pt(17.0, 22.0)], stroke); + p.line_segment([pt(17.0, 22.0), pt(7.0, 22.0)], stroke); + p.line_segment([pt(7.0, 22.0), pt(5.0, 20.0)], stroke); + p.line_segment([pt(5.0, 20.0), pt(5.0, 6.0)], stroke); + p.line_segment([pt(8.0, 6.0), pt(8.0, 4.0)], stroke); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [pt(8.0, 4.0), pt(8.0, 2.0), pt(10.0, 2.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([pt(10.0, 2.0), pt(14.0, 2.0)], stroke); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [pt(14.0, 2.0), pt(16.0, 2.0), pt(16.0, 4.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([pt(16.0, 4.0), pt(16.0, 6.0)], stroke); + p.line_segment([pt(10.0, 11.0), pt(10.0, 17.0)], stroke); + p.line_segment([pt(14.0, 11.0), pt(14.0, 17.0)], stroke); + } + IconName::Refresh => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + let arc = (0..=24) + .map(|step| { + let t = step as f32 / 24.0; + let angle = std::f32::consts::PI + - t * (std::f32::consts::PI + std::f32::consts::FRAC_PI_2); + center + egui::vec2(angle.cos() * 9.0 * s, angle.sin() * 9.0 * s) + }) + .collect::>(); + p.add(egui::Shape::line(arc, stroke)); + p.line_segment([pt(3.0, 3.0), pt(3.0, 8.0)], stroke); + p.line_segment([pt(3.0, 3.0), pt(8.0, 3.0)], stroke); + } + IconName::Download => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [ + pt(21.0, 15.0), + pt(21.0, 19.0), + pt(19.0, 21.0), + pt(5.0, 21.0), + pt(3.0, 19.0), + pt(3.0, 15.0), + ] + .to_vec(), + stroke, + )); + p.add(egui::Shape::line( + [pt(7.0, 10.0), pt(12.0, 15.0), pt(17.0, 10.0)].to_vec(), + stroke, + )); + p.line_segment([pt(12.0, 15.0), pt(12.0, 3.0)], stroke); + } + IconName::Play => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [pt(5.0, 3.0), pt(19.0, 12.0), pt(5.0, 21.0), pt(5.0, 3.0)].to_vec(), + stroke, + )); + } + IconName::Stop => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [ + pt(6.0, 6.0), + pt(18.0, 6.0), + pt(18.0, 18.0), + pt(6.0, 18.0), + pt(6.0, 6.0), + ] + .to_vec(), + stroke, + )); + } + IconName::Vocab => { + let s = 2.0 / 3.0; + let point = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(4.0, 19.5), point(4.0, 17.0), point(6.5, 17.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.add(egui::Shape::line( + [point(6.5, 17.0), point(20.0, 17.0)].to_vec(), + stroke, + )); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(4.0, 19.5), point(4.0, 22.0), point(6.5, 22.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.add(egui::Shape::line( + [ + point(6.5, 22.0), + point(20.0, 22.0), + point(20.0, 4.0), + point(6.5, 4.0), + ] + .to_vec(), + stroke, + )); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(6.5, 4.0), point(4.0, 4.0), point(4.0, 6.5)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([point(4.0, 6.5), point(4.0, 19.5)], stroke); + } + IconName::Style => { + let s = 2.0 / 3.0; + p.line_segment( + [ + center + egui::vec2(0.0, -10.0 * s), + center + egui::vec2(0.0, 10.0 * s), + ], + stroke, + ); + p.add(egui::Shape::line( + [ + center + egui::vec2(5.0 * s, -7.0 * s), + center + egui::vec2(-2.5 * s, -7.0 * s), + center + egui::vec2(-5.5 * s, -5.0 * s), + center + egui::vec2(-5.5 * s, -1.5 * s), + center + egui::vec2(-3.5 * s, 1.5 * s), + center + egui::vec2(3.0 * s, 1.5 * s), + center + egui::vec2(5.0 * s, 3.5 * s), + center + egui::vec2(4.0 * s, 6.0 * s), + center + egui::vec2(1.0 * s, 7.0 * s), + center + egui::vec2(-6.0 * s, 7.0 * s), + ] + .to_vec(), + stroke, + )); + } + IconName::SelectionAsk => { + p.rect_stroke( + egui::Rect::from_center_size( + center + egui::vec2(0.0, -1.0), + egui::vec2(13.0, 10.0), + ), + egui::CornerRadius::same(2), + stroke, + egui::StrokeKind::Inside, + ); + p.line_segment( + [ + center + egui::vec2(-2.0, 4.0), + center + egui::vec2(-5.0, 7.0), + ], + stroke, + ); + } + IconName::Mic => { + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(0.0, -2.0), egui::vec2(7.0, 11.0)), + egui::CornerRadius::same(4), + stroke, + egui::StrokeKind::Inside, + ); + p.line_segment( + [ + center + egui::vec2(-4.0, -2.0), + center + egui::vec2(-4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(4.0, -2.0), + center + egui::vec2(4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-4.0, 1.0), + center + egui::vec2(4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [center + egui::vec2(0.0, 1.0), center + egui::vec2(0.0, 5.0)], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-3.0, 5.0), + center + egui::vec2(3.0, 5.0), + ], + stroke, + ); + } + IconName::Sparkle => { + p.line_segment( + [ + center + egui::vec2(0.0, -7.0), + center + egui::vec2(2.5, -2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(2.5, -2.5), + center + egui::vec2(7.0, 0.0), + ], + stroke, + ); + p.line_segment( + [center + egui::vec2(7.0, 0.0), center + egui::vec2(2.5, 2.5)], + stroke, + ); + p.line_segment( + [center + egui::vec2(2.5, 2.5), center + egui::vec2(0.0, 7.0)], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(0.0, 7.0), + center + egui::vec2(-2.5, 2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.5, 2.5), + center + egui::vec2(-7.0, 0.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-7.0, 0.0), + center + egui::vec2(-2.5, -2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.5, -2.5), + center + egui::vec2(0.0, -7.0), + ], + stroke, + ); + } + IconName::Hash => { + p.line_segment( + [ + center + egui::vec2(-6.0, -3.0), + center + egui::vec2(6.0, -3.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-6.0, 3.0), + center + egui::vec2(6.0, 3.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.0, -7.0), + center + egui::vec2(-4.0, 7.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(4.0, -7.0), + center + egui::vec2(2.0, 7.0), + ], + stroke, + ); + } + IconName::Bolt => { + p.line_segment( + [ + center + egui::vec2(1.0, -8.0), + center + egui::vec2(-5.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-5.0, 1.0), + center + egui::vec2(1.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(1.0, 1.0), + center + egui::vec2(-1.0, 8.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-1.0, 8.0), + center + egui::vec2(6.0, -1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(6.0, -1.0), + center + egui::vec2(1.0, -1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(1.0, -1.0), + center + egui::vec2(1.0, -8.0), + ], + stroke, + ); + } + IconName::Close => { + p.line_segment([point(-4.5, -4.5), point(4.5, 4.5)], stroke); + p.line_segment([point(4.5, -4.5), point(-4.5, 4.5)], stroke); + } + IconName::Check => { + p.add(egui::Shape::line( + [point(-5.0, 0.5), point(-1.5, 4.0), point(5.0, -4.0)].to_vec(), + egui::Stroke::new(1.6, color), + )); + } + IconName::Send => { + p.add(egui::Shape::line( + [point(0.0, -5.5), point(0.0, 5.5)].to_vec(), + egui::Stroke::new(1.6, color), + )); + p.add(egui::Shape::line( + [point(-4.0, -1.5), point(0.0, -5.5), point(4.0, -1.5)].to_vec(), + egui::Stroke::new(1.6, color), + )); + } + IconName::Chat => { + p.rect_stroke( + egui::Rect::from_center_size( + center + egui::vec2(0.0, -1.0), + egui::vec2(18.0, 13.0), + ), + egui::CornerRadius::same(4), + stroke, + egui::StrokeKind::Inside, + ); + p.add(egui::Shape::line( + [ + center + egui::vec2(-3.0, 5.5), + center + egui::vec2(-1.0, 5.5), + center + egui::vec2(-4.0, 8.5), + ] + .to_vec(), + stroke, + )); + } + IconName::Pin => { + p.circle_stroke(center + egui::vec2(0.0, -3.0), 3.4, stroke); + p.add(egui::Shape::line( + [point(-4.6, -6.6), point(4.6, -6.6)].to_vec(), + stroke, + )); + p.add(egui::Shape::line( + [point(0.0, 0.4), point(0.0, 7.0)].to_vec(), + stroke, + )); + } + IconName::Github => { + p.circle_filled(center, 7.0, color.gamma_multiply(0.75)); + p.circle_filled(center + egui::vec2(0.0, 3.0), 3.4, theme::SURFACE_2); + } + IconName::Copy => { + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(1.5, 1.5), egui::vec2(10.0, 12.0)), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(-1.5, -2.5), egui::vec2(8.0, 5.0)), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + } + IconName::Settings => { + p.circle_stroke(center, 4.5, stroke); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_4, + std::f32::consts::FRAC_PI_2, + 3.0 * std::f32::consts::FRAC_PI_4, + std::f32::consts::PI, + 5.0 * std::f32::consts::FRAC_PI_4, + 3.0 * std::f32::consts::FRAC_PI_2, + 7.0 * std::f32::consts::FRAC_PI_4, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + p.line_segment([center + direction * 5.0, center + direction * 7.0], stroke); + } + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/layout.rs b/openless-all/app/linux-egui/src/ui/frontend/layout.rs new file mode 100644 index 000000000..05be1c843 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -0,0 +1,1188 @@ +use eframe::egui; + +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::icons::{self, IconName}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, Page}; + +pub const SIDEBAR_WIDTH: f32 = 188.0; +pub const TITLEBAR_HEIGHT: f32 = 38.0; +const WINDOW_MARGIN: f32 = 6.0; +const WINDOW_RADIUS: u8 = 14; + +// ── Window geometry helpers ───────────────────────────────────────────────── + +pub fn window_rect(ctx: &egui::Context) -> egui::Rect { + ctx.content_rect().shrink(WINDOW_MARGIN) +} + +pub fn body_rect(ctx: &egui::Context) -> egui::Rect { + let window = window_rect(ctx); + egui::Rect::from_min_max(window.min + egui::vec2(0.0, TITLEBAR_HEIGHT), window.max) +} + +// ── App icon ──────────────────────────────────────────────────────────────── + +pub fn load_app_icon(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("openless-frontend-app-icon"); + if let Some(texture) = ctx.data(|data| data.get_temp::(id)) { + return texture; + } + let image = image::load_from_memory(include_bytes!("../../../../public/AppIcon.png")) + .expect("OpenLess AppIcon.png must be valid") + .into_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + image.as_raw(), + ); + let texture = ctx.load_texture("openless-app-icon", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|data| data.insert_temp(id, texture.clone())); + texture +} + +pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect, texture: &egui::TextureHandle) { + ui.painter().image( + texture.id(), + rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); +} + +// ── Window background ─────────────────────────────────────────────────────── + +pub fn paint_window_background(ctx: &egui::Context) { + let window = window_rect(ctx); + let body = body_rect(ctx); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("openless-window-background"), + )); + painter.rect_filled( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + theme::SURFACE, + ); + painter.rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: WINDOW_RADIUS, + }, + theme::CANVAS, + ); + painter.rect_stroke( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +// ── Titlebar ──────────────────────────────────────────────────────────────── + +pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { + let window = window_rect(ctx); + let titlebar = egui::Rect::from_min_max( + window.min, + egui::pos2(window.max.x, window.min.y + TITLEBAR_HEIGHT), + ); + + egui::Area::new(egui::Id::new("openless-titlebar")) + .order(egui::Order::Middle) + // The titlebar defines its own drag zone and window-control buttons. + // Keep the area in the hit-test stack for its children, without adding + // an area-wide click target that would consume their input. + .sense(egui::Sense::hover()) + .fixed_pos(window.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(window.width(), TITLEBAR_HEIGHT)); + + let button_width = 40.0; + let controls_left = titlebar.right() - button_width * 3.0; + // The drag zone stops before the window controls so a press on the + // buttons can never be claimed by the titlebar drag target. + let drag_rect = egui::Rect::from_min_max( + titlebar.min, + egui::pos2(controls_left, titlebar.bottom()), + ); + let drag = ui.interact( + drag_rect, + ui.id().with("titlebar-drag"), + egui::Sense::click_and_drag(), + ); + // Ask the compositor to move the window on the *press* frame: on + // Wayland `xdg_toplevel.move` needs the pointer serial from that + // event, so deferring to `drag_started` silently does nothing. + let pressed_now = + drag.is_pointer_button_down_on() && ui.input(|input| input.pointer.any_pressed()); + if drag.drag_started() || pressed_now { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + actions.push(FrontendAction::WindowMaximize); + } + + let texture = load_app_icon(ctx); + paint_app_icon( + ui, + egui::Rect::from_center_size( + window.min + egui::vec2(16.0, TITLEBAR_HEIGHT / 2.0), + egui::vec2(18.0, 18.0), + ), + &texture, + ); + ui.painter().text( + window.min + egui::vec2(34.0, TITLEBAR_HEIGHT / 2.0 + 0.5), + egui::Align2::LEFT_CENTER, + "OpenLess", + egui::FontId::proportional(13.0), + theme::INK_2, + ); + + let close = egui::Rect::from_min_max( + egui::pos2(titlebar.right() - button_width, titlebar.top()), + titlebar.right_bottom(), + ); + let maximize = close.translate(egui::vec2(-button_width, 0.0)); + let minimize = maximize.translate(egui::vec2(-button_width, 0.0)); + let close_response = ui.interact(close, ui.id().with("close"), egui::Sense::click()); + let maximize_response = + ui.interact(maximize, ui.id().with("maximize"), egui::Sense::click()); + let minimize_response = + ui.interact(minimize, ui.id().with("minimize"), egui::Sense::click()); + if close_response.clicked() { + actions.push(FrontendAction::WindowClose); + } + if maximize_response.clicked() { + actions.push(FrontendAction::WindowMaximize); + } + if minimize_response.clicked() { + actions.push(FrontendAction::WindowMinimize); + } + for (rect, response) in [ + (minimize, &minimize_response), + (maximize, &maximize_response), + (close, &close_response), + ] { + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + } + + let stroke = egui::Stroke::new(1.0, theme::INK_3); + // Minimize: a single horizontal line. + ui.painter().line_segment( + [ + minimize.center() - egui::vec2(5.0, 0.0), + minimize.center() + egui::vec2(5.0, 0.0), + ], + stroke, + ); + // Maximize shows a square, restored windows show the two-square glyph. + let maximized = ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + let center = maximize.center(); + if maximized { + let half = 4.0; + ui.painter().rect_stroke( + egui::Rect::from_min_max( + center + egui::vec2(-half - 2.0, -half), + center + egui::vec2(half - 2.0, half), + ), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().rect_stroke( + egui::Rect::from_min_max( + center + egui::vec2(-half + 2.0, -half + 2.0), + center + egui::vec2(half + 2.0, half + 2.0), + ), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + } else { + ui.painter().rect_stroke( + egui::Rect::from_center_size(center, egui::vec2(10.0, 10.0)), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + } + ui.painter().line_segment( + [ + close.center() - egui::vec2(5.0, 5.0), + close.center() + egui::vec2(5.0, 5.0), + ], + stroke, + ); + ui.painter().line_segment( + [ + close.center() + egui::vec2(5.0, -5.0), + close.center() + egui::vec2(-5.0, 5.0), + ], + stroke, + ); + }); +} + +// ── Resize handles ────────────────────────────────────────────────────────── + +pub fn resize_handles(ctx: &egui::Context) { + let window = window_rect(ctx); + // Keep the draggable titlebar band generous: only a thin strip resizes. + let edge = 6.0; + let corner = 18.0; + let left = window.left(); + let right = window.right(); + let top = window.top(); + let bottom = window.bottom(); + let zones = [ + ( + egui::Rect::from_min_max( + egui::pos2(left, top), + egui::pos2(left + corner, top + corner), + ), + egui::ResizeDirection::NorthWest, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - corner, top), + egui::pos2(right, top + corner), + ), + egui::ResizeDirection::NorthEast, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left, bottom - corner), + egui::pos2(left + corner, bottom), + ), + egui::ResizeDirection::SouthWest, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - corner, bottom - corner), + egui::pos2(right, bottom), + ), + egui::ResizeDirection::SouthEast, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left + corner, top), + egui::pos2(right - corner, top + edge), + ), + egui::ResizeDirection::North, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left + corner, bottom - edge), + egui::pos2(right - corner, bottom), + ), + egui::ResizeDirection::South, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left, top + corner), + egui::pos2(left + edge, bottom - corner), + ), + egui::ResizeDirection::West, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - edge, top + corner), + egui::pos2(right, bottom - corner), + ), + egui::ResizeDirection::East, + ), + ]; + + // Each edge gets its own foreground area. A single window-sized Area would + // become the top hit-test layer for the entire UI, including its transparent + // interior, and would swallow every button click. + for (index, (rect, direction)) in zones.into_iter().enumerate() { + let response = egui::Area::new(egui::Id::new(("openless-resize", index))) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .default_size(rect.size()) + .sense(egui::Sense::drag()) + .show(ctx, |ui| ui.set_min_size(rect.size())) + .response; + if response.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction)); + } + } +} + +// ── Sidebar ───────────────────────────────────────────────────────────────── + +pub fn sidebar(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { + let body = body_rect(ctx); + egui::Area::new(egui::Id::new("openless-sidebar")) + .order(egui::Order::Middle) + // Navigation rows own their input. A hover-only area preserves their + // layer while avoiding an invisible area-wide click target. + .sense(egui::Sense::hover()) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + // Constrain the max size too: without it `available_height()` is the + // whole screen and the pinned settings row lands off-window. + ui.set_max_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.set_clip_rect(egui::Rect::from_min_size( + body.min, + egui::vec2(SIDEBAR_WIDTH, body.height()), + )); + // Paint the exact sidebar rect: `ui.max_rect()` can be the whole + // screen, which pushed the rounded bottom-left corner off-window. + let sidebar_rect = + egui::Rect::from_min_size(body.min, egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.painter().rect_filled( + sidebar_rect, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: 0, + }, + theme::SURFACE, + ); + ui.painter().line_segment( + [ + egui::pos2(body.left() + SIDEBAR_WIDTH, body.top()), + egui::pos2(body.left() + SIDEBAR_WIDTH, body.bottom()), + ], + egui::Stroke::new(1.0, theme::LINE), + ); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(10, 12)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_WIDTH - 20.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("BETA") + .size(9.5) + .strong() + .color(theme::BLUE), + ); + }); + ui.label( + egui::RichText::new(fmt_l10n(vm.lang, "shell.version", &[&vm.version])) + .size(10.5) + .color(theme::INK_4), + ); + }); + ui.add_space(12.0); + // The nav scrolls when the window is short so the pinned + // settings row stays reachable. + const PINNED_SETTINGS_HEIGHT: f32 = 46.0; + let nav_height = (ui.available_height() - PINNED_SETTINGS_HEIGHT).max(80.0); + ui.allocate_ui_with_layout( + egui::vec2(SIDEBAR_WIDTH - 20.0, nav_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-sidebar-nav") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(SIDEBAR_WIDTH - 20.0); + nav( + ui, + vm, + "nav.overview", + NavTarget::Page(Page::Overview), + IconName::Overview, + actions, + ); + nav( + ui, + vm, + "nav.history", + NavTarget::Page(Page::History), + IconName::History, + actions, + ); + nav( + ui, + vm, + "nav.vocab", + NavTarget::Page(Page::Vocab), + IconName::Vocab, + actions, + ); + ui.add_space(4.0); + group( + ui, + vm, + "nav.group_style", + IconName::Style, + vm.style_open, + FrontendAction::SidebarToggleStyle, + actions, + ); + if vm.style_open { + subnav( + ui, + vm, + "nav.polish_mode", + NavTarget::Page(Page::Style), + actions, + ); + subnav( + ui, + vm, + "nav.marketplace", + NavTarget::Page(Page::Marketplace), + actions, + ); + } + group( + ui, + vm, + "nav.group_tools", + IconName::SelectionAsk, + vm.tools_open, + FrontendAction::SidebarToggleTools, + actions, + ); + if vm.tools_open { + subnav( + ui, + vm, + "nav.translation", + NavTarget::Page(Page::Translation), + actions, + ); + subnav( + ui, + vm, + "nav.selection_ask", + NavTarget::Page(Page::SelectionAsk), + actions, + ); + subnav( + ui, + vm, + "nav.corrections", + NavTarget::Page(Page::Corrections), + actions, + ); + } + }); + }, + ); + ui.add_space(4.0); + nav_with_icon( + ui, + vm, + "nav.settings", + NavTarget::Page(Page::Settings), + IconName::Settings, + actions, + ); + }); + }); +} + +/// Where a sidebar row navigates to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum NavTarget { + Page(Page), +} + +fn nav_active(vm: &FrontendViewModel, target: NavTarget) -> bool { + match target { + // Settings is an overlay: it highlights while open instead of owning a page. + NavTarget::Page(Page::Settings) => vm.settings_open, + NavTarget::Page(page) => vm.active_page == page, + } +} + +fn nav_click(target: NavTarget, actions: &mut Vec) { + match target { + NavTarget::Page(Page::Settings) => { + // Keep the current page rendered behind the modal. + actions.push(FrontendAction::ToggleSettings); + } + NavTarget::Page(page) => { + actions.push(FrontendAction::Navigate(page)); + } + } +} + +fn nav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + key: &'static str, + target: NavTarget, + icon: IconName, + actions: &mut Vec, +) { + nav_with_icon(ui, vm, key, target, icon, actions); +} + +fn nav_with_icon( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + key: &'static str, + target: NavTarget, + icon: IconName, + actions: &mut Vec, +) { + let label = tr_l10n(vm.lang, key); + let active = nav_active(vm, target); + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let color = if active { theme::INK } else { theme::INK_3 }; + icons::draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + if response.clicked() { + nav_click(target, actions); + } +} + +fn subnav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + key: &'static str, + target: NavTarget, + actions: &mut Vec, +) { + let label = tr_l10n(vm.lang, key); + let active = nav_active(vm, target); + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 30.0), egui::Sense::click()); + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + ui.painter().text( + rect.min + egui::vec2(30.0, 15.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + if active { theme::INK } else { theme::INK_3 }, + ); + if response.clicked() { + nav_click(target, actions); + } +} + +#[allow(clippy::too_many_arguments)] +fn group( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + key: &'static str, + icon: IconName, + is_open: bool, + toggle: FrontendAction, + actions: &mut Vec, +) { + let label = tr_l10n(vm.lang, key); + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + let color = if response.hovered() { + theme::INK_2 + } else { + theme::INK_3 + }; + icons::draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + let x = rect.max.x - 18.0; + let y = rect.center().y; + if is_open { + ui.painter().line_segment( + [egui::pos2(x - 3.0, y - 1.0), egui::pos2(x, y + 2.0)], + egui::Stroke::new(1.2, color), + ); + ui.painter().line_segment( + [egui::pos2(x, y + 2.0), egui::pos2(x + 3.0, y - 1.0)], + egui::Stroke::new(1.2, color), + ); + } else { + ui.painter().line_segment( + [egui::pos2(x - 1.0, y - 3.0), egui::pos2(x + 2.0, y)], + egui::Stroke::new(1.2, color), + ); + ui.painter().line_segment( + [egui::pos2(x + 2.0, y), egui::pos2(x - 1.0, y + 3.0)], + egui::Stroke::new(1.2, color), + ); + } + if response.clicked() { + actions.push(toggle); + } +} + +// ── Content panel ─────────────────────────────────────────────────────────── + +pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::Ui)) { + let body = body_rect(ctx); + let content = egui::Rect::from_min_max( + egui::pos2(body.left() + SIDEBAR_WIDTH + 28.0, body.top()), + egui::pos2(body.right() - 2.0, body.bottom() - 8.0), + ); + egui::Area::new(egui::Id::new("openless-content")) + .order(egui::Order::Middle) + // Buttons and text fields inside the panel register their own hit targets. + .sense(egui::Sense::hover()) + .fixed_pos(content.min) + .show(ctx, |ui| { + ui.set_min_size(content.size()); + ui.set_max_size(content.size()); + ui.set_clip_rect(content); + let scroll = &mut ui.style_mut().spacing.scroll; + scroll.floating = true; + scroll.bar_width = 8.0; + scroll.handle_min_length = 24.0; + scroll.bar_inner_margin = 0.0; + scroll.bar_outer_margin = 0.0; + scroll.foreground_color = false; + scroll.floating_width = 6.0; + scroll.floating_allocated_width = 0.0; + let visuals = &mut ui.style_mut().visuals.widgets; + visuals.inactive.corner_radius = egui::CornerRadius::same(6); + visuals.hovered.corner_radius = egui::CornerRadius::same(6); + visuals.active.corner_radius = egui::CornerRadius::same(6); + add_contents(ui); + }); +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +/// A stable per-card salt derived from its position, so child widget ids stay +/// unique across the cards on a page without threading a name through. +pub fn card_salt(rect: egui::Rect) -> (i32, i32) { + (rect.left().round() as i32, rect.top().round() as i32) +} + +/// Run `contents` inside a child `Ui` pinned to `rect`, **without** moving the +/// parent cursor. +/// +/// `Ui::scope_builder` / `Ui::scope` advance the parent cursor to the child's +/// *used* rect (`scope_dyn` calls `advance_cursor_after_rect`). Cards on these +/// pages are positioned explicitly and mostly paint instead of allocating, so +/// a scope would rewind the cursor and make the next row overlap the card. +/// Use this (or `card_at`) for absolutely positioned content. +pub fn fixed_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + id_salt: impl std::hash::Hash, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt(id_salt) + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(child.clip_rect().intersect(rect)); + contents(&mut child) +} + +pub fn soft_separator(ui: &mut egui::Ui) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, egui::Color32::from_rgb(242, 242, 244)), + ); +} + +/// Width of `text` at `size` points, measured with the live font atlas. +/// Needed wherever a control is laid out by hand rather than by egui's cursor. +pub fn text_width(ui: &egui::Ui, text: &str, size: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + ui.fonts_mut(|fonts| { + fonts + .layout_no_wrap( + text.to_owned(), + egui::FontId::proportional(size), + egui::Color32::PLACEHOLDER, + ) + .size() + .x + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ButtonKind { + /// Transparent fill, hairline border, ink text (the default toolbar look). + Ghost, + /// Filled with the accent blue, white text. + Blue, + /// Greyed-out button that swallows clicks (Tauri's 置灰 停用). + Disabled, +} + +/// A bordered button drawn into an exact rectangle, with an optional leading +/// icon. Used by the pages that position their cards explicitly. +pub fn action_button( + ui: &mut egui::Ui, + rect: egui::Rect, + label: &str, + icon: Option, + kind: ButtonKind, +) -> egui::Response { + let id = ui.id().with(( + "openless-action-button", + label, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(rect, id, egui::Sense::click()); + let (fill, stroke, ink) = match kind { + ButtonKind::Ghost => ( + if response.hovered() { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }, + Some(egui::Stroke::new(0.8, theme::LINE)), + theme::INK_2, + ), + ButtonKind::Blue => ( + if response.hovered() { + theme::BLUE.linear_multiply(0.92) + } else { + theme::BLUE + }, + None, + egui::Color32::WHITE, + ), + ButtonKind::Disabled => ( + egui::Color32::TRANSPARENT, + Some(egui::Stroke::new(0.5, theme::LINE_SOFT)), + theme::INK_4.linear_multiply(0.6), + ), + }; + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(8), fill); + if let Some(stroke) = stroke { + painter.rect_stroke( + rect, + egui::CornerRadius::same(8), + stroke, + egui::StrokeKind::Inside, + ); + } + let label_width = text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 19.0 } else { 0.0 }; + let mut x = rect.center().x - (label_width + icon_space) / 2.0; + if let Some(icon) = icon { + icons::draw_icon(ui, egui::pos2(x + 6.5, rect.center().y), icon, ink); + x += icon_space; + } + painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + ink, + ); + response +} + +/// Draw the standard page header (uppercase kicker, title, optional desc) and +/// return the row so the caller can place right-aligned actions on it. +pub fn page_header( + ui: &mut egui::Ui, + width: f32, + kicker: &str, + title: &str, + desc: Option<&str>, +) -> egui::Rect { + let height = if desc.is_some() { 84.0 } else { 60.0 }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top() + 2.0), + egui::Align2::LEFT_TOP, + kicker, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + painter.text( + egui::pos2(rect.left(), rect.top() + 18.0), + egui::Align2::LEFT_TOP, + title, + egui::FontId::proportional(26.0), + theme::INK, + ); + if let Some(desc) = desc { + painter.text( + egui::pos2(rect.left(), rect.top() + 56.0), + egui::Align2::LEFT_TOP, + desc, + egui::FontId::proportional(13.0), + theme::INK_3, + ); + } + rect +} + +/// Paint the standard card background (white, hairline border, 14pt radius). +pub fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + // Tauri `Card`: --ol-r-lg 圆角 + 0.5px --ol-line 边框 + --ol-shadow-sm。 + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +/// A card with padded contents that never moves the parent layout cursor. +pub fn card( + ui: &mut egui::Ui, + rect: egui::Rect, + padding: f32, + contents: impl FnOnce(&mut egui::Ui, egui::Rect), +) { + paint_card(ui.painter(), rect); + let inner = rect.shrink(padding); + fixed_ui(ui, inner, card_salt(rect), |ui| contents(ui, inner)); +} + +/// iOS-style switch painted into an exact rectangle. +pub fn toggle( + ui: &mut egui::Ui, + rect: egui::Rect, + on: bool, + id_salt: impl std::hash::Hash, +) -> egui::Response { + let response = ui.interact(rect, ui.id().with(id_salt), egui::Sense::click()); + // Tauri `Toggle`: 开启 --ol-blue,关闭 --ol-toggle-off-bg。 + let track = if on { theme::BLUE } else { theme::TOGGLE_OFF }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(10), track); + let knob_x = if on { + rect.right() - 10.0 + } else { + rect.left() + 10.0 + }; + ui.painter().circle_filled( + egui::pos2(knob_x, rect.center().y), + 8.0, + egui::Color32::WHITE, + ); + response +} + +/// Lay out text into a galley with a width/row limit. Used by pages that paint +/// their content at explicit positions instead of with the layout cursor. +pub fn text_galley( + ui: &egui::Ui, + text: &str, + color: egui::Color32, + size: f32, + max_width: f32, + max_rows: usize, +) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = max_width.max(1.0); + job.wrap.max_rows = max_rows; + job.append( + text, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(size), + color, + ..Default::default() + }, + ); + ui.fonts_mut(|fonts| fonts.layout_job(job)) +} + +/// Width of a segmented control for `options`. +pub fn segmented_width(ui: &egui::Ui, options: &[&str]) -> f32 { + let mut width = 4.0; + for (index, option) in options.iter().enumerate() { + if index > 0 { + width += 2.0; + } + width += text_width(ui, option, 12.0) + 18.0; + } + width +} + +/// A segmented button group (the Tauri `ol-seg` control). Returns the index the +/// user clicked. Every segment is a real button, not a text label. +pub fn segmented( + ui: &mut egui::Ui, + rect: egui::Rect, + options: &[&str], + selected: usize, +) -> Option { + let painter = ui.painter().with_clip_rect(rect); + // Tauri track: rgba(0,0,0,0.04) with a 2px inset; the active chip is a white + // surface with a hairline + soft shadow, the label stays ink-colored. + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SEGMENTED_TRACK); + let mut x = rect.left() + 2.0; + let mut clicked = None; + for (index, option) in options.iter().enumerate() { + let width = text_width(ui, option, 12.0) + 18.0; + let option_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.top() + 2.0), + egui::vec2(width, rect.height() - 4.0), + ); + let id = ui.id().with(( + "openless-segment", + index, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(option_rect, id, egui::Sense::click()); + let is_selected = index == selected; + if is_selected { + // Tauri `--ol-segmented-active-shadow` = `0 1px 2px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(0,0,0,0.06)`: + // 选中片是白底、无描边,靠向下 1px 的浅投影 + 0.5px 细环从轨道上"浮"起来。 + painter.rect_filled( + option_rect.expand(1.0).translate(egui::vec2(0.0, 1.0)), + egui::CornerRadius::same(7), + theme::SEGMENTED_ACTIVE_SHADOW, + ); + painter.rect_filled( + option_rect, + egui::CornerRadius::same(6), + theme::SEGMENTED_ACTIVE_BG, + ); + painter.rect_stroke( + option_rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::SEGMENTED_ACTIVE_RING), + egui::StrokeKind::Inside, + ); + } else if response.hovered() { + painter.rect_filled( + option_rect, + egui::CornerRadius::same(6), + egui::Color32::from_white_alpha(140), + ); + } + painter.text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + option, + theme::medium_font(12.0), + if is_selected { + theme::INK + } else { + theme::INK_3 + }, + ); + if response.clicked() { + clicked = Some(index); + } + x += width + 2.0; + } + clicked +} +/// A segmented button group (the Tauri `ol-seg` control). Returns the index the +/// user clicked. Every segment is a real button, not a text label. +/// Tauri `inputStyle`(`src/pages/settings/shared.tsx:243-256`):高度 32、字号 13.5、 +/// 左右 padding 10、radius 8、底色 `--ol-select-trigger-bg`(= `--ol-control-solid`)、 +/// 最大宽度 360。所有设置页的文本输入都走这里,不再逐处写尺寸。 +pub const INPUT_HEIGHT: f32 = 32.0; +pub const INPUT_FONT_SIZE: f32 = 13.5; +pub const INPUT_MAX_WIDTH: f32 = 360.0; + +/// A single-line text input styled like the Tauri settings rows. +/// +/// `password` maps to Tauri's `type="password"`(密钥/令牌字段)。 +pub fn text_input( + ui: &mut egui::Ui, + value: &mut String, + id: egui::Id, + hint: &str, + width: f32, + password: bool, +) -> egui::Response { + let width = width.clamp(80.0, INPUT_MAX_WIDTH); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, INPUT_HEIGHT), egui::Sense::hover()); + ui.painter() + .rect_filled(outer, egui::CornerRadius::same(8), theme::SURFACE); + ui.painter().rect_stroke( + outer, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + // padding 0/10:左右缩进 10,纵向留 1 抵消 0.5px 描边,文字由 vertical_align 居中。 + let inner = outer.shrink2(egui::vec2(10.0, 1.0)); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt(id) + .max_rect(inner) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + let mut edit = egui::TextEdit::singleline(value) + .id(id) + .hint_text(hint) + .font(egui::FontId::proportional(INPUT_FONT_SIZE)) + .text_color(theme::INK) + .frame(false) + .desired_width(inner.width()) + .vertical_align(egui::Align::Center); + if password { + edit = edit.password(true); + } + child.add(edit) +} + +/// A labelled section block: title plus optional smaller description line. +pub fn section_title(ui: &mut egui::Ui, width: f32, title: &str, desc: Option<&str>) { + let height = if desc.is_some() { 40.0 } else { 20.0 }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top()), + egui::Align2::LEFT_TOP, + title, + // Tauri `SectionTitle`: 14 / 600。 + egui::FontId::proportional(14.0), + theme::INK, + ); + if let Some(desc) = desc { + painter.text( + egui::pos2(rect.left(), rect.top() + 20.0), + egui::Align2::LEFT_TOP, + desc, + egui::FontId::proportional(11.5), + theme::INK_4, + ); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PillTone { + /// Transparent fill with a hairline border (used for "raw"). + Outline, + /// Neutral `SURFACE_2` fill. + Gray, + /// Accent-tinted fill. + Blue, +} + +/// Natural size of a small pill for `text`. +pub fn pill_size(ui: &egui::Ui, text: &str) -> egui::Vec2 { + egui::vec2(text_width(ui, text, 10.5) + 16.0, 18.0) +} + +/// Paint a small rounded pill into an exact rectangle. +pub fn paint_pill(painter: &egui::Painter, rect: egui::Rect, text: &str, tone: PillTone) { + let (fill, border, color) = match tone { + PillTone::Outline => (egui::Color32::TRANSPARENT, Some(theme::LINE), theme::INK_3), + PillTone::Gray => (theme::SURFACE_2, None, theme::INK_3), + PillTone::Blue => (theme::BLUE_SOFT, None, theme::BLUE), + }; + painter.rect_filled(rect, egui::CornerRadius::same(9), fill); + if let Some(border) = border { + painter.rect_stroke( + rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, border), + egui::StrokeKind::Inside, + ); + } + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.5), + color, + ); +} + +pub fn unsupported_page(ui: &mut egui::Ui, lang: openless_linux_egui::Lang, title: &str) { + ui.add_space(28.0); + if !title.is_empty() { + ui.label( + egui::RichText::new(title) + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + } + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(openless_linux_egui::tr_l10n( + lang, + "common.unsupported_title", + )) + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(openless_linux_egui::tr_l10n( + lang, + "common.unsupported_hint", + )) + .size(11.0) + .color(theme::INK_4), + ); + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_input_metrics_match_the_tauri_input_style() { + // Tauri `inputStyle`(settings/shared.tsx:243-256):height 32 / fontSize 13.5 / + // padding 0 10 / maxWidth 360。这些取值被设置页所有文本输入共用,改动必须是有意的。 + assert_eq!(INPUT_HEIGHT, 32.0); + assert_eq!(INPUT_FONT_SIZE, 13.5); + assert_eq!(INPUT_MAX_WIDTH, 360.0); + } + + #[test] + fn the_selected_segment_uses_the_tauri_shadow_tokens() { + // Tauri `--ol-segmented-active-shadow` 的两段:细环 + 向下 1px 的浅投影。 + // 选中片没有描边(border: 0),所以环不能等于普通的 --ol-line。 + assert_ne!(theme::SEGMENTED_ACTIVE_RING, theme::LINE); + assert_eq!(theme::SEGMENTED_ACTIVE_BG, theme::SURFACE); + assert!(theme::SEGMENTED_ACTIVE_SHADOW.a() > 0); + assert!(theme::SEGMENTED_ACTIVE_SHADOW.a() < theme::SEGMENTED_ACTIVE_RING.a()); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs new file mode 100644 index 000000000..670cbcb96 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -0,0 +1,507 @@ +use eframe::egui; +use openless_linux_egui::{tr_l10n, Lang}; + +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, MarketplaceSort}; + +/// Every marketplace tile has the same height so rows line up. +const MARKETPLACE_TILE_HEIGHT: f32 = 176.0; + +/// Render the marketplace page. All data comes from the view model; this +/// function is pure rendering — it reads from `vm` and pushes actions. +pub fn marketplace_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + body_rect: egui::Rect, +) { + let lang = vm.lang; + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "marketplace.kicker"), + tr_l10n(lang, "marketplace.title"), + Some(tr_l10n(lang, "marketplace.desc")), + ); + let mine = tr_l10n(lang, "marketplace.my_packs_button_label"); + let mine_width = layout::text_width(ui, mine, 12.5) + 34.0; + let mine_rect = egui::Rect::from_min_size( + egui::pos2(header.right() - mine_width, header.top() + 22.0), + egui::vec2(mine_width, 30.0), + ); + if layout::action_button(ui, mine_rect, mine, None, layout::ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::MarketplaceMyPacks); + } + let refresh = tr_l10n(lang, "marketplace.refresh_btn"); + let refresh_width = layout::text_width(ui, refresh, 12.5) + 34.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(mine_rect.left() - 8.0 - refresh_width, header.top() + 22.0), + egui::vec2(refresh_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(super::icons::IconName::Refresh), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceRefresh); + } + ui.add_space(14.0); + + // Search + sort + ui.horizontal(|ui| { + let search_width = (ui.available_width() - 250.0).max(180.0); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(search_width); + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + let icon_center = icon_rect.center() - egui::vec2(1.5, 1.5); + let icon_stroke = egui::Stroke::new(1.4, theme::INK_3); + ui.painter().circle_stroke(icon_center, 5.5, icon_stroke); + ui.painter().line_segment( + [ + icon_center + egui::vec2(4.0, 4.0), + icon_center + egui::vec2(8.0, 8.0), + ], + icon_stroke, + ); + // Bind the view-model field itself: a local clone loses every + // keystroke on the next frame (the host never echoed it back), + // so the search box looked like it ignored typing. + let resp = ui.add( + egui::TextEdit::singleline(&mut vm.marketplace_query) + .id(egui::Id::new("openless-marketplace-search")) + .hint_text(tr_l10n(lang, "marketplace.search_placeholder")) + .text_color(theme::INK) + .frame(false) + .desired_width(search_width - 34.0), + ); + if resp.changed() { + actions.push(FrontendAction::MarketplaceSearch( + vm.marketplace_query.clone(), + )); + } + }); + }); + ui.add_space(10.0); + for (mode, label) in [ + ( + MarketplaceSort::Popular, + tr_l10n(lang, "marketplace.sort_popular"), + ), + (MarketplaceSort::New, tr_l10n(lang, "marketplace.sort_new")), + ( + MarketplaceSort::Liked, + tr_l10n(lang, "marketplace.sort_liked"), + ), + ] { + let selected = vm.marketplace_sort == mode; + let response = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if selected { + theme::BLUE + } else { + theme::INK_2 + })) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE + }) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(64.0, 30.0)), + ); + if response.clicked() { + actions.push(FrontendAction::MarketplaceSort(mode)); + } + } + }); + ui.add_space(16.0); + + // Notice + if let Some(notice) = &vm.marketplace_notice { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 7)) + .show(ui, |ui| { + ui.label(egui::RichText::new(notice).size(11.5).color(theme::BLUE)); + }); + ui.add_space(10.0); + } + + if vm.marketplace_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tr_l10n(lang, "common.loading")); + }); + return; + } + + if vm.marketplace_unsupported { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "marketplace.kicker")) + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "marketplace.desc")) + .size(11.0) + .color(theme::INK_4), + ); + }); + }); + return; + } + + // 「我赞过的」 is a client-side filter over the signed-in user's like list. + let liked_only = vm.marketplace_sort == MarketplaceSort::Liked; + let visible: Vec = vm + .marketplace_packs + .iter() + .enumerate() + .filter(|(_, pack)| !liked_only || pack.liked) + .map(|(index, _)| index) + .collect(); + + if visible.is_empty() { + let (title, hint) = if liked_only { + ( + tr_l10n(lang, "marketplace.liked_empty"), + tr_l10n(lang, "marketplace.liked_empty_hint"), + ) + } else { + ( + tr_l10n(lang, "marketplace.empty"), + tr_l10n(lang, "marketplace.empty_hint"), + ) + }; + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(title).size(13.0).color(theme::INK_3)); + ui.add_space(4.0); + ui.label(egui::RichText::new(hint).size(11.0).color(theme::INK_4)); + }); + }); + } else { + // Fixed-size tiles, three per row on a wide window: every card is the + // same height so the grid stays aligned regardless of description length. + let columns = if ui.available_width() >= 900.0 { + 3 + } else if ui.available_width() >= 600.0 { + 2 + } else { + 1 + }; + let gap = 12.0; + let width = ui.available_width(); + let card_width = (width - gap * (columns - 1) as f32) / columns as f32; + for chunk in visible.chunks(columns) { + let (row, _) = ui.allocate_exact_size( + egui::vec2(width, MARKETPLACE_TILE_HEIGHT), + egui::Sense::hover(), + ); + for (slot, index) in chunk.iter().enumerate() { + let Some(pack) = vm.marketplace_packs.get(*index) else { + continue; + }; + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (card_width + gap), row.top()), + egui::vec2(card_width, MARKETPLACE_TILE_HEIGHT), + ); + marketplace_card(ui, rect, pack, *index, vm, actions); + } + ui.add_space(gap); + } + } + + // Detail modal + if let Some(index) = vm.marketplace_selected { + if let Some(pack) = vm.marketplace_packs.get(index) { + marketplace_detail(ui.ctx(), lang, pack, index, pack.liked, body_rect, actions); + } + } +} + +fn marketplace_card( + ui: &mut egui::Ui, + rect: egui::Rect, + pack: &super::view_model::MarketplacePack, + index: usize, + vm: &FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let padding = 14.0; + let inner = rect.shrink(padding); + let (response_rect, response) = ( + rect, + ui.interact( + rect, + ui.id().with(("marketplace-card", index)), + egui::Sense::click(), + ), + ); + let _ = response_rect; + let fill = if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(14), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let painter = ui.painter().with_clip_rect(rect); + + // Title row. + painter.text( + inner.left_top(), + egui::Align2::LEFT_TOP, + &pack.name, + egui::FontId::proportional(14.0), + theme::INK, + ); + painter.text( + egui::pos2(inner.right(), inner.top() + 2.0), + egui::Align2::RIGHT_TOP, + format!("v{}", pack.version), + egui::FontId::monospace(10.0), + theme::INK_4, + ); + + // Description, clamped so every tile keeps the same height. + let description = + layout::text_galley(ui, &pack.description, theme::INK_3, 12.0, inner.width(), 3); + let description_top = inner.top() + 26.0; + painter.galley( + egui::pos2(inner.left(), description_top), + description.clone(), + theme::INK_3, + ); + + // Tags directly under the clamped description. + let mut x = inner.left(); + let tags_top = description_top + description.size().y + 8.0; + for (text, tone) in std::iter::once((pack.mode.as_str(), layout::PillTone::Outline)).chain( + pack.tags + .iter() + .take(2) + .map(|tag| (tag.as_str(), layout::PillTone::Gray)), + ) { + let size = layout::pill_size(ui, text); + if x + size.x > inner.right() { + break; + } + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(x, tags_top), size), + text, + tone, + ); + x += size.x + 6.0; + } + + // Footer pinned to the bottom of the fixed tile. + let footer_center_y = rect.bottom() - padding - 12.0; + painter.text( + egui::pos2(inner.left(), footer_center_y), + egui::Align2::LEFT_CENTER, + format!("@{}", pack.author), + egui::FontId::proportional(11.0), + theme::INK_3, + ); + let download = tr_l10n(lang, "marketplace.download_zip_btn"); + let download_width = layout::text_width(ui, download, 11.5) + 26.0; + let download_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - download_width, footer_center_y - 12.0), + egui::vec2(download_width, 24.0), + ); + let install = tr_l10n(lang, "marketplace.install_btn"); + let install_width = layout::text_width(ui, install, 11.5) + 26.0; + let install_rect = egui::Rect::from_min_size( + egui::pos2( + download_rect.left() - 6.0 - install_width, + footer_center_y - 12.0, + ), + egui::vec2(install_width, 24.0), + ); + painter.text( + egui::pos2(install_rect.left() - 10.0, footer_center_y), + egui::Align2::RIGHT_CENTER, + format!("☆ {} · ↓ {}", pack.likes, pack.downloads), + egui::FontId::proportional(10.5), + theme::INK_4, + ); + if layout::action_button(ui, install_rect, install, None, layout::ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::MarketplaceInstall(index)); + } + if layout::action_button(ui, download_rect, download, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::MarketplaceDownload(index)); + } + if response.clicked() { + actions.push(FrontendAction::MarketplaceDetail(index)); + } +} + +fn marketplace_detail( + ctx: &egui::Context, + lang: Lang, + pack: &super::view_model::MarketplacePack, + index: usize, + liked: bool, + body_rect: egui::Rect, + actions: &mut Vec, +) { + let modal_width = (body_rect.width() - 48.0).clamp(320.0, 480.0); + let viewport_center = ctx.content_rect().center(); + let body_center_offset = body_rect.center() - viewport_center; + + // Backdrop + let backdrop_layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("marketplace-detail-backdrop"), + ); + ctx.layer_painter(backdrop_layer).rect_filled( + body_rect, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 14, + se: 14, + }, + theme::OVERLAY, + ); + // Input capture + egui::Area::new(egui::Id::new("marketplace-detail-backdrop-input")) + .order(egui::Order::Foreground) + .fixed_pos(body_rect.min) + .default_size(body_rect.size()) + .constrain(false) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(body_rect.size()); + ui.set_max_size(body_rect.size()); + let _ = ui.allocate_exact_size(body_rect.size(), egui::Sense::click()); + }); + + egui::Area::new(egui::Id::new("marketplace-detail-overlay")) + .order(egui::Order::Tooltip) + .anchor(egui::Align2::CENTER_CENTER, body_center_offset) + .constrain_to(body_rect) + .show(ctx, |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + ui.set_width(modal_width - 40.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&pack.name).size(18.0).strong()); + ui.label( + egui::RichText::new(&pack.mode) + .size(11.0) + .color(theme::INK_3), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(format!("v{}", pack.version)) + .size(10.0) + .color(theme::INK_4), + ); + }); + }); + ui.label( + egui::RichText::new(format!( + "@{} · ☆ {} · ↓ {}", + pack.author, pack.likes, pack.downloads + )) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new(&pack.description) + .size(13.0) + .color(theme::INK_2), + ); + ui.add_space(12.0); + ui.add_space(14.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(if liked { + "★" + } else { + "☆" + })) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceToggleLike(index)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(tr_l10n(lang, "marketplace.install_btn")) + .fill(theme::BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceInstall(index)); + actions.push(FrontendAction::MarketplaceCloseDetail); + } + if ui + .add( + egui::Button::new(tr_l10n(lang, "common.cancel")) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceCloseDetail); + } + }); + }); + }); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs new file mode 100644 index 000000000..acccc324c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -0,0 +1,1282 @@ +pub mod corrections; +pub mod format; +pub mod history; +pub mod icons; +pub mod layout; +pub mod marketplace; +pub mod overview; +pub mod pages; +pub mod popups; +pub mod selection_ask; +pub mod settings; +pub mod siri_gl; +pub mod style; +pub mod translation; +pub mod view_model; +pub mod vocab; + +use eframe::egui; +use view_model::{FrontendAction, FrontendViewModel, Page}; + +/// Re-export the theme module from the parent ui module. +pub use super::theme; + +/// Render the complete egui frontend for one frame. This is the single entry +/// point called from `OpenLessEguiApp::update`. It replaces the old +/// `shell::titlebar` + `shell::sidebar` + `shell::content_panel` calls. +/// +/// The frontend is a pure function of `ctx` and `vm` — it reads display state +/// from the view model and pushes user actions into the `actions` vec. The host +/// drains actions after this call and dispatches them to existing Core/backend +/// methods. +pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { + // Paint the rounded window surface and body canvas. + layout::paint_window_background(ctx); + + // Titlebar with window controls. + layout::titlebar(ctx, actions); + + // Sidebar with navigation. + layout::sidebar(ctx, vm, actions); + + // Resize handles for borderless window. + layout::resize_handles(ctx); + + // Content area. + layout::content_panel(ctx, |ui| { + let body = layout::body_rect(ctx); + + // The Overview dashboard is a single-screen fixed page: it fills the + // height the shell gives it and manages its own internal scrolling, so + // it must not be wrapped in the shared page scroll area. Same for the + // style page (full-height card) and history (two independent columns). + // + // These are *branching* arms rather than early returns: the settings + // overlay below has to be painted on every page, and an early return + // used to skip it (设置按钮在概览/风格/历史页点了没反应). + match vm.active_page { + Page::Overview => overview::page(ui, vm, actions), + Page::Style => style::page(ui, vm, actions), + Page::History => history::page(ui, vm, actions), + page => { + egui::ScrollArea::vertical() + .id_salt("openless-main-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + match page { + Page::Vocab => { + vocab::page(ui, vm, actions); + } + Page::Marketplace => { + marketplace::marketplace_page(ui, vm, actions, body); + } + Page::SelectionAsk => { + selection_ask::page(ui, vm, actions); + } + Page::Translation => { + translation::page(ui, vm, actions); + } + Page::Corrections => { + corrections::page(ui, vm, actions); + } + Page::Overview | Page::History | Page::Style | Page::Settings => { + // Handled above or via overlay. + } + } + ui.add_space(32.0); + }); + } + } + + // Settings overlay (rendered on top of everything). + if vm.settings_open { + settings::settings_overlay(ctx, vm, actions, body); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn viewport() -> egui::Rect { + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)) + } + + fn frame(ctx: &egui::Context, events: Vec) -> Vec { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events, + ..Default::default() + }); + let mut vm = FrontendViewModel::default(); + let mut actions = Vec::new(); + render(ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + actions + } + + #[test] + fn overview_page_renders_populated_dashboard_without_panicking() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + overview_loading: false, + ..Default::default() + }; + vm.settings.activity_heatmap = true; + let heatmap = (0..365) + .map(|index| super::view_model::OverviewHeatmapDay { + date: format!("2026-{:02}-{:02}", index / 31 + 1, index % 31 + 1), + count: (index % 4) as u32, + }) + .collect(); + let activity_daily = (0..30) + .map(|index| super::view_model::OverviewActivityDay { + date: format!("2026-01-{:02}", index + 1), + count: index as u32, + chars: (index * 12) as u64, + duration_ms: (index * 900) as u64, + }) + .collect(); + vm.overview = Some(super::view_model::OverviewSummary { + asr_provider: "volcengine".into(), + llm_provider: "ark".into(), + asr_configured: true, + llm_configured: true, + chars_today: 1234, + segments_today: 7, + duration_ms_today: 45_000, + avg_latency_ms: 6_400, + history_total: 9, + recent: (0..5) + .map(|index| super::view_model::OverviewRecentEntry { + created_at: "2026-01-15T12:34:00+00:00".into(), + final_text: format!("recent item {index}"), + raw_transcript: "raw transcript".into(), + mode: super::view_model::OverviewMode::Raw, + duration_ms: Some(3_100), + }) + .collect(), + activity_daily, + heatmap_year: 2026, + heatmap, + }); + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + + // One more pass whose painted text we inspect: this is the end-to-end + // check that the localized dashboard chrome actually reaches the painter. + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + let painted = painted_text(&output); + // Expected labels are read back from the catalog so the test cannot + // drift from the keys the page actually uses (and stays free of raw + // CJK literals, as the localization contract requires). + for key in [ + "overview.title", + "overview.refresh", + "overview.stats_title", + "overview.metric_chars", + "overview.metric_duration", + "overview.metric_avg", + "overview.metric_total", + "overview.period_last7", + "overview.period_last30", + "overview.metric_count", + "overview.metric_chars_name", + "overview.metric_duration_name", + "overview.recent_title", + "overview.recent_all", + "overview.activity_title", + "overview.mode_raw", + "nav.overview", + "nav.history", + "nav.vocab", + "nav.group_style", + "nav.group_tools", + "nav.translation", + "nav.selection_ask", + "nav.corrections", + "nav.settings", + ] { + let expected = openless_linux_egui::tr_l10n(openless_linux_egui::Lang::ZhCn, key); + assert!( + painted.contains(expected), + "expected the overview dashboard to paint {key} ({expected:?})" + ); + } + } + + fn painted_text(output: &egui::FullOutput) -> String { + fn collect(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + out.push_str(text.galley.text()); + out.push('\n'); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + let mut out = String::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut out); + } + out + } + + #[test] + fn history_page_renders_populated_state_without_panicking() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::History, + history_loading: false, + ..Default::default() + }; + vm.history_entries = vec![ + super::view_model::HistoryEntry { + id: "a".into(), + created_at: "2026-01-15T12:34:00+00:00".into(), + mode: super::view_model::OverviewMode::Raw, + style_label: "raw".into(), + raw_transcript: "raw transcript of the first entry".into(), + final_text: String::new(), + duration_ms: Some(3_100), + insert_status: super::view_model::HistoryInsertStatus::Failed, + has_audio: true, + asr_provider: Some("zhipu".into()), + asr_model: Some("glm-asr-2512".into()), + asr_ms: Some(465), + llm_provider: None, + llm_model: None, + polish_ms: None, + app_name: Some("OpenLess".into()), + dictionary_count: Some(2), + }, + super::view_model::HistoryEntry { + id: "b".into(), + created_at: "2026-01-15T11:00:00+00:00".into(), + mode: super::view_model::OverviewMode::Light, + style_label: "light".into(), + raw_transcript: "second raw".into(), + final_text: "second polished text".into(), + duration_ms: Some(2_400), + insert_status: super::view_model::HistoryInsertStatus::Inserted, + has_audio: false, + ..Default::default() + }, + ]; + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + let painted = painted_text(&output); + let tr = |key: &'static str| openless_linux_egui::tr_l10n(zh, key); + for key in [ + "history.title", + "history.desc", + "common.refresh", + "common.clear", + "history.raw_label", + "history.play", + "history.export", + "history.retranscribe", + "history.step_asr", + "history.step_polish", + "history.step_insert", + "common.copy", + "common.delete", + ] { + let expected = tr(key); + assert!( + painted.contains(expected), + "expected the history page to paint {key} ({expected:?})" + ); + } + // Formatted entries are checked on their substituted form: the first + // entry has an empty final text but two dictionary hits. + let chars = openless_linux_egui::fmt_l10n(zh, "history.chars", &[&0]); + let hits = openless_linux_egui::fmt_l10n(zh, "history.vocab_hits", &[&2]); + let insert_detail = format!("OpenLess · {chars} · {hits}"); + assert!( + painted.contains(&insert_detail), + "expected {insert_detail:?}" + ); + let placeholder = + openless_linux_egui::fmt_l10n(zh, "history.search_placeholder", &[&"Ctrl+K"]); + assert!(painted.contains(&placeholder), "expected {placeholder:?}"); + // The detail panel shows the ASR step and its millisecond timing. + let ms = openless_linux_egui::fmt_l10n(zh, "dur.ms", &[&465]); + assert!(painted.contains(&ms), "expected {ms:?}"); + + // Confirm dialog renders on top when a destructive action is pending. + vm.history_confirm = Some(super::view_model::HistoryConfirm::Clear); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + let painted = painted_text(&output); + let confirm_msg = openless_linux_egui::fmt_l10n( + zh, + "history.confirm_clear", + &[&vm.history_entries.len()], + ); + assert!(painted.contains(&confirm_msg), "expected {confirm_msg:?}"); + assert!(painted.contains(tr("common.cancel"))); + assert!(painted.contains(tr("common.confirm"))); + } + + #[test] + fn text_inputs_keep_and_show_what_the_user_types() { + // Two separate regressions live here: + // * the marketplace search bound a local clone, so the host never wrote + // the field back and every keystroke vanished on the next frame; + // * the settings text rows were re-hydrated from preferences every + // frame, so editing them snapped back to the stored value. + let zh = openless_linux_egui::Lang::ZhCn; + + // 1) marketplace search + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Marketplace, + ..Default::default() + }; + let id = egui::Id::new("openless-marketplace-search"); + // warm up: egui needs a frame before the widget exists / accepts focus + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + for step in ["a", "b", "c"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + assert_eq!( + vm.marketplace_query, "abc", + "the marketplace search field must keep typed characters" + ); + + // 2) settings text row (历史条数上限 lives in 权限与数据 → 数据存储) + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Privacy, + ..Default::default() + }; + let label = + openless_linux_egui::tr_l10n(zh, "settings.recording.history_max_entries_label"); + let id = egui::Id::new(("openless-settings-text", label)); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + let mut painted = String::new(); + for step in ["7", "7"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + assert_eq!( + vm.settings.history_max_entries, "77", + "settings text rows must keep typed characters" + ); + assert!( + painted.contains("77"), + "the typed value must actually be painted" + ); + + // 3) 添加渠道表单里的名称输入框(AI 服务与模型 → 语音识别) + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Services, + services_view: 1, + channel_form_open: true, + ..Default::default() + }; + let id = egui::Id::new("openless-settings-channel-name"); + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + for step in ["m", "y"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + assert_eq!( + vm.channel_form_name, "my", + "the add-channel form must accept typed characters" + ); + assert!( + painted.contains("my"), + "the add-channel form must paint what was typed" + ); + } + + #[test] + fn settings_overlay_opens_from_every_page() { + // Regression: Overview / Style / History returned early from `render`, so + // the settings overlay at the end of the function never ran and the + // 设置 button did nothing on those pages. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let rail_general = openless_linux_egui::tr_l10n(zh, "modal.sections.general"); + for page in [Page::Overview, Page::Style, Page::History, Page::Vocab] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: page, + settings_open: true, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + assert!( + painted.contains(rail_general), + "the settings overlay must render on {page:?} too" + ); + } + } + + #[test] + fn settings_overlay_lists_every_section() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + for section in [ + super::view_model::SettingsSection::General, + super::view_model::SettingsSection::Shortcuts, + super::view_model::SettingsSection::Appearance, + super::view_model::SettingsSection::Services, + super::view_model::SettingsSection::Privacy, + super::view_model::SettingsSection::Advanced, + super::view_model::SettingsSection::About, + ] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: section, + ..Default::default() + }; + // The shortcuts section renders key caps for the live bindings. + vm.dictation_hotkey = "Ctrl+Shift+Z".to_string(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + let painted = painted_text(&output); + for key in [ + "modal.sections.general", + "modal.sections.shortcuts", + "modal.sections.appearance", + "modal.sections.services", + "modal.sections.privacy", + "modal.sections.advanced", + "modal.sections.about", + ] { + let expected = openless_linux_egui::tr_l10n(zh, key); + assert!( + painted.contains(expected), + "settings rail must paint {key} ({expected:?})" + ); + } + // Section blurb under the title, mirroring the Tauri modal. + let desc_key = match section { + super::view_model::SettingsSection::General => "modal.descriptions.general", + super::view_model::SettingsSection::Shortcuts => "modal.descriptions.shortcuts", + super::view_model::SettingsSection::Services => "modal.descriptions.services", + super::view_model::SettingsSection::Appearance => "modal.descriptions.appearance", + super::view_model::SettingsSection::Privacy => "modal.descriptions.privacy", + super::view_model::SettingsSection::Advanced => "modal.descriptions.advanced", + super::view_model::SettingsSection::About => "modal.descriptions.about", + }; + let desc = openless_linux_egui::tr_l10n(zh, desc_key); + assert!( + painted.contains(desc), + "settings section blurb must paint {desc_key} ({desc:?})" + ); + if section == super::view_model::SettingsSection::Shortcuts { + // Key caps: one painted chip per key in the binding. + for cap in ["Ctrl", "Shift", "Z"] { + assert!( + painted.contains(cap), + "shortcut rows must paint the {cap} key cap" + ); + } + } + } + } + + #[test] + fn sidebar_settings_row_stays_reachable_in_a_short_window() { + // Regression: the sidebar painted against `ui.max_rect()` (the whole + // screen), so the rounded bottom-left corner and the pinned settings row + // both landed off-window in a short window. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 520.0)); + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + ..Default::default() + }; + let mut painted: Vec<(String, egui::Rect)> = Vec::new(); + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(small), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + painted.clear(); + for clipped in &output.shapes { + if let egui::Shape::Text(text) = &clipped.shape { + painted.push(( + text.galley.text().to_string(), + text.visual_bounding_rect().intersect(clipped.clip_rect), + )); + } + } + } + let settings = + openless_linux_egui::tr_l10n(openless_linux_egui::Lang::ZhCn, "nav.settings"); + let (_, rect) = painted + .iter() + .find(|(text, _)| text == settings) + .expect("the sidebar must paint the settings row"); + assert!( + rect.height() > 0.0 && rect.bottom() <= small.bottom(), + "the settings row must be visible inside the window: {rect:?}" + ); + } + + #[test] + fn less_computer_rows_follow_the_enable_toggle() { + // Tauri `CodingAgentSection` shows 后端/权限/模型等配置行 only while the + // feature is enabled; a disabled section is just the toggle. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let provider = openless_linux_egui::tr_l10n(zh, "settings.coding_agent.provider"); + for (enabled, expected) in [(false, false), (true, true)] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Advanced, + advanced_open: 0, + ..Default::default() + }; + vm.settings.less_computer = enabled; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + assert_eq!( + painted.lines().any(|line| line.trim() == provider), + expected, + "Less Computer config rows must follow the enable toggle" + ); + } + } + + /// 渲染设置页并把这一帧画出的文字按行返回。 + fn painted_settings_lines( + section: super::view_model::SettingsSection, + vm: &mut FrontendViewModel, + ) -> Vec { + let ctx = egui::Context::default(); + let mut lines = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + vm.lang = openless_linux_egui::Lang::ZhCn; + vm.active_page = Page::Settings; + vm.settings_open = true; + vm.settings_section = section; + render(&ctx, vm, &mut actions); + lines = painted_text(&ctx.end_pass()) + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + } + lines + } + + #[test] + fn shortcut_menu_reveals_record_and_disable() { + let zh = openless_linux_egui::Lang::ZhCn; + let record = openless_linux_egui::tr_l10n(zh, "settings.recording.combo_record_btn"); + let disable = openless_linux_egui::tr_l10n(zh, "settings.shortcuts.disable"); + let mut vm = FrontendViewModel { + shortcut_menu: Some(super::view_model::ShortcutField::Qa), + ..Default::default() + }; + let open = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + assert!( + open.iter().any(|line| line == record), + "the record button must be painted" + ); + assert!( + open.iter().any(|line| line == disable), + "the disable button must be painted" + ); + // 收起菜单后两个按钮都要消失。 + vm.shortcut_menu = None; + let closed = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + assert!(!closed.iter().any(|line| line == record)); + assert!(!closed.iter().any(|line| line == disable)); + } + + #[test] + fn shortcut_rows_follow_the_video_order() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + dictation_hotkey: "Alt+Z".to_string(), + ..Default::default() + }; + let lines = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + let index_of = |key: &'static str| { + let label = openless_linux_egui::tr_l10n(zh, key); + lines + .iter() + .position(|line| line == label) + .unwrap_or_else(|| panic!("{key} ({label:?}) not painted in {lines:?}")) + }; + let start = index_of("settings.shortcuts.start_stop"); + let translation = index_of("hotkey.translation"); + let qa = index_of("selection_ask.hotkey_title"); + let switch_style = index_of("settings.shortcuts.switch_style"); + let style_pack = index_of("settings.shortcuts.style_pack_title"); + let open_app = index_of("settings.shortcuts.open_app"); + let cancel = index_of("settings.shortcuts.cancel"); + assert!( + start < translation + && translation < qa + && qa < switch_style + && switch_style < style_pack + && style_pack < open_app + && open_app < cancel, + "shortcut rows must keep the Tauri order, painted: {lines:?}" + ); + } + + #[test] + fn recording_captures_a_bare_modifier_after_release() { + // egui 没有修饰键的 Key 事件,所以「按住修饰键当热键」只能跨帧判断: + // 第一帧按住 Ctrl、第二帧松开且期间没有其它键 → 记为 LeftControl。 + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::CodingAgentVoice), + ..Default::default() + }; + let held = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + let mut captured = Vec::new(); + for modifiers in [held, held, egui::Modifiers::default()] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + modifiers, + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + for action in actions { + if let FrontendAction::ShortcutCaptured(field, primary, modifiers) = action { + captured.push((field, primary, modifiers)); + } + } + } + assert_eq!(captured.len(), 1, "exactly one capture after the release"); + assert_eq!(captured[0].0, ShortcutField::CodingAgentVoice); + assert_eq!(captured[0].1, "LeftControl"); + assert!(captured[0].2.is_empty(), "a bare modifier carries no tags"); + } + + #[test] + fn recording_ignores_a_modifier_combination_without_a_key() { + // Ctrl+Shift 同按后松手:不是有效的裸修饰键触发,不能录进去。 + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::Qa), + ..Default::default() + }; + let both = egui::Modifiers { + ctrl: true, + command: true, + shift: true, + ..Default::default() + }; + let mut captured = Vec::new(); + for modifiers in [both, both, egui::Modifiers::default()] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + modifiers, + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + captured.extend(actions.into_iter().filter_map(|action| match action { + FrontendAction::ShortcutCaptured(..) => Some(()), + _ => None, + })); + } + assert!(captured.is_empty(), "no capture for a modifier chord"); + } + + #[test] + fn recording_captures_the_pressed_combination() { + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::Qa), + ..Default::default() + }; + let mut captured = None; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Key { + key: egui::Key::K, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { + ctrl: true, + shift: true, + ..Default::default() + }, + }], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + for action in actions { + if let FrontendAction::ShortcutCaptured(field, primary, modifiers) = action { + captured = Some((field, primary, modifiers)); + } + } + } + let (field, primary, modifiers) = captured.expect("a captured binding"); + assert_eq!(field, ShortcutField::Qa); + assert_eq!(primary, "K"); + assert!(modifiers.contains(&"ctrl".to_string())); + assert!(modifiers.contains(&"shift".to_string())); + } + + #[test] + fn style_pack_hotkey_rows_render_their_pack_and_keycaps() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel::default(); + vm.style_packs = vec![ + super::view_model::StylePack { + id: "builtin-polish".into(), + name: "Polish".into(), + description: String::new(), + tags: Vec::new(), + is_builtin: true, + enabled: true, + is_active: true, + selection_active: false, + }, + super::view_model::StylePack { + id: "custom-legal".into(), + name: "Legal".into(), + description: String::new(), + tags: Vec::new(), + is_builtin: false, + enabled: false, + is_active: false, + selection_active: false, + }, + ]; + vm.settings.style_pack_hotkeys = vec![super::view_model::StylePackHotkeyRow { + pack_id: "custom-legal".into(), + name: "Legal".into(), + hotkey: "Ctrl+Shift+L".into(), + }]; + let lines = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + // 停用中的风格包在下拉里带「(已停用)」后缀(Tauri `stylePackDisabledSuffix`)。 + let disabled = format!( + "Legal{}", + openless_linux_egui::tr_l10n(zh, "settings.shortcuts.style_pack_disabled_suffix") + ); + assert!( + lines.iter().any(|line| line == &disabled), + "disabled pack suffix must be shown, painted: {lines:?}" + ); + // 键帽逐键渲染。 + assert!(lines.iter().any(|line| line == "Ctrl"), "modifier keycap"); + assert!(lines.iter().any(|line| line == "Shift"), "modifier keycap"); + assert!(lines.iter().any(|line| line == "L"), "primary keycap"); + } + + #[test] + fn style_pack_add_button_opens_the_draft_row() { + use super::view_model::SettingsSection; + let zh = openless_linux_egui::Lang::ZhCn; + let add = format!( + "+ {}", + openless_linux_egui::tr_l10n(zh, "settings.shortcuts.style_pack_add") + ); + let mut vm = FrontendViewModel::default(); + let closed = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + assert!(closed.iter().any(|line| line == &add), "add button shows"); + vm.style_hotkey_draft_open = true; + let open = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + assert!( + !open.iter().any(|line| line == &add), + "add button hides while drafting" + ); + } + + #[test] + fn ai_service_tabs_follow_the_host_capabilities() { + // Tauri gates the local-model view on `supports_local_asr`; the Linux + // host reports false, so the tab (and its "not supported" card) must + // disappear instead of being permanently visible. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let models = openless_linux_egui::tr_l10n(zh, "modal.service_views.models"); + for (supported, expected) in [(false, false), (true, true)] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Services, + supports_local_asr: supported, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&ctx.end_pass()); + } + // Compare whole painted lines: the section description also mentions + // 「本地模型」, so a substring check would always match. + assert_eq!( + painted.lines().any(|line| line.trim() == models), + expected, + "local-model tab visibility must follow supports_local_asr" + ); + } + } + + #[test] + fn empty_library_pages_render_their_empty_state_not_unsupported() { + // Regression: the library pages only cleared `*_unsupported` when the + // store was non-empty, so an empty dictionary/correction store rendered + // the "not wired up yet" placeholder instead of the empty state. + let zh = openless_linux_egui::Lang::ZhCn; + let unsupported = openless_linux_egui::tr_l10n(zh, "common.unsupported_title"); + for (label, page, empty_key) in [ + ("vocab", Page::Vocab, "vocab.empty"), + ("corrections", Page::Corrections, "vocab.corrections_empty"), + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: page, + // What the host reports once the (empty) library has loaded. + vocab_unsupported: false, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + painted = painted_text(&output); + } + assert!( + !painted.contains(unsupported), + "{label} must not show the unsupported placeholder for an empty store" + ); + let empty = openless_linux_egui::tr_l10n(zh, empty_key); + assert!( + painted.contains(empty), + "{label} must show its empty-state hint ({empty:?})" + ); + } + } + + #[test] + fn overlays_stay_inside_a_small_window() { + // Regression: the style editor used to force a minimum card height, so a + // long prompt pushed the button row past the window edge. Every overlay + // must stay inside the viewport at a small window size. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 620.0)); + let long_prompt = "line\n".repeat(120); + + let cases: [(&str, FrontendViewModel); 2] = [ + ( + "style editor", + FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Style, + style_editor_open: true, + style_prompt: long_prompt.clone(), + ..Default::default() + }, + ), + ( + "settings overlay", + FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + ..Default::default() + }, + ), + ]; + + for (label, mut vm) in cases { + let ctx = egui::Context::default(); + let mut output = None; + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(small), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + output = Some(ctx.end_pass()); + } + let output = output.expect("a frame was rendered"); + for clipped in &output.shapes { + // Only the part inside the shape's clip rect is actually drawn. + let bounds = clipped + .shape + .visual_bounding_rect() + .intersect(clipped.clip_rect); + if !bounds.is_finite() || bounds.width() <= 0.0 || bounds.height() <= 0.0 { + continue; + } + assert!( + bounds.bottom() <= small.bottom() + 2.0, + "{label} painted below the window: {bounds:?} (window {small:?})" + ); + assert!( + bounds.right() <= small.right() + 2.0, + "{label} painted right of the window: {bounds:?} (window {small:?})" + ); + } + } + } + + #[test] + fn fixed_ui_keeps_the_parent_cursor_in_place() { + // Regression: `ui.scope_builder` rewinds the parent cursor to the + // child's used rect, which made each card in a row pull the next row up + // over itself. `layout::fixed_ui` must not move the parent cursor even + // when the card body paints instead of allocating. + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + egui::CentralPanel::default().show(&ctx, |ui| { + ui.allocate_exact_size(egui::vec2(100.0, 10.0), egui::Sense::hover()); + let before = ui.next_widget_position(); + let rect = egui::Rect::from_min_size(before, egui::vec2(240.0, 120.0)); + layout::fixed_ui(ui, rect, "test-card", |ui| { + ui.label("card body"); + }); + assert_eq!( + ui.next_widget_position(), + before, + "fixed_ui must leave the parent layout cursor untouched" + ); + }); + let _ = ctx.end_pass(); + } + + #[test] + fn sidebar_navigation_receives_pointer_clicks_above_window_layers() { + let ctx = egui::Context::default(); + + // Areas use their first pass to establish their screen rectangles. + frame(&ctx, Vec::new()); + frame(&ctx, Vec::new()); + + let pointer = egui::pos2(50.0, 133.0); + assert_eq!( + ctx.layer_id_at(pointer), + Some(egui::LayerId::new( + egui::Order::Middle, + egui::Id::new("openless-sidebar"), + )), + "the sidebar must be the top input layer at a navigation button" + ); + frame( + &ctx, + vec![ + egui::Event::PointerMoved(pointer), + egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + let actions = frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + + assert!( + actions + .iter() + .any(|action| matches!(action, FrontendAction::Navigate(Page::History))), + "the foreground resize layer must not consume sidebar clicks" + ); + } + + #[test] + fn titlebar_close_control_receives_pointer_clicks() { + let ctx = egui::Context::default(); + frame(&ctx, Vec::new()); + frame(&ctx, Vec::new()); + + let pointer = egui::pos2(1214.0, 20.0); + frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let actions = frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + + assert!( + actions + .iter() + .any(|action| matches!(action, FrontendAction::WindowClose)), + "the titlebar container must not consume the close button click" + ); + } + + #[test] + fn style_page_marks_only_the_active_pack_as_current() { + // Regression: the page used to treat its page-local `style_selected` + // index as "active" as well, so a stale index painted a second card in + // the active style. Only the pack the host reports as active may say + // "current" — one badge plus one primary button. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let pack = |name: &str, is_active: bool| super::view_model::StylePack { + id: format!("pack-{name}"), + enabled: true, + name: name.to_string(), + description: "sample description".to_string(), + tags: vec!["light".to_string()], + is_builtin: true, + is_active, + selection_active: false, + }; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Style, + style_unsupported: false, + ..Default::default() + }; + vm.style_packs = vec![ + pack("first", false), + pack("second", true), + pack("third", false), + ]; + vm.style_selected = 0; + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = ctx.end_pass(); + let painted = painted_text(&output); + + let current = openless_linux_egui::tr_l10n(zh, "style.pack.current"); + let activate = openless_linux_egui::tr_l10n(zh, "style.pack.activate"); + assert_eq!( + painted.matches(current).count(), + 2, + "exactly one pack (badge + primary button) may read as current" + ); + assert_eq!( + painted.matches(activate).count(), + 2, + "the two other packs offer an activate button" + ); + assert!( + painted.contains("first") && painted.contains("second") && painted.contains("third") + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/overview.rs b/openless-all/app/linux-egui/src/ui/frontend/overview.rs new file mode 100644 index 000000000..966b81288 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/overview.rs @@ -0,0 +1,1311 @@ +//! Overview page — the 2.0 dashboard. +//! +//! This is a 1:1 port of the Tauri `pages/Overview.tsx` layout: +//! +//! ```text +//! ┌ title + refresh ───────────────────────────────────────────┐ +//! │ 当前语音服务 (only while a provider is unconfigured) │ +//! │ 使用记录 [chars] [duration] [avg] [total] │ +//! │ ┌ period chart (7/30d × count/chars/duration) ┐ ┌ recent ┐ │ +//! │ └───────────────────────────────────────────────┘ └────────┘ │ +//! │ 年度活动 heatmap │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! The page is a single-screen dashboard: it fills the height the shell gives +//! it, wraps its columns when the window is narrow, and never scrolls as a +//! whole (only the recent list scrolls internally). Every visible string comes +//! from the localization catalog through `vm.lang`; nothing is hardcoded. + +use eframe::egui; + +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, OverviewActivityDay, OverviewMode, OverviewRecentEntry, + OverviewSummary, Page, +}; + +const GAP: f32 = 12.0; +const SECTION_GAP: f32 = 14.0; +/// Below this content width the four metric cards collapse into two rows. +const METRIC_STACK_WIDTH: f32 = 660.0; +/// Below this content width the chart and recent cards stack vertically. +const ROW_STACK_WIDTH: f32 = 760.0; +const METRIC_CARD_HEIGHT: f32 = 92.0; +const PROVIDER_CARD_HEIGHT: f32 = 104.0; +const CARD_PADDING: f32 = 14.0; +const BOTTOM_MIN_HEIGHT: f32 = 170.0; +const HEATMAP_MIN_HEIGHT: f32 = 90.0; +const LINE_SOFT: egui::Color32 = theme::LINE_SOFT; +const MONO: f32 = 12.0; + +// ── Entry point ───────────────────────────────────────────────────────────── + +/// Minimum height the single-screen dashboard needs before it starts +/// scrolling: header + stats row + chart row + heatmap. +const OVERVIEW_MIN_HEIGHT: f32 = 600.0; + +pub fn page(ui: &mut egui::Ui, vm: &FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + // Single-screen by default, but a short window must still be reachable: + // scroll the whole dashboard instead of clipping it. + if ui.available_height() < OVERVIEW_MIN_HEIGHT { + egui::ScrollArea::vertical() + .id_salt("openless-overview-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + body(ui, width, vm, actions, OVERVIEW_MIN_HEIGHT); + }); + return; + } + body(ui, width, vm, actions, 0.0); +} + +/// One dashboard layout. `forced_total` > 0 lays the page out for a scroll +/// container of that height instead of the current viewport. +fn body( + ui: &mut egui::Ui, + width: f32, + vm: &FrontendViewModel, + actions: &mut Vec, + forced_total: f32, +) { + let lang = vm.lang; + let start_y = ui.cursor().min.y; + + header(ui, width, lang, actions); + + if vm.overview_loading { + ui.add_space(SECTION_GAP); + placeholder_card( + ui, + width, + tr_l10n(lang, "loading.overview"), + false, + lang, + actions, + ); + return; + } + if let Some(error) = vm.overview_error.as_deref() { + ui.add_space(SECTION_GAP); + placeholder_card(ui, width, error, true, lang, actions); + return; + } + let Some(summary) = vm.overview.as_ref() else { + ui.add_space(SECTION_GAP); + placeholder_card( + ui, + width, + tr_l10n(lang, "overview.metric_no_data"), + true, + lang, + actions, + ); + return; + }; + + ui.add_space(SECTION_GAP); + + if !(summary.asr_configured && summary.llm_configured) { + providers_section(ui, width, summary, lang, actions); + ui.add_space(SECTION_GAP); + } + + stats_section(ui, width, summary, lang); + ui.add_space(SECTION_GAP); + + // The bottom row absorbs the leftover height; the heatmap keeps its + // natural size unless the window is too short, in which case it shrinks + // (and below `HEATMAP_MIN_HEIGHT` it is dropped) so nothing overflows. + // It is only shown when the preference is on and there is activity to draw + // (matching the Tauri `showOverviewActivityHeatmap` gate). + let available = if forced_total > 0.0 { + (forced_total - (ui.cursor().min.y - start_y)).max(BOTTOM_MIN_HEIGHT) + } else { + ui.available_height().max(0.0) + }; + let heatmap_cols = heatmap_columns(summary.heatmap_year, summary.heatmap.len()); + let ideal_heatmap = heatmap_card_height(width, heatmap_cols); + let mut heatmap_height = ideal_heatmap; + if available - heatmap_height - SECTION_GAP < BOTTOM_MIN_HEIGHT { + heatmap_height = (available - BOTTOM_MIN_HEIGHT - SECTION_GAP).max(0.0); + } + let has_activity = summary.heatmap.iter().any(|day| day.count > 0); + let show_heatmap = + heatmap_height >= HEATMAP_MIN_HEIGHT && vm.settings.activity_heatmap && has_activity; + let bottom_height = if show_heatmap { + (available - heatmap_height - SECTION_GAP).max(BOTTOM_MIN_HEIGHT) + } else { + available.max(BOTTOM_MIN_HEIGHT) + }; + + bottom_row(ui, width, bottom_height, vm, summary, lang, actions); + + if show_heatmap { + ui.add_space(SECTION_GAP); + heatmap_card(ui, width, heatmap_height, heatmap_cols, summary, lang); + } +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header(ui: &mut egui::Ui, width: f32, lang: Lang, actions: &mut Vec) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + rect.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.title"), + egui::FontId::proportional(26.0), + theme::INK, + ); + let label = tr_l10n(lang, "overview.refresh"); + let button_width = layout::text_width(ui, label, 12.5) + 42.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - button_width, rect.center().y - 15.0), + egui::vec2(button_width, 30.0), + ); + if layout::action_button( + ui, + button_rect, + label, + Some(IconName::Refresh), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::OverviewRefresh); + } +} + +fn placeholder_card( + ui: &mut egui::Ui, + width: f32, + message: &str, + retry: bool, + lang: Lang, + actions: &mut Vec, +) { + let height = 132.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + paint_card(ui.painter(), rect); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2( + rect.center().x, + rect.center().y - if retry { 14.0 } else { 0.0 }, + ), + egui::Align2::CENTER_CENTER, + message, + egui::FontId::proportional(12.5), + theme::INK_3, + ); + if retry { + let label = tr_l10n(lang, "overview.retry"); + let button_width = layout::text_width(ui, label, 12.5) + 26.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.center().x - button_width / 2.0, rect.center().y + 4.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::OverviewRefresh); + } + } +} + +// ── Providers ─────────────────────────────────────────────────────────────── + +fn providers_section( + ui: &mut egui::Ui, + width: f32, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + let (heading, _) = ui.allocate_exact_size(egui::vec2(width, 20.0), egui::Sense::hover()); + ui.painter().with_clip_rect(heading).text( + heading.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.services_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + ui.add_space(8.0); + + // Only providers still waiting for configuration are worth a card. + let mut pending: Vec<(&'static str, String, &'static str, IconName)> = Vec::new(); + if !summary.asr_configured { + pending.push(( + tr_l10n(lang, "overview.asr_kind"), + provider_name(&summary.asr_provider, lang), + tr_l10n(lang, "overview.provider_help_asr"), + IconName::Mic, + )); + } + if !summary.llm_configured { + pending.push(( + tr_l10n(lang, "overview.llm_kind"), + provider_name(&summary.llm_provider, lang), + tr_l10n(lang, "overview.provider_help_llm"), + IconName::Sparkle, + )); + } + if pending.is_empty() { + return; + } + + let columns = if pending.len() < 2 || width < 620.0 { + 1 + } else { + 2 + }; + let mut index = 0; + while index < pending.len() { + let count = columns.min(pending.len() - index); + cards_row( + ui, + width, + PROVIDER_CARD_HEIGHT, + count, + GAP, + |ui, slot, rect| { + let (kind, name, help, icon) = &pending[index + slot]; + provider_card(ui, rect, kind, name, help, *icon, lang, actions); + }, + ); + index += count; + if index < pending.len() { + ui.add_space(GAP); + } + } +} + +fn provider_name(active: &str, lang: Lang) -> String { + if active.trim().is_empty() { + tr_l10n(lang, "overview.not_set").to_string() + } else { + active.to_string() + } +} + +#[allow(clippy::too_many_arguments)] +fn provider_card( + ui: &mut egui::Ui, + rect: egui::Rect, + kind: &str, + name: &str, + help: &str, + icon: IconName, + lang: Lang, + actions: &mut Vec, +) { + card_scope(ui, rect, 16.0, |ui, inner| { + let icon_rect = egui::Rect::from_min_size(inner.min, egui::vec2(38.0, 38.0)); + let painter = ui.painter().with_clip_rect(inner); + painter.rect_filled(icon_rect, egui::CornerRadius::same(10), theme::BLUE_SOFT); + icons::draw_icon(ui, icon_rect.center(), icon, theme::BLUE); + + let text_left = inner.left() + 50.0; + painter.text( + egui::pos2(text_left, inner.top() + 3.0), + egui::Align2::LEFT_TOP, + kind, + egui::FontId::proportional(12.5), + theme::INK_4, + ); + let pill = tr_l10n(lang, "overview.unconfigured"); + let pill_width = layout::text_width(ui, pill, 10.5) + 18.0; + let pill_rect = egui::Rect::from_min_size( + egui::pos2( + text_left + layout::text_width(ui, kind, 12.5) + 8.0, + inner.top() + 2.0, + ), + egui::vec2(pill_width, 18.0), + ); + painter.rect_stroke( + pill_rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.text( + pill_rect.center(), + egui::Align2::CENTER_CENTER, + pill, + egui::FontId::proportional(10.5), + theme::INK_3, + ); + painter.text( + egui::pos2(text_left, inner.top() + 24.0), + egui::Align2::LEFT_TOP, + name, + egui::FontId::proportional(15.0), + theme::INK, + ); + + // Bottom row: help text on the left, configure action on the right. + let label = tr_l10n(lang, "overview.configure_provider"); + let button_width = layout::text_width(ui, label, 12.5) + 34.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - button_width, inner.bottom() - 28.0), + egui::vec2(button_width, 28.0), + ); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = (button_rect.left() - inner.left() - 12.0).max(10.0); + job.wrap.max_rows = 2; + job.append( + help, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(12.5), + color: theme::INK_3, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.galley( + egui::pos2(inner.left(), button_rect.center().y - galley.size().y / 2.0), + galley, + theme::INK_3, + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::ToggleSettings); + } + }); +} + +// ── Usage metrics ─────────────────────────────────────────────────────────── + +fn stats_section(ui: &mut egui::Ui, width: f32, summary: &OverviewSummary, lang: Lang) { + let (heading, _) = ui.allocate_exact_size(egui::vec2(width, 18.0), egui::Sense::hover()); + ui.painter().with_clip_rect(heading).text( + heading.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.stats_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + ui.add_space(8.0); + + if width < METRIC_STACK_WIDTH { + cards_row(ui, width, METRIC_CARD_HEIGHT, 2, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot, summary, lang); + }); + ui.add_space(GAP); + cards_row(ui, width, METRIC_CARD_HEIGHT, 2, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot + 2, summary, lang); + }); + } else { + cards_row(ui, width, METRIC_CARD_HEIGHT, 4, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot, summary, lang); + }); + } +} + +fn metric_card( + ui: &mut egui::Ui, + rect: egui::Rect, + index: usize, + summary: &OverviewSummary, + lang: Lang, +) { + let (icon, label, value, trend) = match index { + 0 => ( + IconName::Hash, + tr_l10n(lang, "overview.metric_chars"), + format_number(summary.chars_today), + fmt_l10n(lang, "overview.metric_segments", &[&summary.segments_today]), + ), + 1 => ( + IconName::Mic, + tr_l10n(lang, "overview.metric_duration"), + short_duration(summary.duration_ms_today, lang), + String::new(), + ), + 2 => ( + IconName::Clock, + tr_l10n(lang, "overview.metric_avg"), + short_duration(summary.avg_latency_ms, lang), + if summary.segments_today > 0 { + tr_l10n(lang, "overview.metric_avg_trend").to_string() + } else { + tr_l10n(lang, "overview.metric_no_data").to_string() + }, + ), + _ => ( + IconName::Bolt, + tr_l10n(lang, "overview.metric_total"), + format_number(summary.history_total as u64), + fmt_l10n( + lang, + "overview.metric_total_trend", + &[&openless_core::HISTORY_CAP], + ), + ), + }; + + card_scope(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + icons::draw_icon( + ui, + egui::pos2(inner.left() + 6.5, inner.top() + 7.0), + icon, + theme::INK_3, + ); + painter.text( + egui::pos2(inner.left() + 18.0, inner.top() + 7.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::INK_3, + ); + painter.text( + egui::pos2(inner.left(), inner.top() + 20.0), + egui::Align2::LEFT_TOP, + value, + egui::FontId::proportional(22.0), + theme::INK, + ); + painter.text( + egui::pos2(inner.left(), inner.bottom() - 16.0), + egui::Align2::LEFT_TOP, + trend, + egui::FontId::proportional(12.0), + theme::INK_4, + ); + }); +} + +// ── Bottom row: period chart + recent list ────────────────────────────────── + +fn bottom_row( + ui: &mut egui::Ui, + width: f32, + height: f32, + vm: &FrontendViewModel, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + if width >= ROW_STACK_WIDTH { + let (row, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let left_width = ((width - GAP) / 2.4).max(220.0); + let right_width = (width - GAP - left_width).max(220.0); + period_card( + ui, + egui::Rect::from_min_size(row.min, egui::vec2(left_width, height)), + vm, + summary, + lang, + actions, + ); + recent_card( + ui, + egui::Rect::from_min_size( + egui::pos2(row.left() + left_width + GAP, row.top()), + egui::vec2(right_width, height), + ), + summary, + lang, + actions, + ); + } else { + let chart_height = 190.0; + let (chart, _) = + ui.allocate_exact_size(egui::vec2(width, chart_height), egui::Sense::hover()); + period_card(ui, chart, vm, summary, lang, actions); + ui.add_space(GAP); + let recent_height = (height - chart_height - GAP).max(150.0); + let (recent, _) = + ui.allocate_exact_size(egui::vec2(width, recent_height), egui::Sense::hover()); + recent_card(ui, recent, summary, lang, actions); + } +} + +fn period_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &FrontendViewModel, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + let period = vm.overview_period.min(1); + let metric = vm.overview_metric.min(2); + card_scope(ui, rect, 18.0, |ui, inner| { + let period_labels = [ + tr_l10n(lang, "overview.period_last7").to_string(), + tr_l10n(lang, "overview.period_last30").to_string(), + ]; + let metric_labels = [ + tr_l10n(lang, "overview.metric_count").to_string(), + tr_l10n(lang, "overview.metric_chars_name").to_string(), + tr_l10n(lang, "overview.metric_duration_name").to_string(), + ]; + let period_width = segmented_width(ui, &period_labels); + let metric_width = segmented_width(ui, &metric_labels); + let toggle_height = 26.0; + let mut y = inner.top(); + if period_width + metric_width + 8.0 <= inner.width() { + let period_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(period_width, toggle_height), + ); + if let Some(selected) = segmented(ui, period_rect, &period_labels, period) { + actions.push(FrontendAction::OverviewPeriod(selected)); + } + let metric_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - metric_width, y), + egui::vec2(metric_width, toggle_height), + ); + if let Some(selected) = segmented(ui, metric_rect, &metric_labels, metric) { + actions.push(FrontendAction::OverviewMetric(selected)); + } + y += toggle_height + 14.0; + } else { + let period_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(period_width, toggle_height), + ); + if let Some(selected) = segmented(ui, period_rect, &period_labels, period) { + actions.push(FrontendAction::OverviewPeriod(selected)); + } + y += toggle_height + 8.0; + let metric_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(metric_width, toggle_height), + ); + if let Some(selected) = segmented(ui, metric_rect, &metric_labels, metric) { + actions.push(FrontendAction::OverviewMetric(selected)); + } + y += toggle_height + 14.0; + } + + let buckets = period_buckets(summary, period); + let total: f64 = buckets + .iter() + .map(|(_, day)| metric_value(day, metric)) + .sum(); + let daily = if buckets.is_empty() { + 0.0 + } else { + total / buckets.len() as f64 + }; + + let painter = ui.painter().with_clip_rect(inner); + painter.text( + egui::pos2(inner.left(), y), + egui::Align2::LEFT_TOP, + format_metric_value(total, metric, lang), + egui::FontId::proportional(26.0), + theme::INK, + ); + painter.text( + egui::pos2(inner.left(), y + 34.0), + egui::Align2::LEFT_TOP, + fmt_l10n( + lang, + "overview.daily_avg", + &[&format_metric_value(daily, metric, lang)], + ), + egui::FontId::proportional(12.0), + theme::INK_4, + ); + let chart_rect = + egui::Rect::from_min_max(egui::pos2(inner.left(), y + 54.0), inner.right_bottom()); + if chart_rect.height() > 24.0 && chart_rect.width() > 24.0 { + period_chart(ui, chart_rect, &buckets, metric, lang); + } + }); +} + +fn period_buckets(summary: &OverviewSummary, period: usize) -> Vec<(String, OverviewActivityDay)> { + let days = if period == 0 { 7 } else { 30 }; + let start = summary.activity_daily.len().saturating_sub(days); + summary.activity_daily[start..] + .iter() + .map(|day| (day.date.clone(), day.clone())) + .collect() +} + +fn period_chart( + ui: &mut egui::Ui, + rect: egui::Rect, + buckets: &[(String, OverviewActivityDay)], + metric: usize, + lang: Lang, +) { + if buckets.is_empty() { + return; + } + let dense = buckets.len() > 7; + let gap = if dense { 2.0 } else { 8.0 }; + let max = buckets + .iter() + .map(|(_, day)| metric_value(day, metric)) + .fold(1.0_f64, f64::max); + let label_height = 14.0; + let bars_top = rect.top() + if dense { 0.0 } else { 14.0 }; + let bars_bottom = rect.bottom() - label_height; + let bars_height = (bars_bottom - bars_top).max(8.0); + let bar_width = + ((rect.width() - gap * (buckets.len() as f32 - 1.0)) / buckets.len() as f32).max(1.0); + let weekday_labels = split_labels(tr_l10n(lang, "overview.week_days")); + let painter = ui.painter().with_clip_rect(rect); + let last = buckets.len() - 1; + + for (index, (date, day)) in buckets.iter().enumerate() { + let value = metric_value(day, metric); + let left = rect.left() + index as f32 * (bar_width + gap); + let is_today = index == last; + let height = ((value / max) * bars_height as f64) as f32; + let bar = egui::Rect::from_min_max( + egui::pos2(left, bars_bottom - height.max(2.0)), + egui::pos2(left + bar_width, bars_bottom), + ); + let color = if is_today { + theme::BLUE + } else { + with_alpha(theme::INK_4, if value <= 0.0 { 0.15 } else { 0.85 }) + }; + painter.rect_filled( + bar, + egui::CornerRadius::same(if dense { 2 } else { 4 }), + color, + ); + if !dense { + painter.text( + egui::pos2(left + bar_width / 2.0, bars_top - 1.0), + egui::Align2::CENTER_BOTTOM, + format_metric_value(value, metric, lang), + egui::FontId::proportional(9.5), + if is_today { theme::BLUE } else { theme::INK_4 }, + ); + let weekday = weekday_label(date, &weekday_labels); + if !weekday.is_empty() { + painter.text( + egui::pos2(left + bar_width / 2.0, rect.bottom() - label_height + 2.0), + egui::Align2::CENTER_TOP, + weekday, + egui::FontId::proportional(9.5), + theme::INK_4, + ); + } + } + } + + if dense { + for (index, align) in [ + (0usize, egui::Align2::LEFT_TOP), + (last / 2, egui::Align2::CENTER_TOP), + (last, egui::Align2::RIGHT_TOP), + ] { + painter.text( + egui::pos2( + match align { + egui::Align2::LEFT_TOP => rect.left(), + egui::Align2::RIGHT_TOP => rect.right(), + _ => rect.center().x, + }, + rect.bottom() - label_height + 2.0, + ), + align, + short_date(&buckets[index].0), + egui::FontId::proportional(10.0), + theme::INK_4, + ); + } + } +} + +// ── Recent list ───────────────────────────────────────────────────────────── + +fn recent_card( + ui: &mut egui::Ui, + rect: egui::Rect, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + let header_height = 42.0; + let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), header_height)); + let painter = ui.painter().with_clip_rect(rect); + painter.line_segment( + [header.left_bottom(), header.right_bottom()], + egui::Stroke::new(0.5, theme::LINE), + ); + painter.text( + egui::pos2(rect.left() + 18.0, header.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.recent_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + let label = tr_l10n(lang, "overview.recent_all"); + let button_width = layout::text_width(ui, label, 12.0) + 20.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 18.0 - button_width, header.center().y - 14.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::Navigate(Page::History)); + } + + let list = egui::Rect::from_min_max(egui::pos2(rect.left(), header.bottom()), rect.max); + layout::fixed_ui(ui, list, layout::card_salt(rect), |ui| { + egui::ScrollArea::vertical() + .id_salt("overview-recent-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if summary.recent.is_empty() { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 64.0), egui::Sense::hover()); + ui.painter().with_clip_rect(rect).text( + rect.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "overview.recent_empty_hint"), + egui::FontId::proportional(12.0), + theme::INK_4, + ); + return; + } + for (index, entry) in summary.recent.iter().enumerate() { + recent_row(ui, width, entry, index, lang); + } + }); + }); +} + +fn recent_row( + ui: &mut egui::Ui, + width: f32, + entry: &OverviewRecentEntry, + index: usize, + lang: Lang, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 54.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.line_segment( + [rect.left_bottom(), rect.right_bottom()], + egui::Stroke::new(0.5, LINE_SOFT), + ); + + let padding = 18.0; + let time_label = history_time_label(&entry.created_at); + let mode_label = mode_label(lang, entry.mode); + painter.text( + egui::pos2(rect.left() + padding, rect.top() + 15.0), + egui::Align2::LEFT_CENTER, + &time_label, + egui::FontId::monospace(MONO), + theme::INK_3, + ); + let pill_width = layout::text_width(ui, mode_label, 10.5) + 16.0; + let pill = egui::Rect::from_min_size( + egui::pos2(rect.left() + padding, rect.top() + 25.0), + egui::vec2(pill_width, 17.0), + ); + painter.rect_filled(pill, egui::CornerRadius::same(9), theme::SURFACE_2); + painter.text( + pill.center(), + egui::Align2::CENTER_CENTER, + mode_label, + egui::FontId::proportional(10.5), + theme::INK_3, + ); + let column_width = layout::text_width(ui, &time_label, MONO) + .max(pill_width) + .max(60.0); + + let duration_label = entry + .duration_ms + .map(|ms| short_duration(ms, lang)) + .unwrap_or_else(|| "—".to_string()); + let duration_width = layout::text_width(ui, &duration_label, MONO); + let copy_label = tr_l10n(lang, "overview.copy"); + let copy_width = layout::text_width(ui, copy_label, 11.5) + 36.0; + let copy_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - padding - copy_width, rect.top() + 14.0), + egui::vec2(copy_width, 26.0), + ); + painter.text( + egui::pos2(copy_rect.left() - 7.0, rect.top() + 16.0), + egui::Align2::RIGHT_CENTER, + duration_label, + egui::FontId::monospace(11.5), + theme::INK_4, + ); + + let text = if entry.final_text.trim().is_empty() { + entry.raw_transcript.as_str() + } else { + entry.final_text.as_str() + }; + let first_line = text.split('\n').next().unwrap_or(""); + let text_left = rect.left() + padding + column_width + 12.0; + let text_right = copy_rect.left() - 7.0 - duration_width - 10.0; + if text_right > text_left && !first_line.is_empty() { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = text_right - text_left; + job.wrap.max_rows = 2; + job.append( + first_line, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(13.5), + color: theme::INK_2, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.galley( + egui::pos2(text_left, rect.top() + 14.0), + galley, + theme::INK_2, + ); + } + + let copied_id = egui::Id::new(("overview-recent-copied", index)); + let now = ui.input(|input| input.time); + let copied = ui + .ctx() + .data(|data| data.get_temp::(copied_id)) + .is_some_and(|at| now - at < 1.5); + let label = if copied { + tr_l10n(lang, "overview.copied") + } else { + tr_l10n(lang, "overview.copy") + }; + if layout::action_button( + ui, + copy_rect, + label, + Some(IconName::Copy), + layout::ButtonKind::Ghost, + ) + .clicked() + { + ui.ctx().copy_text(text.to_string()); + ui.ctx().data_mut(|data| data.insert_temp(copied_id, now)); + } +} + +fn mode_label(lang: Lang, mode: OverviewMode) -> &'static str { + match mode { + OverviewMode::Raw => tr_l10n(lang, "overview.mode_raw"), + OverviewMode::Light => tr_l10n(lang, "overview.mode_light"), + OverviewMode::Structured => tr_l10n(lang, "overview.mode_structured"), + OverviewMode::Formal => tr_l10n(lang, "overview.mode_formal"), + } +} + +// ── Annual activity heatmap ───────────────────────────────────────────────── + +fn heatmap_card_height(width: f32, columns: f32) -> f32 { + let inner_width = (width - CARD_PADDING * 2.0).max(1.0); + let (_, cell, gap) = heatmap_cell(inner_width, columns); + let grid_height = 7.0 * cell + 6.0 * gap + 16.0; + // The card must be tall enough that the height-constrained cell equals the + // width-constrained cell, otherwise the grid stops short of the right edge. + CARD_PADDING * 2.0 + 24.0 + grid_height +} + +/// Sunday-first week columns needed to lay out the calendar year. +fn heatmap_columns(year: i32, days: usize) -> f32 { + let offset = chrono::NaiveDate::from_ymd_opt(year, 1, 1) + .map(weekday_index) + .unwrap_or(0); + ((offset as f32 + days as f32) / 7.0).ceil().max(1.0) +} + +/// `(step, cell, gap)` for the heatmap grid given the inner card width. +fn heatmap_cell(inner_width: f32, columns: f32) -> (f32, f32, f32) { + let label_width = 30.0; + let available = (inner_width - label_width).max(60.0); + let step = available / columns.max(1.0); + let gap = if step >= 13.0 { 3.0 } else { 2.0 }; + (step, (step - gap).max(4.0), gap) +} + +fn heatmap_card( + ui: &mut egui::Ui, + width: f32, + height: f32, + columns: f32, + summary: &OverviewSummary, + lang: Lang, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + card_scope(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + painter.text( + inner.left_top(), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "overview.activity_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + let grid = egui::Rect::from_min_max( + egui::pos2(inner.left(), inner.top() + 24.0), + inner.right_bottom(), + ); + heatmap_grid(ui, grid, columns, summary, lang); + }); +} + +fn heatmap_grid( + ui: &mut egui::Ui, + rect: egui::Rect, + columns: f32, + summary: &OverviewSummary, + lang: Lang, +) { + if summary.heatmap.is_empty() || rect.height() < 20.0 { + return; + } + let weekdays = split_labels(tr_l10n(lang, "overview.week_days")); + let months = split_labels(tr_l10n(lang, "overview.months")); + let label_width = weekdays + .iter() + .map(|label| layout::text_width(ui, label, 9.0)) + .fold(0.0_f32, f32::max) + + 8.0; + let (_, width_cell, gap) = heatmap_cell(rect.width(), columns); + let month_row = 16.0; + let height_cell = ((rect.height() - month_row - gap * 6.0) / 7.0).max(3.0); + let cell = width_cell.min(height_cell); + let step = cell + gap; + let grid_left = rect.left() + label_width; + let grid_top = rect.top() + month_row; + + // Calendar-year grid, Sunday-first, like the Tauri Heatmap component. + let first = chrono::NaiveDate::from_ymd_opt(summary.heatmap_year, 1, 1); + let Some(first) = first else { + return; + }; + let offset = weekday_index(first) as f32; + let column_count = columns.ceil() as usize; + let max_count = summary + .heatmap + .iter() + .map(|day| day.count) + .max() + .unwrap_or(0); + + let mut month_label: Vec> = vec![None; column_count]; + let painter = ui.painter().with_clip_rect(rect); + for (index, day) in summary.heatmap.iter().enumerate() { + let position = offset as usize + index; + let column = position / 7; + let row = position % 7; + let x = grid_left + column as f32 * step; + let y = grid_top + row as f32 * step; + let color = if day.count == 0 || max_count == 0 { + theme::SURFACE_2 + } else { + heat_color(day.count, max_count) + }; + painter.rect_filled( + egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(cell, cell)), + egui::CornerRadius::same(2), + color, + ); + if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") { + use chrono::Datelike; + if date.day() == 1 && month_label[column].is_none() { + month_label[column] = Some(date.month0() as usize); + } + } + } + + for (column, month) in month_label.iter().enumerate() { + if let Some(month) = month { + if let Some(label) = months.get(*month) { + painter.text( + egui::pos2(grid_left + column as f32 * step, rect.top()), + egui::Align2::LEFT_TOP, + *label, + egui::FontId::proportional(9.0), + theme::INK_4, + ); + } + } + } + for (row, label) in weekdays.iter().enumerate() { + if row % 2 == 1 { + painter.text( + egui::pos2(rect.left(), grid_top + row as f32 * step + cell / 2.0), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(9.0), + theme::INK_4, + ); + } + } +} + +fn heat_color(count: u32, max: u32) -> egui::Color32 { + let ratio = count as f64 / max.max(1) as f64; + let t = ratio.sqrt().min(1.0) as f32; + let min = (191.0, 219.0, 254.0); + let max = (29.0, 78.0, 216.0); + egui::Color32::from_rgb( + (min.0 + (max.0 - min.0) * t) as u8, + (min.1 + (max.1 - min.1) * t) as u8, + (min.2 + (max.2 - min.2) * t) as u8, + ) +} + +// ── Layout primitives ─────────────────────────────────────────────────────── + +fn cards_row( + ui: &mut egui::Ui, + width: f32, + height: f32, + count: usize, + gap: f32, + mut draw: impl FnMut(&mut egui::Ui, usize, egui::Rect), +) { + if count == 0 { + return; + } + let (row, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let card_width = ((width - gap * (count as f32 - 1.0)) / count as f32).max(1.0); + for slot in 0..count { + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (card_width + gap), row.top()), + egui::vec2(card_width, height), + ); + draw(ui, slot, rect); + } +} + +fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +fn card_scope( + ui: &mut egui::Ui, + rect: egui::Rect, + padding: f32, + contents: impl FnOnce(&mut egui::Ui, egui::Rect), +) { + paint_card(ui.painter(), rect); + let inner = rect.shrink(padding); + // `fixed_ui` (not `scope_builder`) so painting card contents never rewinds + // the page cursor and overlaps the next row. + layout::fixed_ui(ui, inner, layout::card_salt(rect), |ui| contents(ui, inner)); +} + +fn segmented_width(ui: &egui::Ui, options: &[String]) -> f32 { + let mut width = 4.0; + for (index, option) in options.iter().enumerate() { + if index > 0 { + width += 2.0; + } + width += layout::text_width(ui, option, 12.0) + 18.0; + } + width +} + +fn segmented( + ui: &mut egui::Ui, + rect: egui::Rect, + options: &[String], + selected: usize, +) -> Option { + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + painter.rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + let mut x = rect.left() + 2.0; + let mut clicked = None; + for (index, option) in options.iter().enumerate() { + let width = layout::text_width(ui, option, 12.0) + 18.0; + let option_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.top() + 2.0), + egui::vec2(width, rect.height() - 4.0), + ); + let id = ui.id().with(( + "overview-segment", + index, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(option_rect, id, egui::Sense::click()); + let is_selected = index == selected; + if is_selected { + painter.rect_filled(option_rect, egui::CornerRadius::same(6), theme::BLUE); + } else if response.hovered() { + painter.rect_filled(option_rect, egui::CornerRadius::same(6), theme::SURFACE); + } + painter.text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + option, + egui::FontId::proportional(12.0), + if is_selected { + egui::Color32::WHITE + } else { + theme::INK_3 + }, + ); + if response.clicked() { + clicked = Some(index); + } + x += width + 2.0; + } + clicked +} + +// ── Text / value helpers ──────────────────────────────────────────────────── + +fn split_labels(value: &str) -> Vec<&str> { + value.split('|').collect() +} + +fn with_alpha(color: egui::Color32, alpha: f32) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + color.r(), + color.g(), + color.b(), + (alpha.clamp(0.0, 1.0) * 255.0) as u8, + ) +} + +fn weekday_index(date: chrono::NaiveDate) -> u32 { + use chrono::Datelike; + date.weekday().num_days_from_sunday() +} + +fn weekday_label(date: &str, labels: &[&str]) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .ok() + .map(weekday_index) + .and_then(|index| labels.get(index as usize).copied()) + .unwrap_or_default() + .to_string() +} + +fn short_date(date: &str) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map(|date| { + use chrono::Datelike; + format!("{}/{}", date.month(), date.day()) + }) + .unwrap_or_else(|_| date.to_string()) +} + +fn history_time_label(created_at: &str) -> String { + use chrono::{Datelike, Timelike}; + let Ok(instant) = chrono::DateTime::parse_from_rfc3339(created_at) else { + return created_at.to_string(); + }; + let local = instant.with_timezone(&chrono::Local); + let now = chrono::Local::now(); + if local.date_naive() == now.date_naive() { + format!("{:02}:{:02}", local.hour(), local.minute()) + } else if local.year() == now.year() { + format!( + "{}/{} {:02}:{:02}", + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } else { + format!( + "{}/{}/{} {:02}:{:02}", + local.year(), + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let bytes = raw.as_bytes(); + let mut out = String::with_capacity(raw.len() + raw.len() / 3); + for (index, byte) in bytes.iter().enumerate() { + if index > 0 && (bytes.len() - index) % 3 == 0 { + out.push(','); + } + out.push(*byte as char); + } + out +} + +fn metric_value(day: &OverviewActivityDay, metric: usize) -> f64 { + match metric { + 1 => day.chars as f64, + 2 => day.duration_ms as f64, + _ => day.count as f64, + } +} + +fn format_metric_value(value: f64, metric: usize, lang: Lang) -> String { + if metric == 2 { + long_duration(value as u64, lang) + } else { + format_number(value.round() as u64) + } +} + +/// Seconds/minutes/hours for a period total (`formatLongDuration`). +fn long_duration(ms: u64, lang: Lang) -> String { + if ms == 0 { + return "0".to_string(); + } + let seconds = (ms as f64 / 1000.0).round() as u64; + if seconds < 60 { + return fmt_l10n(lang, "dur.sec", &[&seconds]); + } + let minutes = seconds / 60; + if minutes < 60 { + return fmt_l10n(lang, "overview.minutes", &[&minutes]); + } + fmt_l10n( + lang, + "overview.hours_minutes", + &[&(minutes / 60), &(minutes % 60)], + ) +} + +/// `formatDuration`: `—` / `3.1 秒` / `3:05`. +fn short_duration(ms: u64, lang: Lang) -> String { + if ms == 0 { + return "—".to_string(); + } + let seconds = ms as f64 / 1000.0; + if seconds < 60.0 { + return fmt_l10n(lang, "dur.sec", &[&format!("{seconds:.1}")]); + } + format!("{}:{:02}", (seconds / 60.0) as u64, (seconds % 60.0) as u64) +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/pages.rs b/openless-all/app/linux-egui/src/ui/frontend/pages.rs new file mode 100644 index 000000000..b1cad03ae --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/pages.rs @@ -0,0 +1,79 @@ +//! Shared page helpers that are still consumed by other page modules. +//! +//! The style page moved to `style.rs`; the correction chip is shared with the +//! corrections page. + +use eframe::egui; + +use super::theme; + +pub fn correction_chip(ui: &mut egui::Ui, label: &str, enabled: bool) -> (bool, bool) { + let fill = if enabled { + theme::SURFACE + } else { + theme::SURFACE_2 + }; + let text_color = if enabled { theme::INK } else { theme::INK_4 }; + let text_galley = ui.painter().layout_no_wrap( + label.to_owned(), + egui::FontId::proportional(12.5), + text_color, + ); + let close_size = 22.0; + let width = 12.0 + text_galley.size().x + 8.0 + close_size + 10.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::click()); + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(16), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.6, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.galley( + egui::pos2( + rect.left() + 12.0, + rect.center().y - text_galley.size().y / 2.0, + ), + text_galley, + text_color, + ); + + let close_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 10.0 - close_size / 2.0, rect.center().y), + egui::vec2(close_size, close_size), + ); + painter.circle_filled(close_rect.center(), close_size / 2.0, theme::SURFACE_2); + painter.circle_stroke( + close_rect.center(), + close_size / 2.0, + egui::Stroke::new(0.5, theme::LINE), + ); + let center = close_rect.center(); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); + painter.line_segment( + [ + center + egui::vec2(-3.0, -3.0), + center + egui::vec2(3.0, 3.0), + ], + x_stroke, + ); + painter.line_segment( + [ + center + egui::vec2(3.0, -3.0), + center + egui::vec2(-3.0, 3.0), + ], + x_stroke, + ); + + if response.clicked() { + if response + .interact_pointer_pos() + .is_some_and(|pointer| close_rect.contains(pointer)) + { + return (false, true); + } + return (true, false); + } + (false, false) +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs new file mode 100644 index 000000000..c2dbcb107 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -0,0 +1,2266 @@ +//! Three auxiliary windows: the dictation capsule, the selection-ask panel and +//! the selection-polish preview. +//! +//! Like the page layer this module is a pure renderer: it reads the popup +//! snapshot and returns at most one action, which the popup host translates into +//! a `PopupToHost` message. Layout, spacing, colours and copy mirror the Tauri +//! windows — `src/components/Capsule.tsx` (classic pill), `src/pages/QaPanel.tsx` +//! (shadcn chat card) and `src/pages/SelectionPolishPreview.tsx`. +//! +//! The chat panel renders in the shadcn zinc palette, which maps onto the theme +//! tokens: white [`theme::SURFACE`], [`theme::INK`] foreground, [`theme::SURFACE_2`] +//! muted fill, [`theme::INK_3`] muted text, [`theme::LINE`] border. + +use eframe::egui; + +use super::{icons, layout, siri_gl, theme}; +use openless_linux_egui::{ + fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, + PreviewPopupState, QaPopupState, +}; + +/// Result of rendering the selection-polish preview. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PreviewAction { + None, + /// ✕ / 取消 → the host sends `CancelPreview`. + Cancel, + /// ✓ 确认并替换 → the host sends `ConfirmPreview` with the edited text. + Confirm(String), +} + +/// Result of rendering the selection-ask panel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QaAction { + None, + /// ✕ → the host sends `DismissQa`. + Dismiss, + /// Enter / 发送 → the host sends `SubmitQa`. + Submit(String), + /// 麦克风按钮 → the host sends `ToggleQaRecording`. + ToggleRecording, + /// 图钉 → the host sends `SetPinned`(固定后不再自动收起)。 + SetPinned(bool), + /// 「编辑指令」勾选框 → the host sends `SetEditInstructionMode`. + SetEditInstructionMode(bool), + /// 「预览并确认插入」→ the host sends `ApplyEdit`. + ApplyEdit, + /// 「保留上一版本」→ the host sends `RevertEdit`. + RevertEdit, +} + +/// Result of rendering the dictation capsule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapsuleAction { + None, + /// ✕ → the host cancels the dictation. + Cancel, + /// ✓ → the host stops the dictation and inserts. + Confirm, +} + +/// Tauri preview window padding (`padding: 18`). +const PREVIEW_PADDING: f32 = 18.0; +/// The QA card uses `--card-spacing` (14px) for its header/footer gutters. +const CARD_SPACING: f32 = 14.0; +/// Composer row height (Tauri `InputGroup`). +const COMPOSER_HEIGHT: f32 = 40.0; +/// Classic capsule pill metrics (Tauri `CLASSIC_PILL_METRICS`). +const PILL_WIDTH: f32 = 176.0; +const PILL_HEIGHT: f32 = 42.0; +/// Round icon buttons in the capsule / composer. +const ROUND_BUTTON: f32 = 28.0; +/// Tauri `getCapsuleHostMetrics(.., 'classic').bottomInset`。 +const CAPSULE_BOTTOM_INSET: f32 = 16.0; +/// 徽章与药丸之间的间距(Tauri `badgeGap`)。 +const CAPSULE_BADGE_GAP: f32 = 8.0; + +// ── 选区润色预览 ──────────────────────────────────────────────────────────── + +/// 选区润色预览:标题 + 副标题 + ✕、可编辑结果框、原文摘要、取消 / 确认并替换。 +pub fn selection_preview( + ctx: &egui::Context, + state: &mut PreviewPopupState, + first_frame: bool, + lang: Lang, +) -> PreviewAction { + let mut action = PreviewAction::None; + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(14)) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .inner_margin(egui::Margin::same(PREVIEW_PADDING as i8)), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "selection.polish_preview.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "selection.polish_preview.subtitle")) + .size(12.0) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3).clicked() { + action = PreviewAction::Cancel; + } + }); + }); + ui.add_space(12.0); + + // 可编辑结果框:撑满剩余高度(Tauri `flex: 1; min-height: 150`)。 + let footer_height = 48.0; + let source_height = if state.source.is_empty() { 0.0 } else { 50.0 }; + let editor_height = (ui.available_height() - footer_height - source_height).max(150.0); + let width = ui.available_width(); + egui::Frame::new() + .fill(theme::CONTENT_BG) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.set_min_size(egui::vec2(width - 24.0, editor_height - 24.0)); + let response = ui.add_sized( + egui::vec2(width - 24.0, editor_height - 24.0), + egui::TextEdit::multiline(&mut state.text) + .frame(false) + .text_color(theme::INK) + .font(egui::FontId::proportional(14.0)), + ); + if first_frame { + response.request_focus(); + } + }); + + if !state.source.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "{}{}", + tr_l10n(lang, "selection.polish_preview.source_prefix"), + truncate(&state.source, 200) + )) + .size(11.0) + .color(theme::INK_4), + ); + } + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let confirm = tr_l10n(lang, "selection.polish_preview.confirm_replace"); + let confirm_width = layout::text_width(ui, confirm, 13.0) + 46.0; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(confirm_width, 34.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::BLUE.gamma_multiply(0.9) + } else { + theme::BLUE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), fill); + icon_text( + ui, + rect, + Some(icons::IconName::Check), + confirm, + theme::SURFACE, + ); + if response.clicked() { + action = PreviewAction::Confirm(state.text.clone()); + } + ui.add_space(8.0); + let cancel = tr_l10n(lang, "selection.polish_preview.cancel"); + let cancel_width = layout::text_width(ui, cancel, 13.0) + 30.0; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(cancel_width, 34.0), egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(7), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(7), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icon_text(ui, rect, None, cancel, theme::INK_2); + if response.clicked() { + action = PreviewAction::Cancel; + } + }); + }); + action +} + +// ── 划词追问 ──────────────────────────────────────────────────────────────── + +/// 划词追问面板:卡片头(标题 + 副行 + ✕)、消息流(空状态 / 对话 / 思考中 / +/// 出错)、底部输入组(选区条 + 输入框 + 麦克风 + 发送)。 +pub fn selection_ask( + ctx: &egui::Context, + state: &QaPopupState, + composer: &mut String, + lang: Lang, + avatar: Option<&egui::TextureHandle>, +) -> QaAction { + let mut action = QaAction::None; + let phase = state.phase.to_ascii_lowercase(); + let recording = phase == "recording"; + // Tauri 在 loading / thinking / awaiting_approval 以及流式增量期间都保持 + // 「思考中」:转圈不停,但已经有流式正文时不再重复显示思考行。 + let thinking = matches!( + phase.as_str(), + "loading" | "thinking" | "awaiting_approval" | "answerdelta" | "answer" + ); + let thinking_row = thinking && state.streaming_answer.is_empty(); + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(14)) + .stroke(egui::Stroke::new(0.5, theme::LINE)), + ) + .show(ctx, |ui| { + // 首帧只编译不绘制地把三个程序编译好(进程内只排一次), + // 免得录音/思考的第一帧才发现要编译——那是按热键后「慢一拍」的来源。 + siri_gl::warm_up(ui); + // ── CardHeader:整条可拖,✕ 在右 ───────────────────────────── + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + let row = ui + .horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.header_hint")) + .size(12.0) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3) + .on_hover_text(tr_l10n(lang, "qa.close_tooltip")) + .clicked() + { + action = QaAction::Dismiss; + } + // 图钉:固定后宿主不再自动收起(Tauri 的 + // qa.pinTooltip / qa.unpinTooltip)。 + let pin_color = if state.pinned { + theme::BLUE + } else { + theme::INK_4 + }; + let pin_tooltip = if state.pinned { + tr_l10n(lang, "qa.unpin_tooltip") + } else { + tr_l10n(lang, "qa.pin_tooltip") + }; + if icon_button(ui, icons::IconName::Pin, pin_color) + .on_hover_text(pin_tooltip) + .clicked() + { + action = QaAction::SetPinned(!state.pinned); + } + }); + }) + .response + .interact(egui::Sense::drag()); + if row.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + }); + hairline(ui, theme::LINE_SOFT); + + // ── CardContent ────────────────────────────────────────────── + // 底部高度必须把新增的「编辑指令」勾选框与「保留上一版本 / 预览并确认 + // 插入」按钮算进去,否则线程区会把它们挤出窗口底部。 + let edit_block = if state.edit_apply_available && phase == "idle" { + (if state.edit_revert_available { + 38.0 + } else { + 0.0 + }) + 38.0 + } else { + 0.0 + }; + let footer_height = CARD_SPACING * 2.0 + COMPOSER_HEIGHT + 12.0 + 22.0 + edit_block; + let content_height = (ui.available_height() - footer_height).max(80.0); + let has_thread = !state.messages.is_empty() + || !state.streaming_answer.is_empty() + || thinking + || state.error.is_some(); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 0)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if !has_thread { + empty_state(ui, lang, content_height); + } else { + egui::ScrollArea::vertical() + .id_salt("openless-qa-thread") + .max_height(content_height) + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + let width = ui.available_width(); + for message in &state.messages { + message_row(ui, message, width, lang, avatar); + ui.add_space(10.0); + } + if !state.streaming_answer.is_empty() { + assistant_row(ui, |ui| { + render_markdown(ui, &state.streaming_answer) + }); + ui.add_space(10.0); + } + if thinking_row { + assistant_row(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.thinking")) + .size(12.0) + .color(theme::INK_3), + ); + }); + ui.add_space(10.0); + } + if let Some(error) = &state.error { + destructive_bubble(ui, |ui| { + ui.label( + egui::RichText::new(error).size(14.0).color(theme::ERR), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "qa.error_retry_hint", + )) + .size(11.5) + .color(theme::ERR.gamma_multiply(0.7)), + ); + }); + } + }); + } + }); + + // ── CardFooter:选区条 + 输入组 ────────────────────────────── + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + // 编辑结果:底部出现「保留上一版本 / 预览并确认插入」 + // (只在轮到 idle 且预览可用时)。 + if state.edit_apply_available && phase == "idle" { + if state.edit_revert_available { + if wide_button(ui, tr_l10n(lang, "qa.edit_revert_previous")) { + action = QaAction::RevertEdit; + } + ui.add_space(6.0); + } + if wide_button_primary( + ui, + tr_l10n(lang, "qa.edit_apply_replace"), + icons::IconName::Check, + ) { + action = QaAction::ApplyEdit; + } + ui.add_space(8.0); + } + if recording { + if let Some(selection) = &state.selection_preview { + selection_chip(ui, selection, lang); + ui.add_space(8.0); + } + } + // 「编辑指令」勾选框(Tauri Composer 左下角,busy 时禁用)。 + let busy = thinking || recording; + let checkbox_label = tr_l10n(lang, "qa.edit_instruction_mode"); + let (checkbox_rect, checkbox_response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 20.0), + if busy { + egui::Sense::hover() + } else { + egui::Sense::click() + }, + ); + let box_rect = egui::Rect::from_center_size( + egui::pos2(checkbox_rect.left() + 7.0, checkbox_rect.center().y), + egui::vec2(14.0, 14.0), + ); + let checked = state.edit_instruction_mode; + ui.painter().rect_filled( + box_rect, + egui::CornerRadius::same(3), + if checked { theme::INK } else { theme::SURFACE }, + ); + ui.painter().rect_stroke( + box_rect, + egui::CornerRadius::same(3), + egui::Stroke::new(0.8, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + if checked { + icons::draw_icon( + ui, + box_rect.center(), + icons::IconName::Check, + theme::SURFACE, + ); + } + ui.painter().text( + egui::pos2(box_rect.right() + 6.0, checkbox_rect.center().y), + egui::Align2::LEFT_CENTER, + checkbox_label, + egui::FontId::proportional(11.5), + if busy { theme::INK_4 } else { theme::INK_3 }, + ); + if checkbox_response.clicked() && !busy { + action = QaAction::SetEditInstructionMode(!checked); + } + ui.add_space(2.0); + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(width, COMPOSER_HEIGHT), + egui::Sense::hover(), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + // olchat-ring:录音红光 / 思考黑光绕输入组转圈。GPU 路径用 + // 圆角矩形 SDF 片元着色器(时间/尺寸/圆角/颜色 4 组 uniform), + // 驱动拒绝着色器时回落到 CPU 采样版。 + if recording || thinking { + let tint = if recording { + color_to_f32(theme::ERR) + } else { + color_to_f32(theme::INK) + }; + let drive = siri_gl::SiriDrive { + level: 0.0, + resolved: if recording { 1.0 } else { 0.0 }, + // 思考态转得更快,和 Tauri 的 state→speed 语义一致。 + speed: if recording { 1.0 } else { 1.45 }, + warming: false, + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "qa-composer-ring", drive, dt); + let glow = siri_gl::SiriGlow::ring( + clock.time, + 12.0, + if recording { 2.0 } else { 1.6 }, + if recording { 1.5 } else { 2.1 }, + ) + .with_tint(tint); + if !siri_gl::paint(ui, rect.expand(3.0), glow) { + spinner_ring(ui, rect, if recording { theme::ERR } else { theme::INK }); + } + } + let inner = rect.shrink2(egui::vec2(10.0, 6.0)); + let mic_rect = egui::Rect::from_center_size( + egui::pos2(inner.right() - ROUND_BUTTON / 2.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let send_rect = egui::Rect::from_center_size( + egui::pos2(inner.right() - ROUND_BUTTON * 1.5 - 4.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let input_rect = egui::Rect::from_min_max( + inner.min, + egui::pos2(send_rect.left() - 6.0, inner.bottom()), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt("openless-qa-composer") + .max_rect(input_rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.set_clip_rect(child.clip_rect().intersect(input_rect)); + let response = child.add( + egui::TextEdit::singleline(composer) + .id(egui::Id::new("openless-qa-composer-input")) + .frame(false) + .text_color(theme::INK) + .font(egui::FontId::proportional(13.5)) + .hint_text(tr_l10n(lang, "qa.composer_placeholder")) + .desired_width(input_rect.width()), + ); + if response.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !composer.trim().is_empty() + { + action = QaAction::Submit(std::mem::take(composer)); + } + let mic = ui.interact( + mic_rect, + ui.id().with("openless-qa-mic"), + egui::Sense::click(), + ); + if recording { + ui.painter().circle_filled( + mic_rect.center(), + ROUND_BUTTON / 2.0, + theme::ERR, + ); + } else if mic.hovered() { + ui.painter().circle_filled( + mic_rect.center(), + ROUND_BUTTON / 2.0, + theme::SURFACE_2, + ); + } + icons::draw_icon( + ui, + mic_rect.center(), + if recording { + icons::IconName::Stop + } else { + icons::IconName::Mic + }, + if recording { + theme::SURFACE + } else { + theme::INK_2 + }, + ); + if mic.clicked() && !thinking { + action = QaAction::ToggleRecording; + } + let can_send = !composer.trim().is_empty() && !thinking; + let send = ui.interact( + send_rect, + ui.id().with("openless-qa-send"), + egui::Sense::click(), + ); + ui.painter().circle_filled( + send_rect.center(), + ROUND_BUTTON / 2.0, + if can_send { + theme::INK + } else { + theme::SURFACE_2 + }, + ); + icons::draw_icon( + ui, + send_rect.center(), + icons::IconName::Send, + if can_send { + theme::SURFACE + } else { + theme::INK_4 + }, + ); + if send.clicked() && can_send { + action = QaAction::Submit(std::mem::take(composer)); + } + }); + }); + action +} + +/// 空状态:居中图标 + 标题 + 说明(Tauri ``)。 +fn empty_state(ui: &mut egui::Ui, lang: Lang, height: f32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), height), + egui::Sense::hover(), + ); + let center = rect.center(); + icons::draw_icon( + ui, + egui::pos2(center.x, center.y - 48.0), + icons::IconName::Chat, + theme::INK_4, + ); + ui.painter().text( + egui::pos2(center.x, center.y - 14.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "qa.empty_title"), + egui::FontId::proportional(14.0), + theme::INK, + ); + let galley = layout::text_galley( + ui, + tr_l10n(lang, "qa.empty_desc"), + theme::INK_4, + 12.0, + (rect.width() - 48.0).min(300.0), + 4, + ); + ui.painter().galley( + egui::pos2(center.x - galley.rect.width() / 2.0, center.y + 6.0), + galley, + theme::INK_4, + ); +} + +/// 一条对话消息:用户右侧深色气泡(带选区引用块)+ 头像;助手左侧头像 + Markdown。 +fn message_row( + ui: &mut egui::Ui, + message: &PopupChatMessage, + width: f32, + lang: Lang, + avatar: Option<&egui::TextureHandle>, +) { + if message.role.eq_ignore_ascii_case("user") { + let selection = message + .selection_text + .as_deref() + .map(|text| truncate(text, 120)) + .filter(|text| !text.is_empty() && *text != message.content); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + user_avatar(ui, avatar); + ui.add_space(8.0); + let max_width = (width - 56.0).max(120.0) * 0.8; + ui.allocate_ui_with_layout( + egui::vec2(max_width, 0.0), + egui::Layout::top_down(egui::Align::Max), + |ui| { + if let Some(selection) = selection { + bubble(ui, theme::SURFACE_2, theme::INK_3, |ui| { + ui.label( + egui::RichText::new(format!("“{selection}”")) + .size(12.0) + .italics() + .color(theme::INK_3), + ); + }); + ui.add_space(4.0); + } + bubble(ui, theme::INK, theme::SURFACE, |ui| { + ui.label( + egui::RichText::new(&message.content) + .size(14.0) + .color(theme::SURFACE), + ); + }); + }, + ); + }); + let _ = lang; + return; + } + assistant_row(ui, |ui| render_markdown(ui, &message.content)); +} + +/// 助手行:深色思考头像 + 内容(内容由调用方渲染在头像右侧)。 +fn assistant_row(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { + ui.horizontal_top(|ui| { + ai_avatar(ui); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.set_max_width((ui.available_width() - 4.0).max(80.0)); + contents(ui); + }); + }); +} + +/// 一个聊天气泡:`rounded-3xl` = 24px,padding 12/10。 +fn bubble( + ui: &mut egui::Ui, + fill: egui::Color32, + ink: egui::Color32, + contents: impl FnOnce(&mut egui::Ui), +) { + let _ = ink; + egui::Frame::new() + .fill(fill) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, contents); +} + +/// 出错气泡(Tauri `variant="destructive"`):红底红字。 +fn destructive_bubble(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::DANGER_SOFT) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, contents); +} + +/// 用户头像:已登录 GitHub 时画真实头像(`github.com/{login}.png`,圆形裁切), +/// 未登录 / 取图失败回落 GitHub 图标(Tauri `UserAvatar`)。 +fn user_avatar(ui: &mut egui::Ui, avatar: Option<&egui::TextureHandle>) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ROUND_BUTTON + 4.0, ROUND_BUTTON + 4.0), + egui::Sense::hover(), + ); + let radius = (ROUND_BUTTON + 4.0) / 2.0; + match avatar { + Some(texture) => { + textured_circle(ui, rect.center(), radius, texture); + } + None => { + ui.painter() + .circle_filled(rect.center(), radius, theme::SURFACE_2); + icons::draw_icon(ui, rect.center(), icons::IconName::Github, theme::INK_2); + } + } +} + +/// 把一张方形贴图画成圆形:以中心为扇形顶点、UV 按圆周比例展开。 +fn textured_circle(ui: &egui::Ui, center: egui::Pos2, radius: f32, texture: &egui::TextureHandle) { + const SEGMENTS: usize = 48; + let mut mesh = egui::Mesh::with_texture(texture.id()); + mesh.vertices.push(egui::epaint::Vertex { + pos: center, + uv: egui::pos2(0.5, 0.5), + color: egui::Color32::WHITE, + }); + for index in 0..=SEGMENTS { + let angle = index as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + let pos = center + egui::vec2(angle.cos(), angle.sin()) * radius; + mesh.vertices.push(egui::epaint::Vertex { + pos, + uv: egui::pos2(0.5 + angle.cos() * 0.5, 0.5 + angle.sin() * 0.5), + color: egui::Color32::WHITE, + }); + if index > 0 { + mesh.indices + .extend_from_slice(&[0, index as u32, index as u32 + 1]); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// 输入区上方的整宽次级按钮(Tauri `Button variant="outline"`)。 +fn wide_button(ui: &mut egui::Ui, label: &str) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 32.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(13.0), + theme::INK_2, + ); + response.clicked() +} + +/// 输入区上方的整宽主按钮(Tauri `Button`,带 ✓)。 +fn wide_button_primary(ui: &mut egui::Ui, label: &str, icon: icons::IconName) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 32.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::INK_2 + } else { + theme::INK + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + icon_text(ui, rect, Some(icon), label, theme::SURFACE); + response.clicked() +} + +/// 助手头像:深色圆底 + 旋转的思考光点(Tauri 的 `OrbAvatar`)。 +fn ai_avatar(ui: &mut egui::Ui) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ROUND_BUTTON + 4.0, ROUND_BUTTON + 4.0), + egui::Sense::hover(), + ); + let radius = (ROUND_BUTTON + 4.0) / 2.0; + ui.painter() + .circle_filled(rect.center(), radius, theme::INK); + let time = ui.input(|input| input.time) as f32; + let mut previous: Option = None; + for step in 0..14 { + let angle = time * 1.6 + step as f32 * std::f32::consts::TAU / 14.0; + let alpha = (30.0 + 225.0 * (step as f32 / 13.0)).min(255.0) as u8; + let point = rect.center() + egui::vec2(angle.cos(), angle.sin()) * (radius * 0.42); + if let Some(previous) = previous { + ui.painter().line_segment( + [previous, point], + egui::Stroke::new( + 2.0, + egui::Color32::from_rgba_unmultiplied(150, 185, 255, alpha), + ), + ); + } + previous = Some(point); + } + ui.painter().circle_filled( + rect.center(), + 2.6, + egui::Color32::from_rgba_unmultiplied(150, 185, 255, 235), + ); +} + +/// 录音时的选区上下文条(Tauri `SelectionChip`)。 +fn selection_chip(ui: &mut egui::Ui, text: &str, lang: Lang) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.selection_preview")) + .size(11.5) + .color(theme::INK_3), + ); + ui.label( + egui::RichText::new(truncate(text, 60)) + .size(11.5) + .color(theme::INK_2), + ); + }); + }); +} + +/// 输入组外圈:Tauri 用 conic-gradient 假 border,这里按圆角矩形周长采样做出 +/// 同样的「转圈高光」(egui 没有锥形渐变)。 +fn spinner_ring(ui: &egui::Ui, rect: egui::Rect, color: egui::Color32) { + let time = ui.input(|input| input.time) as f32; + let points = rounded_rect_points(rect.expand(2.0), 10.0, 64); + let head = (time * 1.1).rem_euclid(1.0); + for (index, window) in points.windows(2).enumerate() { + let phase = index as f32 / points.len() as f32; + let distance = (phase - head).rem_euclid(1.0); + let intensity = if distance < 0.22 { + 1.0 - distance / 0.22 + } else { + 0.0 + }; + let alpha = (38.0 + intensity * 217.0).min(255.0) as u8; + ui.painter().line_segment( + [window[0], window[1]], + egui::Stroke::new( + 2.0, + egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha), + ), + ); + } +} + +/// 圆角矩形的周长采样点(顺时针,从右下角弧开始)。 +fn rounded_rect_points(rect: egui::Rect, radius: f32, segments: usize) -> Vec { + let radius = radius.min(rect.width() / 2.0).min(rect.height() / 2.0); + let corners = [ + (rect.right() - radius, rect.bottom() - radius, 0.0_f32), + ( + rect.left() + radius, + rect.bottom() - radius, + std::f32::consts::FRAC_PI_2, + ), + ( + rect.left() + radius, + rect.top() + radius, + std::f32::consts::PI, + ), + ( + rect.right() - radius, + rect.top() + radius, + 3.0 * std::f32::consts::FRAC_PI_2, + ), + ]; + let per_corner = (segments / 4).max(2); + let mut points = Vec::with_capacity(per_corner * 4); + for (center_x, center_y, start) in corners { + for step in 0..=per_corner { + let angle = start + std::f32::consts::FRAC_PI_2 * (step as f32 / per_corner as f32); + points.push(egui::pos2( + center_x + radius * angle.cos(), + center_y + radius * angle.sin(), + )); + } + } + points +} + +/// egui color → the shader's `uTint` (linear 0..1, gamma-space value is fine +/// here because the glow is additive on a translucent window). +fn color_to_f32(color: egui::Color32) -> [f32; 3] { + [ + f32::from(color.r()) / 255.0, + f32::from(color.g()) / 255.0, + f32::from(color.b()) / 255.0, + ] +} + +fn hairline(ui: &mut egui::Ui, color: egui::Color32) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, color), + ); +} + +// ── 录音胶囊 ──────────────────────────────────────────────────────────────── + +/// 药丸上方的「正在翻译」徽章(Tauri `ClassicCapsule` 的 `capsule.translating`): +/// 蓝点 + 蓝字、圆角胶囊、`--ol-capsule-badge-bg` 底、`--ol-capsule-badge-border` 边。 +fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { + let label = tr_l10n(lang, "capsule.translating"); + let text_width = layout::text_width(ui, label, 10.5); + let width = text_width + 5.0 + 5.0 + 20.0; + let height = 19.0; + let rect = egui::Rect::from_center_size( + egui::pos2( + pill.center().x, + pill.top() - CAPSULE_BADGE_GAP - height / 2.0, + ), + egui::vec2(width, height), + ); + let painter = ui.painter(); + painter.rect_filled( + rect, + egui::CornerRadius::same((height / 2.0) as u8), + theme::CAPSULE_BADGE_BG, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same((height / 2.0) as u8), + egui::Stroke::new(0.5, theme::CAPSULE_BADGE_BORDER), + egui::StrokeKind::Inside, + ); + let dot = egui::pos2(rect.left() + 10.0, rect.center().y); + painter.circle_filled(dot, 2.5, theme::BLUE); + painter.text( + egui::pos2(dot.x + 5.0 + 2.5, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(10.5), + theme::BLUE, + ); +} + +/// 录音胶囊:经典药丸(Tauri `ClassicPill`)—— 左 ✕、中间状态、右 ✓。 +pub fn dictation_capsule( + ctx: &egui::Context, + state: &CapsulePopupState, + lang: Lang, +) -> CapsuleAction { + let mut action = CapsuleAction::None; + let phase = state.phase.to_ascii_lowercase(); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + // 胶囊进程的首帧预热(同 QA 面板;录音环与 Siri 波都是 GPU 路径)。 + siri_gl::warm_up(ui); + // Tauri 经典药丸宿主:窗口高 100,药丸水平居中、距底 16,徽章再上移 8。 + let available = ui.available_rect_before_wrap(); + let rect = egui::Rect::from_min_size( + egui::pos2( + available.center().x - PILL_WIDTH / 2.0, + available.bottom() - CAPSULE_BOTTOM_INSET - PILL_HEIGHT, + ), + egui::vec2(PILL_WIDTH, PILL_HEIGHT), + ); + let _ = ui.allocate_rect(rect, egui::Sense::hover()); + if state.translation_active { + translating_badge(ui, rect, lang); + } + // Tauri 的经典药丸只有「1px 中性描边」+「随音量轻微放大」两件事 + // (Capsule.tsx 的 ClassicPill:border 1px var(--ol-capsule-pill-border)、 + // transform scale(1 + ambient * 0.018)),**没有**任何外圈扫光/描边颜色变化。 + // 所以这里不再把录音相位画成红圈(那是本仓自己加的,用户报「有一个红边」); + // 运动感只保留药丸中心的音量波形。 + let ambient = if phase == "recording" { + state.audio_level.unwrap_or(0.0).clamp(0.0, 1.0) + } else { + 0.0 + }; + let pill = + egui::Rect::from_center_size(rect.center(), rect.size() * (1.0 + ambient * 0.018)); + ui.painter().rect_filled( + pill, + egui::CornerRadius::same((PILL_HEIGHT / 2.0) as u8), + theme::SURFACE, + ); + ui.painter().rect_stroke( + pill, + egui::CornerRadius::same((PILL_HEIGHT / 2.0) as u8), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let cancel_rect = egui::Rect::from_center_size( + egui::pos2(rect.left() + 8.0 + ROUND_BUTTON / 2.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let cancel = ui.interact( + cancel_rect, + ui.id().with("openless-capsule-cancel"), + egui::Sense::click(), + ); + round_button( + ui, + cancel_rect, + icons::IconName::Close, + cancel.hovered(), + theme::INK_2, + ); + if cancel.clicked() { + action = CapsuleAction::Cancel; + } + let confirm_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 8.0 - ROUND_BUTTON / 2.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let confirm = ui.interact( + confirm_rect, + ui.id().with("openless-capsule-confirm"), + egui::Sense::click(), + ); + round_button( + ui, + confirm_rect, + icons::IconName::Check, + confirm.hovered(), + theme::INK_2, + ); + if confirm.clicked() { + action = CapsuleAction::Confirm; + } + let center = egui::Rect::from_min_max( + egui::pos2(cancel_rect.right() + 4.0, rect.top() + 4.0), + egui::pos2(confirm_rect.left() - 4.0, rect.bottom() - 4.0), + ); + let processing = matches!( + phase.as_str(), + "starting" | "transcribing" | "polishing" | "inserting" + ); + if phase == "recording" { + // Siri 声波(Tauri `SiriGL` wave 模式):GPU 路径用真实电平驱动, + // 失败时回落到经典五根音量条。 + let drive = siri_gl::SiriDrive { + level: state.audio_level.unwrap_or_default(), + resolved: 1.0, + speed: 1.0, + warming: state.audio_level.is_none(), + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "capsule-siri-wave", drive, dt); + let glow = siri_gl::SiriGlow::wave(clock.time, clock.level, clock.resolved); + if !siri_gl::paint(ui, center, glow) { + audio_bars(ui, center, state.audio_level.unwrap_or_default()); + } + } else if processing { + // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 + let drive = siri_gl::SiriDrive { + level: 0.0, + resolved: 0.0, + speed: 1.3, + warming: false, + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "capsule-siri-orb", drive, dt); + // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 + let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); + let glow = siri_gl::SiriGlow::orb(clock.time, gather); + if !siri_gl::paint(ui, center, glow) { + ui.painter().text( + center.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "capsule.thinking"), + egui::FontId::proportional(17.0), + theme::INK, + ); + } + } else if state.text.is_empty() { + let label = if processing { + tr_l10n(lang, "capsule.thinking") + } else if phase == "cancelled" { + tr_l10n(lang, "capsule.cancelled") + } else if phase == "failed" { + tr_l10n(lang, "capsule.error") + } else { + tr_l10n(lang, "capsule.thinking") + }; + let size = if processing { 17.0 } else { 11.0 }; + ui.painter().text( + center.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(size), + if phase == "failed" { + theme::ERR + } else { + theme::INK + }, + ); + } else { + // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 + let galley = + layout::text_galley(ui, &state.text, theme::INK_2, 11.0, center.width(), 1); + ui.painter().galley( + egui::pos2( + center.center().x - galley.rect.width() / 2.0, + center.center().y - galley.rect.height() / 2.0, + ), + galley, + theme::INK_2, + ); + } + }); + action +} + +/// 28×28 圆形按钮(Tauri `CircleButton`)。 +fn round_button( + ui: &egui::Ui, + rect: egui::Rect, + icon: icons::IconName, + hovered: bool, + ink: egui::Color32, +) { + ui.painter().circle_filled( + rect.center(), + rect.width() / 2.0, + if hovered { + theme::SURFACE_2.gamma_multiply(1.06) + } else { + theme::SURFACE_2 + }, + ); + ui.painter().circle_stroke( + rect.center(), + rect.width() / 2.0, + egui::Stroke::new(0.8, theme::LINE), + ); + icons::draw_icon(ui, rect.center(), icon, ink); +} + +/// 音量条:Tauri `AudioBars`(5 根 3px 竖条,包络 0.55/0.85/1/0.85/0.55, +/// 过静音门限后按 0.42 次幂提亮)。 +fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32) { + const ENVELOPE: [f32; 5] = [0.55, 0.85, 1.0, 0.85, 0.55]; + const BASE: f32 = 2.0; + const MAX: f32 = 24.0; + let voice = level.clamp(0.0, 1.0); + let gated = ((voice - 0.012) / (0.34 - 0.012)).clamp(0.0, 1.0); + let eased = gated * gated * (3.0 - 2.0 * gated); + let visual = eased.powf(0.42); + let bar_width = 3.0; + let gap = 3.0; + let total = ENVELOPE.len() as f32 * bar_width + (ENVELOPE.len() - 1) as f32 * gap; + let mut x = rect.center().x - total / 2.0; + for envelope in ENVELOPE { + let height = BASE + (MAX - BASE) * visual * envelope; + ui.painter().rect_filled( + egui::Rect::from_center_size( + egui::pos2(x + bar_width / 2.0, rect.center().y), + egui::vec2(bar_width, height), + ), + egui::CornerRadius::same(2), + theme::INK_2, + ); + x += bar_width + gap; + } +} + +// ── 共享小件 ──────────────────────────────────────────────────────────────── + +/// 30×30 无底色图标按钮(Tauri `size-icon-sm` ghost)。 +fn icon_button(ui: &mut egui::Ui, icon: icons::IconName, color: egui::Color32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(30.0, 30.0), egui::Sense::click()); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), theme::SURFACE_2); + } + icons::draw_icon(ui, rect.center(), icon, color); + response +} + +/// 在矩形内居中画「[图标] 文字」。 +fn icon_text( + ui: &egui::Ui, + rect: egui::Rect, + icon: Option, + text: &str, + color: egui::Color32, +) { + let text_width = layout::text_width(ui, text, 13.0); + let icon_width = if icon.is_some() { 16.0 } else { 0.0 }; + let gap = if icon.is_some() { 6.0 } else { 0.0 }; + let start = rect.center().x - (text_width + gap + icon_width) / 2.0; + if let Some(icon) = icon { + icons::draw_icon( + ui, + egui::pos2(start + icon_width / 2.0, rect.center().y), + icon, + color, + ); + } + ui.painter().text( + egui::pos2(start + icon_width + gap, rect.center().y), + egui::Align2::LEFT_CENTER, + text, + egui::FontId::proportional(13.0), + color, + ); +} + +/// 极简 Markdown:标题 / 列表 / 代码块 / `**粗体**` / `*斜体*` / `` `等宽` ``。 +/// 覆盖 Tauri `AssistantMarkdown` 会产出的块级结构;不做表格与引用块。 +pub fn render_markdown(ui: &mut egui::Ui, markdown: &str) { + let mut code = String::new(); + let mut in_code = false; + for line in markdown.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + if in_code { + code_block(ui, code.trim_end()); + code.clear(); + } + in_code = !in_code; + continue; + } + if in_code { + code.push_str(line); + code.push('\n'); + continue; + } + if trimmed.is_empty() { + ui.add_space(6.0); + continue; + } + let (text, size, strong, bullet) = if let Some(value) = trimmed.strip_prefix("### ") { + (value, 14.0, true, false) + } else if let Some(value) = trimmed.strip_prefix("## ") { + (value, 15.0, true, false) + } else if let Some(value) = trimmed.strip_prefix("# ") { + (value, 16.0, true, false) + } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + (&trimmed[2..], 14.0, false, true) + } else { + (trimmed, 14.0, false, false) + }; + if bullet { + ui.horizontal_top(|ui| { + ui.add_space(2.0); + ui.label(egui::RichText::new("•").size(size).color(theme::INK_3)); + ui.label(inline_job(ui, text, size, strong)); + }); + } else { + ui.label(inline_job(ui, text, size, strong)); + } + } + if !code.is_empty() { + code_block(ui, code.trim_end()); + } +} + +fn inline_job(ui: &egui::Ui, text: &str, size: f32, strong: bool) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = ui.available_width().max(40.0); + append_inline(&mut job, text, size, strong, false, false); + job +} + +/// 行内样式:`**粗体**`、`*斜体*`、`` `等宽` ``。 +fn append_inline( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, +) { + let mut rest = text; + while !rest.is_empty() { + let mut matched = false; + for (open, close, next_strong, next_italics, next_monospace) in [ + ("**", "**", true, italics, monospace), + ("`", "`", strong, italics, true), + ("*", "*", strong, true, monospace), + ("_", "_", strong, true, monospace), + ] { + if let Some(after_open) = rest.strip_prefix(open) { + if let Some(end) = after_open.find(close) { + append_span( + job, + &after_open[..end], + size, + next_strong, + next_italics, + next_monospace, + ); + rest = &after_open[end + close.len()..]; + matched = true; + break; + } + } + } + if matched { + continue; + } + let next = ["**", "*", "`", "_"] + .iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + let length = if next == 0 { + rest.chars().next().map(char::len_utf8).unwrap_or(0) + } else { + next + }; + append_span(job, &rest[..length], size, strong, italics, monospace); + rest = &rest[length..]; + } +} + +fn append_span( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, +) { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: egui::FontId::new( + size, + if monospace { + egui::FontFamily::Monospace + } else { + egui::FontFamily::Proportional + }, + ), + color: if strong { theme::INK } else { theme::INK_2 }, + background: if monospace { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }, + italics, + ..Default::default() + }, + ); +} + +fn code_block(ui: &mut egui::Ui, code: &str) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(code) + .monospace() + .size(12.5) + .color(theme::INK_2), + ); + }); +} + +fn truncate(text: &str, max: usize) -> String { + let chars: Vec = text.chars().collect(); + if chars.len() <= max { + return text.to_string(); + } + let mut out: String = chars[..max].iter().collect(); + out.push('…'); + out +} + +/// 格式化「已插入 N」文案(宿主在 Completed 阶段缺少 Core message 时使用)。 +pub fn inserted_message(lang: Lang, chars: usize) -> String { + fmt_l10n(lang, "capsule.inserted", &[&chars]) +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_linux_egui::PopupChatMessage; + + fn painted_text(output: &egui::FullOutput) -> String { + let mut text = String::new(); + for clipped in output.shapes.iter() { + collect(&clipped.shape, &mut text); + } + text + } + + fn collect(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + for row in &text.galley.rows { + for glyph in &row.glyphs { + if glyph.chr != '\0' { + out.push(glyph.chr); + } + } + out.push('\n'); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + + /// Glyphs are collected row by row, so wrapped copy contains newlines. + /// Compare whitespace-insensitively. + fn flat(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() + } + + /// Whether the painted output contains `needle`, ignoring line wrapping. + fn has(painted: &str, needle: &str) -> bool { + flat(painted).contains(&flat(needle)) + } + + /// Render one popup for two frames (egui sizes some widgets lazily) and + /// return everything it painted. + fn run(size: egui::Vec2, mut render: impl FnMut(&egui::Context) -> String) -> String { + // Every popup test renders the same frontend as the GPU-state tests, so + // they share the process-global glow flags and must not run in parallel. + let _guard = super::siri_gl::gpu_state_guard(); + let ctx = egui::Context::default(); + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + ..Default::default() + }); + let _ = render(&ctx); + painted = painted_text(&ctx.end_pass()); + } + painted + } + + #[test] + fn polish_preview_paints_header_source_and_actions() { + let mut state = PreviewPopupState { + text: "polished text".to_string(), + source: "source paragraph".to_string(), + }; + let painted = run(egui::vec2(480.0, 300.0), |ctx| { + let action = selection_preview(ctx, &mut state, false, Lang::ZhCn); + assert_eq!(action, PreviewAction::None); + String::new() + }); + for expected in [ + tr_l10n(Lang::ZhCn, "selection.polish_preview.title"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.subtitle"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.confirm_replace"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.cancel"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.source_prefix"), + ] { + assert!( + has(&painted, expected), + "preview must paint {expected:?}\n{painted}" + ); + } + } + + #[test] + fn polish_preview_confirm_returns_edited_text() { + let ctx = egui::Context::default(); + let mut state = PreviewPopupState { + text: "edited result".to_string(), + source: String::new(), + }; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(480.0, 300.0), + )), + ..Default::default() + }); + // 直接走一次渲染,确认没有输入时不会误报动作。 + let action = selection_preview(&ctx, &mut state, true, Lang::ZhCn); + let _ = ctx.end_pass(); + assert_eq!(action, PreviewAction::None); + assert!(state.text == "edited result"); + } + + #[test] + fn ask_panel_paints_empty_state_then_thread() { + let empty = QaPopupState { + phase: "idle".to_string(), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &empty, &mut composer, Lang::ZhCn, None); + String::new() + }); + for expected in [ + tr_l10n(Lang::ZhCn, "qa.title"), + tr_l10n(Lang::ZhCn, "qa.header_hint"), + tr_l10n(Lang::ZhCn, "qa.empty_title"), + tr_l10n(Lang::ZhCn, "qa.empty_desc"), + tr_l10n(Lang::ZhCn, "qa.composer_placeholder"), + ] { + assert!( + has(&painted, expected), + "empty ask panel must paint {expected:?}\n{painted}" + ); + } + + let thread = QaPopupState { + phase: "thinking".to_string(), + messages: vec![ + PopupChatMessage { + role: "user".to_string(), + content: "how should I read this?".to_string(), + selection_text: Some("selected source".to_string()), + }, + PopupChatMessage { + role: "assistant".to_string(), + content: "**key point** here.".to_string(), + selection_text: None, + }, + ], + selection_preview: Some("selected source".to_string()), + streaming_answer: String::new(), + error: Some("network error".to_string()), + edit_instruction_mode: false, + edit_apply_available: false, + edit_revert_available: false, + pinned: false, + viewer_login: String::new(), + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &thread, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!(has(&painted, "how should I read this?"), "{painted}"); + assert!(has(&painted, "selected source"), "{painted}"); + assert!(has(&painted, "network error"), "{painted}"); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.thinking")), + "{painted}" + ); + } + + #[test] + fn ask_panel_recording_shows_selection_chip_and_ring() { + let state = QaPopupState { + phase: "recording".to_string(), + selection_preview: Some("selection shown while recording".to_string()), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.selection_preview")), + "{painted}" + ); + assert!( + has(&painted, "selection shown while recording"), + "{painted}" + ); + } + + /// 录音/思考只排队**药丸中心**的 GPU 视觉(录音=声波,思考=流体圆点), + /// 终态一个都没有。 + /// + /// 以前这里是 2:录音还会多排一个**外圈红扫光**、思考多一个黑扫光。Tauri 的 + /// 经典药丸只有 1px 中性描边(Capsule.tsx 的 `border: 1px + /// var(--ol-capsule-pill-border)`),没有外圈扫光——用户报「语音输入弹窗有一个 + /// 红边」就是它。所以数字固定成 1/1/0,谁再把外圈加回来这里就会红。 + #[test] + fn capsule_queues_the_gpu_glow_per_state() { + // The GPU state is process-global; take the shared test guard. + let _guard = super::siri_gl::gpu_state_guard(); + super::siri_gl::seed_gpu_ready_for_tests(); + let callbacks = |state: CapsulePopupState| { + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(200.0, 100.0), + )), + ..Default::default() + }); + let _ = dictation_capsule(&ctx, &state, Lang::ZhCn); + let output = ctx.end_pass(); + output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count() + }; + assert_eq!( + callbacks(CapsulePopupState { + phase: "recording".into(), + audio_level: Some(0.2), + ..Default::default() + }), + 1, + "recording = siri wave only, no perimeter ring" + ); + assert_eq!( + callbacks(CapsulePopupState { + phase: "transcribing".into(), + ..Default::default() + }), + 1, + "thinking = orb only, no perimeter ring" + ); + assert_eq!( + callbacks(CapsulePopupState { + phase: "inserted".into(), + text: "hello".into(), + ..Default::default() + }), + 0, + "terminal capsule paints no glow" + ); + } + + #[test] + fn capsule_shows_the_translating_badge_only_when_translating() { + let badge = tr_l10n(Lang::ZhCn, "capsule.translating"); + let idle = CapsulePopupState { + phase: "Recording".to_string(), + audio_level: Some(0.3), + translation_active: false, + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 100.0), |ctx| { + dictation_capsule(ctx, &idle, Lang::ZhCn); + String::new() + }); + assert!( + !has(&painted, badge), + "badge must stay hidden while translating is off: {painted}" + ); + + let translating = CapsulePopupState { + translation_active: true, + ..idle + }; + let painted = run(egui::vec2(200.0, 100.0), |ctx| { + dictation_capsule(ctx, &translating, Lang::ZhCn); + String::new() + }); + assert!(has(&painted, badge), "{painted}"); + } + + #[test] + fn qa_panel_shows_edit_affordances_only_when_the_host_reports_them() { + let hidden = QaPopupState { + phase: "idle".to_string(), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &hidden, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!(!has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace"))); + assert!(!has( + &painted, + tr_l10n(Lang::ZhCn, "qa.edit_revert_previous") + )); + + let ready = QaPopupState { + phase: "idle".to_string(), + edit_apply_available: true, + edit_revert_available: true, + ..Default::default() + }; + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &ready, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace")), + "{painted}" + ); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_revert_previous")), + "{painted}" + ); + // 「编辑指令」勾选框常驻在输入组左下角。 + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_instruction_mode")), + "{painted}" + ); + + // 只有可回退时才出现「保留上一版本」。 + let apply_only = QaPopupState { + phase: "idle".to_string(), + edit_apply_available: true, + ..Default::default() + }; + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &apply_only, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace")), + "{painted}" + ); + assert!(!has( + &painted, + tr_l10n(Lang::ZhCn, "qa.edit_revert_previous") + )); + } + + #[test] + fn qa_panel_paints_the_pin_affordance_in_both_states() { + // 图钉是无文字的图标按钮:这里断言两种状态都能整帧渲染(含 tooltip 绑定), + // 动作本身由 popup.rs 的协议测试覆盖。 + for pinned in [false, true] { + let state = QaPopupState { + phase: "idle".to_string(), + pinned, + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.empty_title")), + "pinned={pinned}\n{painted}" + ); + } + } + + #[test] + fn qa_panel_renders_the_github_avatar_texture_when_present() { + let ctx = egui::Context::default(); + let image = egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4]); + let texture = ctx.load_texture("test-avatar", image, egui::TextureOptions::LINEAR); + let state = QaPopupState { + phase: "idle".to_string(), + messages: vec![PopupChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + selection_text: None, + }], + ..Default::default() + }; + let mut composer = String::new(); + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(520.0, 520.0), + )), + ..Default::default() + }); + selection_ask(&ctx, &state, &mut composer, Lang::ZhCn, Some(&texture)); + painted = painted_text(&ctx.end_pass()); + } + assert!(has(&painted, "hello"), "{painted}"); + } + + /// Every fill / stroke colour the frame painted, so a test can assert the + /// classic pill never grows a coloured outline again. + fn painted_colors(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(rect) => { + out.push(rect.fill); + out.push(rect.stroke.color); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + painted_colors(shape, out); + } + } + _ => {} + } + } + + /// Render one capsule frame and collect the colours plus callback count. + fn capsule_frame(state: &CapsulePopupState) -> (Vec, usize) { + let _guard = super::siri_gl::gpu_state_guard(); + let ctx = egui::Context::default(); + let mut colors = Vec::new(); + let mut callbacks = 0; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(200.0, 100.0), + )), + ..Default::default() + }); + let _ = dictation_capsule(&ctx, state, Lang::ZhCn); + let output = ctx.end_pass(); + colors.clear(); + callbacks = 0; + for clipped in &output.shapes { + painted_colors(&clipped.shape, &mut colors); + if matches!(clipped.shape, egui::Shape::Callback(_)) { + callbacks += 1; + } + } + } + (colors, callbacks) + } + + /// Whether a colour reads as the OpenLess error red (the old ring tint). + fn is_reddish(color: egui::Color32) -> bool { + color.a() > 40 && color.r() > 150 && color.g() < 110 && color.b() < 110 + } + + #[test] + fn recording_capsule_paints_no_coloured_outline() { + // Tauri 的经典药丸只有 1px 中性描边(Capsule.tsx:border 1px + // var(--ol-capsule-pill-border)),录音时只把药丸随音量放大 1.8%。 + // 外圈红/黑扫光是本仓自己加的,用户报「语音输入弹窗有一个红边」—— + // 这条测试锁死它不许回来。 + for phase in ["Recording", "Transcribing", "Polishing"] { + let state = CapsulePopupState { + phase: phase.to_string(), + text: String::new(), + audio_level: Some(0.6), + translation_active: false, + }; + let (colors, _) = capsule_frame(&state); + let reddish: Vec<_> = colors + .iter() + .copied() + .filter(|color| is_reddish(*color)) + .collect(); + assert!( + reddish.is_empty(), + "{phase} capsule must not paint a red outline, found {reddish:?}" + ); + } + } + + #[test] + fn recording_capsule_keeps_its_centre_visual() { + // 去掉外圈之后,录音相位的运动感来自药丸中心(GPU 波形,失败时回退成 + // Tauri 的 5 根音量竖条)——两者至少有一个必须在。 + let state = CapsulePopupState { + phase: "Recording".to_string(), + text: String::new(), + audio_level: Some(0.6), + translation_active: false, + }; + let (colors, callbacks) = capsule_frame(&state); + // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 + let fills = colors.iter().filter(|color| color.a() > 0).count(); + assert!( + callbacks > 0 || fills >= 6, + "recording capsule must keep the centre visual (callbacks={callbacks}, fills={fills})" + ); + } + + #[test] + fn capsule_paints_state_specific_content() { + let recording = CapsulePopupState { + phase: "Recording".to_string(), + text: String::new(), + audio_level: Some(0.4), + translation_active: false, + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &recording, Lang::ZhCn); + String::new() + }); + assert!( + !painted.contains(tr_l10n(Lang::ZhCn, "capsule.thinking")), + "recording capsule shows level bars, not the thinking label: {painted}" + ); + + let transcribing = CapsulePopupState { + phase: "Transcribing".to_string(), + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &transcribing, Lang::ZhCn); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), + "{painted}" + ); + + let done = CapsulePopupState { + phase: "Completed".to_string(), + text: inserted_message(Lang::ZhCn, 12), + audio_level: None, + translation_active: false, + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &done, Lang::ZhCn); + String::new() + }); + assert!(has(&painted, "12"), "{painted}"); + + let failed = CapsulePopupState { + phase: "Failed".to_string(), + text: String::new(), + audio_level: None, + translation_active: false, + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &failed, Lang::ZhCn); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "{painted}" + ); + } +} + +// ── Less Computer 面板 ────────────────────────────────────────────────────── + +/// Less Computer 浮窗的动作(宿主转成 `PopupToHost` 消息)。 +pub enum LessComputerAction { + None, + /// ✕ → 只收起面板(已完成的一轮保留)。 + Dismiss, + /// Esc / 停止 → 取消当前这一轮。 + Cancel, + /// 输入框回车 / 发送。 + Submit(String), + /// 阻塞命令的批准或拒绝。 + Approve { + token: String, + approved: bool, + }, +} + +/// Less Computer 语音 Agent 浮窗(Tauri `LessComputerPanel.tsx`)。 +/// +/// 面板只呈现宿主推来的事件序列(`LessComputerPopupState::entries`),不解释产品意图: +/// 用户指令是右对齐气泡、工具调用与上下文压缩是行内标记、助手正文走 markdown, +/// 阻塞命令在输入框上方给出批准 / 拒绝。 +pub fn less_computer( + ctx: &egui::Context, + state: &LessComputerPopupState, + composer: &mut String, + lang: Lang, +) -> LessComputerAction { + let mut action = LessComputerAction::None; + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(14)) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .inner_margin(egui::Margin::same(CARD_SPACING as i8)), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(3.0); + // 运行中显示「执行中…」,否则是那句「想让电脑做什么?」的副标题。 + let subtitle = if state.working { + tr_l10n(lang, "less_computer.working") + } else { + tr_l10n(lang, "less_computer.subtitle") + }; + ui.label(egui::RichText::new(subtitle).size(12.0).color(theme::INK_4)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3).clicked() { + action = LessComputerAction::Dismiss; + } + }); + }); + ui.add_space(10.0); + + // 审批卡的实际高度随命令/警告文字行数变化(警告会换行到两行),预留值取 + // “单行标题 + 等宽命令 + 两行警告 + 按钮行 + 内边距”。取小了会把 + // 底部输入框挤出卡片外(越出窗口下缘),这是实测发现的。 + let approval_height = if state.approval.is_some() { 158.0 } else { 0.0 }; + let list_height = + (ui.available_height() - COMPOSER_HEIGHT - approval_height - 12.0).max(120.0); + ui.allocate_ui(egui::vec2(ui.available_width(), list_height), |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-less-computer") + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + if state.entries.is_empty() && !state.working { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.subtitle")) + .size(13.0) + .color(theme::INK_4), + ); + }); + } + for entry in &state.entries { + match entry.kind.as_str() { + "user" => user_bubble(ui, &entry.text), + "assistant" => { + ui.add_space(2.0); + render_markdown(ui, &entry.text); + ui.add_space(2.0); + } + "tool" | "note" => marker_row(ui, &entry.text, theme::INK_3), + "compaction" => compaction_marker(ui, &entry.text), + "error" => { + ui.label( + egui::RichText::new(&entry.text) + .size(12.5) + .color(theme::ERR), + ); + } + _ => marker_row(ui, &entry.text, theme::INK_3), + } + } + if state.working { + ui.add_space(2.0); + marker_row(ui, tr_l10n(lang, "less_computer.working"), theme::INK_3); + } + }); + }); + + if let Some(approval) = &state.approval { + ui.add_space(6.0); + egui::Frame::new() + .fill(theme::WARN_SOFT) + .stroke(egui::Stroke::new(0.5, theme::WARN)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.approval_title")) + .size(12.5) + .strong() + .color(theme::INK), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(&approval.command) + .font(egui::FontId::monospace(11.5)) + .color(theme::INK_2), + ); + if !approval.reason.is_empty() { + ui.add_space(3.0); + ui.label( + egui::RichText::new(&approval.reason) + .size(11.0) + .color(theme::INK_3), + ); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if small_action_button(ui, tr_l10n(lang, "less_computer.approve"), true) + .clicked() + { + action = LessComputerAction::Approve { + token: approval.token.clone(), + approved: true, + }; + } + if small_action_button(ui, tr_l10n(lang, "less_computer.deny"), false) + .clicked() + { + action = LessComputerAction::Approve { + token: approval.token.clone(), + approved: false, + }; + } + }); + }); + } + + ui.add_space(6.0); + let width = ui.available_width(); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, COMPOSER_HEIGHT), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let send_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 20.0, rect.center().y), + egui::vec2(28.0, 28.0), + ); + let send = ui + .interact( + send_rect, + egui::Id::new("less-computer-send"), + egui::Sense::click(), + ) + .on_hover_text(tr_l10n(lang, "less_computer.send")); + if !composer.trim().is_empty() { + ui.painter().rect_filled( + send_rect, + egui::CornerRadius::same(14), + if send.hovered() { + theme::INK_2 + } else { + theme::INK + }, + ); + icons::draw_icon( + ui, + send_rect.center(), + icons::IconName::Send, + theme::SURFACE, + ); + } + let text_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + 12.0, rect.top()), + egui::pos2(send_rect.left() - 6.0, rect.bottom()), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt("less-computer-composer") + .max_rect(text_rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + let response = child.add( + egui::TextEdit::singleline(composer) + .id(egui::Id::new("less-computer-composer-input")) + .hint_text(tr_l10n(lang, "less_computer.input_placeholder")) + .font(egui::FontId::proportional(13.5)) + .text_color(theme::INK) + .frame(false) + .desired_width(text_rect.width()) + .vertical_align(egui::Align::Center), + ); + let submitted = + response.lost_focus() && child.input(|input| input.key_pressed(egui::Key::Enter)); + if submitted || send.clicked() { + let text = composer.trim().to_string(); + if !text.is_empty() { + action = LessComputerAction::Submit(text); + } + } + // Esc 取消当前这一轮(Tauri 面板的 Esc 语义)。 + if ui.input(|input| input.key_pressed(egui::Key::Escape)) { + action = LessComputerAction::Cancel; + } + }); + action +} + +/// 右对齐的用户指令气泡(Tauri `Bubble align="end"`)。 +fn user_bubble(ui: &mut egui::Ui, text: &str) { + ui.add_space(2.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let max = (ui.available_width() * 0.82).max(80.0); + ui.set_max_width(max); + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(9, 6)) + .show(ui, |ui| { + ui.set_max_width(max - 18.0); + ui.label(egui::RichText::new(text).size(12.5).color(theme::INK)); + }); + }); + ui.add_space(2.0); +} + +/// 行内标记:小圆点 + 辅助色文字(Tauri `Marker`)。 +fn marker_row(ui: &mut egui::Ui, text: &str, color: egui::Color32) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 2.0, color); + ui.label(egui::RichText::new(text).size(11.5).color(color)); + }); +} + +/// 上下文压缩标记(Tauri `Marker variant="separator"`)。 +fn compaction_marker(ui: &mut egui::Ui, text: &str) { + ui.add_space(2.0); + ui.horizontal(|ui| { + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 16.0), egui::Sense::hover()); + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); + let galley = ui.painter().layout_no_wrap( + text.to_owned(), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + let center = rect.center(); + ui.painter().rect_filled( + egui::Rect::from_center_size(center, galley.size() + egui::vec2(10.0, 0.0)), + egui::CornerRadius::same(7), + theme::SURFACE, + ); + ui.painter().galley( + egui::pos2( + center.x - galley.rect.width() / 2.0, + center.y - galley.rect.height() / 2.0, + ), + galley, + theme::INK_4, + ); + }); + ui.add_space(2.0); +} + +/// 批准 / 拒绝按钮(Tauri `Button`)。 +fn small_action_button(ui: &mut egui::Ui, label: &str, primary: bool) -> egui::Response { + let text = egui::RichText::new(label).size(11.5); + let button = if primary { + egui::Button::new(text.color(theme::SURFACE)).fill(theme::INK) + } else { + egui::Button::new(text.color(theme::INK_2)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + }; + ui.add( + button + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(56.0, 26.0)), + ) +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs b/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs new file mode 100644 index 000000000..e7e9b41f8 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs @@ -0,0 +1,226 @@ +//! Selection-ask (划词追问) page — port of the Tauri `pages/SelectionAsk.tsx`. +//! +//! The guide is a plain section with three columns (01 / 02 / 03) followed by a +//! footer row, then the save-history card with its switch. The shortcut-settings +//! entry sits under the title on the right. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, SettingsSection}; + +const GAP: f32 = 16.0; +const CARD_PADDING: f32 = 20.0; +const COLUMN_GAP: f32 = 18.0; +const GUIDE_HEIGHT: f32 = 96.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.selection_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "selection_ask.title")); + return; + } + + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "nav.selection_ask"), + tr_l10n(lang, "selection_ask.title"), + Some(tr_l10n(lang, "selection_ask.desc")), + ); + + // Saved toast, right aligned above the shortcut-settings entry. + if let Some(notice) = vm.settings_notice.clone() { + let toast = format!("✓ {notice}"); + let size = layout::pill_size(ui, &toast); + let rect = egui::Rect::from_min_size( + egui::pos2(header.right() - size.x, header.top() + 4.0), + size, + ); + layout::paint_pill(ui.painter(), rect, &toast, layout::PillTone::Blue); + } + + let settings_label = tr_l10n(lang, "selection_ask.shortcut_settings"); + let settings_width = layout::text_width(ui, settings_label, 12.5) + 46.0; + let settings_rect = egui::Rect::from_min_size( + egui::pos2(header.right() - settings_width, header.top() + 28.0), + egui::vec2(settings_width, 30.0), + ); + if layout::action_button( + ui, + settings_rect, + settings_label, + Some(IconName::Settings), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::ToggleSettings); + actions.push(FrontendAction::SettingsSection(SettingsSection::Shortcuts)); + } + ui.add_space(GAP); + + guide(ui, width, vm); + ui.add_space(GAP); + save_history_card(ui, width, vm, actions); +} + +/// Three-column usage guide plus its footer row. +fn guide(ui: &mut egui::Ui, width: f32, vm: &FrontendViewModel) { + let lang = vm.lang; + layout::section_title(ui, width, tr_l10n(lang, "selection_ask.howto_title"), None); + ui.add_space(8.0); + + let open_desc = if vm.qa_hotkey.trim().is_empty() { + tr_l10n(lang, "selection_ask.guide_unset_desc").to_string() + } else { + fmt_l10n(lang, "selection_ask.guide_open_desc", &[&vm.qa_hotkey]) + }; + let ask_desc = fmt_l10n( + lang, + "selection_ask.guide_ask_desc", + &[&vm.dictation_hotkey], + ); + let steps = [ + (tr_l10n(lang, "selection_ask.guide_open_title"), open_desc), + ( + tr_l10n(lang, "selection_ask.guide_select_title"), + tr_l10n(lang, "selection_ask.howto_step2").to_string(), + ), + (tr_l10n(lang, "selection_ask.guide_ask_title"), ask_desc), + ]; + + let (row, _) = ui.allocate_exact_size(egui::vec2(width, GUIDE_HEIGHT), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(row); + let column_width = ((width - COLUMN_GAP * 2.0) / 3.0).max(80.0); + for (index, (title, desc)) in steps.iter().enumerate() { + let x = row.left() + index as f32 * (column_width + COLUMN_GAP); + painter.text( + egui::pos2(x, row.top()), + egui::Align2::LEFT_TOP, + format!("{:02}", index + 1), + egui::FontId::monospace(11.0), + theme::BLUE, + ); + painter.text( + egui::pos2(x + 22.0, row.top() - 1.0), + egui::Align2::LEFT_TOP, + title, + egui::FontId::proportional(13.0), + theme::INK, + ); + let galley = layout::text_galley( + ui, + desc, + theme::INK_3, + 11.5, + (column_width - 22.0).max(1.0), + 3, + ); + painter.galley(egui::pos2(x + 22.0, row.top() + 20.0), galley, theme::INK_3); + } + + ui.add_space(10.0); + let (footer, _) = ui.allocate_exact_size(egui::vec2(width, 24.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(footer); + icons::draw_icon( + ui, + egui::pos2(footer.left() + 7.0, footer.center().y), + IconName::Refresh, + theme::INK_4, + ); + painter.text( + egui::pos2(footer.left() + 22.0, footer.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "selection_ask.guide_followup"), + egui::FontId::proportional(11.5), + theme::INK_3, + ); + + // Dismissal hint on the right: an Esc key chip plus its description. + let dismiss = tr_l10n(lang, "selection_ask.guide_dismiss"); + let dismiss_width = layout::text_width(ui, dismiss, 11.5); + let dismiss_left = footer.right() - dismiss_width; + painter.text( + egui::pos2(dismiss_left, footer.center().y), + egui::Align2::LEFT_CENTER, + dismiss, + egui::FontId::proportional(11.5), + theme::INK_3, + ); + let chip_text = "Esc"; + let chip_width = layout::text_width(ui, chip_text, 10.5) + 14.0; + let chip = egui::Rect::from_min_size( + egui::pos2(dismiss_left - 8.0 - chip_width, footer.center().y - 9.0), + egui::vec2(chip_width, 18.0), + ); + painter.rect_filled(chip, egui::CornerRadius::same(5), theme::SURFACE_2); + painter.rect_stroke( + chip, + egui::CornerRadius::same(5), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.text( + chip.center(), + egui::Align2::CENTER_CENTER, + chip_text, + egui::FontId::proportional(10.5), + theme::INK_3, + ); +} + +/// Save-history card: icon, title, description and the switch on the right. +fn save_history_card( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 78.0), egui::Sense::hover()); + layout::card(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + icons::draw_icon( + ui, + egui::pos2(inner.left() + 10.0, inner.center().y), + IconName::History, + theme::INK_3, + ); + painter.text( + egui::pos2(inner.left() + 34.0, inner.top() + 4.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "selection_ask.history_title"), + egui::FontId::proportional(13.5), + theme::INK, + ); + painter.text( + egui::pos2(inner.left() + 34.0, inner.top() + 26.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "selection_ask.history_desc"), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + let toggle_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - 40.0, inner.center().y - 11.0), + egui::vec2(40.0, 22.0), + ); + if layout::toggle( + ui, + toggle_rect, + vm.qa_save_history, + "selection-ask-history-toggle", + ) + .clicked() + { + actions.push(FrontendAction::SelectionAskToggleHistory); + } + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs new file mode 100644 index 000000000..c231a3145 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -0,0 +1,3537 @@ +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::layout; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, SettingsActionField, SettingsChannelProvider, + SettingsComboField, SettingsField, SettingsProviderAuth, SettingsProviderEditor, + SettingsProviderField, SettingsSection, SettingsTextField, ShortcutField, StylePack, +}; + +const RAIL_WIDTH: f32 = 214.0; +const SIDEBAR_RAIL_INPUT: f32 = 150.0; + +#[derive(Clone, Copy)] +enum SettingsIcon { + Settings, + Keyboard, + Sun, + Cloud, + Shield, + Bolt, + Info, + Help, + Document, + External, +} + +impl SettingsSection { + fn label(self, lang: Lang) -> &'static str { + // Keys are spelled out per arm so the i18n sync script can see them. + match self { + Self::General => tr_l10n(lang, "modal.sections.general"), + Self::Shortcuts => tr_l10n(lang, "modal.sections.shortcuts"), + Self::Appearance => tr_l10n(lang, "modal.sections.appearance"), + Self::Services => tr_l10n(lang, "modal.sections.services"), + Self::Privacy => tr_l10n(lang, "modal.sections.privacy"), + Self::Advanced => tr_l10n(lang, "modal.sections.advanced"), + Self::About => tr_l10n(lang, "modal.sections.about"), + } + } + + fn description(self, lang: Lang) -> &'static str { + // Keys are spelled out per arm so the i18n sync script can see them. + match self { + Self::General => tr_l10n(lang, "modal.descriptions.general"), + Self::Shortcuts => tr_l10n(lang, "modal.descriptions.shortcuts"), + Self::Services => tr_l10n(lang, "modal.descriptions.services"), + Self::Appearance => tr_l10n(lang, "modal.descriptions.appearance"), + Self::Privacy => tr_l10n(lang, "modal.descriptions.privacy"), + Self::Advanced => tr_l10n(lang, "modal.descriptions.advanced"), + Self::About => tr_l10n(lang, "modal.descriptions.about"), + } + } + + fn icon(self) -> SettingsIcon { + match self { + Self::General => SettingsIcon::Settings, + Self::Shortcuts => SettingsIcon::Keyboard, + Self::Appearance => SettingsIcon::Sun, + Self::Services => SettingsIcon::Cloud, + Self::Privacy => SettingsIcon::Shield, + Self::Advanced => SettingsIcon::Bolt, + Self::About => SettingsIcon::Info, + } + } +} + +/// Paint the in-window settings modal. Returns true when the caller should +/// close it. Actions are pushed into the provided vec. +pub fn settings_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, + body: egui::Rect, +) { + // Mask the content area (not the sidebar/titlebar) and centre the card in + // it — the same backdrop the marketplace detail uses. + let size = egui::vec2( + (body.width() - 40.0).max(320.0).min(960.0), + (body.height() - 40.0).max(280.0).min(680.0), + ); + let center_offset = body.center() - ctx.content_rect().center(); + + let backdrop_layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("openless-settings-backdrop"), + ); + ctx.layer_painter(backdrop_layer).rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 14, + se: 14, + }, + theme::OVERLAY, + ); + // Input capture so the page behind cannot be clicked while the modal is up. + egui::Area::new(egui::Id::new("openless-settings-backdrop-input")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .default_size(body.size()) + .constrain(false) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + ui.set_max_size(body.size()); + let _ = ui.allocate_exact_size(body.size(), egui::Sense::click()); + }); + + egui::Area::new(egui::Id::new("openless-settings-modal")) + .order(egui::Order::Tooltip) + .anchor(egui::Align2::CENTER_CENTER, center_offset) + .constrain_to(body) + .show(ctx, |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + // Tauri `--ol-settings-content-bg`:整块弹窗是浅灰底,卡片才是白色。 + .fill(theme::CONTENT_BG) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .shadow(egui::Shadow { + offset: [0, 18], + blur: 32, + spread: 0, + color: egui::Color32::from_black_alpha(96), + }) + .show(ui, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + let lang = vm.lang; + // Modal header: title, then the auto-save hint and close on + // the right, above the rail / content split. + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(20, 14)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "nav.settings")) + .size(21.0) + .strong() + .color(theme::INK), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("×") + .size(20.0) + .color(theme::INK_3), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(28.0, 28.0)), + ) + .clicked() + { + actions.push(FrontendAction::CloseSettings); + } + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "modal.auto_save_hint", + )) + .size(11.0) + .color(theme::INK_4), + ); + }, + ); + }); + }); + ui.separator(); + let body_height = (size.y - 58.0).max(120.0); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(RAIL_WIDTH, body_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_min_height(body_height); + ui.set_max_height(body_height); + egui::ScrollArea::vertical() + .id_salt("openless-settings-rail") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(RAIL_WIDTH - 20.0); + rail(ui, vm, actions); + }); + }, + ); + ui.separator(); + ui.allocate_ui_with_layout( + egui::vec2((size.x - RAIL_WIDTH - 1.0).max(0.0), body_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_min_height(body_height); + panel(ui, vm, actions); + }, + ); + }); + }); + }); +} + +fn rail(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // Tauri `.ol-settings-surface aside`: 214px 宽的独立底色条带(左侧跟随弹窗圆角)。 + egui::Frame::new() + .fill(theme::RAIL_BG) + .corner_radius(egui::CornerRadius { + nw: 14, + sw: 14, + ne: 0, + se: 0, + }) + .inner_margin(egui::Margin::symmetric(12, 16)) + .show(ui, |ui| { + // Section search, like the Tauri rail. + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 5)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_RAIL_INPUT); + let width = ui.available_width().max(40.0); + ui.add_sized( + [width, 20.0], + egui::TextEdit::singleline(&mut vm.settings_query) + .id(egui::Id::new("openless-settings-search")) + .hint_text(tr_l10n(lang, "modal.search_placeholder")) + // 明确文字颜色:默认的控件前景色在浅底上过淡, + // 看上去像「输入了但没有显示字符」。 + .text_color(theme::INK) + .frame(false) + .vertical_align(egui::Align::Center), + ); + }); + ui.add_space(10.0); + + let query = vm.settings_query.trim().to_lowercase(); + // Tauri's rail order. + for section in [ + SettingsSection::General, + SettingsSection::Shortcuts, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + if !rail_section_visible(section, vm.hotkeys_supported) { + continue; + } + let label = section.label(lang); + if !query.is_empty() && !label.to_lowercase().contains(&query) { + continue; + } + let response = rail_item(ui, label, section.icon(), vm.settings_section == section); + if response.clicked() { + actions.push(FrontendAction::SettingsSection(section)); + } + } + ui.add_space(14.0); + ui.separator(); + ui.add_space(7.0); + for (label, icon) in [ + ( + tr_l10n(lang, "modal.sections.help_center"), + SettingsIcon::Help, + ), + ( + tr_l10n(lang, "modal.sections.release_notes"), + SettingsIcon::Document, + ), + ] { + let response = rail_item(ui, label, icon, false); + let row = response.rect; + draw_rail_icon( + ui, + egui::pos2(row.right() - 14.0, row.center().y), + SettingsIcon::External, + theme::INK_4, + ); + if response.clicked() { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenHelp, + )); + } + } + }); +} + +/// Tauri `visibleSettingsSections(supportsDesktopHotkey)`:没有桌面热键后端时 +/// 整个「快捷键」分区不出现(其余分区与平台无关)。 +fn rail_section_visible(section: SettingsSection, hotkeys_supported: bool) -> bool { + section != SettingsSection::Shortcuts || hotkeys_supported +} + +fn rail_item(ui: &mut egui::Ui, label: &str, icon: SettingsIcon, active: bool) -> egui::Response { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 34.0), egui::Sense::click()); + // Tauri:激活项是 --ol-blue-soft 底 + --ol-blue 字(滑动块),悬停是 + // --ol-nav-hover-bg + --ol-ink。 + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + } else if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::NAV_HOVER); + } + let color = if active { + theme::BLUE + } else if response.hovered() { + theme::INK + } else { + theme::INK_2 + }; + let icon_center = egui::pos2(rect.left() + 17.0, rect.center().y); + draw_rail_icon(ui, icon_center, icon, color); + ui.painter().text( + egui::pos2(rect.left() + 34.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + response +} + +fn draw_rail_icon(ui: &egui::Ui, center: egui::Pos2, icon: SettingsIcon, color: egui::Color32) { + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.35, color); + let point = |x: f32, y: f32| center + egui::vec2(x, y); + match icon { + SettingsIcon::Settings => { + painter.circle_stroke(center, 4.2, stroke); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_4, + std::f32::consts::FRAC_PI_2, + 3.0 * std::f32::consts::FRAC_PI_4, + std::f32::consts::PI, + 5.0 * std::f32::consts::FRAC_PI_4, + 3.0 * std::f32::consts::FRAC_PI_2, + 7.0 * std::f32::consts::FRAC_PI_4, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment([center + direction * 5.0, center + direction * 7.0], stroke); + } + } + SettingsIcon::Keyboard => { + painter.rect_stroke( + egui::Rect::from_center_size(center, egui::vec2(15.0, 11.0)), + egui::CornerRadius::same(2), + stroke, + egui::StrokeKind::Inside, + ); + painter.line_segment([point(-5.0, 2.5), point(5.0, 2.5)], stroke); + for x in [-4.5, 0.0, 4.5] { + painter.circle_filled(point(x, -2.0), 0.9, color); + } + } + SettingsIcon::Sun => { + painter.circle_stroke(center, 3.6, stroke); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_2, + std::f32::consts::PI, + 3.0 * std::f32::consts::FRAC_PI_2, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment([center + direction * 5.6, center + direction * 7.6], stroke); + } + } + SettingsIcon::Cloud => { + painter.circle_stroke(point(-2.6, 0.0), 4.2, stroke); + painter.circle_stroke(point(2.7, -2.1), 4.0, stroke); + painter.circle_stroke(point(5.5, 1.0), 3.4, stroke); + painter.line_segment([point(-6.0, 4.0), point(5.7, 4.0)], stroke); + painter.line_segment([point(-6.0, 4.0), point(-6.0, 2.0)], stroke); + painter.line_segment([point(5.7, 4.0), point(6.8, 2.0)], stroke); + } + SettingsIcon::Shield => { + painter.add(egui::Shape::line( + [ + point(0.0, 8.0), + point(6.0, 5.0), + point(6.0, -4.0), + point(0.0, -7.0), + point(-6.0, -4.0), + point(-6.0, 5.0), + point(0.0, 8.0), + ] + .to_vec(), + stroke, + )); + } + SettingsIcon::Bolt => { + painter.add(egui::Shape::line( + [ + point(1.0, -8.0), + point(-5.0, 1.0), + point(1.0, 1.0), + point(-1.0, 8.0), + point(6.0, -1.0), + point(1.0, -1.0), + point(1.0, -8.0), + ] + .to_vec(), + stroke, + )); + } + SettingsIcon::Info => { + painter.circle_stroke(center, 8.0, stroke); + painter.line_segment([point(0.0, -1.0), point(0.0, 5.0)], stroke); + painter.circle_filled(point(0.0, -4.0), 0.8, color); + } + SettingsIcon::Help => { + painter.circle_stroke(center, 8.0, stroke); + painter.add(egui::Shape::line( + [ + point(-2.2, -2.3), + point(-1.2, -4.0), + point(1.2, -4.0), + point(2.2, -2.2), + point(0.4, 0.0), + point(0.4, 2.0), + ] + .to_vec(), + stroke, + )); + painter.circle_filled(point(0.4, 5.0), 0.75, color); + } + SettingsIcon::Document => { + painter.rect_stroke( + egui::Rect::from_center_size( + center + egui::vec2(-1.0, 0.0), + egui::vec2(12.0, 16.0), + ), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + painter.add(egui::Shape::line( + [point(1.0, -8.0), point(1.0, -3.0), point(6.0, -3.0)].to_vec(), + stroke, + )); + } + SettingsIcon::External => { + painter.add(egui::Shape::line( + vec![ + point(-5.0, 4.0), + point(-5.0, 7.0), + point(4.0, 7.0), + point(4.0, -2.0), + point(1.0, -2.0), + ], + stroke, + )); + painter.line_segment([point(-1.0, 3.0), point(7.0, -5.0)], stroke); + painter.add(egui::Shape::line( + [point(3.0, -5.0), point(7.0, -5.0), point(7.0, -1.0)].to_vec(), + stroke, + )); + } + } +} + +fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(24, 16)) + .show(ui, |ui| { + { + // 控件(下拉/输入框/按钮)统一成 Tauri 的 SelectLite / inputStyle: + // 白底、0.5px --ol-line-strong 描边、r8、高 30。 + let style = ui.style_mut(); + style.visuals.menu_corner_radius = egui::CornerRadius::same(10); + style.visuals.extreme_bg_color = theme::SURFACE; + style.spacing.interact_size.y = 30.0; + style.spacing.button_padding = egui::vec2(9.0, 5.0); + for widget in [ + &mut style.visuals.widgets.inactive, + &mut style.visuals.widgets.hovered, + &mut style.visuals.widgets.active, + &mut style.visuals.widgets.open, + ] { + widget.corner_radius = egui::CornerRadius::same(8); + widget.bg_stroke = egui::Stroke::new(0.5, theme::LINE_STRONG); + widget.bg_fill = theme::SURFACE; + widget.weak_bg_fill = theme::SURFACE; + } + } + // 实验与扩展的下钻页把标题换成子页标题,并在左侧给出返回箭头 + // (Tauri 的 `activeAdvancedPage` 顶栏)。 + let detail = if vm.settings_section == SettingsSection::Advanced { + advanced_page_title(vm, lang) + } else { + None + }; + ui.horizontal(|ui| { + if let Some((title, _description)) = detail { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + theme::SURFACE_2, + ); + } + let center = rect.center(); + let stroke = egui::Stroke::new(1.4, theme::INK_2); + ui.painter().line_segment( + [ + egui::pos2(center.x + 3.0, center.y - 5.0), + egui::pos2(center.x - 2.5, center.y), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(center.x - 2.5, center.y), + egui::pos2(center.x + 3.0, center.y + 5.0), + ], + stroke, + ); + if response.clicked() { + vm.advanced_open = usize::MAX; + } + ui.label( + egui::RichText::new(title) + .size(20.0) + .strong() + .color(theme::INK), + ); + } else { + ui.label( + egui::RichText::new(vm.settings_section.label(lang)) + .size(21.0) + .strong() + .color(theme::INK), + ); + } + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new(match detail { + Some((_, description)) => description, + None => vm.settings_section.description(lang), + }) + .size(13.0) + .color(theme::INK_3), + ); + if let Some(notice) = &vm.settings_notice { + ui.add_space(4.0); + ui.label(egui::RichText::new(notice).size(11.0).color(theme::BLUE)); + } + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("openless-settings-content") + .auto_shrink([false, false]) + .show(ui, |ui| match vm.settings_section { + SettingsSection::General => general(ui, vm, actions), + SettingsSection::Shortcuts => shortcuts(ui, vm, actions), + SettingsSection::Appearance => appearance(ui, vm, actions), + SettingsSection::Services => services(ui, vm, actions), + SettingsSection::Privacy => privacy(ui, vm, actions), + SettingsSection::Advanced => advanced(ui, vm, actions), + SettingsSection::About => about(ui, vm, actions), + }); + }); +} + +/// Title + description of the open 实验与扩展 sub-page (`None` on the list page). +fn advanced_page_title(vm: &FrontendViewModel, lang: Lang) -> Option<(&'static str, &'static str)> { + match vm.advanced_open { + 0 => Some(( + tr_l10n(lang, "settings.coding_agent.title"), + tr_l10n(lang, "modal.advanced_pages.less_computer"), + )), + 1 => Some(( + tr_l10n(lang, "settings.advanced.multimodal_pipeline_title"), + tr_l10n(lang, "modal.advanced_pages.multimodal"), + )), + 2 => Some(( + tr_l10n(lang, "settings.debug.title"), + tr_l10n(lang, "modal.advanced_pages.debug"), + )), + _ => None, + } +} + +fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + + // 录音与输入(Tauri RecordingInputSection) + card( + ui, + tr_l10n(lang, "settings.recording.title"), + tr_l10n(lang, "settings.recording.desc"), + |ui| { + text_row( + ui, + tr_l10n(lang, "settings.recording.hotkey_label"), + tr_l10n(lang, "settings.recording.combo_disable_hint"), + &vm.dictation_hotkey, + ); + let modes = [ + tr_l10n(lang, "settings.recording.mode_toggle"), + tr_l10n(lang, "settings.recording.mode_hold"), + tr_l10n(lang, "settings.recording.mode_auto"), + ]; + segmented_row( + ui, + tr_l10n(lang, "settings.recording.mode_label"), + tr_l10n(lang, "settings.recording.mode_desc"), + &modes, + vm.settings.recording_mode.min(2), + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::RecordingMode, + val, + )); + }, + ); + // 「静音后自动停止」只在切换式模式下可用(Tauri 同样只在该模式渲染)。 + if vm.settings.recording_mode == 0 { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.silence_auto_stop_label"), + tr_l10n(lang, "settings.recording.silence_auto_stop_desc"), + vm.settings.silence_auto_stop, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::SilenceAutoStop, + )); + }, + ); + if vm.settings.silence_auto_stop { + let seconds: Vec = [1usize, 2, 3, 4, 5] + .iter() + .map(|value| { + fmt_l10n( + lang, + "settings.recording.silence_auto_stop_seconds_value", + &[value], + ) + }) + .collect(); + let refs: Vec<&str> = seconds.iter().map(String::as_str).collect(); + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.silence_auto_stop_seconds_label"), + "", + vm.settings.silence_seconds.saturating_sub(1), + &refs, + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::SilenceSeconds, + val, + )); + }, + ); + } + } + let mut microphones: Vec = + vec![tr_l10n(lang, "settings.recording.microphone_system_default").to_string()]; + microphones.extend(vm.settings.microphone_options.iter().cloned()); + let microphone_index = microphones + .iter() + .position(|name| name == &vm.settings.microphone_name) + .unwrap_or(0); + let microphone_refs: Vec<&str> = microphones.iter().map(String::as_str).collect(); + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.microphone_label"), + tr_l10n(lang, "settings.recording.microphone_desc"), + microphone_index, + µphone_refs, + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Microphone, + val, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.mute_during_recording_label"), + tr_l10n(lang, "settings.recording.mute_during_recording_desc"), + vm.settings.mute_while_recording, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::MuteWhileRecording, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.audio_cue_label"), + tr_l10n(lang, "settings.recording.audio_cue_desc"), + vm.settings.audio_cue, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::AudioCue)); + }, + ); + }, + ); + + // 插入与剪贴板(Tauri:可折叠分组,含流式输入) + card_group( + ui, + tr_l10n(lang, "settings.recording.insert_group_title"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.restore_clipboard_label"), + tr_l10n(lang, "settings.recording.restore_clipboard_desc"), + vm.settings.restore_clipboard, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RestoreClipboard, + )); + }, + ); + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.paste_shortcut_label"), + tr_l10n(lang, "settings.recording.paste_shortcut_desc"), + vm.settings.paste_shortcut.min(1), + &[ + tr_l10n(lang, "settings.recording.paste_shortcut_ctrl_v"), + tr_l10n(lang, "settings.recording.paste_shortcut_ctrl_shift_v"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::PasteShortcut, + val, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.advanced.streaming_insert_label"), + tr_l10n(lang, "settings.advanced.streaming_insert_desc"), + vm.settings.streaming_insert, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingInsert, + )); + }, + ); + toggle_row( + ui, + tr_l10n( + lang, + "settings.advanced.streaming_insert_save_clipboard_label", + ), + "", + vm.settings.streaming_save_clipboard, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingSaveClipboard, + )); + }, + ); + }, + ); + + // 启动(Tauri:可折叠分组) + card_group( + ui, + tr_l10n(lang, "settings.recording.startup_group_title"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.start_minimized_label"), + "", + vm.settings.start_minimized, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StartMinimized, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.startup_at_boot"), + "", + vm.settings.launch_at_login, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::LaunchAtLogin)); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.auto_update_check_label"), + "", + vm.settings.auto_update, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::AutoUpdate)); + }, + ); + }, + ); + + // 远程输入(Tauri RemoteInputSection) + card( + ui, + tr_l10n(lang, "settings.remote_input.title"), + tr_l10n(lang, "settings.remote_input.security_hint"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.remote_input.enable_label"), + tr_l10n(lang, "settings.remote_input.enable_desc"), + vm.settings.remote_input, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::RemoteInput)); + }, + ); + let port = vm.settings.remote_port.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.remote_input.port_label"), + "", + &mut vm.settings.remote_port, + "8765", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::RemotePort, + port, + )); + }, + ); + combo_index_row( + ui, + tr_l10n(lang, "settings.remote_input.default_mode_label"), + "", + vm.settings.remote_default_mode, + &[ + tr_l10n(lang, "settings.remote_input.mode_toggle"), + tr_l10n(lang, "settings.remote_input.mode_hold"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::RemoteDefaultMode, + val, + )); + }, + ); + // 连接细节(配对码 / 网址 / 证书指纹)只在服务真的在监听、且地址未过期时 + // 展示:过期地址可能指向别的主机,展示它等于诱导用户在错误地址上配对。 + let cert_state = remote_cert_fingerprint_state( + vm.remote_running, + vm.remote_urls_stale, + vm.remote_cert_fingerprint.as_deref(), + ); + if vm.remote_running && !vm.remote_urls_stale { + if !vm.remote_pin.is_empty() { + text_row( + ui, + tr_l10n(lang, "settings.remote_input.pin_label"), + "", + &vm.remote_pin, + ); + } + if !vm.remote_urls.is_empty() { + text_row( + ui, + tr_l10n(lang, "settings.remote_input.url_label"), + tr_l10n(lang, "settings.remote_input.security_hint"), + &vm.remote_urls.join(" · "), + ); + } + if matches!(cert_state, RemoteCertFingerprintState::Available) { + action_row( + ui, + tr_l10n(lang, "settings.remote_input.cert_fingerprint_label"), + tr_l10n(lang, "settings.remote_input.cert_verify_hint"), + tr_l10n(lang, "settings.remote_input.cert_fingerprint_copy"), + SettingsActionField::CopyCertFingerprint, + actions, + ); + ui.label( + egui::RichText::new(vm.remote_cert_fingerprint.clone().unwrap_or_default()) + .size(10.5) + .color(theme::INK_4), + ); + } else { + // 拿不到可核验的完整指纹时必须显式告警:静默省略会让人以为 + // 「不用核对证书也能连」。 + ui.colored_label( + theme::WARN, + tr_l10n(lang, "settings.remote_input.cert_fingerprint_unavailable"), + ); + } + } + }, + ); +} + +/// 证书指纹的展示决定(对齐 Tauri `RemoteInputSection`):服务未监听或地址已过期 +/// 时整块连接细节都不展示;服务在监听但没有可核验的完整指纹时必须显式告警。 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RemoteCertFingerprintState { + /// 没有可展示的连接细节。 + Hidden, + /// 在监听但拿不到完整指纹 → 必须显式告警。 + Unavailable, + /// 有完整指纹 → 展示并可复制。 + Available, +} + +fn remote_cert_fingerprint_state( + running: bool, + urls_stale: bool, + fingerprint: Option<&str>, +) -> RemoteCertFingerprintState { + if !running || urls_stale { + return RemoteCertFingerprintState::Hidden; + } + match fingerprint { + Some(value) if is_complete_sha256(value) => RemoteCertFingerprintState::Available, + _ => RemoteCertFingerprintState::Unavailable, + } +} + +/// 完整 SHA-256 指纹 = 64 个十六进制字符;截断/非十六进制的值不能当作可核验指纹。 +fn is_complete_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // Tauri `ShortcutsSection` 的行序:开始/停止 → 翻译 → 弹出浮窗 → 切换风格 → + // 风格直达快捷键(子块)→ 打开 OpenLess → Less Computer → 取消本次录音。 + let dictation_hint = match vm.settings.recording_mode { + 1 => tr_l10n(lang, "hotkey.mode_hold_suffix"), + 2 => tr_l10n(lang, "hotkey.mode_auto_suffix"), + _ => tr_l10n(lang, "hotkey.mode_toggle_suffix"), + } + .to_string(); + let rows: [ShortcutRow; 6] = [ + ShortcutRow { + field: ShortcutField::Dictation, + label: tr_l10n(lang, "settings.shortcuts.start_stop"), + desc: "", + value: vm.dictation_hotkey.clone(), + can_disable: false, + hint: dictation_hint, + }, + ShortcutRow { + field: ShortcutField::Translation, + label: tr_l10n(lang, "hotkey.translation"), + desc: "", + value: vm.translation_hotkey.clone(), + can_disable: false, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::Qa, + label: tr_l10n(lang, "selection_ask.hotkey_title"), + desc: "", + value: vm.qa_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::SwitchStyle, + label: tr_l10n(lang, "settings.shortcuts.switch_style"), + desc: "", + value: vm.switch_style_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::OpenApp, + label: tr_l10n(lang, "settings.shortcuts.open_app"), + desc: "", + value: vm.open_app_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::CodingAgentVoice, + label: tr_l10n(lang, "settings.shortcuts.agent_voice"), + desc: tr_l10n(lang, "settings.coding_agent.voice_hotkey_desc"), + value: vm.coding_agent_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ]; + card( + ui, + tr_l10n(lang, "settings.shortcuts.title"), + tr_l10n(lang, "settings.shortcuts.desc_no_acc"), + |ui| { + for row in &rows[..4] { + shortcut_row(ui, vm, actions, row); + } + // 风格直达快捷键:Tauri 把它放在「切换到上一个风格」之后、打开 App 之前。 + style_pack_hotkey_block(ui, vm, actions); + for row in &rows[4..] { + shortcut_row(ui, vm, actions, row); + } + // 取消本次录音:Tauri 只展示 Esc,不可编辑(Windows/Linux 胶囊无确认键)。 + readonly_keycap_row(ui, tr_l10n(lang, "settings.shortcuts.cancel"), "", "Esc"); + }, + ); + // 选区工作区:划词润色快捷键(可录制/停用)+ 交付方式。 + card( + ui, + tr_l10n(lang, "settings.selection_workspace.title"), + tr_l10n(lang, "settings.selection_workspace.hint"), + |ui| { + shortcut_row( + ui, + vm, + actions, + &ShortcutRow { + field: ShortcutField::SelectionPolish, + label: tr_l10n(lang, "settings.selection_workspace.polish_hotkey"), + desc: tr_l10n(lang, "settings.selection_workspace.polish_hotkey_desc"), + value: vm.selection_polish_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ); + segmented_row( + ui, + tr_l10n(lang, "settings.selection_workspace.polish_delivery"), + "", + &[ + tr_l10n(lang, "settings.selection_polish.direct_replace"), + tr_l10n(lang, "settings.selection_polish.preview_confirm"), + ], + vm.settings.selection_polish_delivery.min(1), + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::SelectionPolishDelivery, + val, + )); + }, + ); + }, + ); +} + +/// 一行可编辑快捷键的展示数据。 +struct ShortcutRow { + field: ShortcutField, + label: &'static str, + desc: &'static str, + /// 已格式化的键帽文本(`Ctrl+Shift+;`),空串 = 未设置。 + value: String, + /// 核心热键(录音)不可停用,Tauri 用 `comboDisableHint` 说明原因。 + can_disable: bool, + /// 行下方的补充说明(录音行显示当前录音方式后缀)。 + hint: String, +} + +/// 快捷键行:标签(+「?」)→ 键帽 → 最右的 chevron;点 chevron 展开 +/// 「录制快捷键 / 停用」菜单,进入录制后键帽位置换成「请按下快捷键组合…」面板。 +fn shortcut_row( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + row: &ShortcutRow, +) { + let lang = vm.lang; + let recording = vm.shortcut_recording == Some(row.field); + let menu_open = vm.shortcut_menu == Some(row.field); + ui.horizontal(|ui| { + ui.set_min_height(46.0); + ui.label( + egui::RichText::new(row.label) + .font(theme::medium_font(14.0)) + .color(theme::INK), + ); + if !row.desc.is_empty() { + help_dot(ui, row.desc); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if recording { + recording_panel(ui, vm, actions, row.field); + return; + } + let (rect, response) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + draw_chevron_down(ui, rect.center(), menu_open, theme::INK_4); + if response.clicked() { + actions.push(FrontendAction::ShortcutMenu(if menu_open { + None + } else { + Some(row.field) + })); + } + ui.add_space(4.0); + keycaps_in(ui, &row.value); + }); + }); + separator_line(ui); + if !row.hint.is_empty() { + ui.label( + egui::RichText::new(row.hint.as_str()) + .size(11.0) + .color(theme::INK_4), + ); + } + if menu_open && !recording { + // Tauri 的展开菜单:录制快捷键(主按钮)+ 停用(录音行置灰)。 + ui.horizontal(|ui| { + ui.set_min_height(36.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let disable = tr_l10n(lang, "settings.shortcuts.disable"); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(58.0, 28.0), egui::Sense::hover()); + let kind = if row.can_disable { + layout::ButtonKind::Ghost + } else { + layout::ButtonKind::Disabled + }; + if layout::action_button(ui, rect, disable, None, kind).clicked() && row.can_disable + { + actions.push(FrontendAction::ShortcutDisable(row.field)); + } + ui.add_space(6.0); + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::Blue).clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some(row.field))); + } + }); + }); + if row.field == ShortcutField::Dictation { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.recording.combo_disable_hint")) + .size(10.5) + .color(theme::INK_4), + ); + } + ui.add_space(4.0); + } +} + +/// 「请按下快捷键组合…」面板:读本帧输入,Escape 取消、其它键即完成录入。 +fn recording_panel( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + field: ShortcutField, +) { + let lang = vm.lang; + let (rect, _) = ui.allocate_exact_size(egui::vec2(240.0, 44.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(37, 99, 235, 60)), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y - 7.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "settings.recording.combo_record_hint"), + egui::FontId::proportional(12.0), + theme::BLUE, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + format!("Esc · {}", tr_l10n(lang, "common.cancel")), + egui::FontId::proportional(10.5), + theme::INK_4, + ); + if ui.input(|input| input.key_pressed(egui::Key::Escape)) { + actions.push(FrontendAction::ShortcutRecording(None)); + return; + } + if let Some((primary, modifiers)) = captured_binding(ui, &mut vm.shortcut_pending_modifier) { + actions.push(FrontendAction::ShortcutCaptured(field, primary, modifiers)); + } +} + +/// 读本帧按下的第一个「真键」+ 当时按住的修饰键,转成 Core 的 +/// `ShortcutBinding` 形式(primary + modifiers)。 +/// +/// 修饰键自身在 egui 里没有 Key 事件(`Key` 枚举只有 `Colon`/`Semicolon` 这类 +/// 具体键,修饰键只在 `Modifiers` 里),所以「按住某个修饰键当热键」只能跨帧判断: +/// 按住期间没有按下任何真键 → 松开时记为修饰键触发。`pending` 就是这份挂起状态。 +/// egui 分不清左右修饰键,因此统一记左侧名(Core 的 legacy trigger 表接受 +/// LeftControl/LeftShift/LeftAlt/LeftSuper)。 +fn captured_binding(ui: &egui::Ui, pending: &mut Option) -> Option<(String, Vec)> { + // 用按键事件自带的修饰键(RawInput.modifiers 在某些输入法/后端下会滞后), + // 并跳过 egui 合成的剪贴板命令与 Escape(后者由调用方当取消处理)。 + if let Some((key, modifiers)) = ui.input(|input| { + input.events.iter().find_map(|event| match event { + egui::Event::Key { + key, + pressed: true, + repeat: false, + modifiers, + .. + } if !matches!( + key, + egui::Key::Escape | egui::Key::Copy | egui::Key::Cut | egui::Key::Paste + ) => + { + Some((*key, *modifiers)) + } + _ => None, + }) + }) { + // 按下真键 = 组合键,之前挂起的修饰键作废。 + *pending = None; + let primary = shortcut_primary(key)?; + return Some((primary, modifier_tags(modifiers))); + } + + let modifiers = ui.input(|input| input.modifiers); + match bare_modifier_name(modifiers) { + Some(name) => { + if pending.is_none() { + *pending = Some(name.to_string()); + } + None + } + None if modifiers.any() => { + // 多个修饰键同按:不当作修饰键热键(松手也不触发)。 + *pending = None; + None + } + None => pending.take().map(|name| (name, Vec::new())), + } +} + +fn modifier_tags(modifiers: egui::Modifiers) -> Vec { + let mut tags: Vec = Vec::new(); + if modifiers.ctrl || modifiers.command { + tags.push("ctrl".to_string()); + } + if modifiers.alt { + tags.push("alt".to_string()); + } + if modifiers.shift { + tags.push("shift".to_string()); + } + if modifiers.mac_cmd { + tags.push("super".to_string()); + } + tags +} + +/// 恰好按住「一个类别」的修饰键时返回它的 Core 主键名,否则 `None`。 +/// 顺序 ctrl → alt → shift → super:egui 在 Linux 上把 Ctrl 同时标成 +/// `command`,所以先判 ctrl。 +fn bare_modifier_name(modifiers: egui::Modifiers) -> Option<&'static str> { + let categories = [ + modifiers.ctrl || modifiers.command, + modifiers.alt, + modifiers.shift, + modifiers.mac_cmd, + ]; + if categories.iter().filter(|held| **held).count() != 1 { + return None; + } + if categories[0] { + Some("LeftControl") + } else if categories[1] { + Some("LeftAlt") + } else if categories[2] { + Some("LeftShift") + } else { + Some("LeftSuper") + } +} + +/// egui 的物理键 → Core 认可的主键名(见 `shortcut_types::validate_primary`)。 +fn shortcut_primary(key: egui::Key) -> Option { + use egui::Key; + let name = match key { + Key::Num0 => "0".to_string(), + Key::Num1 => "1".to_string(), + Key::Num2 => "2".to_string(), + Key::Num3 => "3".to_string(), + Key::Num4 => "4".to_string(), + Key::Num5 => "5".to_string(), + Key::Num6 => "6".to_string(), + Key::Num7 => "7".to_string(), + Key::Num8 => "8".to_string(), + Key::Num9 => "9".to_string(), + Key::A => "A".to_string(), + Key::B => "B".to_string(), + Key::C => "C".to_string(), + Key::D => "D".to_string(), + Key::E => "E".to_string(), + Key::F => "F".to_string(), + Key::G => "G".to_string(), + Key::H => "H".to_string(), + Key::I => "I".to_string(), + Key::J => "J".to_string(), + Key::K => "K".to_string(), + Key::L => "L".to_string(), + Key::M => "M".to_string(), + Key::N => "N".to_string(), + Key::O => "O".to_string(), + Key::P => "P".to_string(), + Key::Q => "Q".to_string(), + Key::R => "R".to_string(), + Key::S => "S".to_string(), + Key::T => "T".to_string(), + Key::U => "U".to_string(), + Key::V => "V".to_string(), + Key::W => "W".to_string(), + Key::X => "X".to_string(), + Key::Y => "Y".to_string(), + Key::Z => "Z".to_string(), + Key::F1 => "F1".to_string(), + Key::F2 => "F2".to_string(), + Key::F3 => "F3".to_string(), + Key::F4 => "F4".to_string(), + Key::F5 => "F5".to_string(), + Key::F6 => "F6".to_string(), + Key::F7 => "F7".to_string(), + Key::F8 => "F8".to_string(), + Key::F9 => "F9".to_string(), + Key::F10 => "F10".to_string(), + Key::F11 => "F11".to_string(), + Key::F12 => "F12".to_string(), + Key::F13 => "F13".to_string(), + Key::F14 => "F14".to_string(), + Key::F15 => "F15".to_string(), + Key::F16 => "F16".to_string(), + Key::F17 => "F17".to_string(), + Key::F18 => "F18".to_string(), + Key::F19 => "F19".to_string(), + Key::F20 => "F20".to_string(), + Key::ArrowUp => "ArrowUp".to_string(), + Key::ArrowDown => "ArrowDown".to_string(), + Key::ArrowLeft => "ArrowLeft".to_string(), + Key::ArrowRight => "ArrowRight".to_string(), + Key::Space => "Space".to_string(), + Key::Enter => "Enter".to_string(), + Key::Tab => "Tab".to_string(), + Key::Backspace => "Backspace".to_string(), + Key::Delete => "Delete".to_string(), + Key::Home => "Home".to_string(), + Key::End => "End".to_string(), + Key::PageUp => "PageUp".to_string(), + Key::PageDown => "PageDown".to_string(), + Key::Semicolon => ";".to_string(), + Key::Comma => ",".to_string(), + Key::Period => ".".to_string(), + Key::Slash => "/".to_string(), + Key::Backslash => "\\".to_string(), + Key::Minus => "-".to_string(), + Key::Equals => "=".to_string(), + Key::Plus => "+".to_string(), + Key::Quote => "'".to_string(), + Key::Backtick => "`".to_string(), + Key::OpenBracket => "[".to_string(), + Key::CloseBracket => "]".to_string(), + Key::Colon => ":".to_string(), + Key::Pipe => "|".to_string(), + Key::Questionmark => "?".to_string(), + Key::Exclamationmark => "!".to_string(), + _ => return None, + }; + Some(name) +} + +/// 只读键帽行(Tauri 的 `readonlyRows`:取消本次录音 = Esc)。 +fn readonly_keycap_row(ui: &mut egui::Ui, label: &str, desc: &str, combo: &str) { + row_desc(ui, label, desc, |ui| { + keycaps_in(ui, combo); + }); +} + +/// 把 `Ctrl+Shift+;` 这样的标签画成一枚枚键帽,供 right_to_left 布局使用 +/// (因此倒序绘制)。 +fn keycaps_in(ui: &mut egui::Ui, combo: &str) { + let parts: Vec<&str> = combo + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + for part in parts.iter().rev() { + let width = layout::text_width(ui, part, 11.0) + 16.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + *part, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + } +} + +/// 行右侧的 chevron(展开/收起快捷键菜单)。 +fn draw_chevron_down(ui: &egui::Ui, center: egui::Pos2, open: bool, color: egui::Color32) { + let stroke = egui::Stroke::new(1.4, color); + let dy = if open { -1.6 } else { 1.6 }; + ui.painter().line_segment( + [ + egui::pos2(center.x - 4.0, center.y - dy), + egui::pos2(center.x, center.y + dy), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(center.x, center.y + dy), + egui::pos2(center.x + 4.0, center.y - dy), + ], + stroke, + ); +} +/// 「风格直达快捷键」子块:小标题 + 说明 + 每行(风格选择器 + 键帽 + chevron)+ +/// 「+ 添加风格快捷键」。整块放在「快捷键设置」卡片内部(Tauri 的位置)。 +fn style_pack_hotkey_block( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.shortcuts.style_pack_title")) + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.shortcuts.style_pack_desc")) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(6.0); + + let rows = vm.settings.style_pack_hotkeys.clone(); + let packs = vm.style_packs.clone(); + for (index, row) in rows.iter().enumerate() { + let recording = vm.shortcut_recording == Some(ShortcutField::StylePack(index)); + let menu_open = vm.shortcut_menu == Some(ShortcutField::StylePack(index)); + ui.horizontal(|ui| { + ui.set_min_height(40.0); + style_pack_picker( + ui, + &packs, + &row.pack_id, + &row.name, + Some(index), + actions, + lang, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if recording { + recording_panel(ui, vm, actions, ShortcutField::StylePack(index)); + return; + } + // chevron → 录制 / 移除 + let (rect, _) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + let response = ui.interact( + rect, + ui.id().with(("style-hotkey-menu", index)), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + draw_chevron_down(ui, rect.center(), menu_open, theme::INK_4); + if response.clicked() { + actions.push(FrontendAction::ShortcutMenu(if menu_open { + None + } else { + Some(ShortcutField::StylePack(index)) + })); + } + ui.add_space(4.0); + keycaps_in(ui, &row.hotkey); + }); + }); + separator_line(ui); + if menu_open && !recording { + ui.horizontal(|ui| { + ui.set_min_height(34.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let remove = tr_l10n(lang, "settings.shortcuts.style_pack_remove"); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(58.0, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, remove, None, layout::ButtonKind::Ghost) + .clicked() + { + actions.push(FrontendAction::StyleHotkeyRemove(index)); + } + ui.add_space(6.0); + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::Blue) + .clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some( + ShortcutField::StylePack(index), + ))); + } + }); + }); + } + } + + // 草稿行:先选风格包、再录快捷键(Tauri 的 draft 行)。 + if vm.style_hotkey_draft_open { + let draft_id = packs + .get(vm.style_hotkey_draft_pack) + .map(|pack| pack.id.clone()) + .unwrap_or_default(); + let draft_recording = vm.shortcut_recording == Some(ShortcutField::StyleDraft); + ui.horizontal(|ui| { + ui.set_min_height(40.0); + style_pack_picker(ui, &packs, &draft_id, "", None, actions, lang); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); + let cancel_response = ui.interact( + rect, + ui.id().with("style-hotkey-draft-cancel"), + egui::Sense::click(), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "✕", + egui::FontId::proportional(12.0), + if cancel_response.hovered() { + theme::ERR + } else { + theme::INK_4 + }, + ); + if cancel_response.clicked() { + actions.push(FrontendAction::StyleHotkeyDraft(false)); + } + ui.add_space(6.0); + if draft_recording { + recording_panel(ui, vm, actions, ShortcutField::StyleDraft); + } else { + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::Blue) + .clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some( + ShortcutField::StyleDraft, + ))); + } + } + }); + }); + separator_line(ui); + } else { + // 「+ 添加风格快捷键」虚线按钮(Tauri 的 stylePackAdd)。 + let label = format!("+ {}", tr_l10n(lang, "settings.shortcuts.style_pack_add")); + let width = layout::text_width(ui, &label, 12.0) + 24.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + &label, + egui::FontId::proportional(12.0), + theme::INK_3, + ); + if ui + .interact(rect, ui.id().with("style-hotkey-add"), egui::Sense::click()) + .clicked() + { + actions.push(FrontendAction::StyleHotkeyDraft(true)); + } + } + ui.add_space(4.0); +} + +/// 选择器变更后要发的动作。 +/// +/// `row` 是已有风格包行的下标;草稿行(`None`)只能改「待新建的包」,绝不能写成 +/// `StyleHotkeyRepack`——那会把**别的**已有行的包换掉(曾经的真 bug:草稿行选包 +/// 会重绑第一行的风格包,而 `StyleHotkeyDraftPack` 从未被构造)。 +fn style_pack_pick_action(row: Option, next: usize) -> FrontendAction { + match row { + Some(index) => FrontendAction::StyleHotkeyRepack(index, next), + None => FrontendAction::StyleHotkeyDraftPack(next), + } +} + +/// 风格包选择器(Tauri 的 `SelectLite`):显示名 +「(已停用)」后缀,整表替换。 +#[allow(clippy::too_many_arguments)] +fn style_pack_picker( + ui: &mut egui::Ui, + packs: &[StylePack], + current_pack_id: &str, + fallback_name: &str, + row: Option, + actions: &mut Vec, + lang: Lang, +) { + let options: Vec = packs + .iter() + .map(|pack| { + if pack.enabled { + pack.name.clone() + } else { + format!( + "{}{}", + pack.name, + tr_l10n(lang, "settings.shortcuts.style_pack_disabled_suffix") + ) + } + }) + .collect(); + let selected = packs + .iter() + .position(|pack| pack.id == current_pack_id) + .unwrap_or(0); + let mut next = selected; + // 草稿行的选择器也要有稳定且互不冲突的 id。 + let picker_salt = match row { + Some(index) => format!("style-pack-hotkey-{index}"), + None => "style-pack-hotkey-draft".to_string(), + }; + egui::ComboBox::from_id_salt(picker_salt) + .width(170.0) + .selected_text( + options + .get(selected) + .cloned() + .unwrap_or_else(|| fallback_name.to_string()), + ) + .show_ui(ui, |ui| { + for (option_index, option) in options.iter().enumerate() { + if ui + .selectable_label(option_index == selected, option) + .clicked() + { + next = option_index; + ui.close(); + } + } + }); + if next != selected { + actions.push(style_pack_pick_action(row, next)); + } +} + +/// 设置行下方的细线(与 `row_desc` 同款)。 +fn separator_line(ui: &mut egui::Ui) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); +} + +fn appearance(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + card(ui, tr_l10n(lang, "settings.theme.title"), "", |ui| { + combo_index_row( + ui, + tr_l10n(lang, "settings.theme.label"), + "", + vm.settings.theme, + &[ + tr_l10n(lang, "settings.theme.system"), + tr_l10n(lang, "settings.theme.light"), + tr_l10n(lang, "settings.theme.dark"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Theme, + val, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.theme.activity_heatmap_label"), + "", + vm.settings.activity_heatmap, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::ActivityHeatmap, + )); + }, + ); + }); + card( + ui, + tr_l10n(lang, "settings.language.title"), + tr_l10n(lang, "settings.language.desc"), + |ui| { + combo_index_row( + ui, + tr_l10n(lang, "settings.language.label"), + tr_l10n(lang, "settings.language.label_desc"), + vm.settings.language, + &[ + tr_l10n(lang, "settings.language.follow_system"), + tr_l10n(lang, "settings.language.zh"), + tr_l10n(lang, "settings.language.zh_tw"), + tr_l10n(lang, "settings.language.en"), + tr_l10n(lang, "settings.language.ja"), + tr_l10n(lang, "settings.language.ko"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Language, + val, + )); + }, + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.language.restart_hint")) + .size(11.0) + .color(theme::INK_4), + ); + }, + ); +} + +/// AI-services sub-views. The list mirrors the Tauri `availableServiceViews` +/// gate: the multimodal view appears once the pipeline is enabled, the local +/// model view only when the host really has a local engine, and the tab strip +/// always ends with the connection settings. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ServiceView { + Llm, + Asr, + Models, + Connections, + Omni, +} + +impl ServiceView { + fn id(self) -> usize { + match self { + Self::Llm => 0, + Self::Asr => 1, + Self::Models => 2, + Self::Connections => 3, + Self::Omni => 4, + } + } + + fn label(self, lang: Lang) -> &'static str { + match self { + Self::Llm => tr_l10n(lang, "modal.service_views.llm"), + Self::Asr => tr_l10n(lang, "modal.service_views.asr"), + Self::Models => tr_l10n(lang, "modal.service_views.models"), + Self::Connections => tr_l10n(lang, "modal.service_views.connections"), + Self::Omni => tr_l10n(lang, "modal.service_views.omni"), + } + } + + fn visible(vm: &FrontendViewModel) -> Vec { + let mut views = Vec::new(); + if vm.multimodal_view { + views.push(Self::Omni); + } + if !vm.pipeline_multimodal { + views.push(Self::Llm); + views.push(Self::Asr); + } + if vm.supports_local_asr { + views.push(Self::Models); + } + views.push(Self::Connections); + views + } +} + +fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + let views = ServiceView::visible(vm); + let active = views + .iter() + .position(|view| view.id() == vm.services_view) + .unwrap_or(0); + let items: Vec<(&str, Option)> = views + .iter() + .map(|view| { + let dot = match view { + ServiceView::Llm | ServiceView::Asr => { + let configured = vm.service_configured[usize::from(*view == ServiceView::Asr)]; + Some(if configured { theme::WARN } else { theme::ERR }) + } + _ => None, + }; + (view.label(lang), dot) + }) + .collect(); + if let Some(index) = service_tabs(ui, &items, active) { + if let Some(view) = views.get(index) { + actions.push(FrontendAction::SettingsServicesView(view.id())); + } + } + ui.add_space(10.0); + + match views.get(active).copied().unwrap_or(ServiceView::Llm) { + ServiceView::Models => { + card( + ui, + tr_l10n(lang, "modal.service_views.models"), + tr_l10n(lang, "settings.advanced.local_asr_desc"), + |ui| { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.advanced.platform_not_supported", + )) + .size(11.5) + .color(theme::INK_4), + ); + }, + ); + } + ServiceView::Connections => { + card(ui, tr_l10n(lang, "settings.network.title"), "", |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.network.use_system_proxy_label"), + tr_l10n(lang, "settings.network.use_system_proxy_desc"), + vm.settings.system_proxy, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::SystemProxy)); + }, + ); + }); + card( + ui, + tr_l10n(lang, "settings.marketplace.title"), + tr_l10n(lang, "settings.marketplace.desc"), + |ui| { + action_row( + ui, + tr_l10n(lang, "settings.marketplace.github.sign_in"), + "", + tr_l10n(lang, "settings.marketplace.github.open_github"), + SettingsActionField::OpenGitHub, + actions, + ); + }, + ); + } + view => { + // 视图 → 渠道类型(0 = 语言模型,1 = 语音识别)。宿主按该类型取数。 + let kinds: &[usize] = match view { + ServiceView::Asr => &[1], + ServiceView::Omni => &[0, 1], + _ => &[0], + }; + for kind in kinds { + let asr = *kind == 1; + let title = if asr { + tr_l10n(lang, "settings.channels.asr_title") + } else { + tr_l10n(lang, "settings.channels.llm_title") + }; + let add = tr_l10n(lang, "settings.channels.add"); + let mut add_clicked = false; + // 卡片头:标题在左、+添加渠道在右(Tauri 的 ProvidersSection), + // 标题下方一行说明,再下面是渠道行。 + card_header( + ui, + title, + "", + |ui| { + let add_width = layout::text_width(ui, add, 12.0) + 30.0; + let (add_rect, _) = ui + .allocate_exact_size(egui::vec2(add_width, 26.0), egui::Sense::hover()); + add_clicked = layout::action_button( + ui, + add_rect, + add, + None, + layout::ButtonKind::Blue, + ) + .clicked(); + }, + |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.order_hint")) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(6.0); + if vm.channels_loading { + ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(11.5) + .color(theme::INK_4), + ); + } else if vm.channels.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.empty")) + .size(11.5) + .color(theme::INK_4), + ); + } else { + for (index, channel) in vm.channels.iter().enumerate() { + channel_row( + ui, + channel, + index, + &vm.channel_providers, + lang, + actions, + ); + } + } + if let Some(editor) = &vm.provider_editor { + ui.add_space(8.0); + provider_editor_panel(ui, editor, lang, actions); + } + if vm.channel_form_open { + ui.add_space(8.0); + add_channel_form(ui, vm, actions); + } + }, + ); + if add_clicked { + actions.push(FrontendAction::SettingsChannelFormOpen(true)); + } + } + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.providers.credential_storage_notice", + )) + .size(11.0) + .color(theme::INK_4), + ); + } + } +} + +fn privacy(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.about.local_first")) + .strong() + .color(theme::BLUE), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.about.privacy_desc")) + .size(11.5) + .color(theme::INK_3), + ); + }); + }); + ui.add_space(10.0); + + // 权限:状态全部来自宿主快照(Linux 没有系统级授权弹窗,标为「不适用」)。 + card( + ui, + tr_l10n(lang, "settings.permissions.title"), + tr_l10n(lang, "settings.permissions.desc_no_acc"), + |ui| { + permission_row( + ui, + tr_l10n(lang, "settings.permissions.mic_label"), + "", + vm.permissions.microphone, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.acc_label"), + "", + vm.permissions.accessibility, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.hotkey_label"), + "", + vm.permissions.hotkey, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.network_label"), + "", + vm.permissions.network, + lang, + ); + }, + ); + + // 数据存储(Tauri DataStorageSection:保留时长 / 上限 / 润色上下文 / 光标上下文)。 + card( + ui, + tr_l10n(lang, "settings.data_storage.title"), + tr_l10n(lang, "settings.data_storage.desc"), + |ui| { + let retention = vm.settings.retention_days.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_retention_label"), + "", + &mut vm.settings.retention_days, + "0", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::RetentionDays, + retention, + )); + }, + ); + let entries = vm.settings.history_max_entries.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_max_entries_label"), + "", + &mut vm.settings.history_max_entries, + "200", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::HistoryMaxEntries, + entries, + )); + }, + ); + let window = vm.settings.polish_context_window.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.polish_context_window_label"), + tr_l10n(lang, "settings.recording.polish_context_window_desc"), + &mut vm.settings.polish_context_window, + "0", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::PolishContextWindow, + window, + )); + }, + ); + }, + ); +} + +fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // The Tauri page is a list of drill-in rows; the panel swaps to the detail + // page (title + back button) while `advanced_open` is set. + let rows = [ + ( + SettingsIcon::Settings, + tr_l10n(lang, "settings.coding_agent.title"), + tr_l10n(lang, "modal.advanced_pages.less_computer"), + ), + ( + SettingsIcon::Bolt, + tr_l10n(lang, "settings.advanced.multimodal_pipeline_title"), + tr_l10n(lang, "modal.advanced_pages.multimodal"), + ), + ( + SettingsIcon::Document, + tr_l10n(lang, "settings.debug.title"), + tr_l10n(lang, "modal.advanced_pages.debug"), + ), + ]; + if vm.advanced_open < rows.len() { + let (icon, title, description) = rows[vm.advanced_open]; + let _ = icon; + // 多模态管线是实验性功能:Tauri 用 `ExperimentalSectionTitle`(标题 + 徽章 + // + 悬停说明),所以它不用普通卡片。 + if vm.advanced_open == 1 { + experimental_card( + ui, + title, + tr_l10n(lang, "common.experimental"), + description, + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.advanced.multimodal_pipeline_label"), + tr_l10n(lang, "settings.advanced.multimodal_pipeline_hint"), + vm.settings.multimodal, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::Multimodal)); + }, + ); + }, + ); + return; + } + // Less Computer 与调试工具在 Tauri 里都是「无标题卡片」(标题只出现在 + // 右栏顶栏),只有多模态用 ExperimentalSectionTitle。 + card(ui, "", "", |ui| match vm.advanced_open { + 0 => { + toggle_row( + ui, + tr_l10n(lang, "settings.coding_agent.enable"), + tr_l10n(lang, "settings.coding_agent.hotkey_hint"), + vm.settings.less_computer, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::LessComputer)); + }, + ); + // Tauri `CodingAgentSection`:后端 / 模型等高级项只在启用后展开。 + if !vm.settings.less_computer { + return; + } + combo_index_row( + ui, + tr_l10n(lang, "settings.coding_agent.provider"), + tr_l10n(lang, "settings.coding_agent.coming_soon_note"), + vm.settings.coding_agent_provider.min(3), + &["Claude Code", "OpenCode", "Codex", "dsh"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::CodingAgentProvider, + val, + )); + }, + ); + combo_index_row( + ui, + tr_l10n(lang, "settings.coding_console.permission_mode"), + "", + vm.settings.coding_agent_permission.min(3), + &[ + tr_l10n(lang, "settings.coding_console.mode.accept_edits"), + tr_l10n(lang, "settings.coding_console.mode.plan"), + tr_l10n(lang, "settings.coding_console.mode.default"), + tr_l10n(lang, "settings.coding_console.mode.bypass_permissions"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::CodingAgentPermission, + val, + )); + }, + ); + let model = vm.settings.coding_agent_model.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_agent.model"), + tr_l10n(lang, "settings.coding_agent.model_hint"), + &mut vm.settings.coding_agent_model, + tr_l10n(lang, "settings.coding_agent.model_placeholder"), + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentModel, + model, + )); + }, + ); + let workdir = vm.settings.coding_agent_workdir.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_console.workdir"), + tr_l10n(lang, "settings.coding_console.workdir_desc"), + &mut vm.settings.coding_agent_workdir, + tr_l10n(lang, "settings.coding_console.workdir_placeholder"), + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentWorkdir, + workdir, + )); + }, + ); + let exe = vm.settings.coding_agent_exe.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_agent.exe"), + "", + &mut vm.settings.coding_agent_exe, + "claude", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentExe, + exe, + )); + }, + ); + } + 1 => {} + _ => { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.record_audio_for_debug_label"), + "", + vm.settings.record_audio_for_debug, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RecordAudioForDebug, + )); + }, + ); + let entries = vm.settings.audio_recording_max_entries.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.audio_recording_max_entries_label"), + tr_l10n(lang, "settings.recording.audio_recording_max_entries_desc"), + &mut vm.settings.audio_recording_max_entries, + "50", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::AudioRecordingMaxEntries, + entries, + )); + }, + ); + action_row( + ui, + tr_l10n(lang, "settings.debug.title"), + "", + tr_l10n(lang, "btn.export_error_log"), + SettingsActionField::ExportDiagnostics, + actions, + ); + } + }); + return; + } + card(ui, "", "", |ui| { + for (index, (icon, title, description)) in rows.iter().enumerate() { + if drill_row(ui, *icon, title, description) { + vm.advanced_open = index; + } + } + }); +} + +/// A drill-in row: icon + bold title + description + chevron. Returns true when +/// the row was clicked (the panel then swaps to that detail page). +fn drill_row(ui: &mut egui::Ui, icon: SettingsIcon, title: &str, description: &str) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 54.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + egui::Color32::from_rgba_unmultiplied(244, 244, 245, 140), + ); + } + draw_rail_icon( + ui, + egui::pos2(rect.left() + 18.0, rect.center().y), + icon, + theme::INK_2, + ); + ui.painter().text( + egui::pos2(rect.left() + 40.0, rect.center().y - 9.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(13.5), + theme::INK, + ); + ui.painter().text( + egui::pos2(rect.left() + 40.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + description, + egui::FontId::proportional(11.5), + theme::INK_4, + ); + let chevron = egui::pos2(rect.right() - 12.0, rect.center().y); + let stroke = egui::Stroke::new(1.2, theme::INK_4); + ui.painter().line_segment( + [ + egui::pos2(chevron.x - 2.5, chevron.y - 4.5), + egui::pos2(chevron.x + 2.0, chevron.y), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(chevron.x + 2.0, chevron.y), + egui::pos2(chevron.x - 2.5, chevron.y + 4.5), + ], + stroke, + ); + response.clicked() +} + +fn about(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + card(ui, "", "", |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("OpenLess").size(17.0).strong()); + if vm.auto_update_capable { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "settings.about.check_stable_update_btn", + )) + .size(11.5), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::CheckUpdate, + )); + } + }); + } + }); + ui.label( + egui::RichText::new(format!( + "{} · v{}", + tr_l10n(lang, "settings.about.tagline"), + vm.version + )) + .size(12.0) + .color(theme::INK_3), + ); + if let Some(notice) = &vm.settings_notice { + ui.label(egui::RichText::new(notice).size(11.0).color(theme::BLUE)); + } + }); + card(ui, tr_l10n(lang, "settings.about.links_title"), "", |ui| { + link_row( + ui, + tr_l10n(lang, "settings.about.source"), + "GitHub", + SettingsActionField::OpenGitHub, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.docs"), + tr_l10n(lang, "modal.about.docs_btn"), + SettingsActionField::OpenHelp, + actions, + ); + link_row( + ui, + tr_l10n(lang, "modal.sections.help_center"), + tr_l10n(lang, "modal.sections.help_center"), + SettingsActionField::OpenHelp, + actions, + ); + link_row( + ui, + tr_l10n(lang, "modal.sections.release_notes"), + tr_l10n(lang, "modal.sections.release_notes"), + SettingsActionField::OpenReleaseNotes, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.feedback"), + tr_l10n(lang, "modal.about.feedback_btn"), + SettingsActionField::OpenFeedback, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.qq"), + "1078960553", + SettingsActionField::CopyQQ, + actions, + ); + }); + // Beta 渠道(Tauri BetaChannelSection):只在宿主支持自更新时出现。 + if vm.auto_update_capable { + card( + ui, + tr_l10n(lang, "settings.about.beta_channel_label"), + tr_l10n(lang, "settings.about.beta_channel_desc"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.about.beta_channel_toggle_label"), + "", + vm.settings.beta_channel, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::BetaChannel)); + }, + ); + action_row( + ui, + "", + "", + tr_l10n(lang, "settings.about.check_beta_update_btn"), + SettingsActionField::CheckBetaUpdate, + actions, + ); + }, + ); + } +} + +/// One credential channel row: name + current marker, provider/model, actions. +fn channel_row( + ui: &mut egui::Ui, + channel: &super::view_model::SettingsChannel, + index: usize, + providers: &[SettingsChannelProvider], + lang: Lang, + actions: &mut Vec, +) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&channel.name) + .size(12.5) + .strong() + .color(theme::INK), + ); + if channel.is_active { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.current")) + .size(10.0) + .color(theme::BLUE), + ); + }); + } + if !channel.enabled { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.disabled")) + .size(10.5) + .color(theme::INK_4), + ); + } + }); + let detail = if channel.model.trim().is_empty() { + channel.provider.clone() + } else { + format!("{} · {}", channel.provider, channel.model) + }; + ui.label(egui::RichText::new(detail).size(11.0).color(theme::INK_3)); + let last_check = channel + .last_check + .clone() + .unwrap_or_else(|| tr_l10n(lang, "settings.channels.not_verified").to_string()); + ui.label( + egui::RichText::new(last_check) + .size(10.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // 编辑入口:选中渠道后由宿主向 Core 读回该渠道的描述符与凭据形态。 + if ui + .add( + egui::Button::new(egui::RichText::new(tr_l10n(lang, "btn.edit")).size(11.0)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelSelect(index)); + } + for (label, delta) in [("↑", -1isize), ("↓", 1isize)] { + if ui + .add( + egui::Button::new(egui::RichText::new(label).size(11.0)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(22.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelMove { index, delta }); + } + } + // 渠道的 provider 类型就是 Core 的 `set_channel_provider_type`: + // 换类型等于换描述符,因此比编辑表单更早生效。 + if !providers.is_empty() { + let selected = providers + .iter() + .position(|provider| provider.provider_type == channel.provider_type) + .unwrap_or(0); + let mut picked = selected; + egui::ComboBox::from_id_salt(("settings-channel-provider", index)) + .selected_text(&channel.provider) + .width(150.0) + .show_ui(ui, |ui| { + for (option_index, provider) in providers.iter().enumerate() { + if ui + .selectable_label(option_index == selected, &provider.label) + .clicked() + { + picked = option_index; + ui.close(); + } + } + }); + if picked != selected { + actions.push(FrontendAction::SettingsChannelProviderType { + index, + provider_type: providers[picked].provider_type.clone(), + }); + } + } + // 当前生效的渠道由 Core 记录:界面只读「哪个是当前」(is_active), + // 并通过 Core 切换,不自己判定谁该生效。 + if !channel.is_active + && ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "btn.activate")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelActivate(index)); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.delete")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelDelete(index)); + } + let (switch, _) = ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, switch, channel.enabled, ("settings-channel", index)).clicked() { + actions.push(FrontendAction::SettingsChannelToggle(index)); + } + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + .size(11.0) + .color(theme::INK_3), + ); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.verify")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelValidate(index)); + } + ui.add_space(6.0); + }); + }); + ui.separator(); +} + +/// Provider + name form used by "add channel". +/// One editor text row. The pushed value is the post-edit text: pushing the +/// pre-edit copy would make the host write the old value straight back into the +/// field on every keystroke. +fn provider_field( + ui: &mut egui::Ui, + label: &str, + value: &str, + field: SettingsProviderField, + password: bool, + actions: &mut Vec, +) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(11.5).color(theme::INK_3)); + let mut draft = value.to_string(); + let id = egui::Id::new(("openless-settings-provider-field", format!("{field:?}"))); + if layout::text_input(ui, &mut draft, id, "", 220.0, password).changed() { + actions.push(FrontendAction::SettingsProviderField(field, draft)); + } + }); +} + +fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, primary: bool) -> bool { + let text = egui::RichText::new(tr_l10n(lang, key)).size(11.5); + let button = if primary { + egui::Button::new(text.color(theme::SURFACE)).fill(theme::INK) + } else { + egui::Button::new(text).fill(theme::SURFACE_2) + }; + ui.add( + button + .stroke(if primary { + egui::Stroke::NONE + } else { + egui::Stroke::new(0.8, theme::LINE) + }) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() +} + +/// Channel editor. Core's `AuthRequirement` decides which inputs exist, and every +/// write goes back through Core's provider/credential API: the UI never owns +/// endpoints, defaults or credential semantics. Secret inputs are write-only — +/// opening an editor never shows a stored key. +fn provider_editor_panel( + ui: &mut egui::Ui, + editor: &SettingsProviderEditor, + lang: Lang, + actions: &mut Vec, +) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&editor.provider) + .strong() + .size(12.0) + .color(theme::INK), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.editing")) + .size(10.5) + .color(theme::INK_4), + ); + if editor.busy { + ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(10.5) + .color(theme::INK_4), + ); + } + }); + provider_field( + ui, + tr_l10n(lang, "providers.name"), + &editor.name, + SettingsProviderField::Name, + false, + actions, + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.credentials")) + .size(11.0) + .color(theme::INK_4), + ); + match editor.auth { + SettingsProviderAuth::None => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.no_cloud_note")) + .size(11.0) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::OAuth => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) + .size(11.0) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::Volcengine => { + let mut mode = editor.auth_mode.clone(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Auth").size(11.5).color(theme::INK_3)); + egui::ComboBox::from_id_salt("settings-provider-auth-mode") + .selected_text(&mode) + .show_ui(ui, |ui| { + for option in ["app_id_token", "api_key"] { + if ui.selectable_label(mode == option, option).clicked() { + mode = option.to_string(); + ui.close(); + } + } + }); + }); + if mode != editor.auth_mode { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::AuthMode, + mode.clone(), + )); + } + if mode == "api_key" { + provider_field( + ui, + "API Key", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + } else { + provider_field( + ui, + "APP ID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Access Token", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); + } + provider_field( + ui, + "Resource ID", + &editor.resource_id, + SettingsProviderField::ResourceId, + false, + actions, + ); + provider_field( + ui, + "Model", + &editor.model, + SettingsProviderField::Model, + false, + actions, + ); + } + SettingsProviderAuth::Xfyun => { + provider_field( + ui, + "AppID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "API Key", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); + provider_field( + ui, + "Model", + &editor.model, + SettingsProviderField::Model, + false, + actions, + ); + } + SettingsProviderAuth::Other => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.core_note")) + .size(11.0) + .color(theme::INK_4), + ); + provider_field( + ui, + "Model", + &editor.model, + SettingsProviderField::Model, + false, + actions, + ); + } + SettingsProviderAuth::ApiKey => { + provider_field( + ui, + tr_l10n(lang, "providers.api_key_hint"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Endpoint", + &editor.endpoint, + SettingsProviderField::Endpoint, + false, + actions, + ); + provider_field( + ui, + "Model", + &editor.model, + SettingsProviderField::Model, + false, + actions, + ); + } + } + ui.horizontal(|ui| { + if provider_small_button(ui, lang, "btn.list_models", false) { + actions.push(FrontendAction::SettingsProviderModels); + } + if editor.models_loading { + ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(10.5) + .color(theme::INK_4), + ); + } + }); + if !editor.models.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.model_list")) + .size(11.0) + .color(theme::INK_3), + ); + ui.horizontal_wrapped(|ui| { + for model in &editor.models { + if ui.selectable_label(editor.model == *model, model).clicked() { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Model, + model.clone(), + )); + } + } + }); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + if provider_small_button(ui, lang, "btn.save_fields", true) { + actions.push(FrontendAction::SettingsProviderSave); + } + if provider_small_button(ui, lang, "btn.clear_secret", false) { + actions.push(FrontendAction::SettingsProviderClearSecrets); + } + if provider_small_button(ui, lang, "btn.close", false) { + actions.push(FrontendAction::SettingsProviderClose); + } + }); + }); +} + +fn add_channel_form( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.horizontal(|ui| { + let options: Vec = vm + .channel_providers + .iter() + .map(|provider| provider.label.clone()) + .collect(); + let selected = vm + .channel_provider_index + .min(options.len().saturating_sub(1)); + let mut new_selection = selected; + egui::ComboBox::from_id_salt("settings-new-channel-provider") + .selected_text(options.get(selected).cloned().unwrap_or_default()) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + if ui.selectable_label(index == selected, option).clicked() { + new_selection = index; + ui.close(); + } + } + }); + if new_selection != selected { + actions.push(FrontendAction::SettingsChannelProvider(new_selection)); + } + // 推的是编辑后的值:推编辑前的拷贝会让宿主把旧值写回字段,每敲一个字 + // 就被回灌一次(渠道名、下划线搜索框都踩过这个坑)。 + let response = layout::text_input( + ui, + &mut vm.channel_form_name, + egui::Id::new("openless-settings-channel-name"), + tr_l10n(lang, "settings.channels.name_placeholder"), + 200.0, + false, + ); + if response.changed() { + actions.push(FrontendAction::SettingsChannelName( + vm.channel_form_name.clone(), + )); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.create")) + .color(theme::SURFACE) + .size(11.5), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelCreate); + } + if ui + .add( + egui::Button::new(egui::RichText::new(tr_l10n(lang, "common.cancel")).size(11.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelFormOpen(false)); + } + }); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) + .size(11.0) + .color(theme::INK_4), + ); +} + +/// A row whose value is a button that opens a link / performs an action. +fn link_row( + ui: &mut egui::Ui, + label: &str, + button: &str, + field: SettingsActionField, + actions: &mut Vec, +) { + row(ui, label, |ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(button).size(12.0).color(theme::INK_2)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsAction(field)); + } + }); +} + +// ── Card & row helpers ────────────────────────────────────────────────────── + +/// A settings card. Tauri hides every `SectionDesc` (the only visible +/// description is the one under the pane title), so the second argument is a +/// hover hint attached to a 「?」 next to the card title. +fn card(ui: &mut egui::Ui, title: &str, hint: &str, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + if !title.is_empty() { + card_title(ui, title, hint); + } + contents(ui); + }); + ui.add_space(16.0); +} + +/// A card whose title carries the 「实验性」 badge (Tauri +/// `ExperimentalSectionTitle`): title + blue-soft pill + hover hint. +fn experimental_card( + ui: &mut egui::Ui, + title: &str, + badge: &str, + hint: &str, + contents: impl FnOnce(&mut egui::Ui), +) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(title) + .size(13.0) + .strong() + .color(theme::INK), + ); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(layout::text_width(ui, badge, 10.0) + 12.0, 16.0), + egui::Sense::hover(), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + badge, + egui::FontId::proportional(10.0), + theme::BLUE, + ); + if !hint.is_empty() { + let _ = response.on_hover_text(hint); + } + }); + ui.add_space(6.0); + contents(ui); + }); + ui.add_space(16.0); +} + +/// A collapsible card group (Tauri wraps 插入与剪贴板 / 启动 in a `Collapsible`). +/// The open state lives in egui memory, keyed by the group title. +fn card_group(ui: &mut egui::Ui, title: &str, contents: impl FnOnce(&mut egui::Ui)) { + let id = egui::Id::new(("openless-settings-group", title)); + let mut open = ui.data(|data| data.get_temp::(id).unwrap_or(true)); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + let (rect, response) = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + ui.painter().text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(13.0), + theme::INK, + ); + let chevron = egui::pos2(rect.right() - 8.0, rect.center().y); + let stroke = egui::Stroke::new(1.2, theme::INK_4); + let dy = if open { -2.0 } else { 2.0 }; + ui.painter().line_segment( + [egui::pos2(chevron.x - 4.0, chevron.y - dy), chevron], + stroke, + ); + ui.painter().line_segment( + [chevron, egui::pos2(chevron.x + 4.0, chevron.y - dy)], + stroke, + ); + if response.clicked() { + open = !open; + } + if open { + ui.add_space(4.0); + contents(ui); + } + }); + ui.data_mut(|data| data.insert_temp(id, open)); + ui.add_space(16.0); +} + +/// A card whose header carries an action on the right (AI-services lists). +fn card_header( + ui: &mut egui::Ui, + title: &str, + hint: &str, + action: impl FnOnce(&mut egui::Ui), + contents: impl FnOnce(&mut egui::Ui), +) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(title) + .size(13.0) + .strong() + .color(theme::INK), + ); + if !hint.is_empty() { + help_dot(ui, hint); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), action); + }); + ui.add_space(6.0); + contents(ui); + }); + ui.add_space(16.0); +} + +/// The AI-services tab strip: Tauri uses underline tabs (active = blue label + +/// blue underline, required services carry a red/yellow status dot). +fn service_tabs( + ui: &mut egui::Ui, + items: &[(&str, Option)], + active: usize, +) -> Option { + let height = 38.0; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), height), + egui::Sense::hover(), + ); + ui.painter().line_segment( + [ + egui::pos2(rect.left(), rect.bottom() - 0.5), + egui::pos2(rect.right(), rect.bottom() - 0.5), + ], + egui::Stroke::new(1.0, theme::LINE), + ); + let mut x = rect.left(); + let mut clicked = None; + for (index, (label, dot)) in items.iter().enumerate() { + let width = + layout::text_width(ui, label, 13.0) + 28.0 + if dot.is_some() { 13.0 } else { 0.0 }; + let tab = egui::Rect::from_min_size(egui::pos2(x, rect.top()), egui::vec2(width, height)); + let response = ui.interact( + tab, + ui.id().with(("openless-service-tab", index)), + egui::Sense::click(), + ); + let selected = index == active; + let mut text_x = tab.left() + 14.0; + if let Some(color) = dot { + ui.painter() + .circle_filled(egui::pos2(text_x + 3.5, tab.center().y), 3.5, *color); + text_x += 13.0; + } + ui.painter().text( + egui::pos2(text_x, tab.center().y), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(13.0), + if selected { + theme::BLUE + } else if response.hovered() { + theme::INK + } else { + theme::INK_3 + }, + ); + if selected { + ui.painter().line_segment( + [ + egui::pos2(tab.left(), tab.bottom() - 1.0), + egui::pos2(tab.right(), tab.bottom() - 1.0), + ], + egui::Stroke::new(2.0, theme::BLUE), + ); + } + if response.clicked() { + clicked = Some(index); + } + x += width + 6.0; + } + clicked +} + +fn card_title(ui: &mut egui::Ui, title: &str, hint: &str) { + ui.horizontal(|ui| { + ui.label( + // Tauri `SectionTitle`: font-size 14 / font-weight 600。 + egui::RichText::new(title) + .size(14.0) + .strong() + .color(theme::INK), + ); + if !hint.is_empty() { + help_dot(ui, hint); + } + }); + ui.add_space(6.0); +} + +/// The small 「?」 Tauri renders next to a setting label: hover for the full +/// explanation instead of spending a permanent paragraph on it. +fn help_dot(ui: &mut egui::Ui, hint: &str) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter().circle_stroke( + rect.center(), + 7.5, + egui::Stroke::new(0.5, theme::LINE_STRONG), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "?", + egui::FontId::proportional(10.0), + theme::INK_4, + ); + response.on_hover_text(hint) +} + +fn toggle_row(ui: &mut egui::Ui, label: &str, desc: &str, value: bool, on_toggle: impl FnOnce()) { + row_desc(ui, label, desc, |ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, rect, value, label).clicked() { + on_toggle(); + } + }); +} + +fn combo_index_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + value: usize, + options: &[&str], + on_change: impl FnOnce(usize), +) { + row_desc(ui, label, desc, |ui| { + let mut selected = value; + egui::ComboBox::from_id_salt(("settings", label)) + .selected_text(options.get(value).copied().unwrap_or("")) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_label(selected == index, *option); + if response.clicked() { + selected = index; + ui.close(); + } + } + }); + if selected != value { + on_change(selected); + } + }); +} + +/// A row whose control is a segmented picker (Tauri uses one for the recording +/// mode and the selection-polish delivery). +fn segmented_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + options: &[&str], + selected: usize, + on_select: impl FnOnce(usize), +) { + row_desc(ui, label, desc, |ui| { + let width = layout::segmented_width(ui, options); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + if let Some(index) = layout::segmented(ui, rect, options, selected) { + on_select(index); + } + }); +} + +fn text_row(ui: &mut egui::Ui, label: &str, desc: &str, value: &str) { + row_desc(ui, label, desc, |ui| { + ui.label(egui::RichText::new(value).size(12.0).color(theme::INK_2)); + }); +} + +fn text_edit_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + value: &mut String, + hint: &str, + on_change: impl FnOnce(), +) { + row_desc(ui, label, desc, |ui| { + let response = layout::text_input( + ui, + value, + egui::Id::new(("openless-settings-text", label)), + hint, + ui.available_width().min(300.0), + false, + ); + if response.changed() { + on_change(); + } + }); +} + +fn status_row(ui: &mut egui::Ui, label: &str, desc: &str, status: &str, color: egui::Color32) { + row_desc(ui, label, desc, |ui| { + ui.label(egui::RichText::new(status).size(12.0).color(color)); + }); +} + +/// A permission row: the host state is rendered as text, colored by severity. +fn permission_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + state: super::view_model::PermissionState, + lang: Lang, +) { + use super::view_model::PermissionState; + let (text, color) = match state { + PermissionState::Granted => (tr_l10n(lang, "settings.permissions.granted"), theme::OK), + PermissionState::Unsupported => ( + tr_l10n(lang, "settings.permissions.not_applicable"), + theme::INK_4, + ), + PermissionState::Unknown => ( + tr_l10n(lang, "settings.permissions.indeterminate"), + theme::INK_4, + ), + }; + status_row(ui, label, desc, text, color); +} + +/// A row whose right-hand control is an action button. +fn action_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + button: &str, + field: SettingsActionField, + actions: &mut Vec, +) { + row_desc(ui, label, desc, |ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(button).size(11.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsAction(field)); + } + }); +} + +fn row(ui: &mut egui::Ui, label: &str, control: impl FnOnce(&mut egui::Ui)) { + row_desc(ui, label, "", control); +} + +fn row_desc(ui: &mut egui::Ui, label: &str, desc: &str, control: impl FnOnce(&mut egui::Ui)) { + // Tauri `SettingRow` 是 grid `minmax(0,200px) minmax(0,1fr)` + gap 16,控件在第二列里 + // **左对齐**(`justify-content: flex-start`),所以每一行的控件起点都固定在同一 x, + // 而不是各自贴右边缘 —— 贴右会让不同宽度的控件彼此错开。 + const LABEL_COLUMN: f32 = 200.0; + const COLUMN_GAP: f32 = 16.0; + ui.horizontal(|ui| { + ui.set_min_height(46.0); + // 窄窗口下标签列最多占一半宽度,避免控件列被挤没。 + let label_width = LABEL_COLUMN.min(ui.available_width() * 0.5); + ui.allocate_ui_with_layout( + egui::vec2(label_width, 0.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + if !label.is_empty() { + ui.label( + egui::RichText::new(label) + .font(theme::medium_font(14.0)) + .color(theme::INK), + ); + } + if !desc.is_empty() { + help_dot(ui, desc); + } + }, + ); + ui.add_space(COLUMN_GAP); + control(ui); + }); + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shortcut_section_hides_without_a_hotkey_backend() { + // Tauri 的 `visibleSettingsSections` 只在 supportsDesktopHotkey 为真时才列出 + // 「快捷键」;没有 fcitx5 监听器时要跟着隐藏,否则用户会进到一个改不动任何 + // 东西的分区。其余分区的可见性不受该能力影响。 + for section in [ + SettingsSection::General, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + assert!(rail_section_visible(section, false)); + assert!(rail_section_visible(section, true)); + } + assert!(rail_section_visible(SettingsSection::Shortcuts, true)); + assert!(!rail_section_visible(SettingsSection::Shortcuts, false)); + } + + #[test] + fn provider_editor_renders_every_auth_shape() { + // 描述符决定渲染哪组字段:六种形态都必须能画出来,且静态渲染不得产生任何 + // 动作(否则每帧都会往宿主推重复的写入)。 + let shapes = [ + SettingsProviderAuth::None, + SettingsProviderAuth::OAuth, + SettingsProviderAuth::Volcengine, + SettingsProviderAuth::Xfyun, + SettingsProviderAuth::Other, + SettingsProviderAuth::ApiKey, + ]; + for auth in shapes { + let editor = SettingsProviderEditor { + channel_id: "channel".to_string(), + provider: "volcengine".to_string(), + provider_type: "volcengine".to_string(), + name: "main".to_string(), + endpoint: "https://example.invalid".to_string(), + model: "model".to_string(), + resource_id: "resource".to_string(), + auth_mode: "app_id_token".to_string(), + auth, + primary_secret: String::new(), + secondary_secret: String::new(), + models: vec!["m1".to_string()], + models_loading: false, + busy: false, + }; + let mut actions = Vec::new(); + let ctx = egui::Context::default(); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + provider_editor_panel(ui, &editor, Lang::ZhCn, &mut actions); + }); + }); + assert!( + actions.is_empty(), + "渲染 {auth:?} 形态的编辑器时不应产生动作" + ); + } + } + + #[test] + fn style_pack_draft_selection_only_touches_the_draft() { + // 草稿行的选择器必须走 StyleHotkeyDraftPack:以前传的是 draft_pack 下标, + // 于是「+ 添加风格快捷键 → 选另一个包」会把**已有行**的包换掉,而 + // StyleHotkeyDraftPack 从未被构造(编译告警)。 + assert!(matches!( + style_pack_pick_action(None, 3), + FrontendAction::StyleHotkeyDraftPack(3) + )); + assert!(matches!( + style_pack_pick_action(Some(2), 3), + FrontendAction::StyleHotkeyRepack(2, 3) + )); + } + + #[test] + fn remote_certificate_details_hide_when_stopped_or_stale_and_warn_without_a_full_fingerprint() { + let fingerprint = "ab".repeat(32); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some(&fingerprint)), + RemoteCertFingerprintState::Available + ); + // 服务没在跑 / 地址已过期 → 配对码与旧地址都不能展示。 + assert_eq!( + remote_cert_fingerprint_state(false, false, Some(&fingerprint)), + RemoteCertFingerprintState::Hidden + ); + assert_eq!( + remote_cert_fingerprint_state(true, true, Some(&fingerprint)), + RemoteCertFingerprintState::Hidden + ); + // 在监听但指纹缺失 / 被截断 / 不是十六进制 → 必须走「不可用」告警。 + assert_eq!( + remote_cert_fingerprint_state(true, false, None), + RemoteCertFingerprintState::Unavailable + ); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some("ab")), + RemoteCertFingerprintState::Unavailable + ); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some(&"zz".repeat(32))), + RemoteCertFingerprintState::Unavailable + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs new file mode 100644 index 000000000..cddbbf7f3 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs @@ -0,0 +1,1090 @@ +//! GPU implementation of the Apple-Siri glow used by the Linux popups. +//! +//! Tauri renders this effect with WebGL (`components/SiriGL.tsx`): a single +//! full-screen triangle plus a fragment shader, driven by four uniforms. The +//! egui host used to approximate the same effect on the CPU by sampling the +//! rounded-rect perimeter (`popups::spinner_ring`, five volume bars on the +//! capsule), which cannot reproduce the spectral dispersion, the Lorentzian +//! light lines or the metaball merge — and costs a line segment per sample on +//! every animated frame. +//! +//! This module ports the *same* shader source (wave + orb) to the renderer the +//! host actually runs: eframe's glow (OpenGL) backend, via +//! [`egui_glow::CallbackFn`]. One cached program per mode; the only per-frame +//! uniforms are time / resolution / level / gather / tint, so no allocation +//! happens while animating. +//! +//! ``` +//! // Shader header is chosen from the live context: `#version 330 core` on +//! // desktop GL, `#version 300 es` on GLES (Wayland/EGL can hand us either). +//! ``` +//! +//! Fallback: [`paint`] reports whether the GPU path owns the frame. Until the +//! program has been compiled *and* drawn successfully at least once, the caller +//! keeps drawing its CPU fallback, so a driver that rejects the shader (or a +//! headless/software GL that half-supports it) degrades to the old look instead +//! of an empty popup. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use eframe::egui_glow::CallbackFn; +use eframe::glow::{self, HasContext}; + +/// Shared vertex stage: one oversized triangle covers the whole callback +/// viewport, so no per-frame vertex data is uploaded (Tauri's `VERTEX_SRC`). +pub const SIRI_VERTEX_SRC: &str = r#"layout(location = 0) in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +"#; + +/// Ported from `SiriGL.tsx::WAVE_FRAGMENT_SRC` (siri-glsl spectral wave). +/// Only the version header and the output variable differ from the web build; +/// the final line is premultiplied because the capsule window is translucent. +pub const SIRI_WAVE_FRAGMENT_SRC: &str = r#" +uniform vec2 iResolution; uniform float iTime; +uniform float uResolved; +uniform float uLevel; +uniform vec3 uTint; +const float PI = 3.14159265359; +const float AMPLITUDE=0.32, FREQ=1.1, ABER_FREQ=1.0, SPEED=2.4, WAVE_SCALE=0.6; +const float ABERRATION=2.6, THICKNESS=3.0, INTENSITY=2., FALLOFF=1.7; +const float EDGE_MASK=0.4, EDGE_INSET=0.0, BAND_FILL=30000.0, BAND_THICK=0.08, SOFTNESS=2.5; +const float LOW_AMP=6.0, LOW_INT=1.5, MID_ABER=0.8, MID_ABAMP=0.05, MID_SOFT=0.4; +const float HIGH_ABER=0.5, HIGH_ABAMP=0.06, UNRES_SCALE=0.14; +out vec4 fragColor; +vec3 spectral4(int s){ float x=float(s); + return clamp(vec3(abs(x-3.0)-1.0, 2.0-abs(x-2.0), 2.0-abs(x-4.0)), 0.0, 1.0); } +void main(){ + vec2 R=iResolution.xy; float aspect=R.x/R.y; + vec2 p=(gl_FragCoord.xy+0.5)*2.0/R-1.0; p.x*=aspect; + float yScreen=p.y; p/=max(WAVE_SCALE,0.1); + float t=iTime; + float dv=clamp(uLevel,0.0,1.0); + float low =clamp(0.45+0.45*sin(t*0.8)*sin(t*0.37+1.0),0.0,1.0)*dv; + float mid =clamp(0.40+0.40*sin(t*1.7+2.0)*sin(t*0.53),0.0,1.0)*dv; + float high=clamp(0.30+0.30*sin(t*2.9+4.0)*sin(t*0.71+2.0),0.0,1.0)*dv; + float res=clamp(uResolved,0.0,1.0); + float drift=mod(t,20.0*PI)*SPEED; + vec2 pw=p; pw.x*=mix(5.0,1.0,res); + float xN=pw.x/max(aspect,1.0); + float env=cos(PI*0.5*min(abs(0.9*xN),1.0)); env*=env; + float A1=(AMPLITUDE*mix(0.14,1.0,dv))+0.01*low*LOW_AMP; + float A2=A1+mid*MID_ABAMP+high*HIGH_ABAMP; + float AB=(ABERRATION+mid*MID_ABER+high*HIGH_ABER)*res; + float th=mix(0.1,0.01*THICKNESS,res); + float inten=mix(0.1,0.01*(INTENSITY+low*LOW_INT),res); + float soft=0.01*res*max(0.0,SOFTNESS+mid*MID_SOFT); + float dUnres=max(length(p)-mix(0.14,UNRES_SCALE,res),0.0); + float yMain=A1*env*res*sin(pw.x*FREQ+drift); + float bandFillTh=max(BAND_THICK,1e-4); + float bandAmt=1e-4*BAND_FILL*inten; + vec3 num=vec3(0.0),den=vec3(0.0); + for(int s=0;s<4;s++){ + vec3 hue=mix(vec3(1.0),spectral4(s),res); den+=hue; + float ab=mix(-AB,AB,float(s)/3.0); + float yL=A2*env*res*sin(pw.x*ABER_FREQ+drift+ab); + float d=mix(dUnres,abs(p.y-yL),res); + float lor=mix(1.0/(1.0+(0.02*d)*(0.02*d)),1.0,res); + float line=inten/(sqrt(d*d+soft*soft)+th); + float lo=min(yMain,yL),hi=max(yMain,yL); + float dBand=max(0.0,max(p.y-hi,lo-p.y)); + float band=bandAmt/(dBand+bandFillTh); + num+=hue*lor*(line+band); + } + vec3 col=num/den; + float dM=mix(dUnres,abs(p.y-yMain),res); + float lorM=mix(1.0/(1.0+(0.02*dM)*(0.02*dM)),1.0,res); + float boost=(1.0-res)*(3.0*low+1.2); + col+=0.5*inten*(lorM+boost)/(sqrt(dM*dM+soft*soft)+th); + col=pow(max(col,0.0),vec3(1.5)); + float emT=clamp((abs(yScreen)-1.0+EDGE_INSET)/(-max(EDGE_MASK,1e-4)),0.0,1.0); + float em=emT*emT*(3.0-2.0*emT); + float gauss=exp(-pow(xN*FALLOFF,2.0)); + col*=em*gauss; + col*=mix(0.55,1.0,res); + col*=uTint; + float a=clamp(max(col.r,max(col.g,col.b)),0.0,1.0); + fragColor=vec4(col*a,a); +}"#; + +/// Ported from `SiriGL.tsx::ORB_FRAGMENT_SRC` (siriFluidDots metaballs), with +/// the same deliberate deviations the web build documents: no 12 s burst, and +/// `uGather` drives the appear/disperse transition. +pub const SIRI_ORB_FRAGMENT_SRC: &str = r#" +uniform vec2 iResolution; uniform float iTime; +uniform float uGather; +uniform vec3 uTint; +const float TAU=6.28318530718; +const int N=6; +const float SMOOTH_K=0.08, INTENSITY=0.0025, FALLOFF_P=1.35, FADE_START=0.02, FADE_END=0.56; +const float ABERR=0.005; const vec3 SPECTRAL=vec3(0.0,0.5,1.0)*ABERR; +const float HUE_SPEED=0.06, COLOR_K=0.5, SAT=0.01, HUE_SPAN=0.667; +const float MERGE_PERIOD=6.0, STAGGER=0.33, HOLD=0.0; +const float W=4.6, L=3.2, PIERCE=0.12, RECOIL=0.035, REC_LAG=0.11; +const float GATHER_R=0.008, GATHER_DIM=0.85; +out vec4 fragColor; +float hash11(float n){ return fract(sin(n*127.1+311.7)*43758.5453); } +float settleWL(float tau,float w,float l){ if(tau<=0.0) return 0.0; return 1.0-exp(-l*tau)*cos(w*tau); } +float settle(float tau){ return settleWL(tau,W,L); } +float smin(float a,float b,float k){ float h=max(k-abs(a-b),0.0)/k; return min(a,b)-h*h*k*0.25; } +vec3 hue2rgb(float h){ h=fract(h); + float r=clamp(abs(h*6.0-3.0)-1.0,0.0,1.0); + float g=clamp(2.0-abs(h*6.0-2.0),0.0,1.0); + float b=clamp(2.0-abs(h*6.0-4.0),0.0,1.0); + return vec3(r,g,b); } +float dotR(float fi,float seed,float t){ return 0.036+0.010*sin(t*1.3+seed*TAU)+0.005*sin(t*2.4+fi*1.3); } +float dotSD(vec2 p,vec2 pos,float r,float t,float fi,float shapeDamp){ + vec2 d=p-pos; + float sq=0.075*(0.5+0.5*sin(t*0.9+fi*2.0))*shapeDamp; + float ca=cos(t*0.35+fi),sa=sin(t*0.35+fi); + d=mat2(ca,-sa,sa,ca)*d; + d*=vec2(1.0+sq,1.0-sq); + return length(d)-r; } +vec3 scene(vec2 p,float t){ + float k=floor(t/MERGE_PERIOD); + float u=fract(t/MERGE_PERIOD); + float te=u*MERGE_PERIOD; + float gC=clamp(uGather,0.0,1.0); + float gBright=mix(1.0,GATHER_DIM,gC)*(1.0+0.30*gC); + vec3 total3=vec3(1e5); + vec3 cAcc=vec3(0.0); + float wAcc=1e-6; + for(int i=0;i &'static str { + match self { + Self::Wave => SIRI_WAVE_FRAGMENT_SRC, + Self::Orb => SIRI_ORB_FRAGMENT_SRC, + Self::Ring => SIRI_RING_FRAGMENT_SRC, + } + } + + /// Uniforms the shader declares. Asserted by unit tests so a future edit + /// cannot silently rename one and leave the draw call feeding a dead slot. + pub fn required_uniforms(self) -> &'static [&'static str] { + match self { + Self::Wave => &["iResolution", "iTime", "uResolved", "uLevel", "uTint"], + Self::Orb => &["iResolution", "iTime", "uGather", "uTint"], + Self::Ring => &[ + "iResolution", + "iTime", + "uRadius", + "uThickness", + "uSpeed", + "uTint", + ], + } + } + + fn index(self) -> usize { + match self { + Self::Wave => 0, + Self::Orb => 1, + Self::Ring => 2, + } + } +} + +/// One frame of glow parameters. Everything is a scalar or a 3-vector: the +/// uniform upload is the only per-frame work besides the draw call. +#[derive(Clone, Copy, Debug)] +pub struct SiriGlow { + pub mode: SiriMode, + pub time: f32, + pub level: f32, + pub resolved: f32, + pub gather: f32, + /// Ring only: corner radius and band thickness in physical pixels. + pub radius: f32, + pub thickness: f32, + /// Ring only: sweep speed (radians/s). + pub speed: f32, + /// Per-call-site tint. `[1.0; 3]` keeps the spectral Siri colors (capsule); + /// the ask/composer ring uses a flat accent (red while recording, ink while + /// thinking) exactly like the previous CPU ring. + pub tint: [f32; 3], +} + +impl SiriGlow { + pub fn wave(time: f32, level: f32, resolved: f32) -> Self { + Self { + mode: SiriMode::Wave, + time, + level, + resolved, + gather: 0.0, + radius: 0.0, + thickness: 2.0, + speed: 1.6, + tint: [1.0; 3], + } + } + + pub fn orb(time: f32, gather: f32) -> Self { + Self { + mode: SiriMode::Orb, + time, + level: 0.0, + resolved: 0.0, + gather, + radius: 0.0, + thickness: 2.0, + speed: 1.6, + tint: [1.0; 3], + } + } + + /// Perimeter ring for a `rect` of `radius` (px) with a `thickness` (px) + /// band, sweeping at `speed` radians/s. + pub fn ring(time: f32, radius: f32, thickness: f32, speed: f32) -> Self { + Self { + mode: SiriMode::Ring, + time, + level: 0.0, + resolved: 0.0, + gather: 0.0, + radius, + thickness, + speed, + tint: [1.0; 3], + } + } + + pub fn with_tint(mut self, tint: [f32; 3]) -> Self { + self.tint = tint; + self + } +} + +/// Voice level → visual amplitude (Tauri `SiriGL.tsx::visualVoice`): noise gate +/// at 0.012, ceiling 0.34, smoothstep, then a 0.42 power for the VU feel. +pub fn visual_voice(raw: f32) -> f32 { + const GATE: f32 = 0.012; + const CEILING: f32 = 0.34; + let gated = ((raw - GATE) / (CEILING - GATE)).clamp(0.0, 1.0); + let eased = gated * gated * (3.0 - 2.0 * gated); + eased.max(0.0).powf(0.42) +} + +/// What the caller wants to drive this frame. +#[derive(Clone, Copy, Debug)] +pub struct SiriDrive { + /// Raw RMS from the audio pipeline (`capsule:state.audio_level`). + pub level: f32, + /// 1 = wave expanded (recording), 0 = collapsed into the thinking point. + pub resolved: f32, + pub speed: f32, + /// Microphone not on yet: the wave breathes at a low, obviously-unready + /// amplitude instead of following the level. + pub warming: bool, +} + +impl Default for SiriDrive { + fn default() -> Self { + Self { + level: 0.0, + resolved: 1.0, + speed: 1.0, + warming: false, + } + } +} + +/// Smoothed animation state, kept in egui memory because egui only repaints +/// while something animates (`SiriGL.tsx` keeps the same values in refs). +#[derive(Clone, Copy, Debug)] +pub struct SiriClock { + pub time: f32, + pub level: f32, + pub resolved: f32, + pub speed: f32, +} + +/// Advance one call site's clock by `dt` (seconds). +pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClock { + let key = egui::Id::new(("openless-siri-clock", id)); + let dt = dt.clamp(0.0, 0.05); + let mut clock = ctx.data_mut(|data| { + data.get_temp::(key).unwrap_or(SiriClock { + time: 0.0, + level: 0.0, + resolved: drive.resolved, + speed: drive.speed, + }) + }); + // Speed is eased before it scales dt, so changing it mid-animation stays + // continuous (Tauri: `smoothSpeed += (speed - smoothSpeed) * (1-e^{-dt*2.5})`). + clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); + clock.time += dt * clock.speed; + let target = if drive.warming { + 0.12 + 0.06 * (clock.time * 3.0).sin() + } else if drive.resolved < 0.5 { + 0.14 + 0.07 * (clock.time * 2.2).sin() + } else { + visual_voice(drive.level) + }; + // Fast attack, slow release — VU-meter feel. + let attack = if target > clock.level { 14.0 } else { 5.0 }; + clock.level += (target - clock.level) * (1.0 - (-dt * attack).exp()); + clock.resolved += (drive.resolved - clock.resolved) * (1.0 - (-dt * 3.0).exp()); + ctx.data_mut(|data| data.insert_temp(key, clock)); + clock +} + +/// True once the driver rejected the shader: callers keep the CPU fallback. +pub fn gpu_disabled() -> bool { + GPU_FAILED.load(Ordering::Relaxed) +} + +/// True once a GPU frame has actually been drawn. +pub fn gpu_ready() -> bool { + GPU_READY.load(Ordering::Relaxed) +} + +/// Test hook: serialises the tests that observe the (process-global) GPU state +/// and brings it back to a known baseline. Every such test takes this guard, so +/// `cargo test`'s parallel threads cannot clobber each other. +#[cfg(test)] +pub fn gpu_state_guard() -> GpuStateGuard { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let guard = LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + reset_gpu_state(); + GpuStateGuard(guard) +} + +/// Serialises GPU-state observations and restores the baseline on drop, so a +/// test that seeds "gpu ready" cannot leak that into its neighbours. +#[cfg(test)] +pub struct GpuStateGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + +#[cfg(test)] +impl Drop for GpuStateGuard { + fn drop(&mut self) { + reset_gpu_state(); + } +} + +/// Test hook: pretend the GPU path already proved itself, so callers stop +/// drawing their CPU fallback (what the first real callback frame does). +#[cfg(test)] +pub fn seed_gpu_ready_for_tests() { + GPU_FAILED.store(false, Ordering::Relaxed); + GPU_READY.store(true, Ordering::Relaxed); +} + +/// Test hook: pretend the driver already rejected the shaders. +#[cfg(test)] +pub fn seed_gpu_failed_for_tests() { + GPU_READY.store(false, Ordering::Relaxed); + GPU_FAILED.store(true, Ordering::Relaxed); +} + +/// Test hook: pretend the warm-up callback already ran. +#[cfg(test)] +pub fn seed_warm_up_done_for_tests() { + WARM_UP_DONE.store(true, Ordering::Relaxed); +} + +/// Test hook: `(queued, done)` for the warm-up. +#[cfg(test)] +pub fn warm_up_state() -> (bool, bool) { + ( + WARM_UP_QUEUED.load(Ordering::Relaxed), + WARM_UP_DONE.load(Ordering::Relaxed), + ) +} + +/// Test hook: forget any previous failure so a fresh `paint` can be observed. +#[cfg(test)] +pub fn reset_gpu_state() { + GPU_FAILED.store(false, Ordering::Relaxed); + GPU_READY.store(false, Ordering::Relaxed); + WARM_UP_QUEUED.store(false, Ordering::Relaxed); + WARM_UP_DONE.store(false, Ordering::Relaxed); +} + +static GPU_FAILED: AtomicBool = AtomicBool::new(false); +static GPU_READY: AtomicBool = AtomicBool::new(false); +/// A compile-only callback has been queued for this process (set by the first +/// `warm_up` caller). +static WARM_UP_QUEUED: AtomicBool = AtomicBool::new(false); +/// That callback ran: every program is compiled, so nobody queues another one. +static WARM_UP_DONE: AtomicBool = AtomicBool::new(false); +static PROGRAMS: OnceLock; 3]>> = OnceLock::new(); + +/// Compile every mode's program ahead of the first glow frame. +/// +/// The programs are otherwise built lazily inside the paint callback, i.e. on +/// the very frame that first needs the glow — which showed up as a one-frame +/// hitch right after pressing the recording hotkey. This queues a compile-only +/// callback (no draw, so it never paints a pixel) on the popup's first frame, +/// so the render thread has the programs ready by the time the glow appears. +/// +/// At most one callback is queued per process, and once it has run (or once the +/// driver has already failed / a GPU frame has already drawn) this is a single +/// relaxed atomic load — no extra work, and no repaint request. +pub fn warm_up(ui: &egui::Ui) { + if !should_queue_warm_up() { + return; + } + let center = ui.max_rect().center(); + if !center.is_finite() { + return; + } + let callback = egui::PaintCallback { + rect: egui::Rect::from_center_size(center, egui::vec2(1.0, 1.0)), + callback: Arc::new(CallbackFn::new(|_info, painter| { + let started = std::time::Instant::now(); + match prepare_all(painter.gl()) { + Ok(()) => { + WARM_UP_DONE.store(true, Ordering::Relaxed); + // 一次性证据行:真机上(每个浮窗进程一次)能确认预热跑过。 + log::info!("siri glow shaders precompiled in {:?}", started.elapsed()); + } + Err(error) => { + // Same degradation contract as a failed draw: fall back to + // the CPU painter for the rest of the process. + if !gpu_disabled() { + log::warn!("siri glow GPU path disabled during warm-up: {error}"); + } + GPU_FAILED.store(true, Ordering::Relaxed); + } + } + })), + }; + ui.painter().add(egui::Shape::Callback(callback)); +} + +/// True when this call is the one that must queue the warm-up callback. +fn should_queue_warm_up() -> bool { + if gpu_disabled() || gpu_ready() || WARM_UP_DONE.load(Ordering::Relaxed) { + return false; + } + // The first caller flips false to true; every later caller sees true. + !WARM_UP_QUEUED.swap(true, Ordering::Relaxed) +} + +/// Queue the GPU glow for `rect`. +/// +/// Returns `true` when the caller must *not* draw its CPU fallback: that is the +/// case only after the program has compiled and drawn successfully once, so the +/// first frames paint both (the callback is a no-op until it has a program, so +/// nothing is double-drawn) and a broken driver keeps the old look forever. +pub fn paint(ui: &egui::Ui, rect: egui::Rect, glow: SiriGlow) -> bool { + if gpu_disabled() || !rect.is_positive() || !rect.is_finite() { + return false; + } + let callback = egui::PaintCallback { + rect, + callback: Arc::new(CallbackFn::new(move |info, painter| { + match draw(painter.gl(), &info, glow) { + Ok(()) => GPU_READY.store(true, Ordering::Relaxed), + Err(error) => { + if !gpu_disabled() { + log::warn!("siri glow GPU path disabled after error: {error}"); + } + GPU_FAILED.store(true, Ordering::Relaxed); + } + } + })), + }; + ui.painter().add(egui::Shape::Callback(callback)); + gpu_ready() +} + +/// A compiled program plus the uniform slots it needs. +struct GlowProgram { + program: glow::Program, + vao: glow::VertexArray, + /// Kept alive for the program's lifetime: the VAO's attribute binding + /// references this buffer's storage, so the handle must outlive setup. + #[allow(dead_code)] + vbo: glow::Buffer, + resolution: Option, + time: Option, + level: Option, + resolved: Option, + gather: Option, + radius: Option, + thickness: Option, + speed: Option, + tint: Option, +} + +impl GlowProgram { + /// `layout(location = 0)` needs no bind call, but only desktop GL 3.3 and + /// GLES 3.0 support it — both of which also accept the header chosen here. + fn header(gl: &glow::Context) -> &'static str { + if gl.version().is_embedded { + "#version 300 es\nprecision highp float;\n" + } else { + "#version 330 core\n" + } + } + + unsafe fn create(gl: &glow::Context, mode: SiriMode) -> Result { + let header = Self::header(gl); + let vertex = compile( + gl, + glow::VERTEX_SHADER, + &format!("{header}{SIRI_VERTEX_SRC}"), + )?; + let fragment = compile( + gl, + glow::FRAGMENT_SHADER, + &format!("{header}{}", mode.fragment_source()), + )?; + let program = gl.create_program()?; + gl.attach_shader(program, vertex); + gl.attach_shader(program, fragment); + gl.link_program(program); + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_shader(vertex); + gl.delete_shader(fragment); + return Err(format!("{mode:?} link failed: {log}")); + } + gl.detach_shader(program, vertex); + gl.detach_shader(program, fragment); + gl.delete_shader(vertex); + gl.delete_shader(fragment); + + let vao = gl.create_vertex_array()?; + gl.bind_vertex_array(Some(vao)); + let vbo = gl.create_buffer()?; + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + // Oversized triangle covering the viewport (Tauri uses the same trick). + let mut bytes = [0u8; 24]; + for (index, value) in [-1.0f32, -1.0, 3.0, -1.0, -1.0, 3.0].iter().enumerate() { + bytes[index * 4..index * 4 + 4].copy_from_slice(&value.to_le_bytes()); + } + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &bytes, glow::STATIC_DRAW); + gl.enable_vertex_attrib_array(0); + gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 0, 0); + gl.bind_vertex_array(None); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + + // Ring declares three extra uniforms after iTime; the other modes jump + // straight from iTime to their own scalar. + let declared_names: Vec<&str> = match mode { + SiriMode::Ring => vec!["iResolution", "iTime", "uTint"], + other => other.required_uniforms().to_vec(), + }; + let mut declared = declared_names.into_iter(); + let mut missing = Vec::new(); + let mut slot = |name: &str| { + let location = gl.get_uniform_location(program, name); + if location.is_none() { + missing.push(name.to_string()); + } + location + }; + // Order matches `required_uniforms` so the closures cannot drift. Ring's + // radius/thickness/speed are taken from the tail below. + let resolution = slot(declared.next().expect("iResolution")); + let time = slot(declared.next().expect("iTime")); + let (level, resolved, gather) = match mode { + SiriMode::Wave => ( + slot(declared.next().expect("uLevel")), + slot(declared.next().expect("uResolved")), + None, + ), + SiriMode::Orb => (None, None, slot(declared.next().expect("uGather"))), + SiriMode::Ring => (None, None, None), + }; + let (radius, thickness, speed) = match mode { + SiriMode::Ring => ( + slot(declared.next().expect("uRadius")), + slot(declared.next().expect("uThickness")), + slot(declared.next().expect("uSpeed")), + ), + _ => (None, None, None), + }; + let tint = slot(declared.next().expect("uTint")); + if !missing.is_empty() { + return Err(format!("{mode:?} uniforms not found: {missing:?}")); + } + + Ok(Self { + program, + vao, + vbo, + resolution, + time, + level, + resolved, + gather, + radius, + thickness, + speed, + tint, + }) + } +} + +fn compile(gl: &glow::Context, kind: u32, source: &str) -> Result { + unsafe { + let shader = gl.create_shader(kind)?; + gl.shader_source(shader, source); + gl.compile_shader(shader); + if !gl.get_shader_compile_status(shader) { + let log = gl.get_shader_info_log(shader); + gl.delete_shader(shader); + return Err(format!( + "{} shader compile failed: {log}", + if kind == glow::VERTEX_SHADER { + "vertex" + } else { + "fragment" + } + )); + } + Ok(shader) + } +} + +/// Compile every program the glow can use. Shared by the lazy path in `draw` +/// and the eager `warm_up`, so both build exactly the same programs. +fn prepare_all(gl: &Arc) -> Result<(), String> { + let programs = PROGRAMS.get_or_init(|| Mutex::new([None, None, None])); + let mut programs = programs + .lock() + .map_err(|_| "siri glow program cache poisoned".to_string())?; + for mode in [SiriMode::Wave, SiriMode::Orb, SiriMode::Ring] { + ensure_program(&mut programs, gl, mode)?; + } + Ok(()) +} + +/// Build `mode`'s program unless the cache already holds it. +fn ensure_program( + programs: &mut [Option; 3], + gl: &Arc, + mode: SiriMode, +) -> Result<(), String> { + let index = mode.index(); + if programs[index].is_none() { + // Compilation happens on the render thread, i.e. exactly where the GL + // context is current — never on the UI thread. + programs[index] = Some(unsafe { GlowProgram::create(gl, mode)? }); + } + Ok(()) +} + +/// Compile (once per mode) and draw. Called from the paint callback, which +/// already has the callback viewport bound and restores egui's GL state after. +fn draw( + gl: &Arc, + info: &egui::PaintCallbackInfo, + glow: SiriGlow, +) -> Result<(), String> { + let programs = PROGRAMS.get_or_init(|| Mutex::new([None, None, None])); + let mut programs = programs + .lock() + .map_err(|_| "siri glow program cache poisoned".to_string())?; + ensure_program(&mut programs, gl, glow.mode)?; + let program = programs[glow.mode.index()].as_ref().expect("just created"); + let viewport = info.viewport_in_pixels(); + let width = viewport.width_px.max(1) as f32; + let height = viewport.height_px.max(1) as f32; + + unsafe { + gl.use_program(Some(program.program)); + gl.bind_vertex_array(Some(program.vao)); + gl.enable(glow::BLEND); + // The shader premultiplies, matching egui's own blend mode. + gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA); + gl.disable(glow::DEPTH_TEST); + gl.disable(glow::CULL_FACE); + gl.uniform_2_f32_slice(program.resolution.as_ref(), &[width, height]); + gl.uniform_1_f32(program.time.as_ref(), glow.time); + gl.uniform_3_f32_slice(program.tint.as_ref(), &glow.tint); + match glow.mode { + SiriMode::Wave => { + gl.uniform_1_f32(program.level.as_ref(), glow.level); + gl.uniform_1_f32(program.resolved.as_ref(), glow.resolved); + } + SiriMode::Orb => gl.uniform_1_f32(program.gather.as_ref(), glow.gather), + SiriMode::Ring => { + gl.uniform_1_f32(program.radius.as_ref(), glow.radius); + gl.uniform_1_f32(program.thickness.as_ref(), glow.thickness); + gl.uniform_1_f32(program.speed.as_ref(), glow.speed); + } + } + gl.draw_arrays(glow::TRIANGLES, 0, 3); + gl.bind_vertex_array(None); + gl.use_program(None); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shaders_declare_every_required_uniform() { + for mode in [SiriMode::Wave, SiriMode::Orb] { + let source = mode.fragment_source(); + for uniform in mode.required_uniforms() { + assert!( + source.contains(&format!("uniform vec2 {uniform};")) + || source.contains(&format!("uniform float {uniform};")) + || source.contains(&format!("uniform vec3 {uniform};")), + "{mode:?} is missing the declaration of {uniform}" + ); + } + } + } + + #[test] + fn shaders_are_desktop_gl_portable() { + for mode in [SiriMode::Wave, SiriMode::Orb] { + let source = mode.fragment_source(); + // WebGL-isms that do not compile in a core profile. + assert!(!source.contains("gl_FragColor"), "{mode:?}"); + assert!(!source.contains("precision highp"), "{mode:?}"); + assert!(!source.contains("attribute "), "{mode:?}"); + // The version header is added per context, so it must not be baked in. + assert!(!source.contains("#version"), "{mode:?}"); + assert!(source.contains("out vec4 fragColor;"), "{mode:?}"); + assert!( + source.contains("fragColor=vec4(col*a,a);"), + "{mode:?} premultiplied" + ); + } + assert!(SIRI_VERTEX_SRC.contains("layout(location = 0)")); + assert!(!SIRI_VERTEX_SRC.contains("#version")); + } + + #[test] + fn paint_queues_a_gpu_callback() { + let _guard = gpu_state_guard(); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }); + egui::CentralPanel::default().show(&ctx, |ui| { + for glow in [ + SiriGlow::wave(0.5, 0.3, 1.0), + SiriGlow::orb(0.5, 1.0), + SiriGlow::ring(0.5, 12.0, 2.0, 1.6), + ] { + // Before a successful GPU frame the caller keeps its CPU fallback. + assert!(!paint(ui, ui.max_rect(), glow), "{:?} ownership", glow.mode); + } + }); + let output = ctx.end_pass(); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!( + callbacks, 3, + "every mode must reach the paint callback registration" + ); + } + + /// The warm-up must queue a single compile-only callback and then stay out + /// of the way, while leaving the CPU fallback ownership rule untouched. + #[test] + fn warm_up_queues_one_compile_callback_and_keeps_the_cpu_fallback() { + let ctx = egui::Context::default(); + // The GPU state is process-global, so serialise with the other GPU tests. + let _guard = gpu_state_guard(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }); + egui::CentralPanel::default().show(&ctx, |ui| { + warm_up(ui); + warm_up(ui); + // Warm-up only compiles: until a real glow frame draws, the caller + // still owns its CPU fallback. + assert!(!paint( + ui, + ui.max_rect(), + SiriGlow::ring(0.0, 12.0, 2.0, 1.6) + )); + }); + let output = ctx.end_pass(); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!( + callbacks, 2, + "one warm-up callback plus the ring, no matter how often warm_up runs" + ); + let (queued, done) = warm_up_state(); + assert!(queued, "the warm-up callback must be queued"); + assert!( + !done, + "the callback body needs a GL context, so it cannot have run" + ); + } + + /// Once the programs exist (or the driver already failed), warm-up is a + /// single atomic load and queues nothing — hidden/idle popups keep repaint + /// costs at the idle rate. + #[test] + fn warm_up_is_skipped_once_the_gpu_path_is_settled() { + for seed in [ + seed_warm_up_done_for_tests, + seed_gpu_ready_for_tests, + seed_gpu_failed_for_tests, + ] { + let ctx = egui::Context::default(); + let _guard = gpu_state_guard(); + seed(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }); + egui::CentralPanel::default().show(&ctx, |ui| warm_up(ui)); + let output = ctx.end_pass(); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!(callbacks, 0, "settled GPU state must not re-queue warm-up"); + let (queued, _) = warm_up_state(); + assert!(!queued, "no warm-up may stay queued after it is settled"); + } + } + + /// Cheap static sanity for the shader sources: the compile call path is + /// exercised by `paint_queues_a_gpu_callback`, but a stray brace would + /// only surface as a driver log on the user's machine. + #[test] + fn shader_sources_are_balanced_and_non_trivial() { + for source in [ + SIRI_VERTEX_SRC, + SIRI_WAVE_FRAGMENT_SRC, + SIRI_ORB_FRAGMENT_SRC, + SIRI_RING_FRAGMENT_SRC, + ] { + assert!(source.len() > 40); + let opens = source.matches('{').count(); + let closes = source.matches('}').count(); + assert_eq!(opens, closes, "unbalanced braces in shader source"); + assert!( + source.contains("void main()"), + "shader needs an entry point" + ); + } + // The vertex stage is shared by every mode, so it must never grow a + // uniform: the fragment stages own those. + assert!(!SIRI_VERTEX_SRC.contains("uniform ")); + } + + #[test] + fn wave_and_orb_use_distinct_programs() { + assert_ne!(SiriMode::Wave.index(), SiriMode::Orb.index()); + assert_ne!( + SiriMode::Wave.fragment_source(), + SiriMode::Orb.fragment_source() + ); + assert_eq!(SiriGlow::wave(1.0, 0.0, 1.0).mode, SiriMode::Wave); + assert_eq!(SiriGlow::orb(1.0, 1.0).mode, SiriMode::Orb); + } + + #[test] + fn clock_smooths_level_time_and_speed() { + let ctx = egui::Context::default(); + let start = tick(&ctx, "test", SiriDrive::default(), 1.0 / 60.0); + assert!( + (start.time - 1.0 / 60.0).abs() < 1e-5, + "one frame of dt*1.0 speed: {}", + start.time + ); + assert_eq!(start.level, 0.0, "level starts from the stored clock"); + let mut clock = start; + for _ in 0..30 { + clock = tick( + &ctx, + "test", + SiriDrive { + level: 0.5, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + assert!( + clock.time > 0.4 && clock.time < 0.6, + "0.5s of frames: {}", + clock.time + ); + assert!(clock.level > 0.0, "level follows the drive"); + assert!(clock.level <= visual_voice(0.5) + f32::EPSILON); + // A speed change is eased into the accumulated time (no jump). + let before = clock.time; + let after = tick( + &ctx, + "test", + SiriDrive { + speed: 3.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + assert!( + after.time - before < 0.06, + "speed eased, not applied at once" + ); + } + + #[test] + fn visual_voice_gates_and_eases() { + assert_eq!(visual_voice(0.0), 0.0); + assert_eq!(visual_voice(0.012), 0.0, "noise gate"); + assert_eq!(visual_voice(0.34), 1.0, "ceiling maps to a full bar"); + let mid = visual_voice(0.18); + assert!( + mid > 0.4 && mid < 1.0, + "curve stays inside the unit range: {mid}" + ); + } + + #[test] + fn disabled_gpu_keeps_the_cpu_fallback() { + let _guard = gpu_state_guard(); + GPU_FAILED.store(true, Ordering::Relaxed); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }); + let mut queued = 0; + egui::CentralPanel::default().show(&ctx, |ui| { + if paint(ui, ui.max_rect(), SiriGlow::orb(0.0, 1.0)) { + queued += 1; + } + }); + let output = ctx.end_pass(); + assert_eq!(queued, 0, "a disabled GPU path reports no ownership"); + assert_eq!( + output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(), + 0, + "no callback is queued once the driver rejected the shader" + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/style.rs b/openless-all/app/linux-egui/src/ui/frontend/style.rs new file mode 100644 index 000000000..22c9eae17 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/style.rs @@ -0,0 +1,728 @@ +//! Style (润色模式) page — port of the Tauri `pages/Style.tsx`. +//! +//! One card holds the pack list: a header row with the raw-mode entry, the +//! dictation/selection workflow switch and a pack counter, followed by a grid +//! of style-pack cards plus a "new pack" tile. The pack editor opens as a +//! floating window. +//! +//! The card's highlighted state comes from `StylePack::is_active` only — the +//! pack the host reports as current. The page-local `style_selected` index is +//! used solely for the raw-mode tab, so a stale index can no longer paint a +//! second card as active. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::IconName; +use super::layout::{self, ButtonKind, PillTone}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, StylePack}; + +const GAP: f32 = 12.0; +const CARD_PADDING: f32 = 20.0; +const PACK_CARD_HEIGHT: f32 = 232.0; +const PACK_CARD_PADDING: f32 = 16.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.style_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "style.title")); + return; + } + + header(ui, width, vm, actions); + ui.add_space(GAP); + + // The list card swallows the remaining height; its grid scrolls inside. + let card_height = ui.available_height().max(PACK_CARD_HEIGHT + 96.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, card_height), egui::Sense::hover()); + layout::card(ui, rect, CARD_PADDING, |ui, inner| { + let grid_top = list_header(ui, inner, vm); + pack_grid(ui, inner, grid_top, vm, actions); + }); + + notice(ui, width, vm); + editor_overlay(ui.ctx(), vm, actions); +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let rect = layout::page_header( + ui, + width, + tr_l10n(lang, "style.kicker"), + tr_l10n(lang, "style.title"), + Some(tr_l10n(lang, "style.desc")), + ); + + let import = tr_l10n(lang, "style.pack.import_zip"); + let import_width = layout::text_width(ui, import, 12.5) + 46.0; + let import_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - import_width, rect.top() + 22.0), + egui::vec2(import_width, 30.0), + ); + if layout::action_button( + ui, + import_rect, + import, + Some(IconName::Download), + ButtonKind::Blue, + ) + .clicked() + { + actions.push(FrontendAction::StyleImport); + } + + let refresh = tr_l10n(lang, "common.refresh"); + let refresh_width = layout::text_width(ui, refresh, 12.5) + 40.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(import_rect.left() - 8.0 - refresh_width, rect.top() + 22.0), + egui::vec2(refresh_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(IconName::Refresh), + ButtonKind::Ghost, + ) + .clicked() + { + vm.style_notice = None; + } +} + +// ── List card ─────────────────────────────────────────────────────────────── + +/// Draws the card header (title, raw tab, workflow switch, counter) and returns +/// the y coordinate where the pack grid starts. +fn list_header(ui: &mut egui::Ui, inner: egui::Rect, vm: &mut FrontendViewModel) -> f32 { + let lang = vm.lang; + let row = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), 30.0)); + let painter = ui.painter().with_clip_rect(inner); + + painter.text( + egui::pos2(row.left(), row.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "style.pack.list_title"), + egui::FontId::proportional(15.0), + theme::INK, + ); + + // Raw-mode entry: a small tab next to the title. + let raw_label = tr_l10n(lang, "overview.mode_raw"); + let raw_width = layout::text_width(ui, raw_label, 12.0) + 20.0; + let raw_rect = egui::Rect::from_min_size( + egui::pos2( + row.left() + + layout::text_width(ui, tr_l10n(lang, "style.pack.list_title"), 15.0) + + 12.0, + row.center().y - 12.0, + ), + egui::vec2(raw_width, 24.0), + ); + let raw_active = !vm.style_selection_workflow && vm.style_selected == usize::MAX; + let raw_response = ui.interact( + raw_rect, + ui.id().with("style-raw-tab"), + egui::Sense::click(), + ); + if raw_active { + painter.rect_filled(raw_rect, egui::CornerRadius::same(6), theme::BLUE); + } else if raw_response.hovered() { + painter.rect_filled(raw_rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + painter.text( + raw_rect.center(), + egui::Align2::CENTER_CENTER, + raw_label, + egui::FontId::proportional(12.0), + if raw_active { + egui::Color32::WHITE + } else { + theme::INK_3 + }, + ); + if raw_response.clicked() { + vm.style_selected = usize::MAX; + vm.style_selection_workflow = false; + vm.style_notice = Some(fmt_l10n( + lang, + "status.style_switched", + &[&tr_l10n(lang, "overview.mode_raw")], + )); + } + + // Workflow switch + pack counter, right aligned. + let count = fmt_l10n(lang, "style.pack.list_count", &[&vm.style_packs.len()]); + let count_size = layout::pill_size(ui, &count); + let count_rect = egui::Rect::from_min_size( + egui::pos2( + row.right() - count_size.x, + row.center().y - count_size.y / 2.0, + ), + count_size, + ); + layout::paint_pill(&painter, count_rect, &count, PillTone::Gray); + + let options = [ + tr_l10n(lang, "style.pack.dictation_tab"), + tr_l10n(lang, "style.pack.selection_tab"), + ]; + let tabs_width = layout::segmented_width(ui, &options); + let tabs_rect = egui::Rect::from_min_size( + egui::pos2(count_rect.left() - 10.0 - tabs_width, row.center().y - 13.0), + egui::vec2(tabs_width, 26.0), + ); + let selected = usize::from(vm.style_selection_workflow); + if let Some(index) = layout::segmented(ui, tabs_rect, &options, selected) { + vm.style_selection_workflow = index == 1; + } + + let separator_y = row.bottom() + 14.0; + painter.line_segment( + [ + egui::pos2(inner.left(), separator_y), + egui::pos2(inner.right(), separator_y), + ], + egui::Stroke::new(0.5, theme::LINE), + ); + separator_y + 14.0 +} + +/// Grid of pack cards plus the "new pack" tile, scrolling inside the card. +fn pack_grid( + ui: &mut egui::Ui, + inner: egui::Rect, + grid_top: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let grid = egui::Rect::from_min_max( + egui::pos2(inner.left(), grid_top), + egui::pos2(inner.right(), inner.bottom()), + ); + if grid.height() < 8.0 { + return; + } + layout::fixed_ui(ui, grid, ("openless-style-grid",), |ui| { + egui::ScrollArea::vertical() + .id_salt("style-packs-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let grid_width = ui.available_width(); + let columns = if grid_width >= 820.0 { + 3 + } else if grid_width >= 560.0 { + 2 + } else { + 1 + }; + let card_width = + ((grid_width - GAP * (columns - 1) as f32) / columns as f32).max(1.0); + let tiles = vm.style_packs.len() + 1; + let mut row_start = 0; + while row_start < tiles { + let row_end = (row_start + columns).min(tiles); + let (row_rect, _) = ui.allocate_exact_size( + egui::vec2(grid_width, PACK_CARD_HEIGHT), + egui::Sense::hover(), + ); + for slot in row_start..row_end { + let rect = egui::Rect::from_min_size( + egui::pos2( + row_rect.left() + (slot - row_start) as f32 * (card_width + GAP), + row_rect.top(), + ), + egui::vec2(card_width, PACK_CARD_HEIGHT), + ); + if slot == vm.style_packs.len() { + new_pack_tile(ui, rect, lang, actions); + } else { + let pack = vm.style_packs[slot].clone(); + // The active pack depends on the workflow tab: + // dictation/ASR or selection polish. + let active = if vm.style_selection_workflow { + pack.selection_active + } else { + pack.is_active + }; + style_pack_card(ui, rect, &pack, slot, active, lang, actions); + } + } + ui.add_space(GAP); + row_start = row_end; + } + }); + }); +} + +/// One style-pack card. Highlighted only when the host reports it as active. +fn style_pack_card( + ui: &mut egui::Ui, + rect: egui::Rect, + pack: &StylePack, + index: usize, + // Whether this pack is the active one for the workflow currently shown + // (dictation/ASR vs selection polish). + active: bool, + lang: Lang, + actions: &mut Vec, +) { + let response = ui.interact( + rect, + ui.id().with(("style-pack-card", index)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let (fill, border, border_width) = if active { + (theme::BLUE_SOFT, theme::BLUE, 1.0) + } else if response.hovered() { + (theme::SURFACE_2, theme::LINE, 1.0) + } else { + (theme::SURFACE, theme::LINE, 1.0) + }; + painter.rect_filled(rect, egui::CornerRadius::same(14), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(border_width, border), + egui::StrokeKind::Inside, + ); + + let inner = rect.shrink(PACK_CARD_PADDING); + let mut x = inner.left(); + + // Name, then the builtin / current badges. + painter.text( + egui::pos2(x, inner.top()), + egui::Align2::LEFT_TOP, + &pack.name, + egui::FontId::proportional(14.0), + theme::INK, + ); + x += layout::text_width(ui, &pack.name, 14.0) + 8.0; + for (text, tone) in std::iter::once(( + if pack.is_builtin { + tr_l10n(lang, "style.pack.builtin") + } else { + tr_l10n(lang, "style.pack.imported") + }, + PillTone::Gray, + )) + .chain(active.then_some((tr_l10n(lang, "style.pack.current"), PillTone::Blue))) + { + let size = layout::pill_size(ui, text); + if x + size.x > inner.right() { + break; + } + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(x, inner.top() + 2.0), size), + text, + tone, + ); + x += size.x + 6.0; + } + + // Description. + let description = + layout::text_galley(ui, &pack.description, theme::INK_3, 12.0, inner.width(), 4); + painter.galley( + egui::pos2(inner.left(), inner.top() + 28.0), + description.clone(), + theme::INK_3, + ); + + // Mode / tag pill. + if let Some(tag) = pack.tags.first() { + let size = layout::pill_size(ui, tag); + layout::paint_pill( + &painter, + egui::Rect::from_min_size( + egui::pos2( + inner.left(), + inner.top() + 28.0 + description.size().y + 10.0, + ), + size, + ), + tag, + PillTone::Outline, + ); + } + + // Actions row. + let button_height = 28.0; + let button_y = inner.bottom() - button_height; + let mut button_x = inner.left(); + let primary = if active { + tr_l10n(lang, "style.pack.current") + } else { + tr_l10n(lang, "style.pack.activate") + }; + let primary_width = layout::text_width(ui, primary, 11.5) + 24.0; + let primary_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(primary_width, button_height), + ); + button_x += primary_width + 6.0; + if layout::action_button( + ui, + primary_rect, + primary, + None, + if active { + ButtonKind::Ghost + } else { + ButtonKind::Blue + }, + ) + .clicked() + && !active + { + actions.push(FrontendAction::StyleActivate(index)); + } + + let export = tr_l10n(lang, "style.pack.export_short"); + let export_width = layout::text_width(ui, export, 11.5) + 24.0; + let export_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(export_width, button_height), + ); + button_x += export_width + 6.0; + if layout::action_button(ui, export_rect, export, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::StyleExport(index)); + } + + let edit = tr_l10n(lang, "style.pack.edit"); + let edit_width = layout::text_width(ui, edit, 11.5) + 24.0; + let edit_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(edit_width, button_height), + ); + if layout::action_button(ui, edit_rect, edit, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::StyleEdit(index)); + } +} + +/// The dashed "add pack" tile closing the grid. +fn new_pack_tile( + ui: &mut egui::Ui, + rect: egui::Rect, + lang: Lang, + actions: &mut Vec, +) { + let response = ui.interact( + rect, + ui.id().with("style-new-pack-tile"), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled( + rect, + egui::CornerRadius::same(14), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let center = rect.center(); + let stroke = egui::Stroke::new(1.4, theme::INK_4); + painter.line_segment( + [ + egui::pos2(center.x - 8.0, center.y - 12.0), + egui::pos2(center.x + 8.0, center.y - 12.0), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(center.x, center.y - 20.0), + egui::pos2(center.x, center.y - 4.0), + ], + stroke, + ); + painter.text( + egui::pos2(center.x, center.y + 16.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "style.pack.add_pack_tile_title"), + egui::FontId::proportional(13.0), + theme::INK, + ); + painter.text( + egui::pos2(center.x, center.y + 36.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "style.pack.add_pack_tile_hint"), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + if response.clicked() { + actions.push(FrontendAction::StyleNewPack); + } +} + +// ── Notice ────────────────────────────────────────────────────────────────── + +fn notice(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { + let Some(text) = vm.style_notice.clone() else { + return; + }; + ui.add_space(10.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + "✓", + egui::FontId::proportional(13.0), + theme::OK, + ); + painter.text( + egui::pos2(rect.left() + 18.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &text, + egui::FontId::proportional(11.5), + theme::INK_2, + ); + let dismiss = egui::Rect::from_min_size( + egui::pos2( + rect.left() + 18.0 + layout::text_width(ui, &text, 11.5) + 10.0, + rect.top() + 3.0, + ), + egui::vec2(20.0, 20.0), + ); + let response = ui.interact( + dismiss, + ui.id().with("style-notice-dismiss"), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(dismiss, egui::CornerRadius::same(6), theme::SURFACE_2); + } + painter.text( + dismiss.center(), + egui::Align2::CENTER_CENTER, + "×", + egui::FontId::proportional(12.0), + theme::INK_3, + ); + if response.clicked() { + vm.style_notice = None; + } +} + +// ── Editor ────────────────────────────────────────────────────────────────── + +/// Pack editor window: the style pack's prompt plus save / reset / cancel. +fn editor_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + if !vm.style_editor_open { + return; + } + let lang = vm.lang; + let name = vm + .style_packs + .iter() + .find(|pack| { + if vm.style_selection_workflow { + pack.selection_active + } else { + pack.is_active + } + }) + .map(|pack| pack.name.clone()) + .unwrap_or_else(|| tr_l10n(lang, "btn.new_style").to_string()); + + // In-app overlay: dim the window and float a card above it, exactly like the + // settings modal. (A free-floating `egui::Window` reads as a second OS window.) + // Mask the content area and centre the card there, matching the other + // overlays (settings modal / marketplace detail). + let body = layout::body_rect(ctx); + let size = egui::vec2( + (body.width() - 40.0).max(320.0).min(720.0), + (body.height() - 40.0).max(240.0).min(560.0), + ); + let center_offset = body.center() - ctx.content_rect().center(); + + let backdrop_layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("openless-style-editor-backdrop"), + ); + ctx.layer_painter(backdrop_layer).rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 14, + se: 14, + }, + theme::OVERLAY, + ); + egui::Area::new(egui::Id::new("openless-style-editor-backdrop-input")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .default_size(body.size()) + .constrain(false) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + ui.set_max_size(body.size()); + let _ = ui.allocate_exact_size(body.size(), egui::Sense::click()); + }); + + egui::Area::new(egui::Id::new("openless-style-editor-modal")) + .order(egui::Order::Tooltip) + .anchor(egui::Align2::CENTER_CENTER, center_offset) + .constrain_to(body) + .show(ctx, |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .shadow(egui::Shadow { + offset: [0, 12], + blur: 28, + spread: 0, + color: egui::Color32::from_black_alpha(42), + }) + .show(ui, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(22, 18)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "head.style_pack_editor", + )) + .size(11.0) + .color(theme::INK_4), + ); + ui.label( + egui::RichText::new(&name) + .size(18.0) + .strong() + .color(theme::INK), + ); + }); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Min), + |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("×") + .size(20.0) + .color(theme::INK_3), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(28.0, 28.0)), + ) + .clicked() + { + actions.push(FrontendAction::StyleCloseEditor); + } + }, + ); + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "lbl.style_note")) + .size(11.5) + .color(theme::INK_3), + ); + ui.add_space(14.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "style.pack.dictation_prompt_title", + )) + .size(12.0) + .strong(), + ); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "style.pack.dictation_prompt_hint", + )) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(6.0); + // Everything above the prompt plus the button row is + // fixed; the prompt scrolls inside the space that is + // left, so a long prompt can never grow the card. + let fixed = 44.0 + 8.0 + 18.0 + 8.0 + 16.0 + 14.0 + 8.0 + 10.0 + 34.0; + let editor_height = (size.y - 36.0 - fixed).max(60.0); + egui::ScrollArea::vertical() + .id_salt("openless-style-prompt-scroll") + .max_height(editor_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + let rows = (editor_height / 18.0).floor().max(3.0) as usize; + ui.add_sized( + [ui.available_width(), editor_height], + egui::TextEdit::multiline(&mut vm.style_prompt) + .desired_rows(rows) + .desired_width(f32::INFINITY), + ); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new(tr_l10n( + lang, + "style.custom_prompt_save", + )) + .fill(theme::BLUE) + .corner_radius(egui::CornerRadius::same(7)), + ) + .clicked() + { + let prompt = vm.style_prompt.clone(); + actions.push(FrontendAction::StyleSaveEditor(prompt)); + vm.style_editor_open = false; + } + if ui.button(tr_l10n(lang, "btn.reset_builtin")).clicked() { + // No reset action exists yet: clearing the custom + // prompt falls back to the built-in system prompt. + vm.style_prompt.clear(); + } + if ui.button(tr_l10n(lang, "common.cancel")).clicked() { + actions.push(FrontendAction::StyleCloseEditor); + } + }); + }); + }); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/translation.rs b/openless-all/app/linux-egui/src/ui/frontend/translation.rs new file mode 100644 index 000000000..75b428d86 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/translation.rs @@ -0,0 +1,536 @@ +//! Translation page — port of the Tauri `pages/Translation.tsx`. +//! +//! Language search, a two-column working-language grid with checkboxes, the +//! target language and the inherited style on the right, then the usage guide. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const GAP: f32 = 12.0; +const CARD_PADDING: f32 = 18.0; +const TWO_COLUMN_MIN_WIDTH: f32 = 860.0; + +/// `(native name, language code)`. Core stores the native name, so selection +/// matching must keep using it; the code is the secondary label shown under the +/// name (the Tauri app resolves a localized name through `Intl.DisplayNames`, +/// which egui cannot use). +const SUPPORTED_LANGUAGES: [(&str, &str); 15] = [ + ("简体中文", "zh-Hans"), + ("繁体中文", "zh-Hant"), + ("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"), +]; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.translation_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "translation.title")); + return; + } + + layout::page_header( + ui, + width, + tr_l10n(lang, "translation.kicker"), + tr_l10n(lang, "translation.title"), + Some(tr_l10n(lang, "translation.desc")), + ); + ui.add_space(GAP); + + // Toolbar: language search on the left, selected count on the right. + toolbar(ui, width, vm); + ui.add_space(GAP); + + if width >= TWO_COLUMN_MIN_WIDTH { + let column_width = (width - GAP) / 2.0; + ui.columns(2, |columns| { + // The left card drives the shared height so both columns end level. + let left_height = working_languages(&mut columns[0], column_width, vm, actions); + target_language( + &mut columns[1], + column_width, + vm, + actions, + Some(left_height), + ); + }); + } else { + working_languages(ui, width, vm, actions); + ui.add_space(GAP); + target_language(ui, width, vm, actions, None); + } + ui.add_space(GAP); + usage(ui, width, vm); +} + +fn toolbar(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { + let lang = vm.lang; + let count = vm.translation_working_languages.len(); + let count_text = fmt_l10n(lang, "translation.selected_languages", &[&count]); + let count_width = layout::text_width(ui, &count_text, 11.5) + 4.0; + let (row, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(17)) + .inner_margin(egui::Margin::symmetric(14, 6)) + .show(ui, |ui| { + let inner_width = ui.available_width().max(1.0); + ui.set_width(inner_width); + // 右侧计数标签画在同一行的右端,输入框按它的宽度让位(此前没让, + // 文字会压到「已选 N 种语言」上)。 + let field_width = (inner_width - count_width).max(1.0); + ui.add_sized( + [field_width, 22.0], + egui::TextEdit::singleline(&mut vm.translation_query) + .id(egui::Id::new("openless-translation-search")) + .hint_text(tr_l10n(lang, "translation.search_languages")) + .text_color(theme::INK) + .frame(false) + .vertical_align(egui::Align::Center), + ); + }); + ui.painter().text( + egui::pos2(row.right(), row.center().y), + egui::Align2::RIGHT_CENTER, + count_text, + egui::FontId::proportional(11.5), + theme::INK_4, + ); +} + +fn working_languages( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) -> f32 { + let lang = vm.lang; + let query = vm.translation_query.trim().to_lowercase(); + let visible: Vec<(&str, &str)> = SUPPORTED_LANGUAGES + .iter() + .copied() + .filter(|(native, code)| { + query.is_empty() + || native.to_lowercase().contains(&query) + || code.to_lowercase().contains(&query) + }) + .collect(); + + card(ui, width, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.working_title")) + .size(13.5) + .strong(), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.working_desc")) + .size(11.5) + .color(theme::INK_4), + ); + ui.add_space(10.0); + + if visible.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.no_matching_languages")) + .size(11.5) + .color(theme::INK_4), + ); + } else { + const ROW_HEIGHT: f32 = 46.0; + const ROW_GAP: f32 = 8.0; + const COLUMNS: usize = 2; + let available = ui.available_width(); + let cell_width = (available - ROW_GAP * (COLUMNS as f32 - 1.0)) / COLUMNS as f32; + let mut index = 0; + while index < visible.len() { + let count = COLUMNS.min(visible.len() - index); + let (row, _) = + ui.allocate_exact_size(egui::vec2(available, ROW_HEIGHT), egui::Sense::hover()); + for slot in 0..count { + let (native, code) = visible[index + slot]; + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (cell_width + ROW_GAP), row.top()), + egui::vec2(cell_width, ROW_HEIGHT), + ); + let selected = vm + .translation_working_languages + .iter() + .any(|value| value == native); + if language_row(ui, rect, native, code, selected) { + actions.push(FrontendAction::TranslationToggleLanguage( + native.to_string(), + )); + } + } + index += count; + if index < visible.len() { + ui.add_space(ROW_GAP); + } + } + } + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.language_support_hint")) + .size(11.0) + .color(theme::INK_4), + ); + }) +} + +/// One selectable language row: name (bold) over its code, checkbox on the right. +fn language_row( + ui: &mut egui::Ui, + rect: egui::Rect, + native: &str, + code: &str, + selected: bool, +) -> bool { + let response = ui.interact( + rect, + ui.id().with(("openless-language-row", native)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let fill = if selected || response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + painter.rect_filled(rect, egui::CornerRadius::same(10), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.8, if selected { theme::LINE } else { theme::LINE }), + egui::StrokeKind::Inside, + ); + let checkbox = egui::Rect::from_center_size( + egui::pos2(rect.right() - 18.0, rect.center().y), + egui::vec2(16.0, 16.0), + ); + draw_check(ui, checkbox, selected); + painter.text( + egui::pos2(rect.left() + 12.0, rect.center().y - 8.0), + egui::Align2::LEFT_CENTER, + native, + egui::FontId::proportional(12.5), + theme::INK, + ); + // Only show the code when it adds information next to the native name. + painter.text( + egui::pos2(rect.left() + 12.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + code, + egui::FontId::proportional(10.5), + theme::INK_4, + ); + response.clicked() +} + +fn draw_check(ui: &egui::Ui, rect: egui::Rect, checked: bool) { + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(4), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(4), + egui::Stroke::new(1.0, if checked { theme::INK } else { theme::LINE }), + egui::StrokeKind::Inside, + ); + if checked { + let stroke = egui::Stroke::new(1.5, theme::INK); + painter.line_segment( + [ + rect.left_center() + egui::vec2(3.0, 0.5), + rect.center_bottom() + egui::vec2(-1.0, -3.5), + ], + stroke, + ); + painter.line_segment( + [ + rect.center_bottom() + egui::vec2(-1.0, -3.5), + rect.right_center() + egui::vec2(-2.5, -5.0), + ], + stroke, + ); + } +} + +fn target_language( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, + fill_height: Option, +) { + let lang = vm.lang; + let target = vm.translation_target_language.clone(); + let redundant = !target.is_empty() + && vm.translation_working_languages.len() == 1 + && vm.translation_working_languages[0] == target; + let enabled = !target.is_empty() && !redundant; + let disabled_label = tr_l10n(lang, "translation.target_disabled").to_string(); + + card(ui, width, |ui| { + if let Some(height) = fill_height { + // Match the left column so the two cards end on the same line. + ui.set_min_height((height - CARD_PADDING * 2.0).max(0.0)); + } + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_title")) + .size(13.5) + .strong(), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_desc")) + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let label = if enabled { + tr_l10n(lang, "translation.status_enabled") + } else { + tr_l10n(lang, "translation.status_disabled") + }; + egui::Frame::new() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new(label).size(10.5).color(if enabled { + theme::BLUE + } else { + theme::INK_4 + })); + }); + }); + }); + ui.add_space(10.0); + + let mut selected_target = target.clone(); + egui::ComboBox::from_id_salt("translation-target-language") + .width((ui.available_width() - 4.0).min(360.0)) + .height(32.0) + .truncate() + .selected_text(if target.is_empty() { + egui::RichText::new(&disabled_label).color(theme::INK_4) + } else { + egui::RichText::new(target.as_str()).color(theme::INK) + }) + .show_ui(ui, |ui| { + if ui + .selectable_label(selected_target.is_empty(), &disabled_label) + .clicked() + { + selected_target = String::new(); + ui.close(); + } + for (native, _) in SUPPORTED_LANGUAGES { + if ui + .selectable_label(selected_target == native, native) + .clicked() + { + selected_target = native.to_string(); + ui.close(); + } + } + }); + if selected_target != target { + actions.push(FrontendAction::TranslationSetTarget(selected_target)); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.style_title")) + .size(12.0) + .strong(), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.style_desc")) + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let style_name = if let Some(pack) = vm.style_packs.get(vm.style_selected) { + pack.name.clone() + } else if vm.style_selected == usize::MAX { + tr_l10n(lang, "overview.mode_raw").to_string() + } else { + tr_l10n(lang, "overview.mode_light").to_string() + }; + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(9, 4)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(style_name) + .size(11.0) + .strong() + .color(theme::BLUE), + ); + }); + }); + }); + + if redundant { + ui.add_space(10.0); + egui::Frame::new() + .fill(theme::WARN_SOFT) + .stroke(egui::Stroke::new(0.5, theme::WARN)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_same_as_working")) + .size(11.5) + .color(theme::WARN), + ); + }); + } + }); +} + +fn usage(ui: &mut egui::Ui, width: f32, vm: &FrontendViewModel) { + let lang = vm.lang; + card(ui, width, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.howto_title")) + .size(13.0) + .strong(), + ); + ui.add_space(10.0); + + let steps = [ + tr_l10n(lang, "translation.howto_step1").to_string(), + fmt_l10n(lang, "translation.howto_step2", &[&vm.dictation_hotkey]), + fmt_l10n(lang, "translation.howto_step3", &[&vm.translation_hotkey]), + fmt_l10n(lang, "translation.howto_step4", &[&vm.dictation_hotkey]), + tr_l10n(lang, "translation.howto_step5").to_string(), + ]; + // Steps flow in four columns like the Tauri layout. + let columns = if ui.available_width() >= 720.0 { 4 } else { 2 }; + let gap = 12.0; + let cell_width = (ui.available_width() - gap * (columns as f32 - 1.0)) / columns as f32; + let mut index = 0; + while index < steps.len() { + let count = columns.min(steps.len() - index); + let mut row_height: f32 = 20.0; + let mut galleys = Vec::new(); + for slot in 0..count { + let galley = layout::text_galley( + ui, + &steps[index + slot], + theme::INK_2, + 12.0, + (cell_width - 18.0).max(1.0), + 3, + ); + row_height = row_height.max(galley.size().y + 2.0); + galleys.push(galley); + } + let (row, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), row_height), + egui::Sense::hover(), + ); + for (slot, galley) in galleys.into_iter().enumerate() { + let left = row.left() + slot as f32 * (cell_width + gap); + let painter = ui.painter().with_clip_rect(egui::Rect::from_min_size( + egui::pos2(left, row.top()), + egui::vec2(cell_width, row_height), + )); + painter.text( + egui::pos2(left, row.top() + 1.0), + egui::Align2::LEFT_TOP, + format!("{}.", index + slot + 1), + egui::FontId::proportional(12.0), + theme::INK_4, + ); + painter.galley(egui::pos2(left + 18.0, row.top()), galley, theme::INK_2); + } + index += count; + if index < steps.len() { + ui.add_space(10.0); + } + } + + ui.add_space(12.0); + layout::soft_separator(ui); + ui.add_space(10.0); + note( + ui, + tr_l10n(lang, "translation.howto_indicator_title"), + tr_l10n(lang, "translation.howto_indicator_desc"), + theme::BLUE, + ); + ui.add_space(8.0); + note( + ui, + tr_l10n(lang, "translation.howto_fallback_title"), + tr_l10n(lang, "translation.howto_fallback_desc"), + theme::OK, + ); + }); +} + +fn note(ui: &mut egui::Ui, title: &str, desc: &str, color: egui::Color32) { + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(egui::vec2(8.0, 18.0), egui::Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.0, color); + ui.vertical(|ui| { + ui.label( + egui::RichText::new(title) + .size(12.0) + .strong() + .color(theme::INK_2), + ); + ui.label(egui::RichText::new(desc).size(11.5).color(theme::INK_4)); + }); + }); +} + +/// Full-width card that sizes itself to its contents. Returns its height. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) -> f32 { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }) + .response + .rect + .height() +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs new file mode 100644 index 000000000..05cd3f9b9 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -0,0 +1,944 @@ +use openless_linux_egui::Lang; + +// ── Page / Tab ────────────────────────────────────────────────────────────── + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum Page { + #[default] + Overview, + History, + Vocab, + Style, + Marketplace, + SelectionAsk, + Translation, + Corrections, + Settings, +} + +// ── FrontendAction ────────────────────────────────────────────────────────── + +/// Every user interaction the frontend can produce. The host +/// (`OpenLessEguiApp`) drains these actions and dispatches them to existing +/// Core / backend methods without duplicating the Core state machine. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub enum FrontendAction { + /// Navigate to a different page. + Navigate(Page), + /// Open / close the in-window settings overlay. + ToggleSettings, + /// Close the settings overlay (from the × button). + CloseSettings, + /// Marketplace search query changed. + MarketplaceSearch(String), + /// Marketplace sort mode changed. + MarketplaceSort(MarketplaceSort), + /// Marketplace refresh requested. + MarketplaceRefresh, + /// Marketplace "my packs" requested. + MarketplaceMyPacks, + /// Open marketplace pack detail. + MarketplaceDetail(usize), + /// Close marketplace detail modal. + MarketplaceCloseDetail, + /// Download marketplace pack ZIP. + MarketplaceDownload(usize), + /// Install marketplace pack. + MarketplaceInstall(usize), + /// Toggle marketplace pack like. + MarketplaceToggleLike(usize), + /// Select a history entry (index into `history_entries`). + HistorySelect(usize), + /// Re-read the history list from Core. + HistoryRefresh, + /// Ask for confirmation before clearing all history. + HistoryRequestClear, + /// Ask for confirmation before deleting one entry. + HistoryRequestDelete(usize), + /// Confirm the pending destructive history action. + HistoryConfirmAction, + /// Dismiss the pending confirmation dialog. + HistoryCancelConfirm, + /// Export a history entry's recording to a file. + HistoryExport(usize), + /// Re-run ASR on a history entry's recording. + HistoryRetranscribe(usize), + /// Open a history entry's recording in the system player. + HistoryPlay(usize), + /// Vocab entry added. + VocabAddPhrase(String), + /// Vocab list filter changed (0 = all, 1 = auto-collected, 2 = manual). + VocabFilter(usize), + /// Vocab search query changed. + VocabSearch(String), + /// Vocab entry removed. + VocabRemovePhrase(usize), + /// Vocab entry toggled enabled/disabled. + VocabTogglePhrase(usize), + /// Correction rule added. + VocabAddRule { + pattern: String, + replacement: String, + }, + /// Correction rule removed. + VocabRemoveRule(usize), + /// Correction rule toggled. + VocabToggleRule(usize), + /// Vocab preset applied. + VocabApplyPreset(usize), + /// Vocab preset created. + VocabCreatePreset { + name: String, + phrases: String, + }, + /// Style pack activated. + StyleActivate(usize), + /// Style pack exported. + StyleExport(usize), + /// Style pack editor opened. + StyleEdit(usize), + /// Style editor prompt saved. + StyleSaveEditor(String), + /// Style editor closed. + StyleCloseEditor, + /// New style pack creation requested. + StyleNewPack, + /// Import style ZIP. + StyleImport, + /// Selection ask history toggle. + SelectionAskToggleHistory, + /// Translation working language toggled. + TranslationToggleLanguage(String), + /// Translation target language changed. + TranslationSetTarget(String), + /// Settings toggle changed. + SettingsToggle(SettingsField), + /// Settings combo index changed. + SettingsCombo(SettingsComboField, usize), + /// Settings text field changed. + SettingsText(SettingsTextField, String), + /// Settings action button clicked. + SettingsAction(SettingsActionField), + /// Settings section changed. + SettingsSection(SettingsSection), + /// AI-services sub-tab (0 = LLM, 1 = ASR, 2 = local models, 3 = connections). + SettingsServicesView(usize), + /// Enable/disable a channel (index into `settings.channels`). + SettingsChannelToggle(usize), + /// Validate a channel (index into `settings.channels`). + SettingsChannelValidate(usize), + /// Delete a channel (index into `settings.channels`). + SettingsChannelDelete(usize), + /// Open/close the "add channel" form. + SettingsChannelFormOpen(bool), + /// Provider picked in the add-channel form. + SettingsChannelProvider(usize), + /// Channel name typed in the add-channel form. + SettingsChannelName(String), + /// Create the channel described by the form. + SettingsChannelCreate, + /// Select a channel and open its provider editor (index into `channels`). + SettingsChannelSelect(usize), + /// Move a channel up/down; Core's `reorder_channels` owns the order. + SettingsChannelMove { + index: usize, + delta: isize, + }, + /// Switch a channel to another provider type (Core's `set_channel_provider_type`). + SettingsChannelProviderType { + index: usize, + provider_type: String, + }, + /// Make a channel the active provider (Core's `set_active_provider`). + SettingsChannelActivate(usize), + /// Provider editor field edited. + SettingsProviderField(SettingsProviderField, String), + /// Save the editor: rename + endpoint/model + credentials through Core. + SettingsProviderSave, + /// Drop the channel's stored secrets (Core's `remove_credential`). + SettingsProviderClearSecrets, + /// Ask Core for the provider's model list (Core's `provider.list_models`). + SettingsProviderModels, + /// Close the provider editor. + SettingsProviderClose, + /// 快捷键行的交互:展开/收起编辑菜单(`None` 收起全部)。 + ShortcutMenu(Option), + /// 进入录制态(`None` = 取消录制)。 + ShortcutRecording(Option), + /// 录入完成:主键 + 修饰键写回该绑定。 + ShortcutCaptured(ShortcutField, String, Vec), + /// 停用该绑定(核心录音快捷键不可停用,见 `ShortcutField::Dictation`)。 + ShortcutDisable(ShortcutField), + /// 风格直达:开关草稿行 / 选择风格包 / 移除整行。 + StyleHotkeyDraft(bool), + StyleHotkeyDraftPack(usize), + StyleHotkeyRemove(usize), + StyleHotkeyRepack(usize, usize), + /// Re-read channels for the current AI-services view. + /// Overview: re-read credentials / history / activity from Core. + OverviewRefresh, + /// Overview: period toggle (0 = last 7 days, 1 = last 30 days). + OverviewPeriod(usize), + /// Overview: metric toggle (0 = count, 1 = chars, 2 = duration). + OverviewMetric(usize), + /// Window close requested. + WindowClose, + /// Window maximize/minimize toggle. + WindowMaximize, + /// Window minimize. + WindowMinimize, + /// Sidebar group toggle. + SidebarToggleStyle, + SidebarToggleTools, +} + +// ── Marketplace types ─────────────────────────────────────────────────────── + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarketplaceSort { + #[default] + Popular, + New, + Liked, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct MarketplacePack { + pub name: String, + pub version: String, + pub description: String, + pub mode: String, + pub author: String, + pub tags: Vec, + pub likes: u32, + pub downloads: u32, + /// Whether the signed-in user has liked this pack (`me/likes`). + pub liked: bool, +} + +// ── Settings types ────────────────────────────────────────────────────────── + +/// Host permission state, mirroring the Tauri permission rows. Linux has no +/// OS permission prompts, so most of these stay `Unsupported` — but the value +/// now comes from the host snapshot instead of a hardcoded label. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum PermissionState { + #[default] + Unknown, + Granted, + Unsupported, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsPermissions { + pub microphone: PermissionState, + pub accessibility: PermissionState, + pub hotkey: PermissionState, + pub network: PermissionState, +} + +/// One 风格包直选 row (style pack name + its hotkey chip). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct StylePackHotkeyRow { + pub pack_id: String, + /// 风格包显示名;风格包列表里找不到该 id 时用作下拉的回退文案。 + pub name: String, + pub hotkey: String, +} + +/// One editable shortcut row in 快捷键与选区. `StylePack(index)` addresses an +/// existing entry of [`SettingsFields::style_pack_hotkeys`]; `StyleDraft` is the +/// 「+ 添加风格快捷键」row before it is committed. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum ShortcutField { + Dictation, + Translation, + Qa, + SwitchStyle, + OpenApp, + CodingAgentVoice, + SelectionPolish, + StylePack(usize), + StyleDraft, +} + +/// One credential channel shown in the AI-services settings tab. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsChannel { + pub name: String, + /// Model / endpoint summary shown under the channel name. + pub model: String, + /// Provider descriptor label (already localized by the host). + pub provider: String, + /// Provider type id, used to offer the provider switch without a round trip. + pub provider_type: String, + /// True for the channel currently serving requests. + pub is_active: bool, + pub enabled: bool, + /// Human-readable result of the last validation, if any. + pub last_check: Option, +} + +/// A field of the provider editor. Secret fields are write-only: opening an +/// editor never reads an existing key back into egui state. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum SettingsProviderField { + Name, + Endpoint, + Model, + ResourceId, + AuthMode, + PrimarySecret, + SecondarySecret, +} + +/// Which inputs the editor renders. Core's `AuthRequirement` decides this; +/// the UI never judges whether the credentials are sufficient — ProviderService +/// re-checks the descriptor before any protocol request. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum SettingsProviderAuth { + /// No credentials (local models). + None, + /// OAuth is driven by Core; the editor only explains it. + OAuth, + /// Volcengine: APP ID + Access Token, or API Key + Resource ID. + Volcengine, + /// Xfyun: AppID + API Key. + Xfyun, + /// Another Core-defined shape (Tencent Cloud and friends). + Other, + /// Endpoint + Model + API Key. + ApiKey, +} + +/// The open channel editor. Hydrated once per load from Core, then driven by +/// the host-side draft so typing is never clobbered by a re-read. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct SettingsProviderEditor { + pub channel_id: String, + /// Localized provider label (read-only). + pub provider: String, + pub provider_type: String, + pub name: String, + pub endpoint: String, + pub model: String, + pub resource_id: String, + pub auth_mode: String, + pub auth: SettingsProviderAuth, + // Write-only secret drafts: they are empty on load and cleared once Core has + // them, so a stored key never reaches egui state. + pub primary_secret: String, + pub secondary_secret: String, + /// Result of `provider.list_models`. + pub models: Vec, + pub models_loading: bool, + pub busy: bool, +} + +/// Provider kinds available when creating a channel. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsChannelProvider { + /// Provider type id sent back to Core. + pub provider_type: String, + /// Localized label shown in the picker. + pub label: String, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum SettingsSection { + General, + Shortcuts, + Appearance, + Services, + Privacy, + Advanced, + About, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] +pub enum SettingsField { + StreamingInsert, + StreamingSaveClipboard, + RestoreClipboard, + StartMinimized, + LaunchAtLogin, + AutoUpdate, + RemoteInput, + SilenceAutoStop, + AudioCue, + MuteWhileRecording, + RecordAudioForDebug, + ActivityHeatmap, + SystemProxy, + LessComputer, + Multimodal, + BetaChannel, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] +pub enum SettingsComboField { + Language, + Theme, + Microphone, + RecordingMode, + SilenceSeconds, + PasteShortcut, + RemoteDefaultMode, + /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 + SelectionPolishDelivery, + /// Less Computer 的 Agent 后端(0 = Claude Code, 1 = OpenCode, 2 = Codex, 3 = dsh)。 + CodingAgentProvider, + /// Less Computer 权限模式(0 = 放行, 1 = 只读/计划, 2 = 默认, 3 = 完全放行)。 + CodingAgentPermission, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub enum SettingsTextField { + RemotePort, + HistoryMaxEntries, + /// 历史保留天数(0 = 永久)。 + RetentionDays, + /// 润色上下文窗口(分钟,0 = 关闭)。 + PolishContextWindow, + /// 调试录音最多保留条数。 + AudioRecordingMaxEntries, + CodingAgentModel, + CodingAgentWorkdir, + CodingAgentExe, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] +pub enum SettingsActionField { + ExportDiagnostics, + CheckUpdate, + CheckBetaUpdate, + CopyCertFingerprint, + OpenGitHub, + OpenHelp, + OpenReleaseNotes, + OpenFeedback, + CopyQQ, +} + +// ── Vocab types ───────────────────────────────────────────────────────────── + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct VocabEntry { + pub phrase: String, + pub hits: usize, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct CorrectionRule { + pub pattern: String, + pub replacement: String, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct SavedVocabPreset { + pub name: String, + pub phrases: String, +} + +// ── History types ─────────────────────────────────────────────────────────── + +/// Insert outcome, mirrored from Core's `HistoryInsertStatus` into a plain +/// frontend enum so the page never has to depend on Core types. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum HistoryInsertStatus { + #[default] + NotRequested, + Inserted, + PasteSent, + CopiedFallback, + Failed, +} + +/// In-app playback state for the entry currently being played. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct HistoryPlayback { + pub id: String, + pub position_ms: u64, + pub total_ms: u64, +} + +/// A pending destructive action that needs an in-window confirmation. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum HistoryConfirm { + Clear, + Delete(usize), +} + +/// One history row plus everything the detail panel shows. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct HistoryEntry { + pub id: String, + pub created_at: String, + /// Base polish mode; drives the list pill tone (raw renders as outline). + pub mode: OverviewMode, + /// Pill text: the style-pack name, or the mode name for records without one. + pub style_label: String, + pub raw_transcript: String, + pub final_text: String, + pub duration_ms: Option, + pub insert_status: HistoryInsertStatus, + pub has_audio: bool, + pub asr_provider: Option, + pub asr_model: Option, + pub asr_ms: Option, + pub llm_provider: Option, + pub llm_model: Option, + pub polish_ms: Option, + pub app_name: Option, + pub dictionary_count: Option, +} + +// ── Style types ───────────────────────────────────────────────────────────── + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct StylePack { + pub id: String, + pub name: String, + pub description: String, + pub tags: Vec, + pub is_builtin: bool, + pub enabled: bool, + /// Active pack for the dictation / ASR workflow. + pub is_active: bool, + /// Active pack for the selection-polish workflow (`prefs.selection_polish_style_pack_id`). + pub selection_active: bool, +} + +// ── Overview types ────────────────────────────────────────────────────────── + +/// Polish mode shown as the mode pill on a "recent" row. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverviewMode { + #[default] + Raw, + Light, + Structured, + Formal, +} + +/// One calendar day of activity (chronological inside +/// [`OverviewSummary::activity_daily`]). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewActivityDay { + /// `YYYY-MM-DD` in the host's local timezone. + pub date: String, + pub count: u32, + pub chars: u64, + pub duration_ms: u64, +} + +/// One day of the annual activity heatmap (`YYYY-MM-DD` + dictation count). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewHeatmapDay { + pub date: String, + pub count: u32, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewSummary { + pub asr_provider: String, + pub llm_provider: String, + pub asr_configured: bool, + pub llm_configured: bool, + pub chars_today: u64, + pub segments_today: usize, + pub duration_ms_today: u64, + pub avg_latency_ms: u64, + pub history_total: usize, + pub recent: Vec, + /// Last 30 days ending today, chronological (oldest first). The period + /// chart slices the tail for the 7-day view. + pub activity_daily: Vec, + /// Calendar year rendered by the annual heatmap card. + pub heatmap_year: i32, + /// Every day of `heatmap_year`, chronological. Days without activity are + /// present with `count == 0` so the page can lay out the grid. + pub heatmap: Vec, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewRecentEntry { + pub created_at: String, + pub final_text: String, + pub raw_transcript: String, + pub mode: OverviewMode, + pub duration_ms: Option, +} + +// ── FrontendViewModel ─────────────────────────────────────────────────────── + +/// Pure display state for the egui frontend. Contains no mock data — every +/// field is populated by the host (`OpenLessEguiApp`) from Core / backend +/// sources. Unwired fields show empty / Loading / Unsupported states. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct FrontendViewModel { + pub active_page: Page, + pub style_open: bool, + pub tools_open: bool, + pub settings_open: bool, + + /// Resolved UI language, injected by the host each frame so the pure + /// renderer can look up localized strings without touching global state. + #[serde(with = "lang_tag")] + pub lang: Lang, + + /// 明暗主题(Core 偏好)。宿主注入,UI 进程按它决定配色。 + pub theme_mode: openless_core::shared_types::ThemeMode, + + // Overview + pub overview_loading: bool, + pub overview_error: Option, + pub overview: Option, + pub overview_period: usize, + pub overview_metric: usize, + + // History + pub history_query: String, + pub history_selected: usize, + pub history_entries: Vec, + pub history_loading: bool, + pub history_error: Option, + /// Set while a destructive action awaits confirmation (clear-all / delete). + pub history_confirm: Option, + /// In-app playback progress for one history entry. + pub history_playback: Option, + + // Vocab + pub vocab_entries: Vec, + pub vocab_rules: Vec, + /// 0 = all, 1 = auto-collected, 2 = manual. + pub vocab_filter: usize, + pub vocab_query: String, + pub vocab_input: String, + pub vocab_pattern: String, + pub vocab_replacement: String, + pub vocab_preset_name: String, + pub vocab_preset_phrases: String, + pub vocab_selected_presets: Vec, + pub vocab_editing_preset: Option, + pub vocab_saved_presets: Vec, + pub vocab_error: Option, + pub vocab_unsupported: bool, + + // Style + pub style_packs: Vec, + pub style_selected: usize, + pub style_selection_workflow: bool, + pub style_editor_open: bool, + pub style_prompt: String, + pub style_notice: Option, + pub style_unsupported: bool, + + // Marketplace + pub marketplace_query: String, + pub marketplace_sort: MarketplaceSort, + pub marketplace_packs: Vec, + pub marketplace_selected: Option, + pub marketplace_notice: Option, + pub marketplace_loading: bool, + pub marketplace_unsupported: bool, + + // Settings + pub settings_section: SettingsSection, + /// AI-services sub-tab (0 = LLM, 1 = ASR, 2 = local models, 3 = connections, + /// 4 = 多模态). `ServiceView` decides which of these are shown. + pub services_view: usize, + /// Whether the host has an enabled channel for LLM / ASR (drives the + /// required-service status dots on the AI-services tabs). + pub service_configured: [bool; 2], + /// The host has no local inference engine (Linux) → hide the local-model + /// tab, exactly like the Tauri app gates on `supports_local_asr`. + pub supports_local_asr: bool, + /// Multimodal pipeline is enabled → the 多模态 view joins the tab strip. + pub multimodal_view: bool, + /// The host can self-update (AppImage) → beta-channel / check-update rows. + pub auto_update_capable: bool, + /// The platform has a working desktop hotkey backend (fcitx5 listener up). + /// Tauri's `visibleSettingsSections(supportsDesktopHotkey)` hides the + /// 「快捷键」 section when there is none; the rail follows the same rule. + /// Defaults to true so pure-rendering callers (and the common case where the + /// host did start the listener) keep the section visible. + pub hotkeys_supported: bool, + /// 展开编辑菜单的快捷键行(`None` = 都收起)。 + pub shortcut_menu: Option, + /// 正在录入按键的快捷键行(`None` = 未在录入)。 + pub shortcut_recording: Option, + /// 「+ 添加风格快捷键」草稿行是否打开。 + pub style_hotkey_draft_open: bool, + /// 草稿行选中的风格包(`FrontendViewModel::style_packs` 索引)。 + pub style_hotkey_draft_pack: usize, + /// Real host permission snapshot for 隐私与数据(不再写死「已授权」)。 + pub permissions: SettingsPermissions, + /// 远程输入服务正在监听 → 显示配对码 / 访问网址 / 证书指纹。 + pub remote_running: bool, + /// 服务报出的地址已过期:继续展示旧网址会诱导用户在错误的地址上配对, + /// 因此与 Core 的 `RemoteInputStatus::urls_stale` 一起决定是否展示连接细节。 + pub remote_urls_stale: bool, + pub remote_pin: String, + pub remote_urls: Vec, + pub remote_cert_fingerprint: Option, + /// Channels of the active AI-services kind. + pub channels: Vec, + /// Provider kinds offered by the add-channel form. + pub channel_providers: Vec, + pub channels_loading: bool, + pub channel_form_open: bool, + pub channel_form_name: String, + pub channel_provider_index: usize, + /// Open provider editor, or `None` when the channel list is the whole view. + pub provider_editor: Option, + /// Rail search query in the settings modal. + pub settings_query: String, + /// 录制中的裸修饰键挂起状态(egui 没有修饰键 Key 事件,只能跨帧判断)。 + pub shortcut_pending_modifier: Option, + /// Expanded drill-in row in the 实验与扩展 section (`usize::MAX` = none). + pub advanced_open: usize, + pub settings_notice: Option, + pub settings: SettingsFields, + + // Selection ask + pub qa_save_history: bool, + pub selection_unsupported: bool, + + // Translation + pub translation_working_languages: Vec, + /// Language search query on the translation page. + pub translation_query: String, + pub translation_target_language: String, + pub translation_unsupported: bool, + + // Status bar + pub version: String, + pub status: String, + /// Display label for the dictation shortcut (e.g. `Ctrl+Shift+Space`). + pub dictation_hotkey: String, + /// Display label for the selection-ask popup shortcut. + pub qa_hotkey: String, + /// Display label for the translation modifier shortcut. + pub translation_hotkey: String, + /// Display label for the switch-style shortcut. + pub switch_style_hotkey: String, + /// Display label for the open-app shortcut. + pub open_app_hotkey: String, + /// Display label for the Less Computer voice shortcut. + pub coding_agent_hotkey: String, + /// Display label for the selection-polish shortcut. + pub selection_polish_hotkey: String, + /// Pipeline mode is 多模态 → the task strip hides the legacy LLM/ASR views. + pub pipeline_multimodal: bool, +} + +impl Default for FrontendViewModel { + fn default() -> Self { + Self { + lang: Lang::ZhCn, + theme_mode: openless_core::shared_types::ThemeMode::System, + active_page: Page::Overview, + style_open: true, + tools_open: true, + settings_open: false, + overview_loading: true, + overview_error: None, + overview: None, + overview_period: 0, + overview_metric: 0, + history_query: String::new(), + history_selected: 0, + history_entries: Vec::new(), + history_loading: true, + history_error: None, + history_confirm: None, + history_playback: None, + vocab_entries: Vec::new(), + vocab_rules: Vec::new(), + vocab_filter: 0, + vocab_query: String::new(), + vocab_input: String::new(), + vocab_pattern: String::new(), + vocab_replacement: String::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + vocab_selected_presets: Vec::new(), + vocab_editing_preset: None, + vocab_saved_presets: Vec::new(), + vocab_error: None, + vocab_unsupported: true, + style_packs: Vec::new(), + style_selected: 0, + style_selection_workflow: false, + style_editor_open: false, + style_prompt: String::new(), + style_notice: None, + style_unsupported: true, + marketplace_query: String::new(), + marketplace_sort: MarketplaceSort::Popular, + marketplace_packs: Vec::new(), + marketplace_selected: None, + marketplace_notice: None, + marketplace_loading: true, + marketplace_unsupported: true, + settings_section: SettingsSection::General, + services_view: 0, + channels: Vec::new(), + channel_providers: Vec::new(), + channels_loading: false, + channel_form_open: false, + channel_form_name: String::new(), + channel_provider_index: 0, + provider_editor: None, + settings_query: String::new(), + shortcut_pending_modifier: None, + advanced_open: usize::MAX, + settings_notice: None, + settings: SettingsFields::default(), + qa_save_history: false, + selection_unsupported: true, + translation_working_languages: Vec::new(), + translation_query: String::new(), + translation_target_language: String::new(), + translation_unsupported: true, + version: env!("CARGO_PKG_VERSION").to_string(), + status: String::new(), + selection_polish_hotkey: String::new(), + pipeline_multimodal: false, + remote_running: false, + remote_urls_stale: false, + remote_pin: String::new(), + remote_urls: Vec::new(), + remote_cert_fingerprint: None, + service_configured: [false; 2], + supports_local_asr: false, + multimodal_view: false, + auto_update_capable: false, + hotkeys_supported: true, + shortcut_menu: None, + shortcut_recording: None, + style_hotkey_draft_open: false, + style_hotkey_draft_pack: 0, + permissions: SettingsPermissions::default(), + dictation_hotkey: String::new(), + qa_hotkey: String::new(), + translation_hotkey: String::new(), + switch_style_hotkey: String::new(), + open_app_hotkey: String::new(), + coding_agent_hotkey: String::new(), + } + } +} + +/// Mirror of the egui-frontend `SettingsState` fields, but with no default +/// mock data. All values come from the host. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct SettingsFields { + /// 0 = toggle, 1 = hold, 2 = double click, 3 = auto. + pub recording_mode: usize, + pub streaming_insert: bool, + pub streaming_save_clipboard: bool, + pub restore_clipboard: bool, + pub start_minimized: bool, + pub launch_at_login: bool, + pub auto_update: bool, + pub remote_input: bool, + pub remote_default_mode: usize, + pub silence_auto_stop: bool, + pub silence_seconds: usize, + pub microphone_name: String, + pub microphone_options: Vec, + pub mute_while_recording: bool, + pub audio_cue: bool, + pub record_audio_for_debug: bool, + pub history_max_entries: String, + /// 风格直达快捷键(可录制/停用/移除)。 + pub style_pack_hotkeys: Vec, + /// 润色上下文窗口分钟数(0 = 只用当前这条转写)。 + pub polish_context_window: String, + /// 调试录音最多保留条数。 + pub audio_recording_max_entries: String, + pub paste_shortcut: usize, + /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 + pub selection_polish_delivery: usize, + pub activity_heatmap: bool, + pub system_proxy: bool, + pub less_computer: bool, + /// Less Computer(Coding Agent)配置,全部直连 `coding_agent_*` 偏好。 + pub coding_agent_provider: usize, + pub coding_agent_permission: usize, + pub coding_agent_model: String, + pub coding_agent_workdir: String, + pub coding_agent_exe: String, + pub multimodal: bool, + pub beta_channel: bool, + pub language: usize, + pub theme: usize, + /// 历史保留天数(0 = 永久,输入框)。 + pub retention_days: String, + pub remote_port: String, +} + +impl Default for SettingsFields { + fn default() -> Self { + Self { + recording_mode: 0, + streaming_insert: false, + streaming_save_clipboard: false, + restore_clipboard: false, + start_minimized: false, + launch_at_login: false, + auto_update: false, + remote_input: false, + remote_default_mode: 0, + silence_auto_stop: false, + silence_seconds: 2, + microphone_name: String::new(), + microphone_options: Vec::new(), + mute_while_recording: false, + audio_cue: false, + record_audio_for_debug: false, + history_max_entries: String::new(), + style_pack_hotkeys: Vec::new(), + polish_context_window: String::new(), + audio_recording_max_entries: String::new(), + paste_shortcut: 0, + selection_polish_delivery: 0, + activity_heatmap: true, + system_proxy: false, + less_computer: false, + coding_agent_provider: 0, + coding_agent_permission: 0, + coding_agent_model: String::new(), + coding_agent_workdir: String::new(), + coding_agent_exe: String::new(), + multimodal: false, + beta_channel: false, + language: 0, + theme: 0, + retention_days: "0".to_string(), + remote_port: String::new(), + } + } +} + +/// `Lang` 定义在 `i18n.rs`(由同步脚本生成,禁止手改),所以按语言标签序列化, +/// 而不是给它加 serde derive。跨进程传视图模型时用得上。 +mod lang_tag { + use openless_linux_egui::Lang; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(lang: &Lang, serializer: S) -> Result { + serializer.serialize_str(lang.tag()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let tag = String::deserialize(deserializer)?; + Lang::parse(&tag).ok_or_else(|| serde::de::Error::custom(format!("unknown lang tag {tag}"))) + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs new file mode 100644 index 000000000..0819f60ae --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs @@ -0,0 +1,905 @@ +//! Dictionary (词典) page — port of the Tauri `pages/Vocab.tsx`. +//! +//! Layout: page header with a primary "new word" action, an icon tab row +//! (all / auto-collected / manual) with a select-all checkbox and a circular +//! search control, the word list, then the quick-add row, hint and the +//! scenario presets. Correction rules live on `corrections.rs`. + +use std::collections::BTreeSet; + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::icons::{self, IconName}; +use super::layout::{self, ButtonKind}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, VocabEntry}; + +const GAP: f32 = 14.0; +const CARD_PADDING: f32 = 20.0; +const INPUT_ID: &str = "openless-vocab-input"; +const SEARCH_ID: &str = "openless-vocab-search"; +const SEARCH_INPUT_ID: &str = "openless-vocab-search-input"; +const SELECTION_ID: &str = "openless-vocab-selection"; +const TAB_HEIGHT: f32 = 30.0; +const SEARCH_WIDTH: f32 = 210.0; + +// ── Page-local UI state ───────────────────────────────────────────────────── +// +// Search expansion and the multi-select set are view concerns only: the host +// view model has no fields for them, so they live in egui memory. + +fn search_open(ctx: &egui::Context) -> bool { + ctx.data(|data| { + data.get_temp::(egui::Id::new(SEARCH_ID)) + .unwrap_or(false) + }) +} + +fn set_search_open(ctx: &egui::Context, open: bool) { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(SEARCH_ID), open)); +} + +fn selection(ctx: &egui::Context) -> BTreeSet { + ctx.data(|data| { + data.get_temp::>(egui::Id::new(SELECTION_ID)) + .unwrap_or_default() + }) +} + +fn set_selection(ctx: &egui::Context, selection: BTreeSet) { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(SELECTION_ID), selection)); +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.vocab_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "nav.vocab")); + return; + } + + let selected = selection(ui.ctx()); + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "vocab.kicker"), + tr_l10n(lang, "vocab.title"), + Some(tr_l10n(lang, "vocab.desc")), + ); + let mut right = header.right(); + // Primary "new word" action (dark solid, like the Tauri `variant=primary`). + let new_word = tr_l10n(lang, "vocab.new_word"); + let new_word_width = layout::text_width(ui, new_word, 12.5) + 42.0; + let new_word_rect = egui::Rect::from_min_size( + egui::pos2(right - new_word_width, header.top() + 22.0), + egui::vec2(new_word_width, 30.0), + ); + right = new_word_rect.left() - 8.0; + if primary_button(ui, new_word_rect, new_word, Some(IconName::Hash)).clicked() { + ui.memory_mut(|memory| memory.request_focus(egui::Id::new(INPUT_ID))); + } + // Batch delete appears only while something is selected. + if !selected.is_empty() { + let label = fmt_l10n(lang, "vocab.delete_selected", &[&selected.len()]); + let label_width = layout::text_width(ui, &label, 12.5) + 40.0; + let rect = egui::Rect::from_min_size( + egui::pos2(right - label_width, header.top() + 22.0), + egui::vec2(label_width, 30.0), + ); + if layout::action_button(ui, rect, &label, Some(IconName::Trash), ButtonKind::Ghost) + .clicked() + { + for index in selected.iter().rev() { + actions.push(FrontendAction::VocabRemovePhrase(*index)); + } + set_selection(ui.ctx(), BTreeSet::new()); + } + } + ui.add_space(GAP); + + // ── Tool row: tabs + select-all + expandable search ───────────────────── + let visible: Vec = visible_indices(vm); + tool_row(ui, width, vm, &visible, &selected, actions); + ui.add_space(12.0); + + if let Some(error) = vm.vocab_error.clone() { + error_banner(ui, width, &error); + ui.add_space(10.0); + } + + // Auto-collected group gets a stable "remove all" exit while filtering. + if vm.vocab_filter == 1 { + let learned: Vec = visible + .iter() + .copied() + .filter(|index| vm.vocab_entries[*index].learned) + .collect(); + if !learned.is_empty() { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(fmt_l10n(lang, "vocab.learned_section", &[&learned.len()])) + .size(12.0) + .color(theme::INK_3), + ); + ui.add_space(6.0); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "vocab.remove_all_learned")) + .size(11.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + for index in learned.iter().rev() { + actions.push(FrontendAction::VocabRemovePhrase(*index)); + } + } + }); + ui.add_space(10.0); + } + } + + // ── Word list ────────────────────────────────────────────────────────── + if visible.is_empty() { + ui.add_space(6.0); + let message = if vm.vocab_query.trim().is_empty() { + tr_l10n(lang, "vocab.empty").to_string() + } else { + tr_l10n(lang, "vocab.search_empty").to_string() + }; + ui.label(egui::RichText::new(message).size(12.0).color(theme::INK_4)); + } else { + let mut next_selection = selected.clone(); + let mut remove = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for index in &visible { + let entry = &vm.vocab_entries[*index]; + let is_selected = selected.contains(index); + match word_chip(ui, entry, is_selected) { + ChipAction::None => {} + ChipAction::Toggle => { + actions.push(FrontendAction::VocabTogglePhrase(*index)); + } + ChipAction::Select => { + if is_selected { + next_selection.remove(index); + } else { + next_selection.insert(*index); + } + } + ChipAction::Remove => remove = Some(*index), + } + } + }); + if let Some(index) = remove { + actions.push(FrontendAction::VocabRemovePhrase(index)); + next_selection.remove(&index); + } + if next_selection != selected { + set_selection(ui.ctx(), next_selection); + } + } + + ui.add_space(GAP); + + // ── Quick add + presets ──────────────────────────────────────────────── + quick_add(ui, width, vm, actions); + ui.add_space(12.0); + presets(ui, width, vm, actions); +} + +// ── Tool row ──────────────────────────────────────────────────────────────── + +fn tool_row( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + visible: &[usize], + selected: &BTreeSet, + _actions: &mut Vec, +) { + let lang = vm.lang; + let tabs = [ + (TabIcon::None, tr_l10n(lang, "vocab.filter_all")), + (TabIcon::Sparkle, tr_l10n(lang, "vocab.filter_auto")), + (TabIcon::Pencil, tr_l10n(lang, "vocab.filter_manual")), + ]; + let (row, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + + let mut x = row.left(); + for (index, (icon, label)) in tabs.iter().enumerate() { + let text_width = layout::text_width(ui, label, 12.5); + let has_icon = *icon != TabIcon::None; + let tab_width = text_width + if has_icon { 38.0 } else { 22.0 }; + let rect = egui::Rect::from_min_size( + egui::pos2(x, row.top() + 2.0), + egui::vec2(tab_width, TAB_HEIGHT), + ); + let active = vm.vocab_filter.min(2) == index; + let response = ui.interact( + rect, + ui.id().with(("openless-vocab-tab", index)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + if active { + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } else if response.hovered() { + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let ink = if active { theme::INK } else { theme::INK_3 }; + let text_left = if has_icon { + let center = egui::pos2(rect.left() + 13.0, rect.center().y); + match icon { + TabIcon::Pencil => draw_pencil(ui, center, ink), + _ => icons::draw_icon(ui, center, IconName::Sparkle, ink), + } + rect.left() + 26.0 + } else { + rect.left() + 11.0 + }; + painter.text( + egui::pos2(text_left, rect.center().y), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(12.5), + ink, + ); + if response.clicked() { + _actions.push(FrontendAction::VocabFilter(index)); + } + x = rect.right() + 4.0; + } + + // Select-all checkbox: label shows the selected count while non-empty. + let all_selected = !visible.is_empty() && visible.iter().all(|index| selected.contains(index)); + let partial = !all_selected && visible.iter().any(|index| selected.contains(index)); + let checkbox_rect = egui::Rect::from_min_size( + egui::pos2(x + 10.0, row.center().y - 8.0), + egui::vec2(16.0, 16.0), + ); + let checkbox = ui.interact( + checkbox_rect, + ui.id().with("openless-vocab-select-all"), + egui::Sense::click(), + ); + draw_checkbox(ui, checkbox_rect, all_selected, partial); + let label = if selected.is_empty() { + tr_l10n(lang, "vocab.select_all_visible").to_string() + } else { + fmt_l10n(lang, "vocab.selected_count", &[&selected.len()]) + }; + ui.painter().text( + egui::pos2(checkbox_rect.right() + 7.0, row.center().y), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(12.0), + theme::INK_3, + ); + let label_rect = egui::Rect::from_min_max( + egui::pos2(checkbox_rect.right() + 3.0, row.top() + 4.0), + egui::pos2( + checkbox_rect.right() + 12.0 + layout::text_width(ui, &label, 12.0), + row.bottom() - 4.0, + ), + ); + let label_response = ui.interact( + label_rect, + ui.id().with("openless-vocab-select-all-label"), + egui::Sense::click(), + ); + if checkbox.clicked() || label_response.clicked() { + let mut next: BTreeSet = selected.clone(); + if all_selected { + for index in visible { + next.remove(index); + } + } else { + for index in visible { + next.insert(*index); + } + } + set_selection(ui.ctx(), next); + } + + // Search: a circular button at the right edge that expands into an input. + let open = search_open(ui.ctx()); + let circle = egui::Rect::from_center_size( + egui::pos2(row.right() - 15.0, row.center().y), + egui::vec2(30.0, 30.0), + ); + if open { + let rect = egui::Rect::from_min_size( + egui::pos2(circle.left() - 8.0 - SEARCH_WIDTH, row.center().y - 16.0), + egui::vec2(SEARCH_WIDTH, 32.0), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(16), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink2(egui::vec2(12.0, 6.0)); + let response = ui.put( + inner, + egui::TextEdit::singleline(&mut vm.vocab_query) + .id(egui::Id::new(SEARCH_INPUT_ID)) + .hint_text(tr_l10n(lang, "vocab.search_placeholder")) + .text_color(theme::INK) + .frame(false), + ); + if response.changed() { + _actions.push(FrontendAction::VocabSearch(vm.vocab_query.clone())); + } + } + let search_response = ui.interact( + circle, + ui.id().with("openless-vocab-search-toggle"), + egui::Sense::click(), + ); + ui.painter().rect_filled( + circle, + egui::CornerRadius::same(15), + if search_response.hovered() || open { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + circle, + egui::CornerRadius::same(15), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + icons::draw_icon(ui, circle.center(), IconName::Search, theme::INK_3); + if search_response.clicked() { + if open && !vm.vocab_query.is_empty() { + vm.vocab_query.clear(); + _actions.push(FrontendAction::VocabSearch(String::new())); + } else { + set_search_open(ui.ctx(), !open); + if !open { + ui.memory_mut(|memory| memory.request_focus(egui::Id::new(SEARCH_ID))); + } + } + } +} + +fn visible_indices(vm: &FrontendViewModel) -> Vec { + let query = vm.vocab_query.trim().to_lowercase(); + vm.vocab_entries + .iter() + .enumerate() + .filter(|(_, entry)| match vm.vocab_filter { + 1 => entry.learned, + 2 => !entry.learned, + _ => true, + }) + .filter(|(_, entry)| query.is_empty() || entry.phrase.to_lowercase().contains(&query)) + .map(|(index, _)| index) + .collect() +} + +// ── Word chip ─────────────────────────────────────────────────────────────── + +enum ChipAction { + None, + Toggle, + Select, + Remove, +} + +/// Tab icons: the shared icon set has a sparkle but no pencil, so the manual +/// tab draws a small pencil locally. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TabIcon { + None, + Sparkle, + Pencil, +} + +fn draw_pencil(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) { + let stroke = egui::Stroke::new(1.25, color); + ui.painter().line_segment( + [ + center + egui::vec2(-5.0, 5.0), + center + egui::vec2(3.5, -3.5), + ], + stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(3.5, -3.5), + center + egui::vec2(5.0, -1.2), + ], + stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(-5.0, 5.0), + center + egui::vec2(-2.6, 4.4), + ], + stroke, + ); +} + +fn word_chip(ui: &mut egui::Ui, entry: &VocabEntry, selected: bool) -> ChipAction { + let fill = if !entry.enabled { + theme::SURFACE_2 + } else if entry.hits > 0 { + theme::BLUE_SOFT + } else { + theme::SURFACE + }; + let text_color = if entry.enabled { + theme::INK + } else { + theme::INK_4 + }; + let phrase = ui.painter().layout_no_wrap( + entry.phrase.clone(), + egui::FontId::proportional(13.0), + text_color, + ); + let hits_text = entry.hits.to_string(); + let hits_color = if entry.enabled && entry.hits > 0 { + theme::SURFACE + } else { + theme::INK_4 + }; + let hits = ui + .painter() + .layout_no_wrap(hits_text, egui::FontId::proportional(11.0), hits_color); + let hits_size = egui::vec2((hits.size().x + 12.0).max(24.0), 22.0); + let checkbox_size = 16.0; + let close_size = 22.0; + let width = + 10.0 + checkbox_size + 8.0 + phrase.size().x + 8.0 + hits_size.x + 6.0 + close_size + 10.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::click()); + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(16), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.6, theme::LINE), + egui::StrokeKind::Inside, + ); + let checkbox_rect = egui::Rect::from_center_size( + egui::pos2(rect.left() + 10.0 + checkbox_size / 2.0, rect.center().y), + egui::vec2(checkbox_size, checkbox_size), + ); + draw_checkbox(ui, checkbox_rect, selected, false); + painter.galley( + egui::pos2( + checkbox_rect.right() + 8.0, + rect.center().y - phrase.size().y / 2.0, + ), + phrase, + text_color, + ); + let close_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 10.0 - close_size / 2.0, rect.center().y), + egui::vec2(close_size, close_size), + ); + let hits_rect = egui::Rect::from_min_size( + egui::pos2( + close_rect.left() - 6.0 - hits_size.x, + rect.center().y - hits_size.y / 2.0, + ), + hits_size, + ); + painter.rect_filled( + hits_rect, + egui::CornerRadius::same(5), + if entry.enabled && entry.hits > 0 { + theme::BLUE + } else { + theme::TOGGLE_OFF + }, + ); + painter.galley( + egui::pos2( + hits_rect.center().x - hits.size().x / 2.0, + hits_rect.center().y - hits.size().y / 2.0, + ), + hits, + hits_color, + ); + painter.circle_filled(close_rect.center(), close_size / 2.0, theme::SURFACE_2); + painter.circle_stroke( + close_rect.center(), + close_size / 2.0, + egui::Stroke::new(0.5, theme::LINE), + ); + let center = close_rect.center(); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); + painter.line_segment( + [ + center + egui::vec2(-3.0, -3.0), + center + egui::vec2(3.0, 3.0), + ], + x_stroke, + ); + painter.line_segment( + [ + center + egui::vec2(3.0, -3.0), + center + egui::vec2(-3.0, 3.0), + ], + x_stroke, + ); + + if response.clicked() { + if let Some(pointer) = response.interact_pointer_pos() { + if close_rect.contains(pointer) { + return ChipAction::Remove; + } + if checkbox_rect.contains(pointer) { + return ChipAction::Select; + } + } + return ChipAction::Toggle; + } + ChipAction::None +} + +fn draw_checkbox(ui: &egui::Ui, rect: egui::Rect, checked: bool, partial: bool) { + let painter = ui.painter(); + let fill = if checked || partial { + theme::INK + } else { + theme::SURFACE + }; + painter.rect_filled(rect, egui::CornerRadius::same(4), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(4), + egui::Stroke::new( + 0.8, + if checked || partial { + theme::INK + } else { + theme::LINE + }, + ), + egui::StrokeKind::Inside, + ); + if checked { + let stroke = egui::Stroke::new(1.5, theme::SURFACE); + painter.line_segment( + [ + rect.left_center() + egui::vec2(3.0, 0.5), + rect.center_bottom() + egui::vec2(-1.0, -3.5), + ], + stroke, + ); + painter.line_segment( + [ + rect.center_bottom() + egui::vec2(-1.0, -3.5), + rect.right_center() + egui::vec2(-2.5, -5.0), + ], + stroke, + ); + } else if partial { + painter.rect_filled( + egui::Rect::from_center_size(rect.center(), egui::vec2(8.0, 2.0)), + egui::CornerRadius::same(1), + theme::SURFACE, + ); + } +} + +// ── Bottom: quick add + presets ───────────────────────────────────────────── + +fn quick_add( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.horizontal(|ui| { + let add_width = 88.0; + let input_width = (width - add_width - 8.0).max(80.0); + let content_width = (input_width - 24.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 7)) + .show(ui, |ui| { + ui.set_width(content_width); + let response = ui.add_sized( + [content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_input) + .id(egui::Id::new(INPUT_ID)) + .desired_width(content_width) + .hint_text(tr_l10n(lang, "vocab.placeholder")) + .frame(false), + ); + if (response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter))) + || (ui.input(|input| input.key_pressed(egui::Key::Enter)) + && response.has_focus()) + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + vm.vocab_input.clear(); + } + } + }); + let add = tr_l10n(lang, "btn.add"); + let rect = egui::Rect::from_min_size( + egui::pos2(ui.cursor().min.x, ui.cursor().min.y), + egui::vec2(add_width, 34.0), + ); + if primary_button(ui, rect, add, Some(IconName::Hash)).clicked() { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + vm.vocab_input.clear(); + } + } + ui.allocate_space(egui::vec2(add_width, 34.0)); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.tip")) + .size(11.5) + .color(theme::INK_4), + ); +} + +fn presets( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + card(ui, width, |ui| { + layout::section_title( + ui, + ui.available_width(), + tr_l10n(lang, "vocab.presets_title"), + Some(tr_l10n(lang, "vocab.presets_tip")), + ); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + let names = [ + tr_l10n(lang, "vocab.presets_dev_tools"), + tr_l10n(lang, "vocab.presets_products"), + tr_l10n(lang, "vocab.presets_terms"), + tr_l10n(lang, "vocab.presets_english"), + ]; + for (index, name) in names.iter().enumerate() { + let selected = vm.vocab_selected_presets.contains(&index); + let response = ui.add( + egui::Button::new(egui::RichText::new(*name).size(12.5).color(if selected { + theme::BLUE + } else { + theme::INK_2 + })) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .min_size(egui::vec2(88.0, 30.0)), + ); + if response.clicked() { + actions.push(FrontendAction::VocabApplyPreset(index)); + } + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "vocab.presets_create")).size(12.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(96.0, 32.0)), + ) + .clicked() + { + vm.vocab_editing_preset = Some(usize::MAX); + vm.vocab_preset_name = tr_l10n(lang, "vocab.presets_new_preset").into(); + vm.vocab_preset_phrases.clear(); + } + if !vm.vocab_selected_presets.is_empty() { + let apply = tr_l10n(lang, "vocab.presets_apply"); + let rect = egui::Rect::from_min_size( + egui::pos2(ui.cursor().min.x, ui.cursor().min.y), + egui::vec2(88.0, 32.0), + ); + if primary_button(ui, rect, apply, None).clicked() { + actions.push(FrontendAction::VocabApplyPreset(usize::MAX)); + } + ui.allocate_space(egui::vec2(88.0, 32.0)); + } + }); + + if vm.vocab_editing_preset.is_some() { + ui.add_space(12.0); + ui.horizontal(|ui| { + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.add_sized( + [180.0, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_preset_name) + .hint_text(tr_l10n(lang, "vocab.presets_name_placeholder")) + .frame(false), + ); + }); + let save = tr_l10n(lang, "vocab.presets_save"); + if primary_button( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(84.0, 32.0)), + save, + None, + ) + .clicked() + { + let name = vm.vocab_preset_name.trim().to_owned(); + if !name.is_empty() { + actions.push(FrontendAction::VocabCreatePreset { + name, + phrases: vm.vocab_preset_phrases.clone(), + }); + } + vm.vocab_editing_preset = None; + } + ui.allocate_space(egui::vec2(84.0, 32.0)); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "common.cancel")).size(12.0), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(72.0, 32.0)), + ) + .clicked() + { + vm.vocab_editing_preset = None; + } + }); + ui.add_space(8.0); + ui.add_sized( + [ui.available_width(), 64.0], + egui::TextEdit::multiline(&mut vm.vocab_preset_phrases) + .desired_rows(3) + .hint_text(tr_l10n(lang, "vocab.presets_words_placeholder")), + ); + } + + if vm.vocab_editing_preset.is_none() && !vm.vocab_saved_presets.is_empty() { + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + let saved = vm.vocab_saved_presets.clone(); + for (index, preset) in saved.iter().enumerate() { + if ui + .add( + egui::Button::new( + egui::RichText::new(fmt_l10n( + lang, + "vocab.presets_edit", + &[&preset.name], + )) + .size(12.5), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.6, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .min_size(egui::vec2(0.0, 30.0)), + ) + .clicked() + { + vm.vocab_preset_name = preset.name.clone(); + vm.vocab_preset_phrases = preset.phrases.clone(); + vm.vocab_editing_preset = Some(index); + } + } + }); + } + }); +} + +// ── Small shared pieces ───────────────────────────────────────────────────── + +fn error_banner(ui: &mut egui::Ui, width: f32, message: &str) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 36.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(10), theme::DANGER_SOFT); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::ERR), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + message, + egui::FontId::proportional(12.0), + theme::ERR, + ); +} + +/// Dark solid button (the Tauri `variant=primary`). +fn primary_button( + ui: &mut egui::Ui, + rect: egui::Rect, + label: &str, + icon: Option, +) -> egui::Response { + let response = ui.interact( + rect, + ui.id().with(("openless-vocab-primary", label)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let fill = if response.hovered() { + theme::INK_2 + } else { + theme::INK + }; + painter.rect_filled(rect, egui::CornerRadius::same(8), fill); + let label_width = layout::text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 18.0 } else { 0.0 }; + let mut x = rect.center().x - (label_width + icon_space) / 2.0; + if let Some(icon) = icon { + icons::draw_icon( + ui, + egui::pos2(x + 6.0, rect.center().y), + icon, + theme::SURFACE, + ); + x += icon_space; + } + painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::SURFACE, + ); + response +} + +/// Full-width card that sizes itself to its contents. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/mod.rs b/openless-all/app/linux-egui/src/ui/mod.rs new file mode 100644 index 000000000..79a9b1abd --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod bridge; +pub mod frontend; +pub mod shell; +pub mod theme; diff --git a/openless-all/app/linux-egui/src/ui/shell.rs b/openless-all/app/linux-egui/src/ui/shell.rs new file mode 100644 index 000000000..bdaeea873 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/shell.rs @@ -0,0 +1,19 @@ +//! Shell page identity. +//! +//! The egui shell used to live here (titlebar / sidebar / content panel). All of +//! that was replaced by `ui::frontend`; only the page identity remains because +//! the host keeps its own navigation state and maps it onto the frontend pages. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Page { + #[default] + Overview, + History, + Vocabulary, + Styles, + Marketplace, + Providers, + Assistant, + Translation, + Corrections, +} diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs new file mode 100644 index 000000000..b47a9ae8a --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -0,0 +1,251 @@ +use std::path::PathBuf; + +use eframe::egui; + +pub const BLUE: egui::Color32 = egui::Color32::from_rgb(37, 99, 235); +pub const BLUE_SOFT: egui::Color32 = egui::Color32::from_rgb(239, 245, 255); +pub const CANVAS: egui::Color32 = egui::Color32::from_rgb(250, 250, 250); +pub const SURFACE: egui::Color32 = egui::Color32::WHITE; +pub const SURFACE_2: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); +/// Tauri `--ol-segmented-bg`: the segmented-control track. +pub const SEGMENTED_TRACK: egui::Color32 = egui::Color32::from_rgba_premultiplied(10, 10, 10, 10); +/// Tauri `--ol-segmented-active-bg`: the selected chip is a plain white surface. +pub const SEGMENTED_ACTIVE_BG: egui::Color32 = SURFACE; +/// Tauri `--ol-segmented-active-shadow` 第二段 `0 0 0 0.5px rgba(0,0,0,0.06)`: +/// 选中片以细环代替描边(Tauri 的选中片 `border: 0`)。 +pub const SEGMENTED_ACTIVE_RING: egui::Color32 = egui::Color32::from_black_alpha(15); +/// Tauri `--ol-segmented-active-shadow` 第一段 `0 1px 2px rgba(0,0,0,0.06)`。 +/// egui 没有高斯模糊的矩形阴影,用向下偏移 1px 的淡色圆角矩形近似同一种"浮起"观感。 +pub const SEGMENTED_ACTIVE_SHADOW: egui::Color32 = egui::Color32::from_black_alpha(10); +pub const LINE: egui::Color32 = egui::Color32::from_rgb(228, 228, 231); +pub const INK: egui::Color32 = egui::Color32::from_rgb(9, 9, 11); +pub const INK_2: egui::Color32 = egui::Color32::from_rgb(63, 63, 70); +pub const INK_3: egui::Color32 = egui::Color32::from_rgb(113, 113, 122); +pub const INK_4: egui::Color32 = egui::Color32::from_rgb(161, 161, 170); +pub const OK: egui::Color32 = egui::Color32::from_rgb(22, 163, 74); +/// Tauri `--ol-line-soft`: 设置行之间的分隔线(比 `--ol-line` 更淡)。 +pub const LINE_SOFT: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); +/// Tauri `--ol-line-strong`: 输入框 / 次级按钮的描边。 +pub const LINE_STRONG: egui::Color32 = egui::Color32::from_rgb(212, 212, 216); +/// Tauri `--ol-settings-rail-bg`: 设置弹窗左侧导航底色。 +pub const RAIL_BG: egui::Color32 = egui::Color32::from_rgb(240, 240, 241); +/// Tauri `--ol-settings-content-bg`: 设置弹窗内容区底色(卡片是白色的)。 +pub const CONTENT_BG: egui::Color32 = egui::Color32::from_rgb(247, 247, 248); +/// Tauri `--ol-nav-hover-bg`: 侧栏/导航项悬停底色。 +pub const NAV_HOVER: egui::Color32 = egui::Color32::from_rgba_premultiplied(0, 0, 0, 10); +/// Tauri `--ol-toggle-off-bg`: 关闭态开关轨道。 +pub const TOGGLE_OFF: egui::Color32 = egui::Color32::from_rgba_premultiplied(0, 0, 0, 38); +/// Tauri `--ol-overlay-bg`: 设置/市场遮罩。 +pub const OVERLAY: egui::Color32 = egui::Color32::from_rgba_premultiplied(5, 5, 7, 82); +/// Tauri `--ol-err` 的淡底:红框提示卡。 +pub const DANGER_SOFT: egui::Color32 = egui::Color32::from_rgba_premultiplied(37, 11, 11, 18); +/// Tauri `--ol-warn-soft`: 警告卡底色。 +pub const WARN_SOFT: egui::Color32 = egui::Color32::from_rgb(255, 247, 237); +/// Tauri `--ol-warn`: 已配置但非必选的提示色。 +pub const WARN: egui::Color32 = egui::Color32::from_rgb(217, 119, 6); +/// Tauri `--ol-err`: used by the denied permission state. +/// Tauri `--ol-capsule-badge-bg` / `--ol-capsule-badge-border`(浅色)。 +pub const CAPSULE_BADGE_BG: egui::Color32 = egui::Color32::from_rgb(250, 250, 250); +pub const CAPSULE_BADGE_BORDER: egui::Color32 = + egui::Color32::from_rgba_premultiplied(9, 24, 58, 64); +pub const ERR: egui::Color32 = egui::Color32::from_rgb(220, 38, 38); + +/// Font key of the registered Medium face (see [`medium_font`]). +const MEDIUM_FACE: &str = "openless-medium"; +/// Named font family holding the Medium face plus the regular chain as fallback. +pub const MEDIUM_FAMILY: &str = "openless-medium-family"; + +static MEDIUM_AVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Font for Tauri's `font-weight: 500` labels (setting rows). +/// +/// Uses the fontconfig-resolved Medium face when the desktop ships one; otherwise +/// falls back to the regular proportional face. The fallback is deliberate: egui +/// cannot synthesise a weight, and faking it would misreport the alignment. +pub fn medium_font(size: f32) -> egui::FontId { + if MEDIUM_AVAILABLE.load(std::sync::atomic::Ordering::Relaxed) { + egui::FontId::new(size, egui::FontFamily::Name(MEDIUM_FAMILY.into())) + } else { + egui::FontId::proportional(size) + } +} + +/// Resolve the file + face index fontconfig would pick for `query`. +/// +/// This is the same face the Tauri/WebKit app gets through `system-ui`. It +/// matters for `.ttc` collections: loading one without an index silently picks +/// face 0 (Noto Sans CJK **JP**), which is why Simplified Chinese used to render +/// with Japanese glyph variants. +fn fontconfig_match(query: &str) -> Option<(PathBuf, u32)> { + let output = std::process::Command::new("fc-match") + .args(["-f", "%{file}|%{index}", query]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let (file, index) = text.split_once('|')?; + let file = file.trim(); + if file.is_empty() { + return None; + } + Some((PathBuf::from(file), index.trim().parse().unwrap_or(0))) +} + +/// Install the Linux font stack. +/// +/// Faces are resolved through fontconfig (so HarmonyOS Sans, Noto or whatever +/// the desktop prefers is used, exactly like the Tauri build) and CJK is loaded +/// with its proper face index. The egui defaults stay as the last fallback. +pub fn install(ctx: &egui::Context) { + // (font key, file, face index, proportional?, monospace?) + let mut candidates: Vec<(String, PathBuf, u32, bool, bool)> = Vec::new(); + if let Some(path) = std::env::var_os("OPENLESS_IME_FONT").map(PathBuf::from) { + candidates.push(("openless-primary".to_owned(), path, 0, true, true)); + } + if let Some((path, index)) = fontconfig_match("sans-serif") { + candidates.push(("openless-ui-sans".to_owned(), path, index, true, false)); + } + if let Some((path, index)) = fontconfig_match("monospace") { + candidates.push(("openless-ui-mono".to_owned(), path, index, false, true)); + } + for (name, query) in [ + ("openless-cjk", ":lang=zh-cn"), + ("openless-arabic", ":lang=ar"), + ("openless-thai", ":lang=th"), + ("openless-devanagari", ":lang=hi"), + ] { + if let Some((path, index)) = fontconfig_match(query) { + candidates.push((name.to_owned(), path, index, true, true)); + } + } + // Last-resort fallbacks for minimal systems without fontconfig entries. + for (name, path) in [ + ( + "openless-legacy-cjk", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + ), + ( + "openless-legacy-latin", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + ), + ] { + candidates.push((name.to_owned(), PathBuf::from(path), 0, true, true)); + } + + let mut fonts = egui::FontDefinitions::default(); + let mut proportional: Vec = Vec::new(); + let mut monospace: Vec = Vec::new(); + let mut seen: Vec<(PathBuf, u32)> = Vec::new(); + for (name, path, index, is_proportional, is_monospace) in candidates { + if seen + .iter() + .any(|(seen_path, seen_index)| seen_path == &path && *seen_index == index) + { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + seen.push((path, index)); + fonts.font_data.insert( + name.clone(), + egui::FontData { + font: bytes.into(), + index, + tweak: Default::default(), + } + .into(), + ); + if is_proportional { + proportional.push(name.clone()); + } + if is_monospace { + monospace.push(name); + } + } + for name in proportional.iter().rev() { + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, name.clone()); + } + for name in monospace.iter().rev() { + fonts + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .insert(0, name.clone()); + } + + // Tauri 的 `SettingRow` 标签是 `font-weight: 500`。egui 不能选可变字体的字重轴, + // 所以去 fontconfig 要一个真正的 Medium 面(例如 Noto Sans CJK Medium)单独注册 + // 成一个命名族;桌面没有 500 面时退回 Proportional(见 `medium_font`)。 + let regular_face = fontconfig_match("sans-serif"); + let medium_face = fontconfig_match("sans-serif:weight=medium"); + if let Some((path, index)) = medium_face { + if regular_face.as_ref() != Some(&(path.clone(), index)) { + if let Ok(bytes) = std::fs::read(&path) { + fonts.font_data.insert( + MEDIUM_FACE.to_owned(), + egui::FontData { + font: bytes.into(), + index, + tweak: Default::default(), + } + .into(), + ); + let mut chain = vec![MEDIUM_FACE.to_owned()]; + chain.extend( + fonts + .families + .get(&egui::FontFamily::Proportional) + .cloned() + .unwrap_or_default(), + ); + fonts + .families + .insert(egui::FontFamily::Name(MEDIUM_FAMILY.into()), chain); + MEDIUM_AVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + } + + ctx.set_fonts(fonts); + + apply_visuals(ctx, openless_core::shared_types::ThemeMode::System); + + let mut style = (*ctx.style()).clone(); + style.spacing.item_spacing = egui::vec2(8.0, 8.0); + style.spacing.button_padding = egui::vec2(10.0, 6.0); + ctx.set_style(style); +} + +/// Apply the light/dark visual theme. +pub fn apply_visuals(ctx: &egui::Context, mode: openless_core::shared_types::ThemeMode) { + let dark = match mode { + openless_core::shared_types::ThemeMode::System => { + ctx.system_theme() == Some(egui::Theme::Dark) + } + openless_core::shared_types::ThemeMode::Light => false, + openless_core::shared_types::ThemeMode::Dark => true, + }; + let mut visuals = if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + if !dark { + visuals.panel_fill = CANVAS; + visuals.window_fill = SURFACE; + visuals.faint_bg_color = SURFACE_2; + visuals.selection.bg_fill = BLUE_SOFT; + } + visuals.selection.stroke = egui::Stroke::new(1.0, BLUE); + visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.active.corner_radius = egui::CornerRadius::same(7); + ctx.set_visuals(visuals); +} diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs index c22ced65e..9b7ebe0c8 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -1,119 +1,177 @@ -//! Presentation state only: changing pages never owns or cancels a Core session. - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub(super) enum Page { - #[default] - Start, - Dictation, - Qa, - Selection, - Agent, - Services, - Models, - Remote, - History, - Settings, +//! Persistence of pure Linux-UI state (view-model preferences that are *not* +//! business truth). Business truth lives in Core's `UserPreferences`; this +//! module keeps only UI-surface state such as the chosen display language, +//! stored on disk beside Core but never inside a Core-owned document. + +use std::path::PathBuf; + +use crate::desktop::atomic_save; +use crate::i18n::{LocalePref, FOLLOW_SYSTEM}; + +const STATE_FILE: &str = "linux-ui-state.json"; + +#[derive(Debug)] +pub enum UiStateError { + Io { + operation: &'static str, + source: std::io::Error, + }, + Json(String), } -impl Page { - pub const ALL: [Self; 10] = [ - Self::Start, - Self::Dictation, - Self::Qa, - Self::Selection, - Self::Agent, - Self::Services, - Self::Models, - Self::Remote, - Self::History, - Self::Settings, - ]; - - pub fn label(self) -> &'static str { +impl std::fmt::Display for UiStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Start => "开始", - Self::Dictation => "听写", - Self::Qa => "问答", - Self::Selection => "选区润色", - Self::Agent => "Less Computer", - Self::Services => "AI 服务", - Self::Models => "本地模型", - Self::Remote => "手机输入", - Self::History => "历史", - Self::Settings => "环境与设置", + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Json(message) => f.write_str(message), } } } -#[derive(Default)] -pub(super) struct Navigation { - pub page: Page, - unread: [bool; Page::ALL.len()], +impl std::error::Error for UiStateError {} + +fn io_error(operation: &'static str, source: std::io::Error) -> UiStateError { + UiStateError::Io { operation, source } } -impl Navigation { - pub fn open(&mut self, page: Page) { - self.page = page; - self.unread[page as usize] = false; - } +/// The application data directory, mirroring the runtime's `backend_config` +/// derivation (XDG_DATA_HOME, falling back to `~/.local/share`). Kept here so +/// the main window *and* the separate popup window resolve the exact same +/// state file without sharing a process-local handle. +pub fn ui_state_dir() -> Option { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")); + Some(base.join("OpenLess")) +} - pub fn notify(&mut self, page: Page) { - if self.page != page { - self.unread[page as usize] = true; - } - } +/// Absolute path to the persisted Linux-UI state document. +pub fn ui_state_path() -> Option { + ui_state_dir().map(|dir| dir.join(STATE_FILE)) +} - pub fn has_update(&self, page: Page) -> bool { - self.unread[page as usize] +/// Read the persisted locale preference. A missing, empty or unreadable file +/// (plus any unrecognised value) degrades to `LocalePref::System` — following +/// the OS locale — so a corrupt state can never wedge the UI on the wrong +/// language. +pub fn load_locale_pref() -> LocalePref { + let Some(path) = ui_state_path() else { + return LocalePref::System; + }; + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(_) => return LocalePref::System, + }; + let value = match serde_json::from_str::(&raw) { + Ok(value) => value, + Err(_) => return LocalePref::System, + }; + match value.get("locale").and_then(serde_json::Value::as_str) { + Some(tag) => LocalePref::from_tag(tag), + None => LocalePref::System, } } +/// Persist the locale preference atomically. Returns an error only when the +/// state cannot be written at all; unknown environments (no HOME) simply leave +/// the preference unsaved and are reported as a recoverable failure. +pub fn save_locale_pref(pref: LocalePref) -> Result<(), UiStateError> { + let Some(dir) = ui_state_dir() else { + return Err(io_error( + "resolve ui-state directory", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + let Some(path) = ui_state_path() else { + return Err(io_error( + "resolve ui-state path", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + std::fs::create_dir_all(&dir).map_err(|error| io_error("create ui-state directory", error))?; + let tag = match pref { + LocalePref::System => FOLLOW_SYSTEM.to_string(), + LocalePref::Lang(lang) => lang.tag().to_string(), + }; + let document = serde_json::json!({ "locale": tag }); + let bytes = serde_json::to_vec_pretty(&document) + .map_err(|error| UiStateError::Json(error.to_string()))?; + atomic_save(&path, &bytes) + .map(|_| ()) + .map_err(|error| match error { + crate::desktop::DesktopError::InvalidInput(message) => UiStateError::Json(message), + crate::desktop::DesktopError::Io { operation, source } => { + UiStateError::Io { operation, source } + } + other => UiStateError::Json(other.to_string()), + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::i18n::Lang; + use std::sync::{Mutex, OnceLock}; - #[test] - fn background_work_keeps_its_notice_until_its_own_page_is_opened() { - let mut navigation = Navigation::default(); - navigation.notify(Page::Qa); - navigation.notify(Page::Selection); - navigation.notify(Page::Agent); - navigation.open(Page::Settings); - for page in [Page::Qa, Page::Selection, Page::Agent] { - assert!(navigation.has_update(page)); + /// Env vars are process-global, so these tests must not interleave. + fn test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn with_tmp_state(run: impl FnOnce(PathBuf)) { + // Recover a poisoned lock (from an earlier assertion) rather than + // failing the whole module: env vars must stay consistent per test. + let _guard = test_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = + std::env::temp_dir().join(format!("openless-ui-state-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // Point every data-dir resolver at the temp dir via XDG_DATA_HOME. + let previous = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", &dir); + run(dir.clone()); + if let Some(value) = previous { + std::env::set_var("XDG_DATA_HOME", value); + } else { + std::env::remove_var("XDG_DATA_HOME"); } - navigation.open(Page::Qa); - assert!(!navigation.has_update(Page::Qa)); - assert!(navigation.has_update(Page::Selection)); - assert!(navigation.has_update(Page::Agent)); + let _ = std::fs::remove_dir_all(dir); } #[test] - fn reading_a_page_does_not_create_an_unread_notice() { - let mut navigation = Navigation::default(); - navigation.open(Page::Agent); - navigation.notify(Page::Agent); - assert!(!navigation.has_update(Page::Agent)); - navigation.open(Page::Start); - navigation.notify(Page::Agent); - assert!(navigation.has_update(Page::Agent)); - assert_eq!(navigation.page, Page::Start); + fn absent_state_resolves_to_follow_system() { + with_tmp_state(|dir| { + // No file has been written in this fresh dir. + assert_eq!(load_locale_pref(), LocalePref::System); + std::fs::remove_dir_all(&dir).ok(); + }); } #[test] - fn every_destination_can_be_opened_without_clearing_other_destinations() { - let mut navigation = Navigation::default(); - for page in Page::ALL { - assert!(!page.label().is_empty()); - navigation.notify(page); - } - for (index, page) in Page::ALL.into_iter().enumerate() { - navigation.open(page); - assert_eq!(navigation.page, page); - assert!(!navigation.has_update(page)); - for remaining in &Page::ALL[index + 1..] { - assert!(navigation.has_update(*remaining)); - } - } + fn locale_preference_persists_and_roundtrips_across_reload() { + with_tmp_state(|_dir| { + save_locale_pref(LocalePref::Lang(Lang::ZhTw)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::ZhTw)); + save_locale_pref(LocalePref::System).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::System); + save_locale_pref(LocalePref::Lang(Lang::Ko)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ko)); + }); + } + + #[test] + fn corrupt_state_file_degrades_to_follow_system() { + with_tmp_state(|_dir| { + let path = ui_state_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{ not json").unwrap(); + assert_eq!(load_locale_pref(), LocalePref::System); + // A later valid write repairs the state. + save_locale_pref(LocalePref::Lang(Lang::Ja)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); + }); } } diff --git a/openless-all/app/linux-egui/src/updater.rs b/openless-all/app/linux-egui/src/updater.rs new file mode 100644 index 000000000..5d63f6733 --- /dev/null +++ b/openless-all/app/linux-egui/src/updater.rs @@ -0,0 +1,1085 @@ +//! Verified AppImage replacement primitives. +//! +//! Network transport is HTTPS-only and every replacement is verified against +//! the same pinned minisign key used by the existing desktop updater. + +use base64::Engine as _; +use futures_util::StreamExt; +use minisign_verify::{PublicKey, Signature}; +use serde::Deserialize; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 1; +pub const MANIFEST_HOST: &str = "linux-egui"; +pub const DEFAULT_MAX_APPIMAGE_BYTES: u64 = 1024 * 1024 * 1024; +pub const DEFAULT_MAX_MANIFEST_BYTES: u64 = 1024 * 1024; +pub const STARTUP_CHECK_DELAY: Duration = Duration::from_secs(15); +pub const PERIODIC_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); +pub const RELEASES_URL: &str = "https://github.com/Open-Less/openless/releases"; +pub const DIRECT_RELEASE_BASE: &str = "https://github.com/Open-Less/openless"; +pub const BETA_RELEASES_API: &str = + "https://api.github.com/repos/Open-Less/openless/releases?per_page=30"; + +/// Pinned OpenLess updater key. This is deliberately compiled into the host, +/// rather than accepted from a manifest fetched over the network. It matches +/// the existing OpenLess updater signing key used by the release workflows. +pub const PINNED_MINISIGN_PUBLIC_KEY: &str = + "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK"; + +pub use openless_core::shared_types::UpdateChannel; + +pub fn manifest_urls(channel: UpdateChannel, arch: &str, beta_tag: Option<&str>) -> Vec { + let name = format!("latest-linux-egui-{arch}.json"); + match channel { + UpdateChannel::Stable => vec![format!( + "{DIRECT_RELEASE_BASE}/releases/latest/download/{name}" + )], + UpdateChannel::Beta => beta_tag + .filter(|tag| valid_release_tag(tag)) + .map(|tag| { + vec![format!( + "{DIRECT_RELEASE_BASE}/releases/download/{tag}/{name}" + )] + }) + .unwrap_or_default(), + } +} + +fn valid_release_tag(tag: &str) -> bool { + !tag.is_empty() + && tag.starts_with('v') + && !tag.contains("..") + && tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckReason { + Startup, + Periodic, + Manual, +} + +/// Pure elapsed-time scheduler. The UI owns the timer and calls `poll`; no +/// updater task can block an egui frame. +#[derive(Debug, Clone)] +pub struct UpdateSchedule { + startup_due: Duration, + periodic_due: Duration, + startup_pending: bool, +} + +impl UpdateSchedule { + pub fn new(now: Duration) -> Self { + Self { + startup_due: now.saturating_add(STARTUP_CHECK_DELAY), + periodic_due: now.saturating_add(PERIODIC_CHECK_INTERVAL), + startup_pending: true, + } + } + + pub fn poll(&mut self, now: Duration, manual: bool) -> Option { + if manual { + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Manual); + } + if self.startup_pending && now >= self.startup_due { + self.startup_pending = false; + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Startup); + } + if now >= self.periodic_due { + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Periodic); + } + None + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateManifest { + pub schema_version: u32, + pub host: String, + pub arch: String, + pub version: String, + pub url: String, + pub sha256: String, + pub minisign: Option, +} + +impl UpdateManifest { + pub fn parse(json: &[u8], expected_arch: &str) -> Result { + let manifest: Self = serde_json::from_slice(json).map_err(UpdateError::ManifestJson)?; + manifest.validate(expected_arch)?; + Ok(manifest) + } + + pub fn validate(&self, expected_arch: &str) -> Result<(), UpdateError> { + if self.schema_version != MANIFEST_SCHEMA_VERSION { + return Err(UpdateError::InvalidManifest(format!( + "unsupported updater schema version {}", + self.schema_version + ))); + } + if self.host != MANIFEST_HOST { + return Err(UpdateError::InvalidManifest(format!( + "manifest is for host {:?}, not {:?}", + self.host, MANIFEST_HOST + ))); + } + if self.arch != expected_arch { + return Err(UpdateError::InvalidManifest(format!( + "manifest architecture {:?} does not match {:?}", + self.arch, expected_arch + ))); + } + if self.version.trim().is_empty() + || self.version.contains(['\0', '\n', '\r']) + || self.url.chars().any(char::is_control) + { + return Err(UpdateError::InvalidManifest( + "manifest version or URL is empty/unsafe".into(), + )); + } + if !is_github_release_url(&self.url) { + return Err(UpdateError::InvalidManifest( + "artifact URL must be a GitHub HTTPS release asset".into(), + )); + } + if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(UpdateError::InvalidManifest( + "manifest SHA-256 must contain exactly 64 hexadecimal digits".into(), + )); + } + if self + .minisign + .as_deref() + .is_some_and(|signature| signature.trim().is_empty() || signature.contains('\0')) + { + return Err(UpdateError::InvalidManifest( + "manifest minisign value is empty or unsafe".into(), + )); + } + Ok(()) + } + + pub fn has_new_version(&self, current: &str) -> bool { + match ( + semver::Version::parse(normalize_version(&self.version)), + semver::Version::parse(normalize_version(current)), + ) { + (Ok(remote), Ok(current)) => remote > current, + _ => normalize_version(&self.version) != normalize_version(current), + } + } +} + +fn normalize_version(version: &str) -> &str { + version.trim().strip_prefix('v').unwrap_or(version.trim()) +} + +fn is_github_release_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://github.com/") else { + return false; + }; + let mut segments = rest.split('/'); + let owner = segments.next().unwrap_or_default(); + let repository = segments.next().unwrap_or_default(); + let releases = segments.next().unwrap_or_default(); + let download = segments.next().unwrap_or_default(); + let tag = segments.next().unwrap_or_default(); + let asset = segments.next().unwrap_or_default(); + !owner.is_empty() + && !repository.is_empty() + && releases == "releases" + && download == "download" + && !tag.is_empty() + && !asset.is_empty() + && segments.next().is_none() +} + +#[derive(Debug)] +pub enum UpdateError { + ManifestJson(serde_json::Error), + InvalidManifest(String), + NotAppImage(String), + MissingSignature, + SignatureUnavailable(String), + SignatureRejected(String), + TooLarge { + limit: u64, + }, + ChecksumMismatch { + expected: String, + actual: String, + }, + Io { + operation: &'static str, + source: io::Error, + }, + Http(String), +} + +impl fmt::Display for UpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ManifestJson(error) => write!(f, "invalid updater manifest JSON: {error}"), + Self::InvalidManifest(message) => write!(f, "invalid updater manifest: {message}"), + Self::NotAppImage(message) => write!(f, "AppImage update unavailable: {message}"), + Self::MissingSignature => f.write_str("update manifest has no minisign signature"), + Self::SignatureUnavailable(message) => { + write!(f, "minisign verification is unavailable: {message}") + } + Self::SignatureRejected(message) => { + write!(f, "minisign verification rejected the update: {message}") + } + Self::TooLarge { limit } => write!(f, "AppImage exceeds the {limit}-byte limit"), + Self::ChecksumMismatch { expected, actual } => { + write!( + f, + "AppImage SHA-256 mismatch: expected {expected}, got {actual}" + ) + } + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Http(message) => write!(f, "update request failed: {message}"), + } + } +} + +impl std::error::Error for UpdateError {} + +fn io_error(operation: &'static str, source: io::Error) -> UpdateError { + UpdateError::Io { operation, source } +} + +/// Signature verification seam. The verifier must validate the complete +/// minisign file stored as base64 in the release manifest against a pinned +/// public key. +pub trait SignatureVerifier { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError>; +} + +/// Honest placeholder used until `minisign-verify` and a pinned public key are +/// added to this crate. It always rejects signed updates. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableSignatureVerifier; + +impl SignatureVerifier for UnavailableSignatureVerifier { + fn verify_base64_minisign( + &self, + _artifact: &Path, + _encoded_signature: &str, + ) -> Result<(), UpdateError> { + Err(UpdateError::SignatureUnavailable( + "the Linux host was built without a minisign verifier".into(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct PinnedMinisignVerifier { + public_key: PublicKey, +} + +impl PinnedMinisignVerifier { + pub fn new() -> Result { + let public_key_file = base64::engine::general_purpose::STANDARD + .decode(PINNED_MINISIGN_PUBLIC_KEY) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + let public_key_file = std::str::from_utf8(&public_key_file) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + let public_key = PublicKey::decode(public_key_file) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + Ok(Self { public_key }) + } +} + +impl SignatureVerifier for PinnedMinisignVerifier { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError> { + let signature_file = base64::engine::general_purpose::STANDARD + .decode(encoded_signature.trim()) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let signature_file = std::str::from_utf8(&signature_file) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let signature = Signature::decode(signature_file) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let mut input = File::open(artifact) + .map_err(|error| io_error("open AppImage for signature verification", error))?; + let mut verifier = self + .public_key + .verify_stream(&signature) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = input + .read(&mut buffer) + .map_err(|error| io_error("read AppImage for signature verification", error))?; + if read == 0 { + break; + } + verifier.update(&buffer[..read]); + } + verifier + .finalize() + .map_err(|error| UpdateError::SignatureRejected(error.to_string())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DownloadProgress { + pub downloaded: u64, + pub content_length: Option, +} + +/// Fully initialized AppImage updater. Construction fails for deb/rpm, +/// development binaries, malformed pinned keys, or HTTP client failures. +#[derive(Clone)] +pub struct AppImageUpdater { + client: reqwest::Client, + target: AppImageTarget, + verifier: PinnedMinisignVerifier, + expected_arch: &'static str, +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + draft: bool, +} + +#[derive(Clone)] +pub enum LinuxUpdateSupport { + AppImage(AppImageUpdater), + /// deb/rpm and development builds must leave package replacement to their + /// package manager and only offer the upstream releases page. + ManualOnly { + releases_url: &'static str, + }, +} + +impl LinuxUpdateSupport { + pub fn initialize(package_kind: crate::LinuxPackageKind) -> Self { + if package_kind == crate::LinuxPackageKind::AppImage { + if let Ok(updater) = AppImageUpdater::initialize() { + return Self::AppImage(updater); + } + } + Self::ManualOnly { + releases_url: RELEASES_URL, + } + } + + pub fn supports_auto_update(&self) -> bool { + matches!(self, Self::AppImage(_)) + } + + pub fn manual_download_url(&self) -> &'static str { + RELEASES_URL + } +} + +impl AppImageUpdater { + pub fn initialize() -> Result { + let target = AppImageTarget::detect()?; + let verifier = PinnedMinisignVerifier::new()?; + let client = reqwest::Client::builder() + .https_only(true) + .timeout(Duration::from_secs(30)) + .user_agent(concat!("OpenLess-Linux/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| UpdateError::Http(error.to_string()))?; + Ok(Self { + client, + target, + verifier, + expected_arch: std::env::consts::ARCH, + }) + } + + pub fn target(&self) -> &AppImageTarget { + &self.target + } + + pub async fn check( + &self, + channel: UpdateChannel, + ) -> Result, UpdateError> { + let beta_tag = match channel { + UpdateChannel::Stable => None, + UpdateChannel::Beta => Some(self.latest_beta_tag().await?), + }; + let urls = manifest_urls(channel, self.expected_arch, beta_tag.as_deref()); + if urls.is_empty() { + return Err(UpdateError::InvalidManifest( + "a valid beta release tag is required".into(), + )); + } + let mut last_error = None; + for url in urls { + let result = async { + let response = self + .client + .get(&url) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let bytes = response_bytes_limited(response, DEFAULT_MAX_MANIFEST_BYTES).await?; + UpdateManifest::parse(&bytes, self.expected_arch) + } + .await; + match result { + Ok(manifest) => { + return Ok(manifest + .has_new_version(env!("CARGO_PKG_VERSION")) + .then_some(manifest)); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| UpdateError::Http("no manifest URL available".into()))) + } + + async fn latest_beta_tag(&self) -> Result { + let response = self + .client + .get(BETA_RELEASES_API) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let bytes = response_bytes_limited(response, DEFAULT_MAX_MANIFEST_BYTES).await?; + let releases: Vec = serde_json::from_slice(&bytes) + .map_err(|error| UpdateError::InvalidManifest(error.to_string()))?; + releases + .into_iter() + .find(|release| { + !release.draft && release.prerelease && valid_release_tag(&release.tag_name) + }) + .map(|release| release.tag_name) + .ok_or_else(|| UpdateError::InvalidManifest("no beta release is available".into())) + } + + pub async fn download_and_install( + &self, + manifest: UpdateManifest, + mut progress: impl FnMut(DownloadProgress), + ) -> Result { + manifest.validate(self.expected_arch)?; + let response = self + .client + .get(&manifest.url) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let total = response.content_length(); + if total.is_some_and(|length| length > DEFAULT_MAX_APPIMAGE_BYTES) { + return Err(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + }); + } + let parent = self.target.path().parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let download = parent.join(format!(".openless-download-{}", uuid::Uuid::new_v4())); + let result = async { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&download) + .map_err(|error| io_error("create AppImage download", error))?; + let mut downloaded = 0u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| UpdateError::Http(error.to_string()))?; + downloaded = + downloaded + .checked_add(chunk.len() as u64) + .ok_or(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + })?; + if downloaded > DEFAULT_MAX_APPIMAGE_BYTES { + return Err(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + }); + } + output + .write_all(&chunk) + .map_err(|error| io_error("write AppImage download", error))?; + progress(DownloadProgress { + downloaded, + content_length: total, + }); + } + output + .sync_all() + .map_err(|error| io_error("sync AppImage download", error))?; + drop(output); + let input = File::open(&download) + .map_err(|error| io_error("open completed AppImage download", error))?; + install_verified_appimage( + &manifest, + self.expected_arch, + input, + &self.target, + &self.verifier, + ) + } + .await; + let _ = fs::remove_file(download); + result + } +} + +async fn response_bytes_limited( + response: reqwest::Response, + limit: u64, +) -> Result, UpdateError> { + if response + .content_length() + .is_some_and(|length| length > limit) + { + return Err(UpdateError::TooLarge { limit }); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| UpdateError::Http(error.to_string()))?; + if (bytes.len() as u64).saturating_add(chunk.len() as u64) > limit { + return Err(UpdateError::TooLarge { limit }); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppImageTarget { + path: PathBuf, +} + +impl AppImageTarget { + pub fn detect() -> Result { + let path = std::env::var_os("APPIMAGE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| UpdateError::NotAppImage("APPIMAGE is not set".into()))?; + Self::new(path) + } + + pub fn new(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(UpdateError::NotAppImage( + "APPIMAGE must be an absolute path".into(), + )); + } + let metadata = fs::symlink_metadata(&path) + .map_err(|error| io_error("inspect current AppImage", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(UpdateError::NotAppImage( + "current AppImage is not a regular, non-symlink file".into(), + )); + } + Ok(Self { path }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledUpdate { + pub path: PathBuf, + pub version: String, + pub bytes_written: u64, + pub sha256: String, +} + +/// Streams, hashes, signature-checks, and atomically replaces an AppImage. +/// Verification happens before rename, so every pre-commit failure leaves the +/// currently installed file untouched. +pub fn install_verified_appimage( + manifest: &UpdateManifest, + expected_arch: &str, + source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, +) -> Result { + install_verified_appimage_with_limit( + manifest, + expected_arch, + source, + target, + verifier, + DEFAULT_MAX_APPIMAGE_BYTES, + ) +} + +pub fn install_verified_appimage_with_limit( + manifest: &UpdateManifest, + expected_arch: &str, + mut source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, + max_bytes: u64, +) -> Result { + manifest.validate(expected_arch)?; + let signature = manifest + .minisign + .as_deref() + .ok_or(UpdateError::MissingSignature)?; + let parent = target.path.parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let name = target + .path + .file_name() + .ok_or_else(|| UpdateError::NotAppImage("current AppImage has no filename".into()))?; + let temp = parent.join(format!( + ".{}.update-{}", + name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + let result = (|| { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| io_error("create AppImage update file", error))?; + let mut digest = Sha256::new(); + let mut written = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|error| io_error("read AppImage download", error))?; + if read == 0 { + break; + } + written = written + .checked_add(read as u64) + .ok_or(UpdateError::TooLarge { limit: max_bytes })?; + if written > max_bytes { + return Err(UpdateError::TooLarge { limit: max_bytes }); + } + output + .write_all(&buffer[..read]) + .map_err(|error| io_error("write AppImage update file", error))?; + digest.update(&buffer[..read]); + } + output + .sync_all() + .map_err(|error| io_error("sync AppImage update file", error))?; + let actual = digest.finish_hex(); + if !actual.eq_ignore_ascii_case(&manifest.sha256) { + return Err(UpdateError::ChecksumMismatch { + expected: manifest.sha256.to_ascii_lowercase(), + actual, + }); + } + verifier.verify_base64_minisign(&temp, signature)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let current_mode = fs::metadata(&target.path) + .map_err(|error| io_error("read current AppImage permissions", error))? + .permissions() + .mode(); + fs::set_permissions(&temp, fs::Permissions::from_mode(current_mode)) + .map_err(|error| io_error("set AppImage update permissions", error))?; + } + commit_with_rollback(&temp, &target.path)?; + Ok(InstalledUpdate { + path: target.path.clone(), + version: manifest.version.clone(), + bytes_written: written, + sha256: actual, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +/// Keep a hard-linked copy of the old inode until the replacement and its +/// directory entry are durable. This permits rollback if the commit itself +/// fails without ever exposing a partially written AppImage. +fn commit_with_rollback(temp: &Path, target: &Path) -> Result<(), UpdateError> { + let parent = target.parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let metadata = fs::symlink_metadata(target) + .map_err(|error| io_error("inspect current AppImage before commit", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(UpdateError::NotAppImage( + "current AppImage changed before update commit".into(), + )); + } + let name = target + .file_name() + .ok_or_else(|| UpdateError::NotAppImage("current AppImage has no filename".into()))?; + let backup = parent.join(format!( + ".{}.rollback-{}", + name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + fs::hard_link(target, &backup) + .map_err(|error| io_error("prepare AppImage rollback link", error))?; + + if let Err(error) = fs::rename(temp, target) { + let _ = fs::remove_file(&backup); + return Err(io_error("atomically replace AppImage", error)); + } + if let Err(error) = File::open(parent).and_then(|directory| directory.sync_all()) { + let _ = fs::rename(&backup, target); + let _ = File::open(parent).and_then(|directory| directory.sync_all()); + return Err(io_error("sync AppImage directory", error)); + } + if let Err(error) = fs::remove_file(&backup) { + // The rollback inode still exists, so restore it before reporting the + // cleanup failure. A failed restoration is intentionally not hidden. + return match fs::rename(&backup, target) { + Ok(()) => Err(io_error("remove AppImage rollback link", error)), + Err(rollback_error) => Err(io_error("restore AppImage rollback link", rollback_error)), + }; + } + Ok(()) +} + +// Small self-contained SHA-256 implementation. This avoids pretending the +// updater is functional while waiting for a direct `sha2` dependency. +struct Sha256 { + state: [u32; 8], + block: [u8; 64], + block_len: usize, + total_len: u64, +} + +impl Sha256 { + fn new() -> Self { + Self { + state: [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ], + block: [0; 64], + block_len: 0, + total_len: 0, + } + } + + fn update(&mut self, mut bytes: &[u8]) { + self.total_len = self.total_len.wrapping_add(bytes.len() as u64); + if self.block_len != 0 { + let take = (64 - self.block_len).min(bytes.len()); + self.block[self.block_len..self.block_len + take].copy_from_slice(&bytes[..take]); + self.block_len += take; + bytes = &bytes[take..]; + if self.block_len == 64 { + let block = self.block; + self.compress(&block); + self.block_len = 0; + } else { + return; + } + } + while bytes.len() >= 64 { + let block: &[u8; 64] = bytes[..64].try_into().expect("slice has exact block size"); + self.compress(block); + bytes = &bytes[64..]; + } + self.block[..bytes.len()].copy_from_slice(bytes); + self.block_len = bytes.len(); + } + + fn finish_hex(mut self) -> String { + let bit_len = self.total_len.wrapping_mul(8); + self.block[self.block_len] = 0x80; + self.block_len += 1; + if self.block_len > 56 { + self.block[self.block_len..].fill(0); + let block = self.block; + self.compress(&block); + self.block_len = 0; + } + self.block[self.block_len..56].fill(0); + self.block[56..].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.block; + self.compress(&block); + let mut result = String::with_capacity(64); + for word in self.state { + use fmt::Write as _; + write!(&mut result, "{word:08x}").expect("formatting into String cannot fail"); + } + result + } + + fn compress(&mut self, block: &[u8; 64]) { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut w = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).take(16).enumerate() { + w[index] = u32::from_be_bytes(chunk.try_into().expect("four-byte SHA word")); + } + for index in 16..64 { + let s0 = w[index - 15].rotate_right(7) + ^ w[index - 15].rotate_right(18) + ^ (w[index - 15] >> 3); + let s1 = w[index - 2].rotate_right(17) + ^ w[index - 2].rotate_right(19) + ^ (w[index - 2] >> 10); + w[index] = w[index - 16] + .wrapping_add(s0) + .wrapping_add(w[index - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for index in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(choose) + .wrapping_add(K[index]) + .wrapping_add(w[index]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (state, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *state = state.wrapping_add(value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "openless-updater-{name}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + fn manifest(body: &[u8]) -> UpdateManifest { + let mut digest = Sha256::new(); + digest.update(body); + UpdateManifest { + schema_version: 1, + host: MANIFEST_HOST.into(), + arch: "x86_64".into(), + version: "2.0.0".into(), + url: "https://github.com/Open-Less/openless/releases/download/v2.0.0/OpenLess.AppImage" + .into(), + sha256: digest.finish_hex(), + minisign: Some("dGVzdC1taW5pc2lnbg==".into()), + } + } + + struct AcceptTestSignature; + + impl SignatureVerifier for AcceptTestSignature { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError> { + if encoded_signature != "dGVzdC1taW5pc2lnbg==" || fs::metadata(artifact).is_err() { + return Err(UpdateError::SignatureRejected( + "test signature mismatch".into(), + )); + } + Ok(()) + } + } + + #[test] + fn sha256_matches_standard_vectors_and_streaming() { + let mut empty = Sha256::new(); + empty.update(b""); + assert_eq!( + empty.finish_hex(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + let mut abc = Sha256::new(); + abc.update(b"a"); + abc.update(b"bc"); + assert_eq!( + abc.finish_hex(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn manifest_parser_enforces_host_arch_hash_and_release_url() { + let valid = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, + "host": "linux-egui", + "arch": "x86_64", + "version": "2.0.0", + "url": "https://github.com/Open-Less/openless/releases/download/v2.0.0/OpenLess.AppImage", + "sha256": "0".repeat(64), + "minisign": "c2ln" + })).unwrap(); + assert!(UpdateManifest::parse(&valid, "x86_64").is_ok()); + assert!(UpdateManifest::parse(&valid, "aarch64").is_err()); + let mut bad_url: serde_json::Value = serde_json::from_slice(&valid).unwrap(); + bad_url["url"] = "http://example.test/update".into(); + assert!(UpdateManifest::parse(&serde_json::to_vec(&bad_url).unwrap(), "x86_64").is_err()); + } + + #[test] + fn update_urls_use_only_upstream_https_release_assets() { + assert_eq!( + manifest_urls(UpdateChannel::Stable, "x86_64", None), + vec![format!( + "{DIRECT_RELEASE_BASE}/releases/latest/download/latest-linux-egui-x86_64.json" + )] + ); + assert!(manifest_urls(UpdateChannel::Beta, "x86_64", Some("../bad")).is_empty()); + assert_eq!( + manifest_urls(UpdateChannel::Beta, "x86_64", Some("v2.0.0-beta.1")).len(), + 1 + ); + } + + #[test] + fn pinned_release_key_decodes() { + PinnedMinisignVerifier::new().expect("repository updater key must remain valid"); + } + + #[test] + fn verified_update_atomically_replaces_appimage() { + let root = temp_dir("success"); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old image").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let body = b"new verified appimage"; + let installed = install_verified_appimage( + &manifest(body), + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + ) + .unwrap(); + assert_eq!(installed.bytes_written, body.len() as u64); + assert_eq!(fs::read(&path).unwrap(), body); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn checksum_signature_and_size_failures_preserve_current_appimage() { + for failure in ["checksum", "signature", "size"] { + let root = temp_dir(failure); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old image").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let body = b"new image"; + let mut candidate = manifest(body); + let result = match failure { + "checksum" => { + candidate.sha256 = "0".repeat(64); + install_verified_appimage( + &candidate, + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + ) + } + "signature" => install_verified_appimage( + &candidate, + "x86_64", + body.as_slice(), + &target, + &UnavailableSignatureVerifier, + ), + "size" => install_verified_appimage_with_limit( + &candidate, + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + 3, + ), + _ => unreachable!(), + }; + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"old image"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn unsigned_update_is_rejected() { + let root = temp_dir("unsigned"); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let mut candidate = manifest(b"new"); + candidate.minisign = None; + assert!(matches!( + install_verified_appimage( + &candidate, + "x86_64", + b"new".as_slice(), + &target, + &AcceptTestSignature + ), + Err(UpdateError::MissingSignature) + )); + assert_eq!(fs::read(&path).unwrap(), b"old"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs new file mode 100644 index 000000000..8cc2f4882 --- /dev/null +++ b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs @@ -0,0 +1,91 @@ +//! 输入法配置不变量契约(Tauri-free)。 +//! +//! 宿主对输入法只做一件事:把热键注册给 fcitx5 插件。它**从不改动输入法自己的 +//! 配置文件**。 +//! +//! 这条不变量是踩过坑后补的:曾有一版认为「拼音把分号注册成快速短语触发键,会在 +//! 插件之前吃掉 `Ctrl+Shift+;`」,于是启动时自动清空 `~/.config/fcitx5/conf/` +//! 里那一行。真机实测证伪了那个前提 —— fcitx 的 `Key::check` 要求修饰位精确相等 +//! (`semicolon` 是 `states=0`,而 `Ctrl+Shift+;` 到达时是 `states=Ctrl`),分号 +//! 占用与 QA 热键本来就能共存。那段逻辑留下的唯一效果是「每次启动偷偷改用户输入法 +//! 配置」,因此删除。 +//! +//! 需要在设置页**读取**(而不是写入)引擎配置时,请在这里显式放行并写明理由, +//! 不要悄悄绕过这条契约。 + +use std::path::{Path, PathBuf}; + +fn strip_comments(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + let n = bytes.len(); + while i < n { + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(n); + continue; + } + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'/' { + while i < n && bytes[i] != b'\n' { + i += 1; + } + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap() +} + +fn source_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + files.extend(source_files_under(&path)); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + files +} + +fn crate_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +/// 用户输入法配置目录:任何写入都必须落到这里,所以出现即违规。 +const INPUT_METHOD_CONFIG_DIR: &str = ".config/fcitx5"; +/// 引擎的按键保留设置:我们曾去清空它,绝不允许再次出现。 +const ENGINE_RESERVED_KEY_SETTING: &str = "QuickPhraseKey"; + +#[test] +fn the_host_never_rewrites_the_input_method_configuration() { + let root = crate_root(); + let mut violations = Vec::new(); + for file in source_files_under(&root.join("src")) { + let source = strip_comments(&std::fs::read_to_string(&file).unwrap()); + for (index, line) in source.lines().enumerate() { + if line.contains(INPUT_METHOD_CONFIG_DIR) || line.contains(ENGINE_RESERVED_KEY_SETTING) + { + violations.push(format!( + "{}:{}: {}", + file.strip_prefix(&root).unwrap().display(), + index + 1, + line.trim() + )); + } + } + } + assert!( + violations.is_empty(), + "The Linux egui host must never touch the input method's own configuration: it registers \ + hotkeys with the fcitx5 addon and leaves ~/.config/fcitx5 alone. Found:\n{}", + violations.join("\n") + ); +} diff --git a/openless-all/app/linux-egui/tests/fcitx5_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_contract.rs index 926abd4ce..4a495dbea 100644 --- a/openless-all/app/linux-egui/tests/fcitx5_contract.rs +++ b/openless-all/app/linux-egui/tests/fcitx5_contract.rs @@ -12,8 +12,11 @@ use openless_linux_egui::{ #[ignore = "requires a running fcitx5 DBus service"] fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { assert!(fcitx5_available(), "fcitx5 service should answer DBus Ping"); - set_fcitx5_hotkeys(vec!["Shift_L".to_string()]).expect("set fcitx5 hotkey"); - set_fcitx5_less_computer_hotkey_raw(65, 0).expect("set Less Computer hotkey"); + // 这个契约测试只验证 DBus 方法可用:**必须清空**而不是注册真实按键。 + // 曾经这里注册 Shift_L + 'A',在开发机上跑 --ignored 会真的让插件吞掉 + // Shift/字母输入(用户踩过这个坑),所以现在只发清零请求。 + set_fcitx5_hotkeys(Vec::new()).expect("clear the legacy fcitx5 hotkey list"); + set_fcitx5_less_computer_hotkey_raw(0, 0).expect("clear the Less Computer hotkey"); let listener = Fcitx5HotkeyListener::start().expect("start fcitx5 hotkey listener"); assert!(listener.take_error().is_none()); diff --git a/openless-all/app/linux-egui/tests/host_contract.rs b/openless-all/app/linux-egui/tests/host_contract.rs index adcfb5e6c..909337fdd 100644 --- a/openless-all/app/linux-egui/tests/host_contract.rs +++ b/openless-all/app/linux-egui/tests/host_contract.rs @@ -283,6 +283,7 @@ async fn forwarded_launch_intents_use_core_state_and_semantic_host_actions() { struct RecordingSettingsEffects { hotkeys: Mutex>, active_asr_providers: Mutex>, + launch_at_login: Mutex>, fail_next_hotkey: std::sync::atomic::AtomicBool, } @@ -314,6 +315,11 @@ impl LinuxSettingsEffects for RecordingSettingsEffects { .push(provider_id.to_string()); Ok(()) } + + fn set_launch_at_login(&self, enabled: bool) -> Result<(), openless_linux_egui::BackendError> { + self.launch_at_login.lock().unwrap().push(enabled); + Ok(()) + } } #[test] @@ -381,6 +387,7 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( primary: "F9".to_string(), modifiers: vec!["ctrl".to_string()], }; + runtime_failure.launch_at_login = true; let error = host .update_settings_strict(runtime_failure, revision) .expect_err("Linux runtime failure must fail the settings transaction"); @@ -395,6 +402,11 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( assert_eq!(applied.len(), 3, "next apply plus previous-target restore"); assert_eq!(applied.last().unwrap().dictation, saved.dictation_hotkey); drop(applied); + assert_eq!( + effects.launch_at_login.lock().unwrap().as_slice(), + [true, false], + "a later commit failure must restore the previous launch-at-login state" + ); let mut provider_change = backend.get_preferences(); provider_change.active_asr_provider = "linux-fixture-asr".to_string(); diff --git a/openless-all/app/linux-egui/tests/localization_contract.rs b/openless-all/app/linux-egui/tests/localization_contract.rs new file mode 100644 index 000000000..f2008922f --- /dev/null +++ b/openless-all/app/linux-egui/tests/localization_contract.rs @@ -0,0 +1,146 @@ +//! Localization regression contract (Tauri-free). +//! +//! Guards against *new* raw user-visible Simplified-Chinese string literals +//! sneaking into the Linux egui source outside the translation catalog +//! (`src/i18n.rs`, the one legitimate home for zh-CN source-of-truth text). +//! +//! The `src/ui/shell.rs` module is fully localized, so it is held to a strict +//! zero-CJK rule. Elsewhere, any Simplified-Chinese literal that is not in the +//! checked-in `zh_user_visible_baseline.txt` fails the build; that baseline is +//! refreshed deliberately (see the file header) when a string is intentionally +//! translated or knowingly kept for migration. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +fn strip_comments(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + let n = bytes.len(); + while i < n { + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'*' { + // Block / doc-block comment. + i += 2; + while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(n); + continue; + } + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'/' { + // Line / line-doc comment. + while i < n && bytes[i] != b'\n' { + i += 1; + } + continue; + } + out.push(bytes[i]); + i += 1; + } + out +} + +/// Collect the inner text of every `"..."` string literal (handling `\"` and +/// `\\` escapes) whose content contains any CJK Unified Ideograph. +fn cjk_string_literals(source: &str) -> BTreeSet { + let cleaned = strip_comments(source); + let n = cleaned.len(); + let mut found = BTreeSet::new(); + let mut i = 0; + while i < n { + if cleaned[i] != b'"' { + i += 1; + continue; + } + // Inside a string literal: read until an unescaped closing quote. + let mut inner = Vec::new(); + let mut j = i + 1; + while j < n { + if cleaned[j] == b'\\' && j + 1 < n { + inner.push(cleaned[j]); + inner.push(cleaned[j + 1]); + j += 2; + continue; + } + if cleaned[j] == b'"' { + break; + } + inner.push(cleaned[j]); + j += 1; + } + if let Ok(text) = String::from_utf8(inner) { + if text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)) { + found.insert(text); + } + } + i = j + 1; // resume after the closing quote + } + found +} + +fn source_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + files.extend(source_files_under(&path)); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + files +} + +fn crate_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +#[test] +fn shell_module_is_fully_localized_with_no_raw_simplified_chinese() { + let shell = std::fs::read_to_string(crate_root().join("src/ui/shell.rs")).unwrap(); + let cleaned = String::from_utf8(strip_comments(&shell)).unwrap(); + let has_cjk = cleaned + .chars() + .any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)); + assert!( + !has_cjk, + "src/ui/shell.rs must be fully localized (no raw Simplified-Chinese text)" + ); +} + +#[test] +fn no_new_raw_simplified_chinese_user_visible_literals_outside_catalog_or_baseline() { + let root = crate_root().join("src"); + // The translation catalog is the one sanctioned home for zh-CN text. + let mut current: BTreeSet = BTreeSet::new(); + for file in source_files_under(&root) { + if file.strip_prefix(&root).unwrap().starts_with("i18n.rs") { + continue; + } + let source = std::fs::read_to_string(&file).unwrap(); + current.extend(cjk_string_literals(&source)); + } + + let baseline_path = crate_root().join("tests/zh_user_visible_baseline.txt"); + let baseline_raw = std::fs::read_to_string(&baseline_path).unwrap(); + let baseline: BTreeSet = baseline_raw + .lines() + .map(str::to_string) + .filter(|line| !line.trim().is_empty()) + .collect(); + + let added: Vec<&String> = current.difference(&baseline).collect(); + assert!( + added.is_empty(), + "New raw Simplified-Chinese user-visible literal(s) were added to the Linux egui UI \ + outside the localization catalog. Translate them in src/i18n.rs and, if one is \ + deliberately dynamic/error text, refresh the baseline file. Found:\n{}", + added + .iter() + .map(|s| format!(" - {s:?}")) + .collect::>() + .join("\n") + ); +} diff --git a/openless-all/app/linux-egui/tests/provider_surface_contract.rs b/openless-all/app/linux-egui/tests/provider_surface_contract.rs new file mode 100644 index 000000000..c4897346b --- /dev/null +++ b/openless-all/app/linux-egui/tests/provider_surface_contract.rs @@ -0,0 +1,63 @@ +//! 契约:Linux 界面必须是 Core provider / 凭据模块的**真实客户端**。 +//! +//! 这里的每一条对应 CI 门禁 `scripts/check-linux-public-surface.ps1` 的断言。 +//! 门禁只为「只有状态、或只有本地模型捷径」的界面留了口子,所以把同一组断言 +//! 复制到 Rust 测试里:某次合并或清理把实现覆盖掉时,本机 `cargo test` 就会 +//! 直接失败,而不是等到发布流水线第 8 步才发现(历史上有一次正是这样丢的)。 + +const MAIN: &str = include_str!("../src/main.rs"); + +/// 每个 token 都对应 Linux 设置页真实发出的 Core 调用。 +#[test] +fn linux_settings_page_drives_core_provider_and_credential_operations() { + for token in [ + // 渠道管理 + "provider_descriptors", + "list_channels", + "create_channel", + "set_channel_enabled", + "rename_channel", + "set_channel_provider_type", + "reorder_channels", + "set_active_provider", + // 凭据写入 / 删除 + "set_credential", + "remove_credential", + ] { + assert!( + MAIN.contains(token), + "Linux UI 缺少 Core provider 调用 `{token}`(设置页不再是 Core 的真实客户端)" + ); + } +} + +/// 校验与模型枚举走的是 `services().provider`(`ProviderApi`),而不是界面自己 +/// 拼 endpoint。这里检查调用形态,避免有人把 `.provider.validate(...)` 换成自造 +/// 的 HTTP 路径。 +#[test] +fn linux_ui_validates_and_lists_models_through_the_provider_api() { + assert!( + MAIN.contains(".provider"), + "Linux UI 必须通过 Core 的 ProviderApi 校验与列模型" + ); + assert!( + MAIN.contains(".validate("), + "Linux UI 缺少 Core 的 provider.validate 调用" + ); + assert!( + MAIN.contains(".list_models("), + "Linux UI 缺少 Core 的 provider.list_models 调用" + ); +} + +/// 界面不得自己拥有服务商默认值(端点 / 模型 / 预设),否则换 provider 只能靠 +/// 改界面代码。与门禁禁止 `ASR_PRESETS` / `LLM_PRESETS` / 写死的 `https://…/v1` 同级。 +#[test] +fn linux_ui_does_not_own_provider_defaults() { + for forbidden in ["ASR_PRESETS", "LLM_PRESETS", "OMNI_PRESETS"] { + assert!( + !MAIN.contains(forbidden), + "Linux UI 不得内置服务商预设 `{forbidden}`,默认值属于 Core" + ); + } +} diff --git a/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt new file mode 100644 index 000000000..00dce3fce --- /dev/null +++ b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt @@ -0,0 +1,265 @@ +# Localization strict allowlist (do NOT grow this silently). +# +# Every Simplified-Chinese string literal that lives outside src/i18n.rs must be +# listed here AND be one of the genuinely non-UI / protocol / test literals below. +# If a string is user-visible UI chrome or a control label, translate it in +# src/i18n.rs instead of adding it here. + 自动 +# 角色\n你是 OpenLess 的{}助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n +# 角色\n你是 OpenLess 的润色助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n +10月 +11月 +12月 +1月 +2月 +3月 +4月 +5月 +6月 +7月 +8月 +9月 +AI 提供商 +ASR 服务 +ASR 语音 +LLM 服务 +LLM 模型 +OpenAI 兼容 +OpenLess fcitx5 插件 +session-一 +session-二 +{} 个风格包 +{} 天 · {} 天活跃 +{} 段 +↻ 刷新 +▣ 导入 ZIP +● 已配置 +下载 ZIP +不启用(Shift 按下不触发翻译) +主题 +产品与平台 +今天第一句 +今日字数 +今日总时长 +今日概览 +今日第一句 has 5 chars +从模板开始创建自己的风格 +他说:\"你好\" C:\\\\tmp\\\\文件 +使用方法 +保存 +保存历史 +修改提示词后保存,下一次润色将使用新的规则。双击风格卡即可打开此编辑器。 +停止播放 +全局录音的快捷键与触发方式。 +全部 +全部删除 +共 {} 条记录 +关于 +内置 +内置麦克风 +再次按右 Option 停止录音。 +切换式 +切换风格 +划词追问 +创建预设 +删除 +刷新 +前天 +历史 +历史记录 +历史记录暂未接线 +原文 +原文 \\\\ source +原文,例如:{num}粒 +原样保留 +发布日志 +发布日志将在 egui 外链桥接完成后打开 +取消 +可继续按 右Ctrl 多轮追问。 +右 Option +启动 +启动失败: {error} +启动时最小化 +启用远程输入 +周一 +周三 +周二 +周五 +周六 +周四 +周日 +唤起 OpenLess +在任意 app 选中文字。 +堆叠设置行 +复制 +复制失败: {error} +外接麦克风 +外观与概览页显示选项。 +多 +失败 +安装到本地 +导出 +导出录音 +将识别结果中的常见错误自动替换为正确写法。 +少 +工作语言 +工具 +已切换到「{}」 +已切换到「原文」 +已发送 +已启用 +已复制 +已恢复默认提示词(演示) +已插入 +市场后端桥接将在后续阶段完成 +布局 +帮助中心 +帮助中心将在 egui 外链桥接完成后打开 +平均段落 +年度活动 +应用 +开发工具 +开始/停止、翻译、问答和风格切换。 +当前 +当前提供商 +录音 {} +录音与输入 +录音快捷键 +录音方式 +录音时静音 +录音过程中按翻译快捷键切换到翻译模式。 +快捷键 +恢复剪贴板 +恢复默认 +我的发布 +我赞过的 +技术术语 +按 Esc 关闭浮窗并清空历史。 +按 ⌘⇧; 打开浮窗。 +按 右Ctrl 录音,再按一次提交。 +按住说话 +按右 Option 开始录音。 +探索社区风格包 +控制 OpenLess 启动时的行为。 +插入 +插入与剪贴板 +搜索转写内容… +搜索风格包 +播放录音 +数据桥接将在后续阶段完成 +新建风格包 +新预设 +新风格包 +日本語 +昨天 +是 +显示活动热力图 +暂无历史记录 +暂无数据 +暂无纠错规则 +暂无记录 +暂时没有找到风格包 +替换为 +最新 +最近记录 +服务 +服务地址 +未启用 +未找到 OpenLess fcitx5 插件;请重新安装当前软件包 +未请求 +未配置 +本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。 +本地服务 +本地风格包 +本机保存的识别记录。 +本机存档 +松开按键后,译文会自动插入当前应用。 +概览 +模拟粘贴快捷键 +正在加载概览数据… +正在加载风格市场… +正在播放录音… +正式表达 +此页面暂未接线 +流式插入 +流式结果保存剪贴板 +浅色 +浏览和切换风格包。 +润色 +润色提示词 +润色模式 +润色结果 +深色 +添加 +添加快捷键 +添加需要优先识别的自定义词汇。 +清晰结构 +清空 +激活 +热门 +版本 {} +界面语言 +百炼 +监听端口 +目标语言与唯一工作语言相同,按翻译快捷键不会触发翻译。 +简体中文 +简短描述这个风格的使用场景 +系统默认 +紧凑布局 +累计记录 +繁体中文 +纠错规则 +编辑 +编辑 {} +编辑风格包 +翻译 +翻译模式会在胶囊顶部显示状态。 +翻译目标语言 +翻译风格 +自动插入光标位置 +自动收集 ({learned}) +自动继承“风格”页当前激活的风格包。 +自定义热词,提升专有名词识别率 +英文写作 +让浮层和内容更适合你的工作方式。 +设置 +识别 +识别结果如何回到当前光标位置。 +词汇 +词汇表 +词汇,用逗号或换行分隔 +试试其他关键词或筛选条件 +语言 +语音润色 +请选择一条记录 +跟随系统 +轻度润色 +输入词汇,按回车添加 +近 30 天 +近 7 天 +近期活动 +远程输入 +选区润色 +选择 OpenLess 使用的界面语言。 +选择一组常用词汇快速添加。 +选择润色风格,让每次输出都保持一致 +通用 +通过局域网接收来自其他设备的输入。 +配置语音识别、润色与翻译所使用的服务。 +隐私 +静音:否 +静音:是 +预设 +预设名称 +风格 +风格包列表已刷新 +风格市场 +风格市场暂未接线 +风格描述 +风格直达 +高级 +麦克风 +默认模式 +(无文字) +(语音问题) ++ 添加 diff --git a/openless-all/app/scripts/i18n-adopt-literals.mjs b/openless-all/app/scripts/i18n-adopt-literals.mjs new file mode 100644 index 000000000..aba38f1ef --- /dev/null +++ b/openless-all/app/scripts/i18n-adopt-literals.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Adopt hardcoded Simplified-Chinese literals into the egui i18n catalog. +// +// The legacy egui pages hardcode zh-CN strings. The Tauri catalogs already hold +// translations for the same product surfaces, so a literal whose text matches a +// Tauri zh-CN value can be replaced by a `tr_l10n(lang, "key")` call without a +// translator touching it. +// +// Usage: node --experimental-strip-types --import ./scripts/register-ts-loader.mjs \ +// scripts/i18n-adopt-literals.mjs [...] +// +// The script only rewrites whole string literals that contain CJK and no `{}` +// placeholders (format templates need `fmt_l10n` and a hand-written call). +// Anything ambiguous (the same zh text under several keys) or unmatched is +// reported instead of guessed at. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const APP_ROOT = resolve(new URL('..', import.meta.url).pathname); +const files = process.argv.slice(2); +if (files.length === 0) { + console.error('usage: i18n-adopt-literals.mjs [...]'); + process.exit(2); +} + +const zhCN = (await import(resolve(APP_ROOT, 'src/i18n/zh-CN.ts'))).zhCN; + +/** zh text -> [dotted keys] */ +const valueToKeys = new Map(); +function walk(value, prefix) { + for (const [key, entry] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (Array.isArray(entry)) continue; // arrays need the joining convention + if (entry && typeof entry === 'object') walk(entry, path); + else if (typeof entry === 'string') { + const list = valueToKeys.get(entry) ?? []; + list.push(path); + valueToKeys.set(entry, list); + } + } +} +walk(zhCN, ''); + +const CJK = /[\u4e00-\u9fff]/; + +/// These literals are used as `match` patterns for action dispatch, so they +/// cannot be replaced by a function call. They are listed in the localization +/// baseline and will be translated when the dispatch moves to typed ids. +const SKIP_LITERALS = new Set(['导出错误日志', '打开 GitHub']); +const cache = new Map(); +function toSnake(dotted) { + if (cache.has(dotted)) return cache.get(dotted); + const result = dotted + .split('.') + .map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()) + .join('.'); + cache.set(dotted, result); + return result; +} + +let totalReplaced = 0; +const unresolved = new Map(); // literal -> reason +for (const file of files) { + const path = resolve(process.cwd(), file); + const source = readFileSync(path, 'utf8'); + let out = ''; + let i = 0; + let replaced = 0; + while (i < source.length) { + const ch = source[i]; + if (ch !== '"') { + out += ch; + i += 1; + continue; + } + // Read the full literal, tracking escapes. + let j = i + 1; + let raw = ''; + while (j < source.length && source[j] !== '"') { + if (source[j] === '\\') { + raw += source[j] + (source[j + 1] ?? ''); + j += 2; + continue; + } + raw += source[j]; + j += 1; + } + const literal = raw; + const text = literal + .replace(/\\n/g, '\n') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + if (SKIP_LITERALS.has(text) || !CJK.test(text) || text.includes('{') || text.includes('}')) { + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + const keys = valueToKeys.get(text); + if (!keys || keys.length === 0) { + if (!unresolved.has(text)) unresolved.set(text, 'no Tauri key with this zh text'); + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + const unique = [...new Set(keys.map(toSnake))]; + if (unique.length > 1) { + if (!unresolved.has(text)) { + unresolved.set(text, `ambiguous: ${unique.join(', ')}`); + } + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + out += `tr_l10n(lang, "${unique[0]}")`; + replaced += 1; + i = j + 1; + } + if (replaced > 0) writeFileSync(path, out); + console.log(`${file}: ${replaced} literal(s) adopted`); + totalReplaced += replaced; +} + +console.log(`total adopted: ${totalReplaced}`); +if (unresolved.size > 0) { + console.log(`\nneeds hand-written keys (${unresolved.size}):`); + for (const [text, reason] of unresolved) { + console.log(` ${JSON.stringify(text)} — ${reason}`); + } +} diff --git a/openless-all/app/scripts/linux-egui-manual-install.sh b/openless-all/app/scripts/linux-egui-manual-install.sh new file mode 100755 index 000000000..56b92ea19 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-manual-install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# OpenLess Linux egui —— 手动安装(给装不了 deb/rpm 的发行版用)。 +# +# 这个脚本随发布 zip 一起分发,和解压出来的散装文件同目录: +# install.sh 本脚本 +# usr/... 安装树(与 deb 内的文件逐项一致) +# SHA256SUMS 对上面散装文件重新计算的校验和 +# +# 安装 = 把 usr/ 按正确权限铺到 /,再让 fcitx5 重新加载插件。 +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +payload="$here/usr" + +if [ ! -d "$payload" ]; then + echo "找不到 $payload —— 请先解压整个 zip,再从解压目录里运行 ./install.sh" >&2 + exit 1 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "需要 root 权限:请运行 sudo ./install.sh" >&2 + exit 1 +fi + +# 散装文件比 deb 更容易在下载/解压中途损坏,所以自带校验和就先验一遍。 +if [ -s "$here/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then + if ! ( cd "$here" && sha256sum -c SHA256SUMS >/dev/null ); then + echo "SHA256SUMS 校验失败:payload 已损坏,请重新下载 zip" >&2 + exit 1 + fi +fi + +# 权限按用途给:可执行文件 0755,配置/桌面项/图标 0644。 +while IFS= read -r -d '' file; do + rel="${file#"$here"/}" + case "$rel" in + usr/bin/* | usr/lib/*/fcitx5/*.so) mode=755 ;; + *) mode=644 ;; + esac + install -D -m "$mode" "$file" "/$rel" +done < <(find "$payload" -type f -print0) + +echo "已安装 openless 与 fcitx5 插件到 /usr。" + +# fcitx5 必须重载才能加载新插件,且要用用户会话里的 D-Bus controller Restart +# (立即返回)。**不要**用 `fcitx5 -r`:它会替换 daemon 并一直前台运行,脚本会卡死, +# 与 deb postinst 保持一致。 +for bus in /run/user/[0-9]*/bus; do + [ -S "$bus" ] || continue + runtime_dir="${bus%/bus}" + uid="${runtime_dir##*/}" + [ "$uid" != 0 ] || continue + user="$(getent passwd "$uid" | cut -d: -f1)" + [ -n "$user" ] && timeout 5s runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call \ + /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true +done + +cat <<'NOTE' +完成。请退出 OpenLess 后重新启动;全局快捷键会在 fcitx5 重载后生效。 +卸载:退出 OpenLess,删除下面这些文件,再重载 fcitx5: + /usr/bin/openless + /usr/lib/*/fcitx5/libopenless.so + /usr/share/fcitx5/addon/openless.conf + /usr/share/applications/openless.desktop + /usr/share/metainfo/top.openless.OpenLess.metainfo.xml + /usr/share/icons/hicolor/256x256/apps/openless.png +NOTE diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs new file mode 100644 index 000000000..4288c9eae --- /dev/null +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const releaseWorkflow = await readFile( + join(repoRoot, '.github/workflows/release-linux-egui.yml'), + 'utf8', +); +const ciWorkflow = await readFile( + join(repoRoot, '.github/workflows/ci.yml'), + 'utf8', +); +const packageScript = await readFile( + join(repoRoot, 'openless-all/app/scripts/package-linux-egui.sh'), + 'utf8', +); +const dependencyGate = await readFile( + join(repoRoot, 'openless-all/app/scripts/check-core-deps.ps1'), + 'utf8', +); + +// 1. CI must exercise the Tauri-free Linux host: build, test, clippy and a +// dependency contract on openless-linux-egui with no WebKit/GTK native deps. +assert.ok(ciWorkflow.includes('cargo test --locked -p openless-linux-egui --all-targets'), + 'CI must test the Linux egui host'); +assert.ok(ciWorkflow.includes('cargo check --locked -p openless-linux-egui --all-targets'), + 'CI must check the Linux egui host'); +assert.ok(ciWorkflow.includes('./scripts/check-core-deps.ps1 openless-linux-egui'), + 'CI must run the cargo-tree dependency gate for the Linux egui host'); +assert.ok(!/libgtk-3|webkit2gtk|libwebkit/.test(ciWorkflow), + 'CI apt list must not pull WebKitGTK/GTK native dependencies'); + +// 2. The Linux dependency gate must forbid Tauri/Wry/WebKit at the cargo-tree +// level while still allowing egui/eframe for the host itself. +assert.ok(dependencyGate.includes('cargo tree --locked'), + 'dependency gate must run cargo tree'); +assert.ok(dependencyGate.includes('openless-linux-egui'), + 'dependency gate must accept the Linux egui host package name'); +assert.ok(/tauri\|wry\|webkit2gtk/.test(dependencyGate), + 'dependency gate must forbid tauri/wry/webkit2gtk'); + +// 3. Release must build x86_64 deb and rpm only; AppImage is intentionally +// retired from the Linux distribution channel. +for (const [format, tool] of [['deb', 'dpkg-deb --build'], ['rpm', 'rpmbuild --define']]) { + assert.ok(packageScript.includes(tool), `package script must build ${format} via ${tool}`); +} +assert.ok(releaseWorkflow.includes("test \"$(find \"$OUTPUT\" -maxdepth 1 -name '*.deb' | wc -l)\" -eq 1"), + 'release must gate exactly one deb'); +assert.ok(releaseWorkflow.includes("test \"$(find \"$OUTPUT\" -maxdepth 1 -name '*.rpm' | wc -l)\" -eq 1"), + 'release must gate exactly one rpm'); +assert.ok(!/appimagetool|AppImage|APPIMAGE/i.test(packageScript), + 'Linux package script must not build or stage AppImage'); + +// 4. deb/rpm must carry the host and fcitx5 addon. Qwen ASR is +// deliberately excluded from Linux packages and must never enter this flow. +assert.ok(releaseWorkflow.includes("! ldd target/release/openless-linux-egui | grep -q 'not found'"), + 'release must run an ldd gate on the host binary'); +assert.ok(/! ldd .*grep -Eqi 'webkit\|wry\|tauri'/.test(releaseWorkflow), + 'release must assert the host binary does not link WebKit/Wry/Tauri'); +assert.ok(releaseWorkflow.includes("dpkg-deb -c \"$OUTPUT\"/*.deb | grep -q 'usr/bin/openless'"), + 'deb must ship the host binary'); +assert.ok(releaseWorkflow.includes("dpkg-deb -c \"$OUTPUT\"/*.deb | grep -q 'fcitx5/libopenless.so'"), + 'deb must ship the fcitx5 addon'); +assert.ok(releaseWorkflow.includes("rpm -qlp \"$OUTPUT\"/*.rpm | grep -q '/usr/bin/openless'"), + 'rpm must ship the host binary'); +assert.ok(releaseWorkflow.includes("rpm -qlp \"$OUTPUT\"/*.rpm | grep -q '/usr/lib64/fcitx5/libopenless.so'"), + 'rpm must ship the fcitx5 addon'); +assert.ok(!/qwen-asr|qwen_asr/i.test(releaseWorkflow), + 'Linux release must neither fetch nor compile nor package Qwen ASR'); +assert.ok(!/qwen-asr|qwen_asr/i.test(packageScript), + 'Linux packaging must not stage Qwen ASR'); + +// The packaging script must carry the fcitx plugin into both package formats. +assert.ok(packageScript.includes('x86_64-linux-gnu/fcitx5/libopenless.so'), 'deb fcitx addon path'); +assert.ok(packageScript.includes('/usr/lib64/fcitx5/libopenless.so'), 'rpm fcitx addon path'); +// 5. ldd + cargo-tree verification gates are required for release and CI. + +// 6. Release must emit and verify checksums for both package artifacts. +assert.ok(releaseWorkflow.includes('sha256sum'), 'release must compute sha256 checksums'); +assert.ok(releaseWorkflow.includes('> SHA256SUMS'), 'release must emit a SHA256SUMS artifact'); +assert.ok(releaseWorkflow.includes('sha256sum -c SHA256SUMS'), 'release must verify SHA256SUMS'); +assert.ok(releaseWorkflow.includes('sha256sum ./*.deb ./*.rpm'), + 'SHA256SUMS must cover deb and rpm only'); +assert.ok(!/appimagetool|APPIMAGE_|LINUX_EGUI_MINISIGN|latest-linux-egui/i.test(releaseWorkflow), + 'Linux release must not retain an AppImage updater or signing path'); + +// 8. Legacy "-tauri" release tags are accepted only for compatibility: the +// suffix is stripped when present and the flow still resolves its own version +// from cargo metadata when no release tag is supplied, so it never depends on +// the Tauri release pipeline or its tag scheme. +assert.ok(releaseWorkflow.includes('VERSION=${RELEASE_TAG#v}'), 'version must strip a leading v'); +assert.ok(releaseWorkflow.includes('VERSION=${VERSION%-tauri}'), 'legacy -tauri tag suffix must be tolerated'); +assert.ok(releaseWorkflow.includes('RELEASE_TAG:-}'), 'flow must run without a release tag'); +assert.ok(releaseWorkflow.includes("require('./package.json').version"), + 'flow must resolve its version from the main app package when no tag is provided'); +assert.ok(!releaseWorkflow.includes('-tauri required') && !/case "\$RELEASE_TAG"[\s\S]*\*-tauri/.test(releaseWorkflow), + 'flow must not mandate a -tauri release tag'); + +// 9. The release flow never touches the Tauri host or its src-tauri tree. +assert.ok(!/src-tauri/.test(packageScript), 'package script must not reference src-tauri'); +assert.ok(!/src-tauri/.test(releaseWorkflow), 'release workflow must not reference src-tauri'); +assert.ok(!/\btauri\b|\bwry\b/.test(packageScript), 'package script must not invoke Tauri tooling'); + +// 10. 发版编排里的 Linux 腿必须是**内联的普通 job**(用户要求:Actions 页面上与 +// 三个平台平级,不能是可复用工作流那种嵌套折叠显示),而且手动安装 zip 必须 +// 在同一个 job 里产出(不是单独的 bundle job)。 +const orchestratedWorkflow = await readFile( + join(repoRoot, '.github/workflows/release-tauri.yml'), + 'utf8', +); +assert.ok(!orchestratedWorkflow.includes('uses: ./.github/workflows/release-linux-egui.yml'), + 'Linux leg must be inlined so Actions does not nest it under a called workflow'); +assert.ok(orchestratedWorkflow.includes('name: Linux egui packages (deb + rpm + manual zip)'), + 'orchestrator must carry the inlined Linux job'); +assert.ok(!/bundle-manual-install/.test(orchestratedWorkflow), + 'the manual zip must be built inside the Linux job, not a separate bundle job'); + +// 两份副本的关键门禁必须一致,否则内联版会悄悄漂移成弱门禁。 +for (const gate of [ + 'test "$(find "$OUTPUT" -maxdepth 1 -name \'*.deb\' | wc -l)" -eq 1', + 'test "$(find "$OUTPUT" -maxdepth 1 -name \'*.rpm\' | wc -l)" -eq 1', + "! ldd target/release/openless-linux-egui | grep -q 'not found'", + 'openless-all/app/scripts/linux-egui-manual-install.sh', + 'bash -n manual/install.sh', + 'usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so', + 'dpkg-deb -x', +]) { + assert.ok(orchestratedWorkflow.includes(gate), + `inlined Linux job must keep the same gate as the standalone entry: ${gate}`); +} + +// 11. 统一发布语义:任何 v* 发布标签都要出全平台产物(Tauri 三平台 + Linux egui +// + 安卓),后缀只作命名约定,不能再当「只构建一半」的开关。 +const androidWorkflow = await readFile( + join(repoRoot, '.github/workflows/android-apk.yml'), + 'utf8', +); +assert.ok(/tags:\s*\n\s*- 'v\*'/.test(orchestratedWorkflow), + 'release pipeline must trigger on every v* release tag'); +assert.ok(/tags:\s*\n\s*- 'v\*'/.test(androidWorkflow), + 'android workflow must trigger on every v* release tag'); +assert.ok(!/endsWith\(github\.ref, '-egui'\)/.test(orchestratedWorkflow), + 'release jobs must not gate on the -egui suffix any more'); +assert.ok(androidWorkflow.includes('softprops/action-gh-release'), + 'android assets must land on the same GitHub release as the desktop builds'); +// build 与 linux 两个 job 都必须对任何发布标签无条件运行(不再按后缀分流)。 +const ungatedJobs = (orchestratedWorkflow.match(/if: \$\{\{ !cancelled\(\) \}\}/g) || []).length; +assert.ok(ungatedJobs >= 2, + 'build and linux jobs must run for every release tag instead of gating on -tauri/-egui'); +// Homebrew cask 仍只跟稳定的 -tauri 正式版:这是刻意的分发边界,不能被顺手放开。 +assert.ok(orchestratedWorkflow.includes("endsWith(github.ref, '-tauri')"), + 'Homebrew cask must keep updating only for stable -tauri tags'); + +console.log('linux-egui-release-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs new file mode 100644 index 000000000..edbc6d867 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs @@ -0,0 +1,50 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const appRoot = new URL('..', import.meta.url); +const roots = [ + new URL('../linux-egui', import.meta.url), + new URL('../scripts/package-linux-egui.sh', import.meta.url), + new URL('../../../.github/workflows/release-linux-egui.yml', import.meta.url), +]; +const sourceExtensions = new Set(['.rs', '.toml', '.sh', '.yml', '.yaml']); + +async function collect(url) { + const path = fileURLToPath(url); + if (extname(path)) return [path]; + const entries = await readdir(path, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'target') continue; + const child = join(path, entry.name); + if (entry.isDirectory()) files.push(...await collect(pathToFileURL(`${child}/`))); + else if (sourceExtensions.has(extname(entry.name))) files.push(child); + } + return files; +} + +const files = (await Promise.all(roots.map(collect))).flat(); +const violations = []; +for (const file of files) { + const source = await readFile(file, 'utf8'); + const normalizedFile = file.replaceAll('\\', '/'); + // The Remote Input TLS identity implementation remains one shared source file + // while its platform-neutral extraction is tracked separately. It is included + // directly by the Linux host and does not introduce a Cargo dependency on the + // Tauri crate, runtime, WebKit, or Qwen ASR. + const allowsSharedTlsIdentity = normalizedFile.endsWith('/linux-egui/src/remote_input.rs'); + if (!allowsSharedTlsIdentity && source.includes('src-tauri')) violations.push(`${file}: references src-tauri`); + if (normalizedFile.endsWith('Cargo.toml') && /^\s*(tauri|wry|webkit\w*)\s*=/mi.test(source)) { + violations.push(`${file}: declares a Tauri/Wry/WebKit dependency`); + } + if (/\b(Mock|Fake)(Backend|Provider|Repository)\b/.test(source)) { + violations.push(`${file}: production source names a mock backend/provider/repository`); + } +} + +if (violations.length) { + throw new Error(`Linux egui Tauri-free contract failed:\n${violations.join('\n')}`); +} + +console.log(`linux-egui-tauri-free-contract.test.mjs passed (${files.length} files)`); diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index adea7b099..4a594acca 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -7,23 +7,59 @@ ARCH=${OPENLESS_LINUX_ARCH:-x86_64} TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} BINARY="$TARGET_DIR/release/openless-linux-egui" PLUGIN_ROOT="$APP_ROOT/../scripts/linux-fcitx5-plugin/build" -QWEN_RUNTIME="$APP_ROOT/src-tauri/vendor/qwen-asr/qwen_asr" PACKAGING="$APP_ROOT/linux-egui/packaging" OUTPUT="$TARGET_DIR/linux-egui-packages" -ICON="$APP_ROOT/src-tauri/icons/128x128@2x.png" +ICON="$APP_ROOT/public/AppIcon.png" test -x "$BINARY" test -s "$PLUGIN_ROOT/libopenless.so" test -s "$PLUGIN_ROOT/openless.conf" -test -x "$QWEN_RUNTIME" test -s "$PACKAGING/openless.desktop" test -s "$PACKAGING/top.openless.OpenLess.metainfo.xml" test -s "$ICON" -command -v fpm > /dev/null -command -v appimagetool > /dev/null +command -v dpkg-deb >/dev/null +command -v rpmbuild >/dev/null mkdir -p "$OUTPUT" +POST_INSTALL="$TARGET_DIR/openless-fcitx5-postinst" +cat > "$POST_INSTALL" <<'EOF' +#!/usr/bin/env bash +set +e +# Package installation runs as root, while fcitx5 belongs to the logged-in +# desktop user. Reconnect only to existing user DBus sessions; never start a +# daemon or fail the package transaction when no graphical session is active. +for bus in /run/user/[0-9]*/bus; do + [ -S "$bus" ] || continue + runtime_dir=${bus%/bus} + uid=${runtime_dir##*/} + [ "$uid" != "0" ] || continue + user=$(getent passwd "$uid" | cut -d: -f1) + [ -n "$user" ] || continue + runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + timeout 5s dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true +done +exit 0 +EOF +chmod 0755 "$POST_INSTALL" + +# 卸载同样要重启 fcitx5:文件被删掉后,运行中的输入法仍持有旧插件的映像。 +# 用 D-Bus 的 controller Restart(立即返回),**不要**用 `fcitx5 -r`:它会替换 daemon +# 并一直前台运行,导致 postinst/postrm 每次都要等满 timeout(安装卡顿),还会被 kill 掉新 daemon。 +POST_REMOVE="$TARGET_DIR/openless-fcitx5-postrm" +sed 's/Package installation runs as root/Package removal runs as root/' \ + "$POST_INSTALL" > "$POST_REMOVE" +chmod 0755 "$POST_REMOVE" + +# 插件指纹清单:装完包后一条命令就能核对「系统里的插件 == 包里的插件」。 +PLUGIN_SHA=$(sha256sum "$PLUGIN_ROOT/libopenless.so" | awk '{print $1}') +PLUGIN_MANIFEST="$TARGET_DIR/openless-fcitx5-manifest" +cat > "$PLUGIN_MANIFEST" < "$DEB_ROOT/DEBIAN/control" <" && $3 ~ /^\// { print $3 }') -for binary in "$QWEN_APPDIR"/*; do - patchelf --set-rpath '$ORIGIN' "$binary" +install -Dm644 "$PLUGIN_MANIFEST" \ + "$RPM_ROOT/usr/share/openless/fcitx5-addon.sha256" +RPM_TOP="$TARGET_DIR/rpmbuild" +RPM_VERSION=${VERSION,,} +RPM_VERSION=${RPM_VERSION//-/.} +rm -rf "$RPM_TOP" +mkdir -p "$RPM_TOP"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS,rpmdb,tmp} +tar -C "$RPM_ROOT" --transform="s,^\./,openless-$RPM_VERSION/," \ + -czf "$RPM_TOP/SOURCES/openless-$RPM_VERSION.tar.gz" . +cat > "$RPM_TOP/SPECS/openless.spec" </dev/null 2>&1 || true +done +exit 0 +%post +set +e +for bus in /run/user/[0-9]*/bus; do + [ -S "\$bus" ] || continue + runtime_dir=\${bus%/bus}; uid=\${runtime_dir##*/} + [ "\$uid" != 0 ] || continue + user=\$(getent passwd "\$uid" | cut -d: -f1) + [ -n "\$user" ] && timeout 5s runuser -u "\$user" -- env XDG_RUNTIME_DIR="\$runtime_dir" DBUS_SESSION_BUS_ADDRESS="unix:path=\$bus" dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true done -ln -s usr/bin/openless "$APPDIR/AppRun" -cp "$PACKAGING/openless.desktop" "$APPDIR/openless.desktop" -cp "$ICON" "$APPDIR/openless.png" -ln -s openless.png "$APPDIR/.DirIcon" -ARCH="$ARCH" appimagetool "$APPDIR" \ - "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.AppImage" +exit 0 +EOF +rpmbuild --define "_topdir $RPM_TOP" --define "_dbpath $RPM_TOP/rpmdb" \ + --define "_tmppath $RPM_TOP/tmp" -bb "$RPM_TOP/SPECS/openless.spec" +mv "$RPM_TOP/RPMS/x86_64/openless-$RPM_VERSION-1.x86_64.rpm" \ + "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.rpm" find "$OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/openless-all/app/scripts/register-ts-loader.mjs b/openless-all/app/scripts/register-ts-loader.mjs new file mode 100644 index 000000000..b94fce2fa --- /dev/null +++ b/openless-all/app/scripts/register-ts-loader.mjs @@ -0,0 +1,4 @@ +// Registers the TypeScript resolve hook so scripts can import `src/i18n/*.ts`. +import { register } from 'node:module'; + +register('./ts-resolve-hook.mjs', import.meta.url); diff --git a/openless-all/app/scripts/remote-input-audio-queue.test.mjs b/openless-all/app/scripts/remote-input-audio-queue.test.mjs index 5a155456f..c74bd8a5d 100644 --- a/openless-all/app/scripts/remote-input-audio-queue.test.mjs +++ b/openless-all/app/scripts/remote-input-audio-queue.test.mjs @@ -3,11 +3,11 @@ import { readFile } from 'node:fs/promises'; import { runInNewContext } from 'node:vm'; const source = await readFile( - new URL('../src-tauri/src/remote_server/assets/app.js', import.meta.url), + new URL('../assets/remote-input/app.js', import.meta.url), 'utf8', ); const html = await readFile( - new URL('../src-tauri/src/remote_server/assets/index.html', import.meta.url), + new URL('../assets/remote-input/index.html', import.meta.url), 'utf8', ); diff --git a/openless-all/app/scripts/remote-input-locales.test.mjs b/openless-all/app/scripts/remote-input-locales.test.mjs index 640d29f0c..cb3e87467 100644 --- a/openless-all/app/scripts/remote-input-locales.test.mjs +++ b/openless-all/app/scripts/remote-input-locales.test.mjs @@ -3,7 +3,7 @@ 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), + new URL('../assets/remote-input/app.js', import.meta.url), 'utf8', ); const prefix = source.slice(0, source.indexOf(' // 极简插值:')); diff --git a/openless-all/app/scripts/sync-egui-i18n.mjs b/openless-all/app/scripts/sync-egui-i18n.mjs new file mode 100644 index 000000000..82330171a --- /dev/null +++ b/openless-all/app/scripts/sync-egui-i18n.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// Sync the Linux egui localization catalog with the Tauri i18n catalogs. +// +// The two UIs ship as one product and support the same five locales, but they +// keep separate catalogs: the Tauri app uses `src/i18n/*.ts`, the egui host uses +// the Rust table in `linux-egui/src/i18n.rs`. This script removes the duplicated +// translation work: for every key the egui catalog shares with the Tauri one it +// copies the five translations over, so translators only maintain the Tauri +// files. +// +// Matching ignores case, `.` and `_`, so `overview.week_days` picks up the +// Tauri `overview.weekDays` value and `nav.polish_mode` picks up +// `nav.polishMode`. Tauri's `{{name}}` placeholders become the positional `{}` +// the Rust `fmt_catalog` expects, and array values (weekday/month label lists) +// are joined with `|`, which is the encoding the egui pages split on. +// +// Run with: node scripts/sync-egui-i18n.mjs [--check] +// --check report drift and exit non-zero instead of rewriting the catalog + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = resolve(HERE, '..'); +const CATALOG_PATH = resolve(APP_ROOT, 'linux-egui/src/i18n.rs'); + +// Rust row order: [zh-CN, zh-TW, en, ja, ko]. +const LOCALES = [ + ['zh-CN', 'zhCN'], + ['zh-TW', 'zhTW'], + ['en', 'en'], + ['ja', 'ja'], + ['ko', 'ko'], +]; + +const RUST_LOCALE_ORDER = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko']; + +/// Keys where the egui host deliberately keeps its own wording and must not be +/// overwritten by the Tauri value (different product surface / screenshot parity). +const KEEP_EGUI = new Set([ + // Screenshot labels the sidebar entry "纠错规则"; Tauri uses "纠正规则". + 'nav.corrections', + // Screenshot labels the detail action "重新转写"; Tauri uses "重新转录". + 'history.retranscribe', + // The egui status line prefixes the product name. + 'less_computer.done', +]); + +function placeholderCount(text) { + return (text.match(/\{\}/g) ?? []).length; +} + +function normalizeKey(key) { + return key.toLowerCase().replace(/[._]/g, ''); +} + +function flatten(value, prefix, out) { + for (const [key, entry] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (Array.isArray(entry)) { + out.set(path, entry.join('|')); + } else if (entry && typeof entry === 'object') { + flatten(entry, path, out); + } else if (typeof entry === 'string') { + out.set(path, entry); + } + } + return out; +} + +async function loadTauriCatalog() { + const byLocale = new Map(); + for (const [tag, exportName] of LOCALES) { + const module = await import(resolve(APP_ROOT, `src/i18n/${tag}.ts`)); + byLocale.set(tag, flatten(module[exportName], '', new Map())); + } + // normalized key -> { tag -> text } + const shared = new Map(); + const primary = byLocale.get('zh-CN'); + for (const [key, text] of primary) { + const entry = { 'zh-CN': text }; + let complete = true; + for (const tag of RUST_LOCALE_ORDER) { + if (tag === 'zh-CN') continue; + const value = byLocale.get(tag).get(key); + if (value === undefined) { + complete = false; + break; + } + entry[tag] = value; + } + if (complete) shared.set(normalizeKey(key), entry); + } + return shared; +} + +/** Convert a Tauri string into the Rust catalog form. */ +function toRustText(text) { + return text + // React markup used only for emphasis in the Tauri UI has no egui meaning. + .replace(/<\/?[a-zA-Z][^>]*>/g, '') + .replace(/\{\{\s*[^}]+?\s*\}\}/g, '{}'); +} + +function rustEscape(text) { + return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); +} + +function quote(text) { + return `"${rustEscape(text)}"`; +} + +// ── Rust catalog scanner ──────────────────────────────────────────────────── + +/** Index of the matching close for the opening bracket at `open`. */ +function matchBracket(source, open, openChar, closeChar) { + let depth = 0; + let i = open; + let inString = false; + while (i < source.length) { + const ch = source[i]; + if (inString) { + if (ch === '\\') i += 1; + else if (ch === '"') inString = false; + } else if (ch === '"') { + inString = true; + } else if (ch === openChar) { + depth += 1; + } else if (ch === closeChar) { + depth -= 1; + if (depth === 0) return i; + } + i += 1; + } + throw new Error(`unbalanced ${openChar} at ${open}`); +} + +/** Read the Rust string literal starting at `start` (which must be `"`). */ +function readString(source, start) { + let out = ''; + let i = start + 1; + while (i < source.length) { + const ch = source[i]; + if (ch === '\\') { + const next = source[i + 1]; + if (next === 'n') out += '\n'; + else if (next === 'r') out += '\r'; + else if (next === 't') out += '\t'; + else out += next; + i += 2; + continue; + } + if (ch === '"') return { text: out, end: i }; + out += ch; + i += 1; + } + throw new Error('unterminated string literal'); +} + +/** All `Msg { key: "…", text: row(…) }` entries with their source ranges. */ +function scanMessages(source) { + const entries = []; + const keyPattern = /key:\s*"/g; + let match; + while ((match = keyPattern.exec(source)) !== null) { + // `match[0]` includes the opening quote; point `readString` at it. + const keyStart = match.index + match[0].length - 1; + const key = readString(source, keyStart); + const blockStart = source.lastIndexOf('Msg {', keyStart); + const braceStart = source.indexOf('{', blockStart); + const blockEnd = matchBracket(source, braceStart, '{', '}'); + const rowStart = source.indexOf('row(', keyStart); + if (rowStart === -1 || rowStart > blockEnd) continue; + const parenStart = rowStart + 'row'.length; + const parenEnd = matchBracket(source, parenStart, '(', ')'); + + const values = []; + let i = parenStart + 1; + while (i < parenEnd) { + const ch = source[i]; + if (ch === '"') { + const literal = readString(source, i); + values.push(literal.text); + i = literal.end + 1; + continue; + } + i += 1; + } + // Consume the block's trailing comma too; `renderBlock` re-emits it. + const end = source[blockEnd + 1] === ',' ? blockEnd + 2 : blockEnd + 1; + entries.push({ key: key.text, values, start: blockStart, end }); + } + return entries; +} + +function renderBlock(key, values) { + const oneLine = ` Msg {\n key: "${key}",\n text: row(${values.map(quote).join(', ')}),\n },`; + const anyLong = values.some((value) => value.length > 12); + if (!anyLong) return oneLine; + return [ + ' Msg {', + ` key: "${key}",`, + ' text: row(', + ...values.map((value) => ` ${quote(value)},`), + ' ),', + ' },', + ].join('\n'); +} + +/** `tr_l10n(…, "key")` / `fmt_l10n(…, "key", …)` call sites under `src/`. */ +function referencedKeys() { + const keys = new Set(); + const files = []; + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith('.rs')) files.push(path); + } + }; + walk(resolve(APP_ROOT, 'linux-egui/src')); + const pattern = /\b(?:tr_l10n|fmt_l10n)\(\s*[^,()]+,\s*"([^"]+)"/g; + for (const file of files) { + const source = readFileSync(file, 'utf8'); + let match; + while ((match = pattern.exec(source)) !== null) keys.add(match[1]); + } + return keys; +} + +/** Insert index for new entries: just before the CATALOG slice terminator. */ +function catalogInsertIndex(source) { + const start = source.indexOf('pub const CATALOG'); + if (start === -1) throw new Error('CATALOG array not found in i18n.rs'); + const end = source.indexOf('\n];', start); + if (end === -1) throw new Error('CATALOG array terminator not found'); + return end + 1; +} + +async function main() { + const check = process.argv.includes('--check'); + const shared = await loadTauriCatalog(); + const source = readFileSync(CATALOG_PATH, 'utf8'); + const messages = scanMessages(source); + + const rewritten = []; + let updated = 0; + let unchanged = 0; + let skipped = 0; + let guarded = 0; + let cursor = 0; + for (const message of messages) { + rewritten.push(source.slice(cursor, message.start)); + const tauri = shared.get(normalizeKey(message.key)); + if (!tauri || KEEP_EGUI.has(message.key)) { + skipped += 1; + rewritten.push(source.slice(message.start, message.end)); + cursor = message.end; + continue; + } + const values = RUST_LOCALE_ORDER.map((tag) => toRustText(tauri[tag])); + // Never take a translation whose placeholders no longer line up with the + // arguments the egui pages pass; that would render a literal `{}`. + const expected = message.values.map(placeholderCount); + const mismatch = values.some( + (value, index) => placeholderCount(value) !== expected[index], + ); + if (mismatch) { + guarded += 1; + if (check) console.log(`skipped (placeholder mismatch): ${message.key}`); + rewritten.push(source.slice(message.start, message.end)); + cursor = message.end; + continue; + } + const same = + values.length === message.values.length && + values.every((value, index) => value === message.values[index]); + if (same) { + unchanged += 1; + rewritten.push(source.slice(message.start, message.end)); + } else { + updated += 1; + if (check) { + console.log(`drift: ${message.key}`); + console.log(` egui: ${JSON.stringify(message.values)}`); + console.log(` tauri: ${JSON.stringify(values)}`); + } + rewritten.push(renderBlock(message.key, values)); + } + cursor = message.end; + } + rewritten.push(source.slice(cursor)); + let output = rewritten.join(''); + + // Add catalog rows for keys the egui sources reference but the catalog lacks. + const known = new Set(messages.map((message) => message.key)); + const missing = []; + for (const key of referencedKeys()) { + if (known.has(key)) continue; + const tauri = shared.get(normalizeKey(key)); + if (!tauri) missing.push(key); + } + const additions = []; + for (const key of [...referencedKeys()].sort()) { + if (known.has(key)) continue; + const tauri = shared.get(normalizeKey(key)); + if (!tauri) continue; + additions.push(renderBlock(key, RUST_LOCALE_ORDER.map((tag) => toRustText(tauri[tag])))); + } + if (additions.length > 0) { + const at = catalogInsertIndex(output); + output = `${output.slice(0, at)}${additions.join('\n')}\n${output.slice(at)}`; + } + + console.log( + `egui i18n catalog: ${messages.length} keys · ${updated} updated · ${unchanged} in sync · ` + + `${skipped} egui-only/kept · ${guarded} skipped (placeholder mismatch) · ` + + `${additions.length} added`, + ); + if (missing.length > 0) { + console.error( + `referenced keys with no Tauri translation (add them by hand):\n ${missing.join('\n ')}`, + ); + } + + if (check) { + if (updated > 0 || additions.length > 0) { + console.error('Catalog is out of sync with the Tauri i18n files.'); + process.exit(1); + } + return; + } + if (updated > 0 || additions.length > 0) { + writeFileSync(CATALOG_PATH, output); + console.log(`wrote ${CATALOG_PATH}`); + } +} + +await main(); diff --git a/openless-all/app/scripts/ts-resolve-hook.mjs b/openless-all/app/scripts/ts-resolve-hook.mjs new file mode 100644 index 000000000..91398b784 --- /dev/null +++ b/openless-all/app/scripts/ts-resolve-hook.mjs @@ -0,0 +1,13 @@ +// ESM resolve hook: let Node import the Tauri i18n TypeScript modules, which +// use extension-less relative specifiers (`./en`) the way a bundler allows but +// Node's ESM resolver does not. +export async function resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) { + try { + return await nextResolve(`${specifier}.ts`, context); + } catch { + // fall through to the default resolution for a clearer error + } + } + return nextResolve(specifier, context); +} diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 0ccccc807..1cc3c5549 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -114,7 +114,7 @@ int openless_common_controls_v6_manifest_dependency_anchor = 0; /// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下 /// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。 fn build_qwen_asr(target_os: &str) { - const VENDOR: &str = "vendor/qwen-asr"; + const VENDOR: &str = "../vendor/qwen-asr"; const SOURCES: &[&str] = &[ "qwen_asr.c", "qwen_asr_kernels.c", diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index 64551de77..877a93445 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -20,10 +20,10 @@ use serde::Serialize; use super::models::ModelId; -/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav` +/// 内嵌测试音频。原始文件 `../vendor/qwen-asr/samples/test_speech.wav` /// 内容:"Hello. This is a test of the Voxtrail speech-to-text system." #[cfg(any(target_os = "macos", target_os = "linux"))] -const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav"); +const TEST_WAV: &[u8] = include_bytes!("../../../../vendor/qwen-asr/samples/test_speech.wav"); /// 测试结果给前端展示。 #[derive(Debug, Serialize)] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index cdb1559a8..f6e9f2efa 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -124,6 +124,9 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> { .map_err(Self::platform_error) }) .unwrap_or(Ok(())), + // Desktop launch-at-login is owned by tauri-plugin-autostart; + // Core currently does not stage this preference on Tauri. + openless_core::SettingsEffectKind::LaunchAtLogin => Ok(()), openless_core::SettingsEffectKind::WindowsKeyboard => plan .windows_keyboard .as_ref() diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index bb0ef1ef3..91b9e16ac 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -28,12 +28,12 @@ use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; mod assets { - pub const INDEX_HTML: &str = include_str!("assets/index.html"); - pub const APP_JS: &str = include_str!("assets/app.js"); - pub const STYLE_CSS: &str = include_str!("assets/style.css"); - pub const ICON_PNG: &[u8] = include_bytes!("assets/icon.png"); - pub const MIC_PNG: &[u8] = include_bytes!("assets/mic.png"); - pub const DONE_PNG: &[u8] = include_bytes!("assets/done.png"); + pub const INDEX_HTML: &str = include_str!("../../../assets/remote-input/index.html"); + pub const APP_JS: &str = include_str!("../../../assets/remote-input/app.js"); + pub const STYLE_CSS: &str = include_str!("../../../assets/remote-input/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("../../../assets/remote-input/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("../../../assets/remote-input/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("../../../assets/remote-input/done.png"); } const HEADER_HTML: &str = "text/html; charset=utf-8"; diff --git a/openless-all/app/src-tauri/src/remote_server/tls_identity.rs b/openless-all/app/src-tauri/src/remote_server/tls_identity.rs index d72b52a2b..d8b9f8358 100644 --- a/openless-all/app/src-tauri/src/remote_server/tls_identity.rs +++ b/openless-all/app/src-tauri/src/remote_server/tls_identity.rs @@ -333,7 +333,9 @@ mod tests { // 两张证书的名称完全相同,描述文件的名称和标识也可以照抄。 assert_eq!( parse_cert(&original.trust_cert).unwrap().distinguished_name, - parse_cert(&replacement.trust_cert).unwrap().distinguished_name + parse_cert(&replacement.trust_cert) + .unwrap() + .distinguished_name ); let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); let substituted = mobileconfig(&original.trust_cert).replace( @@ -361,7 +363,11 @@ mod tests { fingerprint_sha256(&actual_certificate), replacement.ca_fingerprint_sha256 ); - assert!(!verify_server(&replacement, &original.trust_cert, "localhost")); + assert!(!verify_server( + &replacement, + &original.trust_cert, + "localhost" + )); assert_ne!( original.ca_fingerprint_sha256, fingerprint_sha256(&stored(original_dir.path()).leaf_cert) diff --git a/openless-all/app/src/lib/vocabPresets.ts b/openless-all/app/src/lib/vocabPresets.ts index 50c67109e..1c674f352 100644 --- a/openless-all/app/src/lib/vocabPresets.ts +++ b/openless-all/app/src/lib/vocabPresets.ts @@ -1,4 +1,4 @@ -import defaultPresetsJson from './vocab-presets.json'; +import defaultPresetsJson from '../../assets/vocab-presets.json'; import { listVocabPresets, saveVocabPresets } from './ipc'; import type { VocabPreset, VocabPresetStore } from './types'; diff --git a/openless-all/app/src-tauri/vendor/qwen-asr b/openless-all/app/vendor/qwen-asr similarity index 100% rename from openless-all/app/src-tauri/vendor/qwen-asr rename to openless-all/app/vendor/qwen-asr diff --git a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt index c1fa34a9a..cd5680c59 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt +++ b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt @@ -41,6 +41,13 @@ if(BUILD_TESTING) target_include_directories(input_target_contract PRIVATE "${FCITX5_MODULE_INCLUDE_DIR}") target_link_libraries(input_target_contract PRIVATE Fcitx5::Core Fcitx5::Utils Fcitx5::Module::Clipboard) add_test(NAME input_target_contract COMMAND input_target_contract) + + # Pure hotkey-matching contract: Shift+symbol / letter folding and the + # "never consume a bare modifier" rule (the Ctrl+Shift+; regression). + add_executable(hotkey_match_contract hotkey_match_contract.cpp) + target_compile_options(hotkey_match_contract PRIVATE -UNDEBUG) + target_link_libraries(hotkey_match_contract PRIVATE Fcitx5::Utils) + add_test(NAME hotkey_match_contract COMMAND hotkey_match_contract) endif() # Install the plugin .so to fcitx5 addon dir diff --git a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h b/openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h new file mode 100644 index 000000000..d445cfe03 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h @@ -0,0 +1,145 @@ +// Pure matching rules for the fcitx5 OpenLess hotkeys. +// +// Why this exists (measured, not guessed): +// fcitx5 hands the addon the *level-applied* key symbol — pressing +// Ctrl+Shift+; arrives as sym=0x3a (':') on the Wayland frontend, while the +// binding we registered was built from the base symbol 0x3b (';'). +// fcitx::Key::normalize() only folds the letter case (a-z -> A-Z) and drops +// Shift for *symbols*; it does not map ';' to ':'. An exact +// `sym == registered && states == registered` test therefore never fires for +// any Shift+symbol shortcut (Ctrl+Shift+; and Ctrl+Shift+S were both dead). +// +// So compare on a folded pair: letters case-insensitively, US-layout +// base/shifted symbols as the same physical key, and allow the Shift bit to +// differ *only* when the two symbols are such a pair. That keeps Ctrl+; and +// Ctrl+Shift+; apart while accepting either frontend convention. +#pragma once + +#include + +#include + +namespace openless_hotkeys { + +constexpr uint32_t kShiftBit = 0x01; + +/// The only modifier bits fcitx5 keeps after `Key::normalize()` (see +/// fcitx-utils/keysym.h: Shift 1<<0, Ctrl 1<<2, Alt 1<<3, Super 1<<6). +/// CapsLock (1<<1), NumLock (1<<4), Hyper/Mod3, Mod5 and the Gtk virtual +/// Super2/Hyper2/Meta bits are *not* part of a shortcut's identity: they ride +/// along on every key event and must not decide whether a hotkey fires. +constexpr uint32_t kModifierMask = 0x01u | 0x04u | 0x08u | 0x40u; + +/// X11 modifier keysyms (Shift_L … Hyper_R) plus CapsLock/ShiftLock. +inline bool isModifierSym(uint32_t sym) { return sym >= 0xffe1 && sym <= 0xffee; } + +/// US-layout base <-> shifted symbol pairs. Mirrors the host's table in +/// `linux-egui/src/settings.rs::primary_keysym`; keep both in sync. +inline bool isShiftPair(uint32_t left, uint32_t right) { + if (left == right) { + return false; + } + static constexpr uint32_t kPairs[][2] = { + {';', ':'}, {',', '<'}, {'.', '>'}, {'/', '?'}, {'\\', '|'}, + {'[', '{'}, {']', '}'}, {'\'', '"'}, {'`', '~'}, {'-', '_'}, + {'=', '+'}, {'1', '!'}, {'2', '@'}, {'3', '#'}, {'4', '$'}, + {'5', '%'}, {'6', '^'}, {'7', '&'}, {'8', '*'}, {'9', '('}, + {'0', ')'}, + }; + for (const auto &pair : kPairs) { + if ((left == pair[0] && right == pair[1]) || + (left == pair[1] && right == pair[0])) { + return true; + } + } + return false; +} + +/// Fold a symbol the way fcitx's own normalization does for letters: the +/// frontend may report either 'a' or 'A' for the same physical key. +inline uint32_t foldSym(uint32_t sym) { + if (sym >= 'a' && sym <= 'z') { + return sym - 32; + } + return sym; +} + +inline bool symMatches(uint32_t eventSym, uint32_t registeredSym) { + const uint32_t event = foldSym(eventSym); + const uint32_t registered = foldSym(registeredSym); + if (event == registered) { + return true; + } + // A bare modifier key must never be folded into another key. + if (isModifierSym(event) || isModifierSym(registered)) { + return false; + } + return isShiftPair(event, registered); +} + +/// The four modifier bits must match exactly; every other state bit is noise. +/// +/// Measured (trace, KDE/Wayland + this addon): whether Shift shows up in +/// `states` depends on the key kind — letters keep it (`Ctrl+Shift+S` arrives +/// as sym=0x53 states=0x05), symbols do not (`Ctrl+Shift+;` arrives as +/// sym=0x3a states=0x04, Shift folded into the level-applied symbol). +/// `matches` therefore treats the Shift bit as part of the identity only when +/// the symbol itself cannot distinguish the two (see `matches`). +/// +/// Measured (CapsLock): with CapsLock on, every event carries `0x02` in +/// `states`, so `Ctrl+Shift+;` arrived as 0x07 while the binding was registered +/// as 0x05 — an exact comparison never fired for *any* shortcut, and the +/// near-miss logger (which filtered on the same equality) stayed silent too. +/// Masking to the modifier bits keeps Lock/NumLock/Mod3/Mod5 out of the +/// decision while leaving Shift, Ctrl, Alt and Super exact. +inline bool statesMatch(uint32_t eventStates, uint32_t registeredStates) { + return (eventStates & kModifierMask) == (registeredStates & kModifierMask); +} + +/// Match a binding across both frontend conventions for Shift. +/// +/// Measured on this desktop (plugin trace enabled, injected and real keys): +/// Ctrl+Shift+S -> sym=0x53 ('S') states=0x05 (Shift is in `states`) +/// Ctrl+Shift+; -> sym=0x3a (':') states=0x04 (Shift folded into the symbol) +/// The registration for `Ctrl+Shift+;` is sym=0x3b states=0x05, so a plain +/// `symMatches && statesMatch` never fired — the shortcut was dead while the +/// key *was* reaching the addon. Rules here: +/// * same symbol → every modifier bit must match (this is what keeps letter +/// bindings, and unshifted symbol bindings, exact); +/// * base/shifted symbol pair → only meaningful when the *binding* asks for +/// Shift; the event's Shift bit is then ignored, because the level-applied +/// symbol already carries that information. A binding that does not ask for +/// Shift is never satisfied by the shifted symbol, so Ctrl+; and +/// Ctrl+Shift+; stay distinguishable in both directions. +inline bool matches(uint32_t eventSym, uint32_t eventStates, + uint32_t registeredSym, uint32_t registeredStates) { + if (registeredSym == 0) { + return false; + } + const uint32_t event = foldSym(eventSym); + const uint32_t registered = foldSym(registeredSym); + if (event == registered) { + return statesMatch(eventStates, registeredStates); + } + if (isModifierSym(event) || isModifierSym(registered)) { + return false; + } + if (!isShiftPair(event, registered)) { + return false; + } + if ((registeredStates & kShiftBit) == 0) { + return false; + } + const uint32_t mask = kModifierMask & ~kShiftBit; + return (eventStates & mask) == (registeredStates & mask); +} + +/// A matched binding whose primary key is a modifier must never be consumed: +/// swallowing Shift_L/Ctrl_L makes the modifier vanish for every application +/// (pressing Shift+letter stopped producing uppercase system-wide). Those +/// bindings are observed only — the host still gets press/release events. +inline bool shouldConsume(uint32_t registeredSym) { + return !isModifierSym(registeredSym); +} + +} // namespace openless_hotkeys diff --git a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp b/openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp new file mode 100644 index 000000000..1f2c30d03 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp @@ -0,0 +1,119 @@ +// Contract for the hotkey matching table. Pure functions, no fcitx5 instance, +// no display: every case below was measured against fcitx::Key's own +// normalization before it was encoded here. +#include "hotkey_match.h" + +#include +#include +#include + +using openless_hotkeys::matches; +using openless_hotkeys::shouldConsume; +using openless_hotkeys::symMatches; + +static constexpr uint32_t kCtrl = 0x04; +static constexpr uint32_t kAlt = 0x08; +static constexpr uint32_t kShift = 0x01; +static constexpr uint32_t kCapsLock = 0x02; +static constexpr uint32_t kNumLock = 0x10; +static constexpr uint32_t kMod3 = 0x20; +static constexpr uint32_t kMod5 = 0x80; + +static void expect(bool actual, const char *what) { + if (!actual) { + std::fprintf(stderr, "FAIL: %s\n", what); + assert(false); + } + std::printf("ok: %s\n", what); +} + +int main() { + // 1. The reported bug: Ctrl+Shift+; registered as ';' + Shift must fire + // when the frontend reports the level-applied ':' symbol. + expect(matches(':', kCtrl | kShift, ';', kCtrl | kShift), + "Ctrl+Shift+: matches registered ;+Ctrl+Shift (level-applied frontend)"); + expect(matches(';', kCtrl | kShift, ';', kCtrl | kShift), + "Ctrl+Shift+; matches registered ;+Ctrl+Shift (unfolded frontend)"); + // Measured on the real desktop (plugin trace, KDE/Wayland): pressing + // Ctrl+Shift+; delivers sym=0x3a (':') with states=0x04 — the Shift bit is + // NOT in `states` because the frontend folded it into the symbol. This is + // the case the shortcut was dying on, so it must match. + expect(matches(':', kCtrl, ';', kCtrl | kShift), + "Ctrl+Shift+; arrives as ':' with only Ctrl held (measured) and fires"); + expect(matches(':', kCtrl | kCapsLock, ';', kCtrl | kShift), + "the same arrival still fires while CapsLock rides along"); + // The mirror direction stays strict: the shifted symbol must never satisfy + // a binding that does not ask for Shift, otherwise Ctrl+; would steal it. + expect(matches(':', kCtrl, ';', kCtrl) == false, + "Ctrl+Shift+; does not fire a Ctrl+; binding"); + expect(matches(';', kCtrl, ';', kCtrl | kShift) == false, + "Ctrl+; does not fire the Ctrl+Shift+; binding"); + + // 2. Registration in the shifted form (host may pick either spelling). + expect(matches(':', kCtrl | kShift, ':', kCtrl | kShift), + "shifted spelling matches itself"); + expect(matches(';', kCtrl | kShift, ':', kCtrl | kShift), + "base symbol matches a shifted registration"); + + // 3. Letters: fcitx reports A-Z when a modifier is held, a-z otherwise. + expect(matches('S', kCtrl | kShift, 's', kCtrl | kShift), + "Ctrl+Shift+S matches registered s+Ctrl+Shift"); + expect(matches('s', kCtrl | kShift, 's', kCtrl | kShift), + "lowercase event matches registered s+Ctrl+Shift"); + expect(matches('A', kAlt, 'a', kAlt), "Alt+A matches registered a+Alt"); + expect(matches('A', kAlt, 's', kAlt) == false, "Alt+A never matches s+Alt"); + + // 4. Other symbol pairs on the same physical key. + expect(matches('?', kCtrl | kShift, '/', kCtrl | kShift), + "Ctrl+Shift+? matches registered /+Ctrl+Shift"); + expect(symMatches('?', '/') && symMatches('/', '?'), + "symbol pair is symmetric"); + expect(symMatches(';', ';') && symMatches('/', '/'), + "identical symbols match"); + expect(symMatches(';', '/') == false, "different symbols do not match"); + + // 5. Bare modifiers are matched exactly and never folded into anything. + expect(matches(0xffe3, 0, 0xffe3, 0), + "bare Left Control matches its own registration"); + expect(matches(0xffe3, 0, 0xffe1, 0) == false, + "Left Control never matches Shift"); + expect(matches(0xffe3, kCtrl, 0xffe3, 0) == false, + "modifier with a stale modifier bit does not match"); + expect(shouldConsume(0xffe3) == false && shouldConsume(0xffe1) == false, + "modifier-only bindings are never consumed"); + expect(shouldConsume(';') && shouldConsume(0xff0d), + "normal keys are still consumed"); + + // 6. Functional keys and empty registrations. + expect(matches(0xff0d, kCtrl | kShift, 0xff0d, kCtrl | kShift), + "Enter binding matches"); + expect(matches(0xff0d, kCtrl | kShift, 0xff0d, kCtrl) == false, + "Enter does not match with a different modifier set"); + expect(matches(';', kCtrl, 0, 0) == false, "unregistered slot never matches"); + + // 7. Lock / virtual state bits are not part of a shortcut's identity. + // Measured: with CapsLock on, every key event carried 0x02 on top of the + // real modifiers, so the exact comparison this replaced never fired for + // *any* binding (the QA shortcut was the visible casualty) — and the + // near-miss logger filtered on the same equality, so it stayed silent + // and the failure looked like "the key never arrived". + expect(matches(':', kCtrl | kShift | kCapsLock, ';', kCtrl | kShift), + "Ctrl+Shift+; still fires while CapsLock is on"); + expect(matches(':', kCtrl | kShift | kNumLock | kMod3 | kMod5, ';', kCtrl | kShift), + "NumLock/Hyper/Mod5 on the event do not block the binding"); + expect(matches('A', kAlt | kCapsLock, 'a', kAlt), + "Alt+A still fires while CapsLock is on"); + expect(matches(';', kCtrl | kCapsLock, ';', kCtrl | kShift) == false, + "CapsLock does not let Ctrl+; fire the Ctrl+Shift+; binding"); + expect(matches(0xffe3, kCapsLock, 0xffe3, 0), + "a bare modifier binding ignores lock bits"); + expect(openless_hotkeys::statesMatch(kCtrl | kShift | kCapsLock, kCtrl | kShift), + "statesMatch masks lock bits on both sides"); + expect(openless_hotkeys::statesMatch(kCapsLock, 0), + "lock bits alone never change the modifier set"); + expect(openless_hotkeys::statesMatch(kCtrl | kShift, kCtrl) == false, + "statesMatch still distinguishes real modifiers"); + + std::printf("hotkey_match contract passed\n"); + return 0; +} diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 4e143da35..1c15db61f 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -20,6 +20,7 @@ * SetAuxDown(s: text) — 在候选词列表下方显示状态文本 * ClearAuxDown() — 清除候选词列表下方文本 * GetSelectionText() -> s — 读取当前 PRIMARY 选区文本(由 clipboard addon 维护) + * SetClipboardText(s: text) -> b — 通过 clipboard addon 写入 CLIPBOARD * CaptureSelectionTarget(s: ticket) -> s — 捕获选区和原输入上下文 * ApplySelectionTarget(sss: ticket, source, replacement) -> b — 校验后替换 * RevertSelectionTarget(s: ticket) -> b — 校验光标前文本后撤销替换 @@ -34,8 +35,13 @@ * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 */ +#include +#include +#include +#include #include #include +#include #include #include @@ -47,6 +53,8 @@ #include #include #include + +#include "hotkey_match.h" #include #include #include @@ -87,6 +95,10 @@ class OpenLess final : public AddonInstance, translationRawStates_(0), lessComputerRawSym_(0), lessComputerRawStates_(0), + switchStyleRawSym_(0), + switchStyleRawStates_(0), + openAppRawSym_(0), + openAppRawStates_(0), hasCustomDictationKey_(false), dictationTriggerHeld_(false), dictationTriggerCombined_(false), @@ -131,17 +143,45 @@ class OpenLess final : public AddonInstance, savedIc_ = keyEvent.inputContext(); } auto sym = static_cast(keyEvent.key().sym()); - auto states = static_cast(keyEvent.key().states()); + // 只保留 ctrl/alt/shift/super(与 fcitx 的 Key::normalize() 一致)。 + // CapsLock 开着时每个事件都会多带 0x02,下面那些直接比较 states 的 + // 分支(自定义组合键 / triggerKeyList_ / 合并键)会全部失效。 + auto states = static_cast(keyEvent.key().states()) & + openless_hotkeys::kModifierMask; bool isPress = !keyEvent.isRelease(); - if (lessComputerRawSym_ != 0 && sym == lessComputerRawSym_ && - states == lessComputerRawStates_) { + // 命中判定统一走 hotkey_match.h:字母大小写折叠 + US 布局 + // base/shifted 视为同一物理键(前端把 Shift 折进 keysym)。 + const auto hit = [&](uint32_t registeredSym, + uint32_t registeredStates) { + return openless_hotkeys::matches( + sym, states, registeredSym, registeredStates); + }; + // 只有非修饰键才允许吞掉事件。吞掉 Shift_L/Ctrl_L 会让该修饰键 + // 在整个桌面消失(Shift+字母打不出大写就是这么来的)。 + const auto consume = [&](uint32_t registeredSym) { + if (openless_hotkeys::shouldConsume(registeredSym)) { + keyEvent.filterAndAccept(); + } + }; + if (isPress) { + if (hotkeyTraceEnabled()) { + FCITX_LOGC(openless, Info) + << "[trace] key sym=0x" << std::hex << sym + << std::dec << " states=0x" << std::hex << states + << std::dec; + } + logHotkeyNearMiss(sym, states); + } + + if (lessComputerRawSym_ != 0 && + hit(lessComputerRawSym_, lessComputerRawStates_)) { lessComputerTriggerHeld_ = isPress; if (isPress) { lessComputerTriggerCombined_ = false; } lessComputerKeyEvent(sym, states, isPress); - keyEvent.filterAndAccept(); + consume(lessComputerRawSym_); return; } if (isPress && lessComputerTriggerHeld_ && !isModifierKeySym(sym) && @@ -151,26 +191,31 @@ class OpenLess final : public AddonInstance, } // 自定义组合键:Alt 状态下字母 sym 可能大写(A vs a),归一化比较 - if (hasCustomDictationKey_ && states == static_cast(customDictationKey_.states()) && - (sym == static_cast(customDictationKey_.sym()) || - (sym >= 65 && sym <= 90 && sym + 32 == static_cast(customDictationKey_.sym())) || - (sym >= 97 && sym <= 122 && sym - 32 == static_cast(customDictationKey_.sym())))) { + if (hasCustomDictationKey_ && + hit(static_cast(customDictationKey_.sym()), + static_cast(customDictationKey_.states()))) { FCITX_LOGC(openless, Debug) << "Custom dictation: sym=" << sym << " states=" << states; + if (isModifierKeySym( + static_cast(customDictationKey_.sym()))) { + dictationTriggerHeld_ = isPress; + if (isPress) { + dictationTriggerCombined_ = false; + } + } dictationKeyEvent( static_cast(customDictationKey_.sym()), static_cast(customDictationKey_.states()), isPress); - keyEvent.filterAndAccept(); + consume(static_cast(customDictationKey_.sym())); return; } if ((triggerRawSym_ != 0 && - keyEvent.key().check(Key(static_cast(triggerRawSym_), - static_cast(triggerRawStates_)))) || + hit(triggerRawSym_, triggerRawStates_)) || (triggerRawSym_ == 0 && [&]() { for (const auto &hk : triggerKeyList_) { - if (sym == static_cast(hk.sym()) && - states == static_cast(hk.states())) + if (hit(static_cast(hk.sym()), + static_cast(hk.states()))) return true; } return false; @@ -198,7 +243,7 @@ class OpenLess final : public AddonInstance, FCITX_LOGC(openless, Debug) << "Dictation hotkey sym=" << dsym; dictationKeyEvent(dsym, dstates, isPress); - keyEvent.filterAndAccept(); + consume(dsym); return; } if (isPress && dictationTriggerHeld_ && !isModifierKeySym(sym) && @@ -208,29 +253,28 @@ class OpenLess final : public AddonInstance, dictationTriggerCombined_ = true; dictationKeyCombined(sym, states, true); } - if (qaRawSym_ != 0 && sym == qaRawSym_ && - states == qaRawStates_) { + if (qaRawSym_ != 0 && hit(qaRawSym_, qaRawStates_)) { if (isPress) selectionIc_ = keyEvent.inputContext(); FCITX_LOGC(openless, Debug) - << "QA shortcut"; + << "QA shortcut sym=0x" << std::hex << sym << std::dec + << " states=0x" << std::hex << states; qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); - keyEvent.filterAndAccept(); + consume(qaRawSym_); return; } if (selectionPolishRawSym_ != 0 && - sym == selectionPolishRawSym_ && - states == selectionPolishRawStates_) { + hit(selectionPolishRawSym_, selectionPolishRawStates_)) { if (isPress) selectionIc_ = keyEvent.inputContext(); FCITX_LOGC(openless, Debug) << "Selection polish shortcut"; selectionPolishEvent(selectionPolishRawSym_, selectionPolishRawStates_, isPress); - keyEvent.filterAndAccept(); + consume(selectionPolishRawSym_); return; } bool translationMatched = false; - if (translationRawSym_ != 0 && sym == translationRawSym_ && - states == translationRawStates_) + if (translationRawSym_ != 0 && + hit(translationRawSym_, translationRawStates_)) translationMatched = true; if (translationRawSym_ != 0 && (sym == 0xffe1 || sym == 0xffe2)) @@ -240,6 +284,25 @@ class OpenLess final : public AddonInstance, << "Translation modifier: sym=" << sym; translationModifierEvent(sym, states, isPress); } + if (switchStyleRawSym_ != 0 && + hit(switchStyleRawSym_, switchStyleRawStates_)) { + switchStyleEvent(sym, states, isPress); + consume(switchStyleRawSym_); + return; + } + if (openAppRawSym_ != 0 && + hit(openAppRawSym_, openAppRawStates_)) { + openAppEvent(sym, states, isPress); + consume(openAppRawSym_); + return; + } + for (const auto &[packId, packSym, packStates] : stylePackHotkeys_) { + if (packSym != 0 && hit(packSym, packStates)) { + stylePackHotkeyEvent(sym, states, isPress); + consume(packSym); + return; + } + } })); // 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针 @@ -658,6 +721,37 @@ class OpenLess final : public AddonInstance, safeSaveAsIni(raw, configFile()); } + void setSwitchStyleHotkeyRaw(uint32_t sym, uint32_t states) { + switchStyleRawSym_ = sym; + switchStyleRawStates_ = states; + persistRawHotkey("SwitchStyle", sym, states); + } + + void setOpenAppHotkeyRaw(uint32_t sym, uint32_t states) { + openAppRawSym_ = sym; + openAppRawStates_ = states; + persistRawHotkey("OpenApp", sym, states); + } + + void setStylePackHotkeys( + const std::vector> &bindings) { + stylePackHotkeys_.clear(); + stylePackHotkeys_.reserve(bindings.size()); + for (const auto &binding : bindings) { + stylePackHotkeys_.push_back(binding.data()); + } + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("StylePackHotkeyCount", std::to_string(bindings.size())); + for (size_t index = 0; index < stylePackHotkeys_.size(); ++index) { + const auto prefix = "StylePackHotkey" + std::to_string(index); + raw.setValueByPath(prefix + "Id", std::get<0>(stylePackHotkeys_[index])); + raw.setValueByPath(prefix + "Sym", std::to_string(std::get<1>(stylePackHotkeys_[index]))); + raw.setValueByPath(prefix + "States", std::to_string(std::get<2>(stylePackHotkeys_[index]))); + } + safeSaveAsIni(raw, configFile()); + } + /// 读取当前 PRIMARY 选区文本。空字符串表示无选区或 clipboard addon 不可用。 std::string getSelectionText() { auto *clipboard = instance_->addonManager().addon("clipboard"); @@ -674,6 +768,17 @@ class OpenLess final : public AddonInstance, return text; } + bool setClipboardText(const std::string &text) { + auto *clipboard = instance_->addonManager().addon("clipboard"); + if (!clipboard) { + FCITX_LOGC(openless, Debug) + << "SetClipboardText: clipboard addon not loaded"; + return false; + } + clipboard->call("openless", text); + return true; + } + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "b"); FCITX_OBJECT_VTABLE_METHOD(captureDictationTarget, "CaptureDictationTarget", "s", "b"); FCITX_OBJECT_VTABLE_METHOD(commitDictationTarget, "CommitDictationTarget", "ss", "b"); @@ -692,7 +797,11 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setLessComputerHotkeyRaw, "SetLessComputerHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setSwitchStyleHotkeyRaw, "SetSwitchStyleHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setOpenAppHotkeyRaw, "SetOpenAppHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setStylePackHotkeys, "SetStylePackHotkeys", "a(suu)", ""); FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s"); + FCITX_OBJECT_VTABLE_METHOD(setClipboardText, "SetClipboardText", "s", "b"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(lessComputerKeyEvent, "LessComputerKeyEvent", "uub"); @@ -700,6 +809,9 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(selectionPolishEvent, "SelectionPolishEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(switchStyleEvent, "SwitchStyleEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(openAppEvent, "OpenAppEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(stylePackHotkeyEvent, "StylePackHotkeyEvent", "uub"); Instance *instance() { return instance_; } @@ -749,6 +861,24 @@ class OpenLess final : public AddonInstance, auto *v = raw.valueByPath("LessComputerRawStates"); lessComputerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; } + loadRawHotkey(raw, "SwitchStyle", switchStyleRawSym_, switchStyleRawStates_); + loadRawHotkey(raw, "OpenApp", openAppRawSym_, openAppRawStates_); + stylePackHotkeys_.clear(); + if (auto *countValue = raw.valueByPath("StylePackHotkeyCount")) { + const auto count = std::min( + std::stoul(*countValue, nullptr, 0), 128); + for (size_t index = 0; index < count; ++index) { + const auto prefix = "StylePackHotkey" + std::to_string(index); + auto *id = raw.valueByPath(prefix + "Id"); + auto *sym = raw.valueByPath(prefix + "Sym"); + auto *states = raw.valueByPath(prefix + "States"); + if (id && sym && states && !id->empty()) { + stylePackHotkeys_.emplace_back( + *id, std::stoul(*sym, nullptr, 0), + std::stoul(*states, nullptr, 0)); + } + } + } lessComputerTriggerHeld_ = false; lessComputerTriggerCombined_ = false; rebuildTriggerKeys(); @@ -802,7 +932,104 @@ class OpenLess final : public AddonInstance, // X11 modifier keysyms. CapsLock is included to match the desktop hook's // treatment of lock keys: pressing it alongside a trigger must not abort // dictation as if it were a printable companion key. - return sym >= 0xffe1 && sym <= 0xffee; + return openless_hotkeys::isModifierSym(sym); + } + + /// 诊断用:打印「修饰位与某个已注册热键一致、但键不同」的按键。 + /// Ctrl+Shift+; 过去正是因为前端把 level 折进 keysym(到达 ':' 而注册的是 ';') + /// 而永远匹配不上;这条日志让同类问题不必再靠猜。 + void logHotkeyNearMiss(uint32_t sym, uint32_t states) { + struct Entry { + const char *name; + uint32_t sym; + uint32_t states; + }; + std::vector entries = { + {"dictation_raw", triggerRawSym_, triggerRawStates_}, + {"qa", qaRawSym_, qaRawStates_}, + {"selection_polish", selectionPolishRawSym_, selectionPolishRawStates_}, + {"translation", translationRawSym_, translationRawStates_}, + {"switch_style", switchStyleRawSym_, switchStyleRawStates_}, + {"open_app", openAppRawSym_, openAppRawStates_}, + {"less_computer", lessComputerRawSym_, lessComputerRawStates_}, + }; + if (hasCustomDictationKey_) { + entries.push_back({"dictation_custom", + static_cast(customDictationKey_.sym()), + static_cast(customDictationKey_.states())}); + } + for (const auto &[packId, packSym, packStates] : stylePackHotkeys_) { + entries.push_back({"style_pack", packSym, packStates}); + } + const auto now = std::chrono::steady_clock::now(); + // 近失只看「除 Shift 外的修饰位」是否一致,理由有两个: + // 1. 屏蔽掉 CapsLock 之类的锁定位,否则开着 CapsLock 时这里会 continue + // 掉每一条,近失日志永远不会打(就是这么丢的); + // 2. Shift 位可能被前端折进符号里(见 hotkey_match.h 的 `matches`)。 + // 再用「符号是同一物理键」把「用户按了别的键」滤掉(按 Ctrl+C 不会因为 + // 存在 Ctrl+Shift+S 绑定而刷日志)。 + const uint32_t looseMask = openless_hotkeys::kModifierMask & ~openless_hotkeys::kShiftBit; + for (const auto &entry : entries) { + if (entry.sym == 0 || + (entry.states & looseMask) != (states & looseMask)) { + continue; + } + if (openless_hotkeys::matches(sym, states, entry.sym, entry.states)) { + continue; + } + if (!openless_hotkeys::symMatches(sym, entry.sym) && + !openless_hotkeys::isShiftPair(sym, entry.sym)) { + continue; + } + if (now - lastNearMissLog_ < std::chrono::seconds(1)) { + return; + } + lastNearMissLog_ = now; + // Info 而不是 Debug:fcitx5 默认级别是 Info,写 Debug 等于永远看不到 + // (这正是「按了没反应、日志里也什么都没有」的原因之一)。已限速 1 次/秒。 + FCITX_LOGC(openless, Info) + << "hotkey near miss: " << entry.name + << " registered sym=0x" << std::hex << entry.sym << std::dec + << " states=0x" << std::hex << entry.states << std::dec + << " but the key arrived as sym=0x" << std::hex << sym << std::dec + << " states=0x" << std::hex << states; + return; + } + } + + /// 逐键诊断开关:环境变量 OPENLESS_HOTKEY_TRACE=1,或建一个标记文件 + /// ~/.config/fcitx5/openless-hotkey-trace(改完 5 秒内生效,无需重启 fcitx5)。 + /// 打开后每次按键都会打一行 (sym, states),用来回答「按这个键插件到底看到了什么」。 + static bool hotkeyTraceEnabled() { + static std::chrono::steady_clock::time_point checked{}; + static bool enabled = false; + const auto now = std::chrono::steady_clock::now(); + if (checked.time_since_epoch().count() != 0 && + now - checked < std::chrono::seconds(5)) { + return enabled; + } + checked = now; + const char *env = std::getenv("OPENLESS_HOTKEY_TRACE"); + if (env != nullptr && env[0] != '\0' && std::string(env) != "0") { + enabled = true; + return enabled; + } + std::filesystem::path flag; + const char *configHome = std::getenv("XDG_CONFIG_HOME"); + if (configHome != nullptr && configHome[0] != '\0') { + flag = std::filesystem::path(configHome) / "fcitx5" / "openless-hotkey-trace"; + } else { + const char *home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + enabled = false; + return enabled; + } + flag = std::filesystem::path(home) / ".config" / "fcitx5" / + "openless-hotkey-trace"; + } + std::error_code error; + enabled = std::filesystem::exists(flag, error); + return enabled; } void resetDictationTriggerState() { @@ -814,6 +1041,23 @@ class OpenLess final : public AddonInstance, triggerKeyList_ = config_.triggerKey.value(); } + void persistRawHotkey(const std::string &name, uint32_t sym, + uint32_t states) { + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath(name + "RawSym", std::to_string(sym)); + raw.setValueByPath(name + "RawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + } + + static void loadRawHotkey(RawConfig &raw, const std::string &name, + uint32_t &sym, uint32_t &states) { + auto *symValue = raw.valueByPath(name + "RawSym"); + auto *statesValue = raw.valueByPath(name + "RawStates"); + sym = symValue ? std::stoul(*symValue, nullptr, 0) : 0; + states = statesValue ? std::stoul(*statesValue, nullptr, 0) : 0; + } + Instance *instance_; OpenLessConfig config_; KeyList triggerKeyList_; @@ -827,12 +1071,19 @@ class OpenLess final : public AddonInstance, uint32_t translationRawStates_; uint32_t lessComputerRawSym_; uint32_t lessComputerRawStates_; + uint32_t switchStyleRawSym_; + uint32_t switchStyleRawStates_; + uint32_t openAppRawSym_; + uint32_t openAppRawStates_; + std::vector> stylePackHotkeys_; Key customDictationKey_; bool hasCustomDictationKey_; bool dictationTriggerHeld_; bool dictationTriggerCombined_; bool lessComputerTriggerHeld_; bool lessComputerTriggerCombined_; + /// 近似未命中诊断日志的限速时间戳(见 logHotkeyNearMiss)。 + std::chrono::steady_clock::time_point lastNearMissLog_{}; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。