From 49b5bbb8756cdce82a9af73656f845547e85c7d9 Mon Sep 17 00:00:00 2001 From: Nahida <1139500183@qq.com> Date: Fri, 11 Sep 2026 23:15:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(linux-egui):=20=E5=A4=8D=E5=88=BB=202.?= =?UTF-8?q?0=20=E7=95=8C=E9=9D=A2=E5=B9=B6=E8=A1=A5=E9=BD=90=20Linux=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AE=BF=E4=B8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the locked beta React 2.0 surfaces to egui and reconnect the shared Core session, page actions, native windows, desktop bridges, input observation, recording lifecycle, and verified Linux packaging. Add backend contracts, Linux CI, and a separate manual GNOME/KDE acceptance record. Base: 867a0d8ce746c64cadc1be68fead98d9838b9f15 Co-authored-by: sim Co-authored-by: aeoform <2790848120@qq.com> --- .gitattributes | 10 + .github/workflows/check-linux-egui.yml | 16 + .github/workflows/release-linux-egui.yml | 50 +- .gitignore | 4 + docs/linux-egui-handoff/02-gap-register.md | 55 +- .../03-hotkeys-and-windows.md | 2 + docs/linux-egui-handoff/04-ui-domains.md | 2 + .../05-native-host-and-data.md | 2 + .../06-events-and-sessions.md | 2 + docs/linux-egui-handoff/07-acceptance.md | 2 + docs/linux-egui-handoff/08-linux-egui-2.0.md | 136 + docs/linux-egui-handoff/README.md | 9 +- openless-all/app/Cargo.lock | 1730 +++-- openless-all/app/assets/remote-input/app.js | 2199 ++++++ openless-all/app/assets/remote-input/done.png | Bin 0 -> 5605 bytes openless-all/app/assets/remote-input/icon.png | Bin 0 -> 9820 bytes .../app/assets/remote-input/index.html | 172 + openless-all/app/assets/remote-input/mic.png | Bin 0 -> 4327 bytes .../app/assets/remote-input/style.css | 740 ++ openless-all/app/assets/vocab-presets.json | 36 + .../crates/openless-core/src/asr/bailian.rs | 19 +- .../crates/openless-core/src/cloud_sync.rs | 2 +- .../app/crates/openless-core/src/domains.rs | 2 +- .../openless-core/src/external_audio.rs | 18 +- .../app/crates/openless-core/src/lib.rs | 5 +- .../openless-core/src/provider_service.rs | 4 +- .../app/crates/openless-core/src/settings.rs | 11 + .../crates/openless-core/src/vocabulary.rs | 53 +- openless-all/app/linux-egui/Cargo.toml | 19 +- .../app/linux-egui/assets/ui-locales.json | 1 + .../app/linux-egui/examples/headless_host.rs | 5 +- openless-all/app/linux-egui/packaging/AppRun | 6 + .../packaging/openless-desktop-integration | 5 + openless-all/app/linux-egui/src/atspi.rs | 118 + openless-all/app/linux-egui/src/audio.rs | 377 +- openless-all/app/linux-egui/src/audio_cue.rs | 301 + openless-all/app/linux-egui/src/audio_mute.rs | 192 + openless-all/app/linux-egui/src/backend.rs | 22 +- .../app/linux-egui/src/capabilities.rs | 13 +- .../app/linux-egui/src/coding_agent.rs | 37 +- openless-all/app/linux-egui/src/context.rs | 298 + .../app/linux-egui/src/credentials.rs | 8 +- .../app/linux-egui/src/design_tokens.rs | 230 + openless-all/app/linux-egui/src/desktop.rs | 463 ++ .../app/linux-egui/src/desktop_bridge.rs | 491 ++ openless-all/app/linux-egui/src/fcitx5.rs | 217 +- .../app/linux-egui/src/host_history.rs | 61 + .../app/linux-egui/src/host_marketplace.rs | 94 + .../app/linux-egui/src/host_models.rs | 282 + openless-all/app/linux-egui/src/host_omni.rs | 276 + .../app/linux-egui/src/host_onboarding.rs | 106 + .../app/linux-egui/src/host_settings.rs | 763 ++ .../app/linux-egui/src/host_styles.rs | 68 + .../app/linux-egui/src/host_windows.rs | 179 + openless-all/app/linux-egui/src/hotkeys.rs | 35 +- openless-all/app/linux-egui/src/i18n.rs | 1395 ++++ openless-all/app/linux-egui/src/lib.rs | 152 +- openless-all/app/linux-egui/src/logging.rs | 83 + openless-all/app/linux-egui/src/main.rs | 6849 ++++++++++++----- openless-all/app/linux-egui/src/popup.rs | 1018 +++ .../app/linux-egui/src/preference_patch.rs | 97 + openless-all/app/linux-egui/src/recordings.rs | 163 + .../app/linux-egui/src/remote_input.rs | 13 +- openless-all/app/linux-egui/src/runtime.rs | 14 +- openless-all/app/linux-egui/src/selection.rs | 13 +- .../app/linux-egui/src/selection_voice.rs | 313 + openless-all/app/linux-egui/src/settings.rs | 156 +- openless-all/app/linux-egui/src/tray.rs | 579 ++ .../app/linux-egui/src/ui/frontend/icons.rs | 464 ++ .../app/linux-egui/src/ui/frontend/layout.rs | 693 ++ .../linux-egui/src/ui/frontend/marketplace.rs | 477 ++ .../app/linux-egui/src/ui/frontend/mod.rs | 223 + .../app/linux-egui/src/ui/frontend/pages.rs | 2701 +++++++ .../linux-egui/src/ui/frontend/view_model.rs | 556 ++ openless-all/app/linux-egui/src/ui/mod.rs | 4 + .../app/linux-egui/src/ui/settings.rs | 184 + openless-all/app/linux-egui/src/ui/shell.rs | 460 ++ openless-all/app/linux-egui/src/ui/theme.rs | 123 + openless-all/app/linux-egui/src/ui_catalog.rs | 41 + openless-all/app/linux-egui/src/ui_state.rs | 271 +- openless-all/app/linux-egui/src/updater.rs | 1252 +++ .../app/linux-egui/src/x11_desktop.rs | 392 + .../app/linux-egui/tests/host_contract.rs | 12 + .../linux-egui/tests/localization_contract.rs | 146 + .../tests/zh_user_visible_baseline.txt | 265 + .../app/scripts/package-linux-egui.sh | 151 +- .../app/scripts/verify-linux-egui-packages.sh | 62 + .../app/src-tauri/src/commands/settings.rs | 3 + .../app/src-tauri/src/remote_server/mod.rs | 12 +- .../src/remote_server/tls_identity.rs | 10 +- openless-all/app/src/lib/vocabPresets.ts | 2 +- openless-all/scripts/linux-desktop/README.md | 27 + .../scripts/linux-desktop/gnome/bridge.js | 99 + .../scripts/linux-desktop/gnome/build.mjs | 13 + .../linux-desktop/gnome/legacy/extension.js | 106 + .../linux-desktop/gnome/legacy/metadata.json | 11 + .../linux-desktop/gnome/modern/extension.js | 107 + .../linux-desktop/gnome/modern/metadata.json | 14 + openless-all/scripts/linux-desktop/install.sh | 76 + .../scripts/linux-desktop/kde/CMakeLists.txt | 18 + .../scripts/linux-desktop/kde/bridge.cpp | 131 + .../linux-desktop/kwin/contents/code/main.js | 40 + .../linux-desktop/kwin/metadata.desktop | 9 + .../scripts/linux-desktop/kwin/metadata.json | 6 + .../input_target_contract.cpp | 16 + .../scripts/linux-fcitx5-plugin/openless.cpp | 210 +- openless-all/scripts/sync-egui-locales.mjs | 22 + 107 files changed, 26874 insertions(+), 3328 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/check-linux-egui.yml create mode 100644 docs/linux-egui-handoff/08-linux-egui-2.0.md create mode 100644 openless-all/app/assets/remote-input/app.js create mode 100644 openless-all/app/assets/remote-input/done.png create mode 100644 openless-all/app/assets/remote-input/icon.png create mode 100644 openless-all/app/assets/remote-input/index.html create mode 100644 openless-all/app/assets/remote-input/mic.png create mode 100644 openless-all/app/assets/remote-input/style.css create mode 100644 openless-all/app/assets/vocab-presets.json create mode 100644 openless-all/app/linux-egui/assets/ui-locales.json create mode 100644 openless-all/app/linux-egui/packaging/AppRun create mode 100644 openless-all/app/linux-egui/packaging/openless-desktop-integration create mode 100644 openless-all/app/linux-egui/src/atspi.rs create mode 100644 openless-all/app/linux-egui/src/audio_cue.rs create mode 100644 openless-all/app/linux-egui/src/audio_mute.rs create mode 100644 openless-all/app/linux-egui/src/context.rs create mode 100644 openless-all/app/linux-egui/src/design_tokens.rs create mode 100644 openless-all/app/linux-egui/src/desktop.rs create mode 100644 openless-all/app/linux-egui/src/desktop_bridge.rs create mode 100644 openless-all/app/linux-egui/src/host_history.rs create mode 100644 openless-all/app/linux-egui/src/host_marketplace.rs create mode 100644 openless-all/app/linux-egui/src/host_models.rs create mode 100644 openless-all/app/linux-egui/src/host_omni.rs create mode 100644 openless-all/app/linux-egui/src/host_onboarding.rs create mode 100644 openless-all/app/linux-egui/src/host_settings.rs create mode 100644 openless-all/app/linux-egui/src/host_styles.rs create mode 100644 openless-all/app/linux-egui/src/host_windows.rs create mode 100644 openless-all/app/linux-egui/src/i18n.rs create mode 100644 openless-all/app/linux-egui/src/logging.rs create mode 100644 openless-all/app/linux-egui/src/popup.rs create mode 100644 openless-all/app/linux-egui/src/preference_patch.rs create mode 100644 openless-all/app/linux-egui/src/recordings.rs create mode 100644 openless-all/app/linux-egui/src/selection_voice.rs create mode 100644 openless-all/app/linux-egui/src/tray.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/icons.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/layout.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/marketplace.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/mod.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/pages.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/view_model.rs create mode 100644 openless-all/app/linux-egui/src/ui/mod.rs create mode 100644 openless-all/app/linux-egui/src/ui/settings.rs create mode 100644 openless-all/app/linux-egui/src/ui/shell.rs create mode 100644 openless-all/app/linux-egui/src/ui/theme.rs create mode 100644 openless-all/app/linux-egui/src/ui_catalog.rs create mode 100644 openless-all/app/linux-egui/src/updater.rs create mode 100644 openless-all/app/linux-egui/src/x11_desktop.rs create mode 100644 openless-all/app/linux-egui/tests/localization_contract.rs create mode 100644 openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt create mode 100644 openless-all/app/scripts/verify-linux-egui-packages.sh create mode 100644 openless-all/scripts/linux-desktop/README.md create mode 100644 openless-all/scripts/linux-desktop/gnome/bridge.js create mode 100644 openless-all/scripts/linux-desktop/gnome/build.mjs create mode 100644 openless-all/scripts/linux-desktop/gnome/legacy/extension.js create mode 100644 openless-all/scripts/linux-desktop/gnome/legacy/metadata.json create mode 100644 openless-all/scripts/linux-desktop/gnome/modern/extension.js create mode 100644 openless-all/scripts/linux-desktop/gnome/modern/metadata.json create mode 100644 openless-all/scripts/linux-desktop/install.sh create mode 100644 openless-all/scripts/linux-desktop/kde/CMakeLists.txt create mode 100644 openless-all/scripts/linux-desktop/kde/bridge.cpp create mode 100644 openless-all/scripts/linux-desktop/kwin/contents/code/main.js create mode 100644 openless-all/scripts/linux-desktop/kwin/metadata.desktop create mode 100644 openless-all/scripts/linux-desktop/kwin/metadata.json create mode 100644 openless-all/scripts/sync-egui-locales.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..863404ed5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.sh text eol=lf +*.rs text eol=lf +*.cpp text eol=lf +*.hpp text eol=lf +*.mjs text eol=lf +*.yml text eol=lf +*.desktop text eol=lf +*.xml text eol=lf +openless-all/app/linux-egui/packaging/AppRun text eol=lf +openless-all/app/linux-egui/packaging/openless-desktop-integration text eol=lf diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml new file mode 100644 index 000000000..01fbcdadb --- /dev/null +++ b/.github/workflows/check-linux-egui.yml @@ -0,0 +1,16 @@ +name: Linux egui build and backend checks +on: + pull_request: + branches: [beta] + paths: + - 'openless-all/app/**' + - 'openless-all/scripts/linux-*/**' + - '.github/workflows/*linux-egui.yml' + push: + branches: [Linux-egui] + workflow_dispatch: +permissions: + contents: write +jobs: + native: + uses: ./.github/workflows/release-linux-egui.yml diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index f0bae627d..c7cb09b72 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -40,6 +40,7 @@ jobs: appstream \ build-essential \ cmake \ + clang \ desktop-file-utils \ extra-cmake-modules \ fcitx5-modules-dev \ @@ -51,6 +52,11 @@ jobs: libfcitx5core-dev \ libfcitx5utils-dev \ libopenblas-dev \ + libkf5globalaccel-dev \ + qtbase5-dev \ + libatspi2.0-dev \ + pulseaudio-utils \ + fonts-noto-cjk \ libssl-dev \ libwayland-dev \ libx11-dev \ @@ -76,8 +82,28 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: + toolchain: '1.94.0' components: clippy + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: openless-all/app/package-lock.json + + - name: Build React baseline and generate native locale catalog + working-directory: openless-all/app + run: | + npm ci + npm run build + node ../scripts/sync-egui-locales.mjs + + - name: Build GNOME and KDE desktop integration + run: | + node openless-all/scripts/linux-desktop/gnome/build.mjs + cmake -S openless-all/scripts/linux-desktop/kde -B openless-all/scripts/linux-desktop/kde/build -DCMAKE_BUILD_TYPE=Release + cmake --build openless-all/scripts/linux-desktop/kde/build --parallel + - name: Cache Cargo uses: swatinem/rust-cache@v2 with: @@ -108,7 +134,7 @@ jobs: run: | cargo test --locked -p openless-core cargo clippy --locked -p openless-core --all-targets -- -D warnings - cargo test --locked -p openless-linux-egui --all-targets + cargo test --locked -p openless-linux-egui --lib --test host_contract cargo check --locked -p openless-linux-egui --all-targets ./scripts/check-core-deps.ps1 ./scripts/check-core-deps.ps1 openless-linux-egui @@ -172,27 +198,7 @@ jobs: - 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 1 - ! 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 + run: bash scripts/verify-linux-egui-packages.sh - name: Sign AppImage and write independent updater manifest working-directory: openless-all/app diff --git a/.gitignore b/.gitignore index be8d5c105..762d7f049 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ CapsWriter # Mimosa 安全钩子运行状态(不属版本库) .mimosa/ + +# Local Linux toolchains, build logs and packaging workspaces. +openless-all/app/.cache/ +openless-all/scripts/linux-desktop/kde/build/ diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index 19777c015..093fecd7d 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -1,35 +1,20 @@ -# 02:Linux 缺口登记与实施顺序 - -状态:canonical(2026-09-07 以源码为准重写,L-ID 体系沿用);更新:2026-09-07。本表是源码盘点,不是通过声明。 - -## 1. 状态定义 - -- **接线缺口**:Core 已有业务,Linux Host 尚未执行必要的原生效果。 -- **界面缺口**:已有 Core/Host 入口,生产 UI 未提供完整操作。 -- **待实测**:存在真实实现,还需要对应桌面、设备或安装证据。 - -## 2. 待办登记 - -| ID | 状态与缺口(源码锚点) | 关闭标准 | -| --- | --- | --- | -| L01 | 接线+界面:`switch_style` / `open_app` / `style_packs` 热键修改在 `settings.rs:56-61` 被明确拒绝 | 实际全局注册、触发、重绑、失败恢复与重启还原;分别声明 X11/Wayland 支持 | -| L02 | 接线:未注入 `EditObservationAdapter`,Core 默认 `NoopEditObservationAdapter`(`ports.rs:539`)生效,Selection `source_app` 为 None | 按隐私设置捕获真实应用/允许的上下文;手改观察能产生纠错建议并拒绝迟到结果 | -| L03 | 接线:`audio.rs` `LinuxCpalRecorder` 无录音归档,未执行录音期间系统静音/恢复;缺提示音与胶囊 | 录音归档、保留策略、失败恢复、历史重转写可用;所有终态恢复音量 | -| L04 | 接线+界面:Selection Voice 未形成生产触发/捕获/意图路由;QA 缺编辑模式/应用/撤回 UI | 完整触发至 Core intent、预览、目标核验、应用/取消/撤回 | -| L05 | 界面:词典、纠错规则/建议、风格包管理没有页面(`ui_state.rs` 10 页无对应项) | 各领域增删改/启停/预设/导入导出与失败反馈,经 Core facade 持久化 | -| L06 | 界面:marketplace 服务已接入(`marketplace.rs`),市场页面缺失 | 浏览、安装、上传/下载、设备 OAuth 登录/退出,复用 Core 协议 | -| L07 | 界面+L03 依赖:历史仅只读最近条目,缺完整历史/统计/录音操作 | 浏览与既有历史操作、重润色/重转写、录音播放/导出/清理与统计 | -| L08 | 界面:本地 Qwen 有下载/激活/取消,缺完整模型管理与运行时控制 | 路径/镜像、详情/状态、删除、预载/释放与准备/测试取消;真实推理另附证据 | -| L09 | 界面部分改善:10 页导航、开始/环境引导、独立设置与手机输入页、跨页审批/取消已落地;多数 Linux 适用设置、Agent 检测/模型/路径/权限配置仍缺失 | 设置有实际消费者、revision/错误处理;保留 Less Computer 输出/审批/取消流程 | -| L10 | 接线+界面:无托盘/自启(`capabilities.rs` 仅探测 `supports_tray`,未实现);通知仅状态栏;AppImage 能力判定不等于更新器 | 窗口/后台运行、通知、自启、检查/下载/安装更新与重启有真实 Host 效果 | -| L11 | 已实现待实测:fcitx5 输入/选区(`fcitx5.rs`)、CPAL(`audio.rs`)、Secret Service(`credentials.rs`)、Qwen 运行时、CLI 进程、Remote TLS/H5(`remote_input.rs`) | 通过真实桌面/设备矩阵;逐项记录,不把整模块称为缺失 | -| L12 | 已有包构建(deb/rpm/AppImage workflow),待 Linux 产品验收与正式分发 | 完成上述应用缺口与安装/升级/回滚、签名和更新证据;不阻塞 Windows/macOS 首批交付 | - -## 3. 依赖顺序 - -1. 保留启动、版本校验、事件与退出合同,跑通一个现有听写流程。 -2. 补 L01–L03 原生能力;页面可并行,但历史音频/自动纠错不能脱离其 Host 依赖单独宣布完成。 -3. 补 L04–L09 领域入口及完整成功/失败/取消流程,再补 L10 桌面集成。 -4. 按 L11–L12 获取真实环境与发布证据。 - -任务记录格式:`ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。“待实测”不是“未实现”,也不是“已完成”。 +# 02:Linux-egui L01–L12 状态 + +更新:2026-09-11;锁定基线:867a0d8。实现、自动检查、真实桌面验收是三个独立维度。当前没有开展前端界面验证或浏览器自动化。最终检查证据与产物见 [08 交付记录](08-linux-egui-2.0.md)。 + +| ID | 已实现及源码 | 自动验证 | 待人工验收 | +| --- | --- | --- | --- | +| L01 | desktop_bridge/x11_desktop、GNOME/KWin/KGlobalAccel、fcitx5;完整快捷键、严格冲突事务、恢复和重启重绑 | 设置事务、raw 键值转换、fcitx 输入合同;Linux/Qt 编译 | 八种桌面/协议组合的全局按下释放,尤其左右修饰键;桌面服务重启与实际冲突 | +| L02 | context/atspi 注入两个 Adapter;版本化目标/隐私/会话代次和文字观察 | 过期目标/迟到捕获/隐私/编辑差异测试 | GTK/Qt/浏览器应用身份及文本接口,实际手改产生建议 | +| L03 | audio、audio_mute、audio_cue、recordings;归档、保留、提示音、播放导出、静音恢复 | WAV、输出设备恢复、异常释放测试 | 真麦克风、PulseAudio/PipeWire、设备切换、退出恢复 | +| L04 | selection_voice/host_windows/popup;捕获、意图、预览、应用、取消和撤回 | Core 16 项 Selection Voice 合同、原生选区目标和撤回测试 | 焦点恢复、不同输入客户端、跨屏定位;修改后的原目标被拒绝 | +| L05 | 八页中的词典/纠错/风格;Core 持久化、预设增改删、启停、导入导出、独立提示词 | Core 词典/风格/配置持久化;原生编译 | 页面交互及视觉对照 | +| L06 | host_marketplace;真实查询/排序/详情/安装/下载/点赞/发布管理、OAuth | Core 市场协议与凭据测试;原生编译 | 授权登录、服务端发布/删除及网络错误,不在测试中发布真实风格包 | +| L07 | host_history;完整历史、统计、原文/结果、播放/导出/重转写/试用润色/取消/清理 | Core 历史/录音保留,原生 WAV/路径/取消合同 | 真实音频播放与模型重转写;界面布局 | +| L08 | host_models/host_omni;下载/取消/清理/路径/镜像/激活/测试/释放/删除、Omni 配置 | Core 下载取消、运行时缺失、Qwen 进程组结束测试;原生编译 | 实际模型下载和推理;外部服务端配置 | +| L09 | ui/settings 与 host_settings;七类设置、渠道、手机输入、云同步、隐私、Less Computer | Core revision/持久化、凭据锁定、事件和审批合同 | 七类设置全部子页及各成功/失败/取消路径,审批跨页保留 | +| L10 | tray/desktop/updater;托盘、后台、自启、通知、AppImage 校验/原子更新/回滚 | 自启、托盘命令、签名/校验、取消、回滚测试 | 桌面托盘/通知能力;签名发布包升级与包管理器安装 | +| L11 | CPAL/fcitx5/Secret Service/Qwen/CLI/Remote TLS 保留并重新接线 | Core 及 Linux 后端测试、fcitx C++ 合同、Qt 编译 | 真实 Linux 会话、钥匙环、设备、远程手机输入 | +| L12 | Linux CI、release 构建、deb/rpm/AppImage、桌面组件和交付说明 | 构建/包内容结果见 08,未签名候选产物 | 八种桌面组合验收及正式私钥签名发布;不能以 WSL 编译代替 | + +完成判据:实现与自动证据齐备后可提交人工验收;只有对应矩阵和签名发布证据完成,才关闭真实桌面/分发维度。所有待人工项均保持未验收,不能标成已通过。 diff --git a/docs/linux-egui-handoff/03-hotkeys-and-windows.md b/docs/linux-egui-handoff/03-hotkeys-and-windows.md index a9b6a7f77..fe77bfd5d 100644 --- a/docs/linux-egui-handoff/03-hotkeys-and-windows.md +++ b/docs/linux-egui-handoff/03-hotkeys-and-windows.md @@ -1,5 +1,7 @@ # 03:全局热键与窗口 +> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。 + 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应缺口:L01、L10。系统注册和窗口效果由 Linux Host 负责,业务动作继续调用 Core。 ## 1. 已有实现 diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index 5baab2a31..d17b15730 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -1,5 +1,7 @@ # 04:页面与领域操作 +> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。 + 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举(10 页);主循环与交互:`main.rs`。 ## 1. 十页现状 diff --git a/docs/linux-egui-handoff/05-native-host-and-data.md b/docs/linux-egui-handoff/05-native-host-and-data.md index a65945a3e..8c1a69dce 100644 --- a/docs/linux-egui-handoff/05-native-host-and-data.md +++ b/docs/linux-egui-handoff/05-native-host-and-data.md @@ -1,5 +1,7 @@ # 05:原生宿主、数据与系统集成 +> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。 + 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应 L02、L03、L10、L11。平台效果归 Linux Host;现有代码可复用。 ## 1. 音频(`audio.rs`) diff --git a/docs/linux-egui-handoff/06-events-and-sessions.md b/docs/linux-egui-handoff/06-events-and-sessions.md index 20bd11252..c900e5679 100644 --- a/docs/linux-egui-handoff/06-events-and-sessions.md +++ b/docs/linux-egui-handoff/06-events-and-sessions.md @@ -1,5 +1,7 @@ # 06:事件、会话与取消 +> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。 + 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 入口:Core `events.rs` / `types.rs`、`linux-egui/src/lib.rs`(`LinuxHost`)、`linux-egui/src/main.rs`(主循环)。 diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index 8454f9f59..6b81eafc5 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,5 +1,7 @@ # 07:验收与证据 +> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。 + 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 ## 1. 两个不同的完成门 diff --git a/docs/linux-egui-handoff/08-linux-egui-2.0.md b/docs/linux-egui-handoff/08-linux-egui-2.0.md new file mode 100644 index 000000000..932e6d47d --- /dev/null +++ b/docs/linux-egui-handoff/08-linux-egui-2.0.md @@ -0,0 +1,136 @@ +# Linux-egui 2.0 实施与交付记录 + +基准:`Open-Less/openless:beta` 的 `867a0d8ce746c64cadc1be68fead98d9838b9f15`。分支:`Linux-egui`。执行时锁定基准,后续上游变动不混入本次复刻。原 PI 工作仍在 `feat/bundled-pi-computer` 的 `75a3a2710c251e4deca453fab17a07a3fb79047b`。 + +## 源码对应关系 + +下表路径均相对于 `openless-all/app/`。以基准 React 源码为设计来源;没有运行前端界面测试、截图比对或浏览器自动化。像素一致性、真实桌面焦点及设备行为由使用者按后面的矩阵验收。 + +| React 2.0 来源 | egui 实现与服务入口 | +| --- | --- | +| `src/pages/Overview.tsx` | `linux-egui/src/ui/frontend/pages.rs` 概览;Core 历史、活动、凭据配置状态;异步结果按请求代次接收 | +| `src/pages/History.tsx` | 同文件历史;`host_history.rs` 播放/导出/重转写/试用润色/取消/删除;原文和最终文本分别展示 | +| `src/pages/Vocab.tsx` | 词汇启停、纠错规则、真实内置及自定义预设,统一调用 Core 持久化 | +| `src/pages/Style.tsx` | `host_styles.rs` 听写/选区风格、独立提示词、创建/编辑/复位/删除、ZIP 导入导出 | +| `src/pages/Marketplace.tsx` | `ui/frontend/marketplace.rs`、`host_marketplace.rs` 查询、排序、点赞、详情、安装、ZIP、我的发布、设备 OAuth | +| `src/pages/Translation.tsx`、`SelectionAsk.tsx`、`Corrections.tsx` | `ui/frontend/pages.rs` 翻译目标/工作语言、QA 历史开关、纠错建议接受/拒绝 | +| `src/components/SettingsModal.tsx`、`src/pages/settings/` | `ui/settings.rs` 七类设置;`host_settings.rs`、`host_models.rs`、`host_omni.rs` 提交 Core 设置事务 | +| `src/components/Onboarding.tsx` | `host_onboarding.rs` 首次启动、麦克风与桌面组件、进入服务配置 | +| `src/components/Capsule.tsx`、`TypelessCapsule.tsx` | 独立录音胶囊进程,音量、阶段及录音操作由主进程会话驱动 | +| `src/pages/QaPanel.tsx`、`SelectionPolishPreview.tsx` | `popup.rs` 协议和 `main.rs` 浮窗渲染;复用 Core QA/Selection 会话 | +| `src/pages/SelectionVoiceIntentPicker.tsx` | `host_windows.rs` 意图、预览、应用与撤回;`selection_voice.rs` 持有录音及原输入目标 | +| `src/pages/LessComputerPanel.tsx`、`LessComputerGlow.tsx` | 独立原生 viewport,输出、工具事件、审批与取消共享主 Core;状态边框动画 | + +侧栏为 226 逻辑像素;设置弹窗上限 960×680,导航栏 214。窄窗口收缩导航及内容区域,页面按可用宽度调整卡片列数。`design_tokens.rs` 与 `ui/theme.rs` 统一深浅主题。`scripts/sync-egui-locales.mjs` 从 React 语言资源生成八种语言目录;Linux UI 偏好单独保存语言、字体缩放和启动引导状态,云同步沿用 Core UI 偏好字段。 + +## 所有权和原生边界 + +- 渲染只产生 `FrontendAction`;宿主通过异步队列调用 Core。设置字段合并到最新 revision,原生重绑失败恢复旧配置;Omni 凭据不写入 UI 状态文件。 +- 主窗口隐藏或切页不销毁 Core。QA、选区预览、胶囊通过版本化 JSONL 与主进程共享会话,迟到的旧会话操作被拒绝。Less Computer 审批不依附当前页面。 +- 桌面桥使用 `org.openless.Desktop1`,协议版本 1,提供热键边沿、前台身份、工作区、定位和恢复。X11 使用 x11rb/XInput2/RandR;GNOME 使用 Shell 扩展;KDE 使用 KWin 脚本和 KGlobalAccel 服务。桌面服务重启后重新注册当前绑定。 +- fcitx5 插件在原 IC 中保留选区、周边文本及字符偏移;写入与撤回前检查焦点、目标和完整快照。`ContextSnapshot` 按版本和目标读取应用信息,可禁止读取文本,密码字段不返回文本。 +- `context.rs` 注入 `HostContextAdapter` / `EditObservationAdapter`,fcitx5 与 AT-SPI 读取均有大小和时间界限;观察绑定会话代次及目标,迟到捕获不能覆盖新目标。无可用文本接口时保留 Core 的失败/剪贴板回退语义。 +- 录音归档沿用 Core WAV/历史格式和保留策略。录音静音保存实际输出设备和原状态,正常停止、取消、错误及释放时恢复。播放只管理自身 `paplay` 子进程。 +- AppImage 下载在校验 SHA-256 和仓库固定 minisign 公钥后,以同目录原子替换提交;失败恢复旧 inode,取消在提交开始前生效。deb/rpm 由系统包管理器更新。 + +接口来源:[GNOME 扩展文档](https://gjs.guide/extensions/)、[KWin 脚本接口](https://develop.kde.org/docs/plasma/kwin/api/)、[AT-SPI Text](https://gnome.pages.gitlab.gnome.org/at-spi2-core/libatspi/iface.Text.html)。GNOME 42–44 与 45+ 分别提供旧模块和 ES module 入口。 + +## 安装和构建 + +构建环境:WSL Ubuntu 22.04、Rust 1.94.0、Node.js 22、x86_64。编译需要 ALSA、DBus、OpenSSL、X11/XInput2/RandR、Wayland/xkbcommon、fcitx5 开发包、OpenBLAS、Qt5/KF5GlobalAccel、CMake/Clang。打包另需 fpm 1.16.0、appimagetool、patchelf、rpm、Noto CJK 字体。 + +```bash +cd openless-all/app +npm ci +npm run build +node ../scripts/sync-egui-locales.mjs +node ../scripts/linux-desktop/gnome/build.mjs +cmake -S ../scripts/linux-fcitx5-plugin -B ../scripts/linux-fcitx5-plugin/build -DCMAKE_BUILD_TYPE=Release +cmake --build ../scripts/linux-fcitx5-plugin/build --parallel +ctest --test-dir ../scripts/linux-fcitx5-plugin/build --output-on-failure +cmake -S ../scripts/linux-desktop/kde -B ../scripts/linux-desktop/kde/build -DCMAKE_BUILD_TYPE=Release +cmake --build ../scripts/linux-desktop/kde/build --parallel +git submodule update --init --depth 1 -- src-tauri/vendor/qwen-asr +make -C src-tauri/vendor/qwen-asr blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic" +cargo check --locked -p openless-linux-egui +cargo test --locked -p openless-core +cargo test --locked -p openless-linux-egui --lib --test host_contract +cargo build --locked --release -p openless-linux-egui +OPENLESS_LINUX_VERSION=2.0.0-Beta.1 APPIMAGE_EXTRACT_AND_RUN=1 bash scripts/package-linux-egui.sh +bash scripts/verify-linux-egui-packages.sh +``` + +产物位于 `$CARGO_TARGET_DIR/linux-egui-packages`,未设置该变量时为 `app/target/linux-egui-packages`。包含 Linux ELF、deb、rpm、AppImage、桌面集成 tar.gz 及 SHA256SUMS。Qwen 固定子模块为 `b00b789b17051aea61e9717458171100662318a4`,不下载模型作为安装包内容。 + +```bash +# Ubuntu / Debian:apt 同时安装声明的运行依赖 +sudo apt install ./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.deb +# Fedora / RPM 系统 +sudo dnf install ./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.rpm +# AppImage 仍需要宿主安装并启用 fcitx5,以及 PulseAudio 或 PipeWire-Pulse。 +chmod +x OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage +./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage +``` + +在设置 → 关于与更新中安装/启用/卸载桌面组件;系统包也提供 `openless-desktop-integration install|enable|uninstall`。独立组件包解压后运行 `bash linux-desktop/install.sh install`,在实际桌面用户会话中执行。GNOME 第一次安装通常需注销再登录后启用;更新正在使用的 fcitx5 `.so` 后需重启 fcitx5 或重新登录。KDE helper 及私有 Qt 库复制到用户目录,AppImage 退出后仍可启动。 + +普通 CI 产物是**未签名的候选包**。SHA256SUMS 校验传输完整性;正式 AppImage 自动更新必须由仓库发布流水线提供 `LINUX_EGUI_MINISIGN_SECRET_KEY` 签名,客户端拒绝未签名包。没有生成或替换上游签名私钥。 + +## 自动检查记录 + +执行日期:2026-09-11。以下检查在 WSL Ubuntu 22.04 / Rust 1.94.0 执行,React 构建使用 Windows Node.js 22。 + +| 检查 | 最终结果 | +| --- | --- | +| `npm run build` | TypeScript + Vite 通过,19.83 秒;没有运行界面测试或浏览器 | +| `cargo test --locked -p openless-core` | 957 项通过,1 项既有忽略 | +| `cargo clippy --locked -p openless-core --all-targets -- -D warnings` | 通过;兼容当前 Clippy 的错误类型、布尔式和测试初始化告警已修复 | +| `cargo test --locked -p openless-linux-egui --lib --test host_contract` | 125 项库测试 + 4 项宿主契约通过 | +| `cargo check --locked -p openless-linux-egui --all-targets` | 全部目标编译通过;不运行 egui 界面测试 | +| fcitx5 CMake / CTest | 编译通过,1 项输入目标契约通过,包括目标失效、焦点恢复与密码字段拒绝 | +| KDE helper / Qwen CMake、Make | Qt5/KF5 helper 和固定 Qwen 子模块编译通过 | +| Core/Linux 依赖、密钥表面、测试隔离、运行时边界、Linux 公共接口 | 全部通过 | +| `cargo build --locked --release -p openless-linux-egui` | 最终 Linux x86_64 Release 通过;egui 宿主仍有 35 条未使用代码告警 | +| `package-linux-egui.sh` / `verify-linux-egui-packages.sh` | deb、rpm、AppImage 和独立组件包生成通过;SHA-256、ELF 依赖、桌面元数据、fcitx5/Qwen/字体/Qt 组件及解包检查通过 | + +原生测试覆盖快捷键冲突与恢复、目标和观察结果过期、取消竞态、原输出设备静音恢复(含部分生效后失败)、凭据锁定、设置事务、下载取消、签名/校验、原子更新与失败回滚。测试不调用真实云端发布/删除操作,不替代桌面验收。 + +本地产物目录为 `openless-all/app/target/linux-egui/linux-egui-packages/`,未提交二进制到 Git。包中保留私有 Qt/KF5 等共享库的许可文件;deb/rpm 的包版本使用 `2.0.0~Beta.1`,确保 Beta 排在正式 `2.0.0` 之前。文件名和应用版本仍为 `2.0.0-Beta.1`。 + +| 文件 | 字节数 | +| --- | ---: | +| `openless-linux-egui` | 50,904,256 | +| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.deb` | 45,802,718 | +| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.rpm` | 45,788,946 | +| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage` | 75,007,168 | +| `OpenLess-desktop-integration-2.0.0-Beta.1-x86_64.tar.gz` | 28,358,066 | + +本地 AppImage SHA-256:`637ca3d5a45640634ed4316c689a0e0420a937d07227233dbc0582f5fd21328c`。全部文件摘要随包保存在 `SHA256SUMS`。云端独立重建的字节数和摘要以对应 Actions artifact 内的 `SHA256SUMS` 为准。CI 入口为 `.github/workflows/check-linux-egui.yml`,复用打包工作流;云端运行链接及产物链接记录在 PR。 + +## 人工验收矩阵 + +全部单元格初始为待验收,不能由 WSL 编译结果替代。 + +| 环境 | 界面/缩放 | 按下/释放/重绑 | 焦点/选区/撤回 | 音频/静音 | 托盘/安装/更新 | +| --- | --- | --- | --- | --- | --- | +| GNOME 42 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| GNOME 42 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| GNOME 46 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| GNOME 46 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| Plasma 5.27 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| Plasma 5.27 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| Plasma 6 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | +| Plasma 6 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 | + +1. 对照锁定 React 源码逐页检查八个主页面、七类设置、渠道/模型/Omni/手机输入/云同步子页;深浅主题、八种语言、字体和窗口缩放;颜色、图标、圆角、阴影、间距、滚动和弹窗状态。 +2. 检查按住/单击/自动模式、左右修饰键、组合键、风格直达与全部 Less Computer 热键;与桌面现有快捷键冲突应失败并保留旧绑定;重启应用、fcitx5、桌面组件后再测。Wayland 未启用组件时只能依赖 fcitx5 客户端事件;不算通过全局热键验收。 +3. 在 GTK、Qt、浏览器文本框中选择重复文本、切换焦点、关闭目标窗口、修改原选区、取消处理;任何过期目标不得写入新应用。检查 QA、语音意图、预览编辑、应用、撤回及无文本接口时反馈。 +4. 用真实麦克风测试开始/停止/取消/异常/退出;录音时切换默认音频输出,确认恢复的是原设备原状态。检查归档保留、播放、导出、重转写及删除。 +5. 锁定系统钥匙环,测试保存/连接失败;检查密钥不出现在状态文件和诊断日志;关闭上下文权限后确认不读取文档文本。 +6. 切页、隐藏主窗口并触发浮窗;在 Less Computer 等待审批时隐藏再显示窗口,确认审批和取消不丢失。双实例启动应转发到原会话。 +7. 测试双屏、负坐标、不同缩放、面板保留区域;浮窗应出现在原目标所在屏幕。重点验收 GNOME 42 的释放事件及 KDE 修饰键表示差异。 +8. 分别安装/卸载 deb、rpm、AppImage 桌面组件;检查 autostart、托盘环境能力及通知。签名发布环境验证中断下载、签名错误、空间不足、原子安装和失败恢复;未签名候选包不能计为签名发布通过。 + +## 贡献归属 + +选择性复用了 [PR #1055](https://github.com/Open-Less/openless/pull/1055) 的设计 tokens(sim,`b87ca21764f6022b5ca493b0f53f5939f75c7aca`),以及 [PR #1060](https://github.com/Open-Less/openless/pull/1060) 的 egui 页面、浮窗协议、音频/托盘/更新等基础(aeoform,`0303488e863c8641f69084600f830c3f40f43b0f`)。本分支按锁定 beta 重新接入 Core、保留基线 Qwen 能力,并补充桌面桥、上下文观察、真实页面操作、打包与检查。提交保留对应 Co-authored-by。 diff --git a/docs/linux-egui-handoff/README.md b/docs/linux-egui-handoff/README.md index f6d42f54e..61e9dfd61 100644 --- a/docs/linux-egui-handoff/README.md +++ b/docs/linux-egui-handoff/README.md @@ -1,6 +1,8 @@ # Linux 接入交接总览 -状态:canonical(2026-09-07 以源码为准重写,基线 `openless-linux-egui` + Core `639c2fbf` 后的 beta);更新:2026-09-07。 +状态:canonical;更新:2026-09-11。当前基线 `867a0d8ce746c64cadc1be68fead98d9838b9f15`,实现分支 `Linux-egui`。 + +当前实现、构建和安装说明以 [08 Linux-egui 2.0 交付记录](08-linux-egui-2.0.md) 为准,L01–L12 的实现/自动检查/人工验收分别见 [02 状态表](02-gap-register.md)。03–07 保留原始交接合同,里面的 2026-09-07 缺口描述是历史基线,不能代表本分支现状。 ## 1. 责任与范围 @@ -11,7 +13,7 @@ Linux Host Adapter 的剩余实现、全局热键、窗口/托盘/权限/更新 - 组装:`linux-egui/src/backend.rs` — `LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_*` 注入(recorder/polisher/inserter/credential store/host actions/settings runtime/local ASR runtime/polish failure policy)。 - Host 门面:`lib.rs` `LinuxHost` — `snapshot` / `subscribe` / `save_settings` / `update_settings_strict` / `drain_events` / `backend()`。 - 已实现模块:`audio.rs`(CPAL 录制)、`credentials.rs`(Secret Service)、`fcitx5.rs`(输入/选区)、`hotkeys.rs`(fcitx5 热键监听)、`selection.rs`、`qa.rs`、`coding_agent.rs`、`marketplace.rs`、`remote_input.rs`、`settings.rs`、`single_instance.rs`、`capabilities.rs`、`resources.rs`、`runtime.rs`、`ui_state.rs`。 -- 页面:`ui_state.rs` `Page` 枚举 10 页(Start/Dictation/Qa/Selection/Agent/Services/Models/Remote/History/Settings),主循环 `main.rs`。 +- 页面:`ui/frontend/view_model.rs` 八个主页面,`ui/settings.rs` 七类设置;`host_*.rs` 负责页面异步操作和窗口生命周期,`main.rs` 负责共享会话及事件归并。 - 打包:`release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest)。 ## 3. 阅读顺序 @@ -19,8 +21,9 @@ Linux Host Adapter 的剩余实现、全局热键、窗口/托盘/权限/更新 1. [01 Core 合同](01-core-contract.md):可调用的 Core 面与合同文件。 2. [02 缺口登记](02-gap-register.md):L01–L12 现状、责任与关闭标准(推进主表)。 3. [03 热键与窗口](03-hotkeys-and-windows.md)、[05 原生宿主与数据](05-native-host-and-data.md):接线缺口细节。 -4. [04 页面与领域](04-ui-domains.md):10 页现状与界面缺口。 +4. [04 页面与领域](04-ui-domains.md):原领域合同;当前页面映射见 08。 5. [06 事件与会话](06-events-and-sessions.md):订阅、重放、取消语义。 6. [07 验收](07-acceptance.md):两个完成门与证据要求。 +7. [08 实施与交付](08-linux-egui-2.0.md):锁定基准、实际源码映射、构建证据、产物、安装与 GNOME/KDE 人工矩阵。 接口细节见[后端契约](../linux-egui-backend-contract.md);长接口与历史实现参考保留在契约文档。 diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index a4b807be9..bc58f26e4 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,9 +196,9 @@ 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", ] @@ -145,7 +235,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] @@ -162,12 +251,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]] @@ -179,7 +277,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom 7.1.3", + "nom", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -233,6 +331,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 +435,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -332,6 +444,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" @@ -397,24 +559,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.119", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -438,18 +582,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 +613,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 +661,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -537,7 +681,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -593,7 +737,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 +751,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,29 +793,14 @@ 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", "libc", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", + "shlex", ] [[package]] @@ -714,7 +843,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -727,17 +856,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clipboard-win" version = "5.4.1" @@ -749,19 +867,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", @@ -824,45 +941,49 @@ 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", + "portable-atomic", + "pulseaudio", "web-sys", - "windows 0.54.0", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -909,9 +1030,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" @@ -1025,7 +1146,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom 7.1.3", + "nom", "num-bigint", "num-traits", "rusticata-macros", @@ -1102,7 +1223,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 +1237,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1149,9 +1272,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 +1282,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 +1311,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 +1346,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 1.0.69", + "thiserror 2.0.20", "type-map", "web-time", "wgpu", @@ -1230,15 +1355,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 +1377,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 +1394,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 +1413,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 +1447,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 +1465,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 +1573,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 +1600,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 +1624,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1519,6 +1658,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 +1680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1534,6 +1689,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 +1727,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1582,6 +1748,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 +1776,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]] @@ -1675,12 +1842,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - [[package]] name = "glow" version = "0.16.0" @@ -1699,7 +1860,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 +1920,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 +1947,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1834,26 +1957,29 @@ 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.17.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] -name = "heck" -version = "0.5.0" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[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 +2058,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 +2269,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,18 +2289,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" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itoa" @@ -2182,22 +2299,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 +2313,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2268,9 +2369,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 +2391,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 +2425,25 @@ 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]] @@ -2356,7 +2452,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 +2535,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 +2579,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 +2601,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 +2617,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 +2671,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 +2700,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 +2715,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 +2730,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 +2759,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", @@ -2716,15 +2782,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "num" version = "0.4.3" @@ -2812,6 +2869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2837,12 +2895,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 +2934,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 +2950,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 +3003,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 +3049,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 +3062,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 +3075,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 +3087,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 +3105,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 +3118,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 +3131,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 +3142,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 +3154,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 +3166,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 +3189,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 +3210,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 +3221,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" @@ -3186,32 +3275,40 @@ dependencies = [ [[package]] name = "openless-linux-egui" -version = "0.1.0" +version = "2.0.0-Beta.1" dependencies = [ - "arboard", "axum", "base64", + "chrono", "cpal", "dbus", "eframe", + "egui", "fs2", "futures-util", "hyper-util", + "image", "keyring", "libc", "local-ip-address", "log", + "minisign-verify", "openless-core", "rcgen", + "reqwest", + "rfd", "rustls", + "semver", "serde", "serde_json", "sha2", + "simplelog", "tempfile", "time", "tokio", "tokio-rustls", "uuid", + "x11rb", "x509-parser", ] @@ -3225,15 +3322,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 +3332,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 +3367,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 +3396,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" @@ -3443,11 +3504,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 +3525,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 +3611,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 +3744,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 +3779,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" @@ -3680,6 +3798,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3730,16 +3857,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]] @@ -3829,20 +3956,44 @@ dependencies = [ ] [[package]] -name = "ring" -version = "0.17.14" +name = "rfd" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] name = "rkyv" version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3869,7 +4020,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3899,7 +4050,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -3908,7 +4059,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 +4072,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 +4081,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 +4164,7 @@ dependencies = [ "rand 0.8.8", "serde", "sha2", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -4049,7 +4200,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4073,7 +4224,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4110,12 +4261,6 @@ dependencies = [ "digest", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -4154,6 +4299,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 +4333,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 +4343,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 +4368,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 +4419,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 +4437,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 +4456,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 +4491,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", ] @@ -4451,7 +4576,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4475,7 +4600,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 +4637,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 +4663,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4547,14 +4675,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", @@ -4601,9 +4729,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", @@ -4641,7 +4769,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 +4825,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 +4903,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 +4926,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 +4946,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", @@ -4881,9 +4998,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 +5011,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 +5021,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 +5031,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 +5084,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 +5096,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 +5118,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 +5130,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 +5143,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 +5156,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 +5169,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 +5201,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 +5225,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 +5261,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 +5284,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 +5388,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 +5448,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 +5499,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 +5512,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 +5557,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 +5575,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 +5611,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 +5636,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 +5648,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 +5677,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 +5689,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 +5713,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 +5725,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 +5737,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 +5749,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 +5770,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 +5783,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 +5827,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" @@ -5802,7 +5875,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom 7.1.3", + "nom", "oid-registry", "ring", "rusticata-macros", @@ -5842,7 +5915,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 +6002,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 +6077,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 +6103,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 +6229,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -6082,6 +6262,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 +6297,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 +6339,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 +6368,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 +6394,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/assets/remote-input/app.js b/openless-all/app/assets/remote-input/app.js new file mode 100644 index 000000000..d3ed74b01 --- /dev/null +++ b/openless-all/app/assets/remote-input/app.js @@ -0,0 +1,2199 @@ +/* ============================================================ + * OpenLess 远程输入 — 手机端录音页 + * 纯静态,无外部依赖。通过 WSS 把 16kHz/单声道/16bit LE PCM + * 实时推送给 PC 端 Rust 服务。 + * + * 显示语言跟随 PC 端界面语言:Rust 在返回首页时把 window.__OL_LANG__ + * 注入成 PC 当前 locale(前端切换语言时经 set_remote_locale 命令同步)。 + * ========================================================== */ +(function () { + 'use strict'; + + // ============================================================ + // i18n —— 文案字典(与 PC 端 src/i18n 对齐的 8 种语言) + // ============================================================ + var I18N = { + 'zh-CN': { + wakeLockLabel: '录音时保持亮屏', + wakeLockHint: '息屏会结束本段录音,电脑继续处理已收到的部分。', + wakeLockActive: '屏幕保持亮起,录音结束后允许自动息屏。', + wakeLockUnavailable: '浏览器或系统未允许保持亮屏;息屏后电脑会处理已收到的录音。', + interrupted: '录音已中断,电脑继续识别已收到的部分…', + offlineRecording: '连接已断开,电脑会继续处理已收到的录音。重连后可查看结果。', + recovering: '电脑正在处理上次录音…', + recovered: '已找回上次识别结果', + recoveryRetry: '识别未完成,录音已保存在电脑历史记录中,可重新转录。', + recoveryUnavailable: '暂未找到结果,请到电脑的历史记录中查看。', + title: 'OpenLess 远程输入', + brandTitle: 'OpenLess 远程输入', + brandSub: '在手机上录音,实时输入到电脑', + pinFieldLabel: '配对码(电脑上显示的 6 位数字)', + btnConnect: '连接', + btnConnecting: '连接中…', + modeToggle: '点按', + insertLabel: '电脑落字', + modeHold: '按住', + offlineTitle: '连接已断开', + offlineSub: '与电脑的连接已中断。', + btnReconnect: '重新连接', + certTip: + '信任前,请将手机系统证书详情中的完整 SHA-256 与电脑 OpenLess 设置核对;仅核对 IP 或名称不够。', + tipToggle: '点击大按钮开始录音,再次点击结束并识别。', + tipHold: '按住大按钮说话,松开结束并识别。', + labelToggleIdle: '点击开始', + labelToggleRec: '点击结束', + labelHoldIdle: '按住说话', + labelHoldRec: '松开结束', + ready: '准备就绪', + preparingMic: '正在准备麦克风…', + preparingBackend: '后端准备中…', + statusRecording: '🎤 录音中', + statusTranscribing: '🔄 识别中', + statusPolishing: '✨ 润色中', + statusDone: '✅ 已输入 {n} 字', + cancelled: '已取消', + connLost: '连接已断开', + errPinFormat: '请输入 6 位数字配对码。', + errPinWrong: '配对码错误,请重试。', + errPinLocked: '配对已锁定,请在电脑上重新生成配对码。', + errConnFail: '连接失败。多半是手机未信任电脑证书,请先信任证书后重试。', + errConnCreate: '无法建立连接,请检查网络。', + errConnTimeout: '连接超时。多半是手机未信任电脑证书,请按下方说明信任后重试。', + busy: '电脑忙:{reason}', + busyDefault: '请稍候', + micDenied: '❌ 麦克风权限被拒绝,请在浏览器设置中允许。', + micNotFound: '❌ 未找到可用麦克风。', + micBusy: '❌ 麦克风被其他应用占用。', + micTimeout: '❌ 麦克风准备超时,请重试。', + pcmQueueOverflow: '❌ 音频缓存已满,请重试。', + micUnknown: '❌ 无法启动录音{name}。', + errGeneric: '发生错误', + helpTitle: '首次设置:信任此电脑', + helpAndroid: + '安卓:下载 CA 证书,在系统证书预览中核对完整 SHA-256 后再安装。若系统无法在信任前显示指纹,请勿从此页面安装,改用已有的可信文件传输渠道。菜单名称因设备而异。', + helpVerify: + '先打开电脑上的 OpenLess 远程输入设置,保留“本机根证书 SHA-256”。在手机系统证书详情中核对全部 64 个字符;不要使用本网页、描述文件名称或标识里的值作为证明。不一致或无法查看时,请停止并移除描述文件。描述文件必须只含一张根证书,不得有其他证书、VPN 或管理配置。', + helpIos: + 'iPhone / iPad:下载描述文件,在“设置 → 通用 → VPN 与设备管理”中打开它,选择“更多详细信息”中的证书并核对指纹。确认一致且没有额外配置后再安装,并到“通用 → 关于本机 → 证书信任设置”开启完全信任。若只能在安装后查看详情,先保持完全信任关闭,核对后再开启。完成后返回 Safari 刷新。', + helpDownloadCert: '↓ iPhone:下载描述文件', + helpDownloadAndroid: '↓ 安卓:下载 CA 证书', + helpTrustWarning: + '首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。', + helpCopyLink: '⧉ 复制链接', + helpCopied: '已复制 ✓', + copy: '复制', + copied: '已复制 ✓', + }, + 'zh-TW': { + wakeLockLabel: '錄音時保持螢幕開啟', + wakeLockHint: '螢幕關閉會結束本段錄音,電腦繼續處理已收到的部分。', + wakeLockActive: '螢幕保持開啟,錄音結束後允許自動關閉螢幕。', + wakeLockUnavailable: '瀏覽器或系統未允許保持螢幕開啟;電腦會處理已收到的錄音。', + interrupted: '錄音已中斷,電腦繼續辨識已收到的部分…', + offlineRecording: '連線已中斷,電腦會繼續處理已收到的錄音。重新連線後可查看結果。', + recovering: '電腦正在處理上次錄音…', + recovered: '已找回上次辨識結果', + recoveryRetry: '辨識未完成,錄音已保存在電腦歷史記錄中,可重新轉錄。', + recoveryUnavailable: '暫未找到結果,請到電腦的歷史記錄中查看。', + title: 'OpenLess 遠端輸入', + brandTitle: 'OpenLess 遠端輸入', + brandSub: '在手機上錄音,即時輸入到電腦', + pinFieldLabel: '配對碼(電腦上顯示的 6 位數字)', + btnConnect: '連線', + btnConnecting: '連線中…', + modeToggle: '點按', + insertLabel: '電腦落字', + modeHold: '按住', + offlineTitle: '連線已中斷', + offlineSub: '與電腦的連線已中斷。', + btnReconnect: '重新連線', + certTip: + '信任前,請將手機系統憑證詳細資訊中的完整 SHA-256 與電腦 OpenLess 設定核對;僅核對 IP 或名稱不足以驗證。', + tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', + tipHold: '按住大按鈕說話,放開結束並辨識。', + labelToggleIdle: '點擊開始', + labelToggleRec: '點擊結束', + labelHoldIdle: '按住說話', + labelHoldRec: '放開結束', + ready: '準備就緒', + preparingMic: '正在準備麥克風…', + preparingBackend: '後端準備中…', + statusRecording: '🎤 錄音中', + statusTranscribing: '🔄 辨識中', + statusPolishing: '✨ 潤飾中', + statusDone: '✅ 已輸入 {n} 字', + cancelled: '已取消', + connLost: '連線已中斷', + errPinFormat: '請輸入 6 位數字配對碼。', + errPinWrong: '配對碼錯誤,請重試。', + errPinLocked: '配對已鎖定,請在電腦上重新產生配對碼。', + errConnFail: '連線失敗。多半是手機未信任電腦憑證,請先信任憑證後重試。', + errConnCreate: '無法建立連線,請檢查網路。', + errConnTimeout: '連線逾時。多半是手機未信任電腦憑證,請依下方說明信任後重試。', + busy: '電腦忙碌:{reason}', + busyDefault: '請稍候', + micDenied: '❌ 麥克風權限遭拒,請在瀏覽器設定中允許。', + micNotFound: '❌ 找不到可用的麥克風。', + micBusy: '❌ 麥克風被其他應用程式佔用。', + micTimeout: '❌ 麥克風準備逾時,請重試。', + pcmQueueOverflow: '❌ 音訊暫存已滿,請重試。', + micUnknown: '❌ 無法啟動錄音{name}。', + errGeneric: '發生錯誤', + helpTitle: '首次設定:信任這台電腦', + helpAndroid: + 'Android:下載 CA 憑證,在系統憑證預覽中核對完整 SHA-256 後再安裝。若系統無法在信任前顯示指紋,請勿從此頁安裝,改用既有的可信任檔案傳輸管道。選單名稱依裝置而異。', + helpVerify: + '先開啟電腦的 OpenLess 遠端輸入設定,保留「本機根憑證 SHA-256」。在手機系統憑證詳細資訊中核對全部 64 個字元;不要使用本網頁、描述檔名稱或識別碼中的值作為證明。不一致或無法查看時,請停止並移除描述檔。描述檔必須只含一張根憑證,不得有其他憑證、VPN 或管理設定。', + helpIos: + 'iPhone / iPad:下載描述檔,在「設定 → 一般 → VPN 與裝置管理」中開啟,選擇「更多詳細資訊」中的憑證並核對指紋。確認一致且沒有額外設定後再安裝,並到「一般 → 關於本機 → 憑證信任設定」開啟完全信任。若只能在安裝後查看詳細資訊,請先保持完全信任關閉,核對後再開啟。完成後返回 Safari 重新整理。', + helpDownloadCert: '↓ iPhone:下載描述檔', + helpDownloadAndroid: '↓ Android:下載 CA 憑證', + helpTrustWarning: + '首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。', + helpCopyLink: '⧉ 複製連結', + helpCopied: '已複製 ✓', + copy: '複製', + copied: '已複製 ✓', + }, + en: { + wakeLockLabel: 'Keep screen awake while recording', + wakeLockHint: + 'Screen lock ends this recording. The computer processes the audio already received.', + wakeLockActive: 'Screen stays awake until recording ends.', + wakeLockUnavailable: + 'The browser or system did not allow screen wake lock. Received audio will still be processed.', + interrupted: 'Recording interrupted. The computer is processing the audio received…', + offlineRecording: + 'Disconnected. The computer continues processing received audio. Reconnect to see the result.', + recovering: 'The computer is processing your last recording…', + recovered: 'Last transcription recovered', + recoveryRetry: + 'Transcription failed. The recording is saved in computer history and can be retried.', + recoveryUnavailable: 'Result unavailable. Please check history on the computer.', + title: 'OpenLess Remote Input', + brandTitle: 'OpenLess Remote Input', + brandSub: 'Record on your phone, type to your computer in real time', + pinFieldLabel: 'Pairing code (6 digits shown on your computer)', + btnConnect: 'Connect', + btnConnecting: 'Connecting…', + modeToggle: 'Tap', + insertLabel: 'Type on PC', + modeHold: 'Hold', + offlineTitle: 'Disconnected', + offlineSub: 'The connection to your computer was lost.', + btnReconnect: 'Reconnect', + certTip: + "Before trusting, compare the full SHA-256 in the phone's system certificate details with OpenLess settings on the computer. An IP address or name alone is not enough.", + tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', + tipHold: 'Hold the big button to talk, release to finish and transcribe.', + labelToggleIdle: 'Tap to start', + labelToggleRec: 'Tap to stop', + labelHoldIdle: 'Hold to talk', + labelHoldRec: 'Release to stop', + ready: 'Ready', + preparingMic: 'Preparing microphone…', + preparingBackend: 'Preparing backend…', + statusRecording: '🎤 Recording', + statusTranscribing: '🔄 Transcribing', + statusPolishing: '✨ Polishing', + statusDone: '✅ Inserted {n} chars', + cancelled: 'Cancelled', + connLost: 'Connection lost', + errPinFormat: 'Please enter the 6-digit pairing code.', + errPinWrong: 'Wrong pairing code, please try again.', + errPinLocked: 'Pairing locked. Please regenerate the code on your computer.', + errConnFail: + 'Connection failed — usually the phone does not trust the computer certificate. Trust it, then retry.', + errConnCreate: 'Could not connect. Please check your network.', + errConnTimeout: + 'Connection timed out — the phone likely does not trust the certificate. Follow the steps below to trust it, then retry.', + busy: 'Computer busy: {reason}', + busyDefault: 'please wait', + micDenied: '❌ Microphone permission denied. Please allow it in browser settings.', + micNotFound: '❌ No microphone available.', + micBusy: '❌ Microphone is in use by another app.', + micTimeout: '❌ Microphone setup timed out. Please try again.', + pcmQueueOverflow: '❌ Audio buffer is full. Please try again.', + micUnknown: '❌ Could not start recording{name}.', + errGeneric: 'An error occurred', + helpTitle: 'First-time setup: trust this computer', + helpAndroid: + 'Android: download the CA and verify its full SHA-256 in the system certificate preview before installing it. If your device cannot show the fingerprint before trust, do not install from this page; use an existing authenticated file-transfer channel instead. Menu names vary by device.', + helpVerify: + "Open Remote Input settings in OpenLess on the computer and keep its root CA SHA-256 visible. Compare all 64 characters in the phone's system certificate details; do not use a value from this page, a profile name or identifier as proof. If it differs or cannot be viewed, stop and remove the profile. Expect only one root certificate, with no additional certificates, VPN or management settings.", + helpIos: + 'iPhone / iPad: download the profile, open it in Settings → General → VPN & Device Management, then open More Details → certificate and verify its fingerprint. Only after it matches and no extra settings are present, install and enable full trust in General → About → Certificate Trust Settings. If details are available only after installation, leave full trust off until verified. Reload Safari afterwards.', + helpDownloadCert: '↓ iPhone: download profile', + helpDownloadAndroid: '↓ Android: download CA', + helpTrustWarning: + "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", + helpCopyLink: '⧉ Copy link', + helpCopied: 'Copied ✓', + copy: 'Copy', + copied: 'Copied ✓', + }, + es: { + title: 'Entrada remota de OpenLess', + brandTitle: 'Entrada remota de OpenLess', + brandSub: 'Graba desde el móvil y escribe en el ordenador en tiempo real', + pinFieldLabel: 'Código de emparejamiento (6 dígitos mostrados en el ordenador)', + btnConnect: 'Conectar', + btnConnecting: 'Conectando…', + modeToggle: 'Tocar', + insertLabel: 'Escribir en el ordenador', + modeHold: 'Mantener', + offlineTitle: 'Desconectado', + offlineSub: 'Se ha perdido la conexión con el ordenador.', + btnReconnect: 'Volver a conectar', + certTip: + 'Si aparece un aviso de certificado, comprueba que la dirección coincide con tu ordenador y sigue «Configuración inicial: confiar en este ordenador» para instalarlo y confiar plenamente en él.', + tipToggle: 'Toca el botón grande para grabar y vuelve a tocarlo para terminar y transcribir.', + tipHold: 'Mantén pulsado el botón para hablar y suéltalo para terminar y transcribir.', + labelToggleIdle: 'Toca para empezar', + labelToggleRec: 'Toca para terminar', + labelHoldIdle: 'Mantén para hablar', + labelHoldRec: 'Suelta para terminar', + ready: 'Listo', + preparingMic: 'Preparando el micrófono…', + preparingBackend: 'Preparando el servicio…', + statusRecording: '🎤 Grabando', + statusTranscribing: '🔄 Transcribiendo', + statusPolishing: '✨ Puliendo el texto', + statusDone: '✅ Se han insertado {n} caracteres', + cancelled: 'Cancelado', + connLost: 'Conexión perdida', + errPinFormat: 'Introduce el código de emparejamiento de 6 dígitos.', + errPinWrong: 'El código no es correcto. Inténtalo de nuevo.', + errPinLocked: 'El emparejamiento está bloqueado. Genera otro código en el ordenador.', + errConnFail: + 'No se pudo conectar. Comprueba que el móvil confía en el certificado del ordenador e inténtalo de nuevo.', + errConnCreate: 'No se pudo establecer la conexión. Comprueba la red.', + errConnTimeout: + 'Se agotó el tiempo de conexión. Sigue los pasos de abajo para confiar en el certificado e inténtalo de nuevo.', + busy: 'Ordenador ocupado: {reason}', + busyDefault: 'espera un momento', + micDenied: '❌ Permiso de micrófono denegado. Actívalo en los ajustes del navegador.', + micNotFound: '❌ No hay ningún micrófono disponible.', + micBusy: '❌ Otra aplicación está usando el micrófono.', + micTimeout: '❌ Se agotó el tiempo de preparación del micrófono. Inténtalo de nuevo.', + pcmQueueOverflow: '❌ El búfer de audio está lleno. Inténtalo de nuevo.', + micUnknown: '❌ No se pudo iniciar la grabación{name}.', + errGeneric: 'Se ha producido un error', + wakeLockLabel: 'Mantener la pantalla encendida al grabar', + wakeLockHint: + 'El bloqueo de pantalla termina esta grabación; el ordenador procesa el audio ya recibido.', + wakeLockActive: 'La pantalla permanece encendida hasta que termine la grabación.', + wakeLockUnavailable: + 'El navegador o el sistema no permitió mantener la pantalla encendida; el audio recibido se procesará igualmente.', + recovering: 'El ordenador está procesando tu última grabación…', + interrupted: 'Grabación interrumpida. El ordenador procesa el audio ya recibido…', + offlineRecording: 'Conexión perdida; el ordenador seguirá procesando la grabación recibida. Reconecta para ver el resultado.', + recovered: 'Última transcripción recuperada', + recoveryRetry: + 'La transcripción falló. La grabación está guardada en el historial del ordenador y puede reintentarse.', + recoveryUnavailable: 'Resultado no disponible. Consulta el historial en el ordenador.', + helpTitle: 'Configuración inicial: confiar en este ordenador', + helpVerify: + 'Abre los ajustes de entrada remota en OpenLess del ordenador y mantén visible el SHA-256 de la CA raíz. Compara los 64 caracteres en los detalles del certificado del sistema del teléfono; no uses como prueba un valor de esta página, un nombre de perfil o un identificador. Si difiere o no se puede ver, detente y elimina el perfil. Debe haber exactamente un certificado raíz, sin certificados adicionales, VPN ni ajustes de gestión.', + helpAndroid: + 'Android: descarga el certificado CA y verifica su SHA-256 completo en la vista previa de certificados del sistema antes de instalarlo. Si tu dispositivo no puede mostrar la huella antes de confiar, no lo instales desde esta página; usa un canal de transferencia de archivos ya autenticado. Los nombres de los menús varían según el dispositivo.', + helpIos: + 'iPhone / iPad: descarga el perfil, ábrelo en Ajustes → General → VPN y gestión de dispositivos y revisa su huella en Más detalles → certificado. Solo cuando coincida y no haya ajustes extra, instálalo y activa la confianza completa en General → Información → Ajustes de confianza de certificados. Si los detalles solo se ven tras instalar, deja la confianza completa desactivada hasta verificarlo. Después recarga Safari.', + helpDownloadCert: '↓ iPhone: descargar perfil', + helpDownloadAndroid: '↓ Android: descargar certificado CA', + helpTrustWarning: + 'La descarga inicial del certificado no puede verificar la identidad del ordenador: un dispositivo malicioso en la LAN podría sustituir el certificado raíz en un ataque de intermediario. Instálalo solo en una red doméstica o privada de confianza, nunca en redes públicas o compartidas. La CA raíz puede emitir certificados y su clave privada permanece en este ordenador; elimínalo del teléfono cuando dejes de usarlo.', + helpCopyLink: '⧉ Copiar enlace', + helpCopied: 'Copiado ✓', + copy: 'Copiar', + copied: 'Copiado ✓', + }, + fr: { + title: 'Saisie à distance OpenLess', + brandTitle: 'Saisie à distance OpenLess', + brandSub: 'Enregistrez sur votre téléphone et écrivez sur votre ordinateur en temps réel', + pinFieldLabel: 'Code de jumelage (6 chiffres affichés sur votre ordinateur)', + btnConnect: 'Connecter', + btnConnecting: 'Connexion…', + modeToggle: 'Toucher', + insertLabel: 'Saisir sur le PC', + modeHold: 'Maintenir', + offlineTitle: 'Déconnecté', + offlineSub: 'La connexion à votre ordinateur a été perdue.', + btnReconnect: 'Reconnecter', + certTip: + 'Si un avertissement de certificat apparaît, vérifiez que l’adresse correspond à votre ordinateur, puis suivez « Réglage initial : faire confiance à cet ordinateur » pour installer le certificat et l’approuver entièrement.', + tipToggle: + 'Touchez le grand bouton pour enregistrer, puis à nouveau pour terminer et transcrire.', + tipHold: + 'Maintenez le grand bouton pour parler, puis relâchez-le pour terminer et transcrire.', + labelToggleIdle: 'Toucher pour démarrer', + labelToggleRec: 'Toucher pour arrêter', + labelHoldIdle: 'Maintenir pour parler', + labelHoldRec: 'Relâcher pour arrêter', + ready: 'Prêt', + preparingMic: 'Préparation du microphone…', + preparingBackend: 'Préparation du service…', + statusRecording: '🎤 Enregistrement', + statusTranscribing: '🔄 Transcription', + statusPolishing: '✨ Retouche du texte', + statusDone: '✅ {n} caractères insérés', + cancelled: 'Annulé', + connLost: 'Connexion perdue', + errPinFormat: 'Saisissez le code de jumelage à 6 chiffres.', + errPinWrong: 'Code incorrect. Réessayez.', + errPinLocked: 'Jumelage verrouillé. Générez un nouveau code sur votre ordinateur.', + errConnFail: + 'Connexion impossible. Vérifiez que le téléphone accepte le certificat de votre ordinateur, puis réessayez.', + errConnCreate: 'Impossible de se connecter. Vérifiez votre réseau.', + errConnTimeout: + 'Délai de connexion dépassé. Suivez les instructions ci-dessous pour accepter le certificat, puis réessayez.', + busy: 'Ordinateur occupé : {reason}', + busyDefault: 'veuillez patienter', + micDenied: '❌ Accès au microphone refusé. Autorisez-le dans les réglages du navigateur.', + micNotFound: '❌ Aucun microphone disponible.', + micBusy: '❌ Le microphone est utilisé par une autre application.', + micTimeout: '❌ La préparation du microphone a expiré. Réessayez.', + pcmQueueOverflow: '❌ Le tampon audio est plein. Réessayez.', + micUnknown: '❌ Impossible de démarrer l’enregistrement{name}.', + errGeneric: 'Une erreur est survenue', + helpTitle: 'Réglage initial : faire confiance à cet ordinateur', + wakeLockLabel: 'Garder l’écran allumé pendant l’enregistrement', + wakeLockHint: + 'Le verrouillage de l’écran met fin à cet enregistrement ; l’ordinateur traite l’audio déjà reçu.', + wakeLockActive: 'L’écran reste allumé jusqu’à la fin de l’enregistrement.', + wakeLockUnavailable: + 'Le navigateur ou le système n’a pas permis de garder l’écran allumé ; l’audio déjà reçu sera quand même traité.', + recovering: 'L’ordinateur traite votre dernier enregistrement…', + interrupted: 'Enregistrement interrompu. L’ordinateur traite l’audio déjà reçu…', + offlineRecording: 'Connexion perdue ; l’ordinateur continuera de traiter l’enregistrement reçu. Reconnectez-vous pour voir le résultat.', + recovered: 'Dernière transcription récupérée', + recoveryRetry: + 'La transcription a échoué. L’enregistrement est conservé dans l’historique de l’ordinateur et peut être relancé.', + recoveryUnavailable: 'Résultat indisponible. Consultez l’historique sur l’ordinateur.', + helpVerify: + 'Ouvrez les réglages de saisie à distance dans OpenLess sur l’ordinateur et gardez visible le SHA-256 de la CA racine. Comparez les 64 caractères dans les détails du certificat du système du téléphone ; n’utilisez jamais comme preuve une valeur de cette page, un nom de profil ou un identifiant. En cas de différence ou si l’affichage est impossible, arrêtez et supprimez le profil. Il ne doit y avoir exactement qu’un certificat racine, sans certificats, VPN ou réglages de gestion supplémentaires.', + helpAndroid: + 'Android : téléchargez le certificat CA et vérifiez son SHA-256 complet dans l’aperçu des certificats du système avant de l’installer. Si votre appareil ne peut pas afficher l’empreinte avant l’approbation, ne l’installez pas depuis cette page ; utilisez un canal de transfert de fichiers déjà authentifié. Les noms des menus varient selon l’appareil.', + helpIos: + 'iPhone / iPad : téléchargez le profil, ouvrez-le dans Réglages → Général → VPN et gestion des appareils et vérifiez son empreinte dans Plus de détails → certificat. Installez-le et activez la confiance complète dans Général → Informations → Réglages de confiance des certificats uniquement si l’empreinte correspond et sans réglages supplémentaires. Si les détails ne sont visibles qu’après l’installation, laissez la confiance complète désactivée jusqu’à la vérification. Rechargez ensuite Safari.', + helpDownloadCert: '↓ iPhone : télécharger le profil', + helpDownloadAndroid: '↓ Android : télécharger le certificat CA', + helpTrustWarning: + 'Le téléchargement initial du certificat ne permet pas de vérifier l’identité de l’ordinateur : un appareil malveillant sur le LAN pourrait remplacer le certificat racine par une attaque de l’homme du milieu. N’installez le certificat que sur un réseau domestique ou privé de confiance, jamais sur un réseau public ou partagé. La CA racine peut émettre des certificats et sa clé privée reste sur cet ordinateur ; supprimez-la de votre téléphone lorsque vous ne l’utilisez plus.', + helpCopyLink: '⧉ Copier le lien', + helpCopied: 'Copié ✓', + copy: 'Copier', + copied: 'Copié ✓', + }, + de: { + title: 'OpenLess Ferneingabe', + brandTitle: 'OpenLess Ferneingabe', + brandSub: 'Auf dem Smartphone aufnehmen und in Echtzeit am Computer schreiben', + pinFieldLabel: 'Kopplungscode (6 Ziffern auf dem Computer)', + btnConnect: 'Verbinden', + btnConnecting: 'Verbindung wird hergestellt…', + modeToggle: 'Tippen', + insertLabel: 'Am Computer einfügen', + modeHold: 'Gedrückt halten', + offlineTitle: 'Getrennt', + offlineSub: 'Die Verbindung zum Computer wurde getrennt.', + btnReconnect: 'Erneut verbinden', + certTip: + 'Falls eine Zertifikatswarnung erscheint, prüfe, dass die Adresse mit der auf dem Computer angezeigten übereinstimmt, und folge dann „Erstmalige Einrichtung: diesem Computer vertrauen“, um das Zertifikat zu installieren und vollständig zu vertrauen.', + tipToggle: + 'Tippe auf die große Schaltfläche, um aufzunehmen. Tippe erneut, um die Aufnahme zu beenden und zu transkribieren.', + tipHold: + 'Halte die große Schaltfläche zum Sprechen gedrückt. Lass sie los, um die Aufnahme zu beenden und zu transkribieren.', + labelToggleIdle: 'Zum Starten tippen', + labelToggleRec: 'Zum Beenden tippen', + labelHoldIdle: 'Zum Sprechen halten', + labelHoldRec: 'Zum Beenden loslassen', + ready: 'Bereit', + preparingMic: 'Mikrofon wird vorbereitet…', + preparingBackend: 'Dienst wird vorbereitet…', + statusRecording: '🎤 Aufnahme läuft', + statusTranscribing: '🔄 Transkription läuft', + statusPolishing: '✨ Text wird überarbeitet', + statusDone: '✅ {n} Zeichen eingefügt', + cancelled: 'Abgebrochen', + connLost: 'Verbindung getrennt', + errPinFormat: 'Gib den 6-stelligen Kopplungscode ein.', + errPinWrong: 'Der Code ist falsch. Versuche es erneut.', + errPinLocked: 'Die Kopplung ist gesperrt. Erstelle am Computer einen neuen Code.', + errConnFail: + 'Die Verbindung ist fehlgeschlagen. Prüfe, ob das Smartphone dem Zertifikat des Computers vertraut, und versuche es erneut.', + errConnCreate: 'Die Verbindung konnte nicht hergestellt werden. Prüfe dein Netzwerk.', + errConnTimeout: + 'Zeitüberschreitung bei der Verbindung. Befolge die Schritte unten, um dem Zertifikat zu vertrauen, und versuche es erneut.', + busy: 'Computer beschäftigt: {reason}', + busyDefault: 'bitte warten', + micDenied: '❌ Mikrofonzugriff verweigert. Erlaube ihn in den Browsereinstellungen.', + micNotFound: '❌ Kein Mikrofon verfügbar.', + micBusy: '❌ Eine andere App verwendet das Mikrofon.', + micTimeout: '❌ Zeitüberschreitung beim Vorbereiten des Mikrofons. Versuche es erneut.', + pcmQueueOverflow: '❌ Der Audiopuffer ist voll. Versuche es erneut.', + micUnknown: '❌ Die Aufnahme konnte nicht gestartet werden{name}.', + errGeneric: 'Ein Fehler ist aufgetreten', + wakeLockLabel: 'Bildschirm bei Aufnahme eingeschaltet lassen', + wakeLockHint: + 'Der Sperrbildschirm beendet diese Aufnahme; der Computer verarbeitet die bereits empfangenen Daten.', + wakeLockActive: 'Der Bildschirm bleibt eingeschaltet, bis die Aufnahme endet.', + wakeLockUnavailable: + 'Browser oder System erlaubten das Wachhalten nicht; die empfangenen Audiodaten werden dennoch verarbeitet.', + recovering: 'Der Computer verarbeitet deine letzte Aufnahme…', + interrupted: 'Aufnahme unterbrochen. Der Computer verarbeitet die bereits empfangenen Daten…', + offlineRecording: 'Verbindung getrennt; der Computer verarbeitet die empfangene Aufnahme weiter. Zum Ansehen des Ergebnisses neu verbinden.', + recovered: 'Letzte Transkription wiederhergestellt', + recoveryRetry: + 'Die Transkription ist fehlgeschlagen. Die Aufnahme liegt im Verlauf des Computers und kann erneut versucht werden.', + recoveryUnavailable: 'Ergebnis nicht verfügbar. Prüfe den Verlauf am Computer.', + helpTitle: 'Erstmalige Einrichtung: diesem Computer vertrauen', + helpVerify: + 'Öffne die Ferneingabe-Einstellungen in OpenLess am Computer und halte den SHA-256 der Root-CA sichtbar. Vergleiche alle 64 Zeichen in den Zertifikatdetails des Telefons; verwende niemals einen Wert dieser Seite, einen Profilnamen oder Bezeichner als Nachweis. Bei Abweichung oder wenn nichts angezeigt werden kann, brich ab und entferne das Profil. Es darf genau ein Root-Zertifikat enthalten sein, ohne zusätzliche Zertifikate, VPN- oder Verwaltungsprofile.', + helpAndroid: + 'Android: Lade das CA-Zertifikat herunter und prüfe vor der Installation den vollständigen SHA-256 in der System-Zertifikatsvorschau. Kann dein Gerät den Fingerabdruck vor dem Vertrauen nicht anzeigen, installiere nichts von dieser Seite; nutze stattdessen einen bereits authentifizierten Übertragungsweg. Die Menünamen unterscheiden sich je nach Gerät.', + helpIos: + 'iPhone / iPad: Lade das Konfigurationsprofil herunter, öffne es unter Einstellungen → Allgemein → VPN & Geräteverwaltung und prüfe seinen Fingerabdruck unter „Mehr Details“ → Zertifikat. Installiere es und aktiviere die volle Vertrauensstellung unter Allgemein → Info → Zertifikatsvertrauenseinstellungen erst, wenn er übereinstimmt und keine zusätzlichen Einstellungen vorhanden sind. Sind Details erst nach der Installation sichtbar, lasse die volle Vertrauensstellung bis zur Prüfung aus. Lade Safari danach neu.', + helpDownloadCert: '↓ iPhone: Profil herunterladen', + helpDownloadAndroid: '↓ Android: CA-Zertifikat herunterladen', + helpTrustWarning: + 'Beim ersten Zertifikatsdownload kann die Identität des Computers nicht geprüft werden: Ein bösartiges Gerät im LAN könnte das Root-Zertifikat in einem Man-in-the-Middle-Angriff ersetzen. Installiere es nur in einem vertrauenswürdigen Heim- oder Privatnetzwerk, niemals in öffentlichen oder geteilten Netzwerken. Die Root-CA kann Zertifikate ausstellen, ihr privater Schlüssel bleibt auf diesem Computer; entferne sie vom Smartphone, wenn du sie nicht mehr brauchst.', + helpCopyLink: '⧉ Link kopieren', + helpCopied: 'Kopiert ✓', + copy: 'Kopieren', + copied: 'Kopiert ✓', + }, + ja: { + wakeLockLabel: '録音中は画面をオンにする', + wakeLockHint: '画面をロックすると録音を終了し、受信済みの音声をパソコンで処理します。', + wakeLockActive: '録音が終わるまで画面をオンに保ちます。', + wakeLockUnavailable: + 'ブラウザーまたはシステムが画面の維持を許可しませんでした。受信済みの音声は処理されます。', + interrupted: '録音が中断されました。受信済みの音声をパソコンで処理しています…', + offlineRecording: + '接続が切れました。受信済みの音声の処理は続きます。再接続すると結果を確認できます。', + recovering: '前回の録音をパソコンで処理しています…', + recovered: '前回の文字起こし結果を復元しました', + recoveryRetry: + '文字起こしが完了しませんでした。録音はパソコンの履歴に保存され、再試行できます。', + recoveryUnavailable: '結果が見つかりません。パソコンの履歴を確認してください。', + title: 'OpenLess リモート入力', + brandTitle: 'OpenLess リモート入力', + brandSub: 'スマホで録音し、リアルタイムでパソコンに入力', + pinFieldLabel: 'ペアリングコード(パソコンに表示される6桁の数字)', + btnConnect: '接続', + btnConnecting: '接続中…', + modeToggle: 'タップ', + insertLabel: 'PCに入力', + modeHold: '長押し', + offlineTitle: '接続が切断されました', + offlineSub: 'パソコンとの接続が切断されました。', + btnReconnect: '再接続', + certTip: + '信頼する前に、スマートフォンのシステム証明書詳細にある SHA-256 全体をコンピューターの OpenLess 設定と照合してください。IP や名前だけでは確認できません。', + tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', + tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', + labelToggleIdle: 'タップで開始', + labelToggleRec: 'タップで終了', + labelHoldIdle: '長押しで話す', + labelHoldRec: '離して終了', + ready: '準備完了', + preparingMic: 'マイクを準備中…', + preparingBackend: 'バックエンドを準備しています…', + statusRecording: '🎤 録音中', + statusTranscribing: '🔄 認識中', + statusPolishing: '✨ 整文中', + statusDone: '✅ {n}文字を入力しました', + cancelled: 'キャンセルしました', + connLost: '接続が切断されました', + errPinFormat: '6桁の数字のペアリングコードを入力してください。', + errPinWrong: 'ペアリングコードが違います。もう一度お試しください。', + errPinLocked: 'ペアリングがロックされました。パソコンでコードを再生成してください。', + errConnFail: + '接続に失敗しました。多くはスマホがパソコンの証明書を信頼していないためです。証明書を信頼してから再試行してください。', + errConnCreate: '接続できません。ネットワークを確認してください。', + errConnTimeout: + '接続がタイムアウトしました。多くはスマホが証明書を信頼していないためです。下の手順で信頼してから再試行してください。', + busy: 'パソコンがビジー状態です:{reason}', + busyDefault: 'お待ちください', + micDenied: '❌ マイクの許可が拒否されました。ブラウザの設定で許可してください。', + micNotFound: '❌ 利用可能なマイクが見つかりません。', + micBusy: '❌ マイクが他のアプリで使用されています。', + micTimeout: '❌ マイクの準備がタイムアウトしました。もう一度お試しください。', + pcmQueueOverflow: '❌ 音声バッファがいっぱいです。もう一度お試しください。', + micUnknown: '❌ 録音を開始できませんでした{name}。', + errGeneric: 'エラーが発生しました', + helpTitle: '初回設定:このコンピュータを信頼', + helpAndroid: + 'Android:CA をダウンロードし、システムの証明書プレビューで SHA-256 全体を確認してからインストールします。信頼する前に指紋を表示できない端末では、このページからインストールせず、既存の認証済みファイル転送手段を使用してください。項目名は端末によって異なります。', + helpVerify: + 'コンピューターの OpenLess でリモート入力設定を開き、ルート CA の SHA-256 を表示したままにします。スマートフォンのシステム証明書詳細で全 64 文字を照合してください。このページ、プロファイル名や識別子の値は証明に使えません。一致しない場合や表示できない場合は中止し、プロファイルを削除してください。含まれるのはルート証明書 1 枚のみで、追加の証明書、VPN、管理設定がないことも確認してください。', + helpIos: + 'iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」で開き、「詳細情報」の証明書で指紋を照合します。一致し、余分な設定がないことを確認してからインストールし、「一般 → 情報 → 証明書信頼設定」で完全に信頼してください。インストール後にしか詳細を表示できない場合は、確認が終わるまで完全な信頼をオフにしてください。その後 Safari を再読み込みします。', + helpDownloadCert: '↓ iPhone:プロファイルをダウンロード', + helpDownloadAndroid: '↓ Android:CA をダウンロード', + helpTrustWarning: + '初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。', + helpCopyLink: '⧉ リンクをコピー', + helpCopied: 'コピーしました ✓', + copy: 'コピー', + copied: 'コピー済み ✓', + }, + ko: { + wakeLockLabel: '녹음 중 화면 켜짐 유지', + wakeLockHint: '화면을 잠그면 녹음이 끝나고 컴퓨터가 이미 받은 오디오를 처리합니다.', + wakeLockActive: '녹음이 끝날 때까지 화면을 켜진 상태로 유지합니다.', + wakeLockUnavailable: + '브라우저 또는 시스템이 화면 켜짐 유지를 허용하지 않았습니다. 수신한 오디오는 계속 처리됩니다.', + interrupted: '녹음이 중단되었습니다. 컴퓨터가 받은 오디오를 처리하고 있습니다…', + offlineRecording: + '연결이 끊겼습니다. 받은 오디오는 계속 처리됩니다. 다시 연결하면 결과를 볼 수 있습니다.', + recovering: '컴퓨터가 마지막 녹음을 처리하고 있습니다…', + recovered: '마지막 음성 인식 결과를 복구했습니다', + recoveryRetry: + '음성 인식을 완료하지 못했습니다. 녹음은 컴퓨터 기록에 저장되며 다시 시도할 수 있습니다.', + recoveryUnavailable: '결과를 찾을 수 없습니다. 컴퓨터의 기록을 확인해 주세요.', + title: 'OpenLess 원격 입력', + brandTitle: 'OpenLess 원격 입력', + brandSub: '휴대폰으로 녹음하여 실시간으로 컴퓨터에 입력', + pinFieldLabel: '페어링 코드 (컴퓨터에 표시된 6자리 숫자)', + btnConnect: '연결', + btnConnecting: '연결 중…', + modeToggle: '탭', + insertLabel: 'PC에 입력', + modeHold: '길게 누르기', + offlineTitle: '연결이 끊겼습니다', + offlineSub: '컴퓨터와의 연결이 끊겼습니다.', + btnReconnect: '다시 연결', + certTip: + '신뢰하기 전에 휴대폰 시스템의 인증서 상세 정보에 있는 전체 SHA-256을 컴퓨터의 OpenLess 설정과 비교하세요. IP나 이름만 확인해서는 충분하지 않습니다.', + tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', + tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', + labelToggleIdle: '탭하여 시작', + labelToggleRec: '탭하여 종료', + labelHoldIdle: '눌러서 말하기', + labelHoldRec: '떼면 종료', + ready: '준비 완료', + preparingMic: '마이크 준비 중…', + preparingBackend: '백엔드 준비 중…', + statusRecording: '🎤 녹음 중', + statusTranscribing: '🔄 인식 중', + statusPolishing: '✨ 다듬는 중', + statusDone: '✅ {n}자 입력함', + cancelled: '취소됨', + connLost: '연결이 끊겼습니다', + errPinFormat: '6자리 숫자 페어링 코드를 입력하세요.', + errPinWrong: '페어링 코드가 잘못되었습니다. 다시 시도하세요.', + errPinLocked: '페어링이 잠겼습니다. 컴퓨터에서 코드를 다시 생성하세요.', + errConnFail: + '연결에 실패했습니다. 대개 휴대폰이 컴퓨터 인증서를 신뢰하지 않기 때문입니다. 인증서를 신뢰한 후 다시 시도하세요.', + errConnCreate: '연결할 수 없습니다. 네트워크를 확인하세요.', + errConnTimeout: + '연결 시간이 초과되었습니다. 대개 인증서를 신뢰하지 않기 때문입니다. 아래 안내대로 신뢰 후 다시 시도하세요.', + busy: '컴퓨터가 사용 중입니다: {reason}', + busyDefault: '잠시 기다려 주세요', + micDenied: '❌ 마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.', + micNotFound: '❌ 사용 가능한 마이크가 없습니다.', + micBusy: '❌ 마이크가 다른 앱에서 사용 중입니다.', + micTimeout: '❌ 마이크 준비 시간이 초과되었습니다. 다시 시도하세요.', + pcmQueueOverflow: '❌ 오디오 버퍼가 가득 찼습니다. 다시 시도하세요.', + micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', + errGeneric: '오류가 발생했습니다', + helpTitle: '최초 설정: 이 컴퓨터 신뢰', + helpAndroid: + 'Android: CA를 다운로드하고 시스템 인증서 미리보기에서 전체 SHA-256을 확인한 뒤 설치하세요. 신뢰하기 전에 지문을 볼 수 없는 기기에서는 이 페이지에서 설치하지 말고 기존의 인증된 파일 전송 수단을 사용하세요. 메뉴 이름은 기기마다 다릅니다.', + helpVerify: + '컴퓨터의 OpenLess 원격 입력 설정에서 루트 CA SHA-256을 표시해 두세요. 휴대폰 시스템의 인증서 상세 정보에서 64자 전체를 비교하세요. 이 웹 페이지, 프로파일 이름이나 식별자의 값은 증명으로 사용할 수 없습니다. 일치하지 않거나 볼 수 없으면 중단하고 프로파일을 제거하세요. 루트 인증서 한 개만 있고 추가 인증서, VPN 또는 관리 설정이 없는지도 확인하세요.', + helpIos: + 'iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 여세요. 추가 세부사항의 인증서에서 지문을 확인하세요. 일치하고 추가 설정이 없는 경우에만 설치한 뒤 일반 → 정보 → 인증서 신뢰 설정에서 완전한 신뢰를 켜세요. 설치 후에만 상세 정보를 볼 수 있다면 확인이 끝날 때까지 완전한 신뢰를 꺼 두세요. 이후 Safari를 새로고침하세요.', + helpDownloadCert: '↓ iPhone: 프로파일 다운로드', + helpDownloadAndroid: '↓ Android: CA 다운로드', + helpTrustWarning: + '최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.', + helpCopyLink: '⧉ 링크 복사', + helpCopied: '복사됨 ✓', + copy: '복사', + copied: '복사됨 ✓', + }, + }; + + // 解析显示语言:优先 PC 注入的 window.__OL_LANG__,回退手机系统语言。 + var LANG = (function () { + var injected = (window.__OL_LANG__ || '').trim(); + if (Object.prototype.hasOwnProperty.call(I18N, injected)) return injected; + var nav = (navigator.language || '').toLowerCase(); + if (nav.indexOf('zh') === 0) { + if ( + nav.indexOf('hant') >= 0 || + nav.indexOf('tw') >= 0 || + nav.indexOf('hk') >= 0 || + nav.indexOf('mo') >= 0 + ) + return 'zh-TW'; + return 'zh-CN'; + } + var base = nav.split('-')[0]; + if (Object.prototype.hasOwnProperty.call(I18N, base)) return base; + return 'zh-CN'; + })(); + var L = I18N[LANG] || I18N['zh-CN']; + + // 极简插值:把 "{n}" / "{reason}" / "{name}" 替换成对应值。 + function fmt(tpl, vars) { + return String(tpl).replace(/\{(\w+)\}/g, function (_, k) { + return vars && vars[k] != null ? vars[k] : ''; + }); + } + + // 把 index.html 里带 data-i18n 的静态文案按当前语言渲染。 + function applyStaticI18n() { + try { + document.title = L.title; + } catch (e) {} + var nodes = document.querySelectorAll('[data-i18n]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-i18n'); + if (L[key] != null) nodes[i].textContent = L[key]; + } + } + + // ---------- 常量 ---------- + var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 + var MODE_KEY = 'ol_remote_mode'; // localStorage 键:录音方式 + var PIN_KEY = 'ol_remote_pin'; // localStorage 键:上次成功的配对码 + var INSERT_KEY = 'ol_remote_insert'; // localStorage 键:电脑落字开关(默认开) + var WAKE_LOCK_KEY = 'ol_remote_wake_lock'; + var RECOVERY_KEY = 'ol_remote_recovery_session'; + var MIC_PREP_TIMEOUT_MS = 10000; // 麦克风准备超时:超过则判失败让用户重试,避免无限卡"准备中" + var PCM_QUEUE_MAX_BYTES = 128 * 1024; + + // ---------- DOM ---------- + var $ = function (id) { + return document.getElementById(id); + }; + var screenPin = $('screen-pin'); + var screenRec = $('screen-rec'); + var screenOffline = $('screen-offline'); + + var pinInput = $('pin-input'); + var pinError = $('pin-error'); + var btnConnect = $('btn-connect'); + + var recordBtn = $('btn-record'); + var recordLabel = $('record-label'); + var statusBar = $('status-bar'); + var statusText = $('status-text'); + var statusIcon = $('status-icon'); + var statusDots = $('status-dots'); + var resultWrap = $('result-wrap'); + var resultText = $('result-text'); + var resultCopy = $('result-copy'); + var levelBar = $('level-bar'); + var recTip = $('rec-tip'); + var modeSwitch = $('mode-switch'); + var insertSwitch = $('insert-switch'); + var wakeLockSwitch = $('wake-lock-switch'); + var wakeLockHint = $('wake-lock-hint'); + + var btnReconnect = $('btn-reconnect'); + var offlineReason = $('offline-reason'); + var copyCertBtn = $('copy-cert-link'); + + // ---------- 状态 ---------- + var ws = null; + var authed = false; + var recording = false; // 是否正在录音(决定是否 send 音频) + var startSent = false; // 本次录音的 {type:'start'} 是否已真正发出(等 ensureAudio 异步就绪后才发) + var busy = false; // PC 端忙,本次禁用 + var mode = readMode(); // 'toggle' | 'hold' + var lastPin = ''; + var remoteSessionId = ''; + var remoteSequence = 0; + var finishAfterStarted = ''; // ACK 前松手/取消:'stop' | 'cancel' | '' + var pendingPcm = []; + var pendingPcmBytes = 0; + var awaitingResult = false; + var savedRecovery = readRecoverySession(); + var recoverySessionId = savedRecovery.sessionId; + var recoveryKey = savedRecovery.key; + var recoveryTimer = null; + var wakeLock = null; + var wakeLockGeneration = 0; + var wakeLockPending = null; + + // 音频相关 + var audioCtx = null; + var mediaStream = null; + var sourceNode = null; + var workletNode = null; + var scriptNode = null; + var workletUrl = null; + var usingWorklet = false; + // 音频代际计数:每次重置/释放音频时自增。getUserMedia 可能在 withTimeout 超时后 + // 迟到 resolve,若不校验代际,迟到的 stream 会泄漏活跃麦克风轨道,甚至覆盖丢失 + // 用户重试成功后的新流。 + var audioGen = 0; + // ScriptProcessor 兜底用的重采样状态(跨块保留) + var resampleState = { phase: 0, last: 0, hasLast: false }; + + // ============================================================ + // 配对码持久化(localStorage) + // ============================================================ + function readPin() { + try { + var p = localStorage.getItem(PIN_KEY); + return /^\d{6}$/.test(p || '') ? p : ''; + } catch (e) { + return ''; + } + } + function writePin(p) { + try { + if (/^\d{6}$/.test(p)) localStorage.setItem(PIN_KEY, p); + } catch (e) {} + } + function clearPin() { + try { + localStorage.removeItem(PIN_KEY); + } catch (e) {} + saveRecoverySession(''); + } + + // 恢复凭据仅用于本次随机会话;不请求电脑历史记录列表。 + function readRecoverySession() { + try { + var saved = JSON.parse(localStorage.getItem(RECOVERY_KEY) || 'null'); + if (saved && validSessionId(saved.sessionId) && validSessionId(saved.key)) return saved; + } catch (e) {} + return { sessionId: '', key: '' }; + } + function validSessionId(id) { + return ( + typeof id === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + ); + } + function saveRecoverySession(id, key) { + recoverySessionId = validSessionId(id) && validSessionId(key) ? id : ''; + recoveryKey = recoverySessionId ? key : ''; + try { + if (recoverySessionId) + localStorage.setItem( + RECOVERY_KEY, + JSON.stringify({ sessionId: recoverySessionId, key: recoveryKey }), + ); + else localStorage.removeItem(RECOVERY_KEY); + } catch (e) {} + } + function clearRecoveryTimer() { + if (recoveryTimer) { + clearTimeout(recoveryTimer); + recoveryTimer = null; + } + } + function requestRecovery() { + clearRecoveryTimer(); + if (!authed || document.hidden || recording || startSent || !recoverySessionId) return; + wsSendJSON({ type: 'recover', sessionId: recoverySessionId, recoveryKey: recoveryKey }); + // 唤醒后的旧连接可能仍显示 OPEN,却再也收不到数据;超时重新认证。 + recoveryTimer = setTimeout(function () { + recoveryTimer = null; + if (!recording && authed && !document.hidden) { + var pin = readPin(); + if (pin) connect(pin); + } + }, 8000); + } + function handleRecovery(msg) { + if (msg.sessionId !== recoverySessionId || recording || startSent) return; + clearRecoveryTimer(); + clearWorkTimeout(); + var recovery = msg.recovery || {}; + awaitingResult = recovery.kind === 'pending'; + updateRecordBtnUI(); + if (recovery.kind === 'pending') { + setStatus(L.recovering, 'work'); + if (!document.hidden) recoveryTimer = setTimeout(requestRecovery, 1500); + } else if (recovery.kind === 'completed') { + showResult(recovery.text); + setStatus(L.recovered, 'ok'); + } else if (recovery.kind === 'failed') { + setStatus(recovery.hasAudioRecording ? L.recoveryRetry : L.recoveryUnavailable, 'error'); + } else { + saveRecoverySession(''); + setStatus(L.recoveryUnavailable, 'error'); + } + } + + function shouldKeepAwake() { + return recording && !document.hidden && wakeLockSwitch && wakeLockSwitch.checked; + } + function releaseWakeLock() { + wakeLockGeneration++; + wakeLockPending = null; + var previous = wakeLock; + wakeLock = null; + if (previous) previous.release().catch(function () {}); + } + function acquireWakeLock() { + if (!shouldKeepAwake() || wakeLock || wakeLockPending !== null) return; + if (!navigator.wakeLock || !navigator.wakeLock.request) { + if (wakeLockHint) wakeLockHint.textContent = L.wakeLockUnavailable; + return; + } + var generation = wakeLockGeneration; + wakeLockPending = generation; + navigator.wakeLock + .request('screen') + .then(function (sentinel) { + if (wakeLockPending === generation) wakeLockPending = null; + if (generation !== wakeLockGeneration || !shouldKeepAwake()) { + sentinel.release().catch(function () {}); + return; + } + wakeLock = sentinel; + if (wakeLockHint) wakeLockHint.textContent = L.wakeLockActive; + sentinel.addEventListener('release', function () { + if (wakeLock !== sentinel) return; + wakeLock = null; + if (shouldKeepAwake() && wakeLockHint) wakeLockHint.textContent = L.wakeLockUnavailable; + }); + }) + .catch(function () { + if (wakeLockPending === generation) wakeLockPending = null; + if (generation === wakeLockGeneration && shouldKeepAwake() && wakeLockHint) { + wakeLockHint.textContent = L.wakeLockUnavailable; + } + }); + } + function initWakeLockSwitch() { + if (!wakeLockSwitch) return; + try { + wakeLockSwitch.checked = localStorage.getItem(WAKE_LOCK_KEY) !== '0'; + } catch (e) { + wakeLockSwitch.checked = true; + } + if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint; + wakeLockSwitch.addEventListener('change', function () { + try { + localStorage.setItem(WAKE_LOCK_KEY, wakeLockSwitch.checked ? '1' : '0'); + } catch (e) {} + if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint; + if (wakeLockSwitch.checked) acquireWakeLock(); + else releaseWakeLock(); + }); + } + + // ============================================================ + // 屏幕切换 + // ============================================================ + function showScreen(which) { + screenPin.classList.toggle('active', which === 'pin'); + screenRec.classList.toggle('active', which === 'rec'); + screenOffline.classList.toggle('active', which === 'offline'); + } + + // ============================================================ + // 模式(toggle / hold) + // ============================================================ + // ============================================================ + // 电脑落字开关(关闭=只把文字回传手机、不落到电脑光标) + // ============================================================ + function readInsert() { + try { + return localStorage.getItem(INSERT_KEY) !== '0'; + } catch (e) { + return true; + } + } + function writeInsert(v) { + try { + localStorage.setItem(INSERT_KEY, v ? '1' : '0'); + } catch (e) {} + } + // 把当前开关值发给电脑(仅已连接时生效):进录音屏时同步一次,之后每次切换即时下发。 + function sendInsertConfig() { + wsSendJSON({ type: 'set_insert', value: insertSwitch ? insertSwitch.checked : true }); + } + function initInsertSwitch() { + if (!insertSwitch) return; + insertSwitch.checked = readInsert(); + insertSwitch.addEventListener('change', function () { + writeInsert(insertSwitch.checked); + sendInsertConfig(); + }); + } + + function readMode() { + var m = null; + try { + m = localStorage.getItem(MODE_KEY); + } catch (e) {} + // 手机明确保存的两种模式优先;首次访问、旧值损坏或存储被禁用时, + // 跟随 PC 当前默认值。不要把继承值写回存储,否则之后 PC 改设置就失效了。 + if (m === 'hold' || m === 'toggle') return m; + return window.__OL_DEFAULT_MODE__ === 'hold' ? 'hold' : 'toggle'; + } + function writeMode(m) { + mode = m; + try { + localStorage.setItem(MODE_KEY, m); + } catch (e) {} + syncModeUI(); + } + function syncModeUI() { + var btns = modeSwitch.querySelectorAll('.mode-btn'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode); + } + if (mode === 'hold') { + recTip.textContent = L.tipHold; + recordLabel.textContent = recording ? L.labelHoldRec : L.labelHoldIdle; + recordBtn.style.touchAction = 'none'; // hold 防滚动 + } else { + recTip.textContent = L.tipToggle; + recordLabel.textContent = recording ? L.labelToggleRec : L.labelToggleIdle; + recordBtn.style.touchAction = 'manipulation'; + } + } + + // 手机手动切换后保存为本机偏好,后续访问继续优先于 PC 默认值。 + modeSwitch.addEventListener('click', function (e) { + var t = e.target.closest('.mode-btn'); + if (!t) return; + var m = t.getAttribute('data-mode'); + if (m === mode) return; + // 录音中切换模式先安全停止(取消本次,避免状态错乱) + if (recording) cancelRecording(); + writeMode(m); + }); + + // ============================================================ + // 状态文字 / 音量 + // ============================================================ + function setStatus(text, kind) { + statusText.textContent = text; + // 每次切状态先清掉图标/三点动效,由调用方(applyStatusKind)按需重新点亮。 + if (statusIcon) statusIcon.hidden = true; + if (statusDots) statusDots.hidden = true; + statusBar.classList.remove('is-error', 'is-ok', 'is-work'); + if (kind === 'error') statusBar.classList.add('is-error'); + else if (kind === 'ok') statusBar.classList.add('is-ok'); + else if (kind === 'work') statusBar.classList.add('is-work'); + } + function setLevel(v) { + if (typeof v !== 'number' || isNaN(v)) return; + v = Math.max(0, Math.min(1, v)); + levelBar.style.width = (v * 100).toFixed(1) + '%'; + } + + // 去掉状态文案开头的 emoji 图标(如 '🎤 录音中' → '录音中'),改用 DOM 图标/动效呈现。 + function stripLeadingIcon(s) { + return String(s).replace(/^\S+\s+/, ''); + } + + // PC 端落字完成后回传的最终文字,显示在状态区下方;开始新一次录音时清空。 + function showResult(text) { + if (!resultWrap) return; + if (!text) { + clearResult(); + return; + } + resultText.textContent = text; + resultWrap.hidden = false; + } + function clearResult() { + if (!resultWrap) return; + resultWrap.hidden = true; + resultText.textContent = ''; + if (resultCopy) { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + } + } + + // done 后过几秒自动回到"准备就绪",方便直接开始下一次,而不是一直停在结果上。 + var readyTimer = null; + function scheduleReady() { + if (readyTimer) clearTimeout(readyTimer); + readyTimer = setTimeout(function () { + readyTimer = null; + if (!recording && authed) setStatus(L.ready, null); + }, 2500); + } + // 录音/停止/取消入口都要清掉 readyTimer,否则上一次 done 的回 ready 定时器会迟到 + // 触发,把"识别中…"等新状态错盖成"准备就绪"。 + function clearReadyTimer() { + if (readyTimer) { + clearTimeout(readyTimer); + readyTimer = null; + } + } + + // busy 提示的解除定时器:跟踪起来,新状态到来时清除,避免多个 busy 消息叠加定时器 + // 或迟到的定时器覆盖新状态。 + var busyTimer = null; + + // 识别/润色阶段的客户端兜底超时:服务端任何原因不回 done/error(如孤立会话、进程异常) + // 时,30 秒后显示通用错误并回 ready,防止 UI 永久卡在"识别中…"。 + var workTimer = null; + function armWorkTimeout() { + clearWorkTimeout(); + workTimer = setTimeout(function () { + workTimer = null; + if (!recording && authed) { + if (startSent) failRecording('❌ ' + L.errGeneric, true); + else if (recoverySessionId) requestRecovery(); + else { + awaitingResult = false; + updateRecordBtnUI(); + setStatus('❌ ' + L.errGeneric, 'error'); + setLevel(0); + } + scheduleReady(); + } + }, 30000); + } + function clearWorkTimeout() { + if (workTimer) { + clearTimeout(workTimer); + workTimer = null; + } + } + + // ============================================================ + // WebSocket + // ============================================================ + function wsSendJSON(obj) { + if (ws && ws.readyState === 1) { + try { + ws.send(JSON.stringify(obj)); + } catch (e) {} + } + } + + // 连接看门狗:wss 握手或认证在 12s 内没完成,几乎都是手机没信任电脑证书 + // (iOS Safari 对自签名 wss 不复用页面级证书例外)。与其无限"连接中",不如回到 + // 配对屏给出明确提示,引导用户去信任证书。 + var connectTimer = null; + function armConnectTimeout() { + clearConnectTimeout(); + connectTimer = setTimeout(function () { + connectTimer = null; + if (!authed) { + closeWS(); + showScreen('pin'); + showPinError(L.errConnTimeout); + resetConnectBtn(); + } + }, 12000); + } + function clearConnectTimeout() { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + } + + function connect(pin) { + lastPin = pin; + closeWS(); // 清理旧连接 + authed = false; + busy = false; + awaitingResult = false; + + var url = 'wss://' + location.host + '/ws'; + try { + ws = new WebSocket(url); + } catch (e) { + showPinError(L.errConnCreate); + resetConnectBtn(); + return; + } + ws.binaryType = 'arraybuffer'; + armConnectTimeout(); // 看门狗:握手/认证迟迟不完成 → 多半是证书没被信任 + + ws.onopen = function () { + // 连上立即握手 + wsSendJSON({ type: 'hello', pin: pin, prefer: mode }); + }; + + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // 下行只处理文本 + var msg; + try { + msg = JSON.parse(ev.data); + } catch (e) { + return; + } + handleMessage(msg); + }; + + ws.onerror = function () { + // onerror 后通常紧跟 onclose,统一在 close 里处理 UI + }; + + ws.onclose = function () { + clearConnectTimeout(); + clearReadyTimer(); + clearWorkTimeout(); + clearRecoveryTimer(); + if (busyTimer) { + clearTimeout(busyTimer); + busyTimer = null; + } + var wasAuthed = authed; + authed = false; + recording = false; + awaitingResult = false; + detachHoldEnd(); + resetRemoteStreamState(); + teardownAudio(); + if (wasAuthed) { + // 已进入录音屏后断开 → 断线屏 + offlineReason.textContent = recoverySessionId ? L.offlineRecording : L.offlineSub; + showScreen('offline'); + } else { + // 未认证就关闭(握手被拒/证书不受信任/网络中断)。无论当前是否在配对屏都给出 + // 明确提示 —— 否则(尤其安卓 Chrome 对不受信任的自签名 wss 会立刻 onclose) + // 用户只看到按钮闪一下变回"连接",完全不知道发生了什么。 + showScreen('pin'); + showPinError(L.errConnFail); + } + resetConnectBtn(); + }; + } + + function closeWS() { + clearConnectTimeout(); + clearReadyTimer(); + clearWorkTimeout(); + clearRecoveryTimer(); + if (busyTimer) { + clearTimeout(busyTimer); + busyTimer = null; + } + recording = false; + awaitingResult = false; + detachHoldEnd(); + resetRemoteStreamState(); + teardownAudio(); + if (ws) { + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; + try { + ws.close(); + } catch (e) {} + ws = null; + } + } + + function handleMessage(msg) { + if (!msg || typeof msg.type !== 'string') return; + + switch (msg.type) { + case 'auth': + if (msg.ok) { + authed = true; + busy = false; + clearConnectTimeout(); + writePin(lastPin); // 配对成功 → 记住配对码,刷新后免重输 + enterRecScreen(); + requestRecovery(); + } else { + authed = false; + clearPin(); // 配对码失效(错误/锁定)→ 清除,避免下次自动重连又失败 + var reason = msg.reason === 'locked' ? L.errPinLocked : L.errPinWrong; + closeWS(); + showScreen('pin'); + showPinError(reason); + resetConnectBtn(); + } + break; + + case 'status': + applyStatusKind(msg); + break; + + case 'started': + handleStarted(msg.sessionId, msg.recoveryKey); + break; + + case 'recovery': + handleRecovery(msg); + break; + + case 'level': + setLevel(msg.value); + break; + + case 'busy': + clearWorkTimeout(); + busy = true; + recording = false; + awaitingResult = false; + resetRemoteStreamState(); + teardownAudioCapture(); // 停止采集但保留 ctx + updateRecordBtnUI(); + setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); + // 短暂后解除忙态,允许重试。定时器存入 busyTimer 跟踪,重入时先清,避免叠加。 + if (busyTimer) clearTimeout(busyTimer); + busyTimer = setTimeout(function () { + busyTimer = null; + busy = false; + updateRecordBtnUI(); + if (!recording) setStatus(L.ready, null); + }, 1500); + break; + + case 'result': + // 电脑落字完成后回传的最终文字,显示给手机用户看本次识别结果。 + showResult(msg.text); + awaitingResult = false; + clearRecoveryTimer(); + updateRecordBtnUI(); + break; + } + } + + function applyStatusKind(msg) { + // 真实状态到来即解除 busy 兜底定时,避免它迟到触发把新状态错盖成"准备就绪"。 + if (busyTimer) { + clearTimeout(busyTimer); + busyTimer = null; + busy = false; + updateRecordBtnUI(); + } + switch (msg.kind) { + case 'recording': + setStatus(stripLeadingIcon(L.statusRecording), 'work'); + break; + case 'transcribing': + if (recording) { + recording = false; + detachHoldEnd(); + resetRemoteStreamState(); + teardownAudioCapture(); + } + awaitingResult = true; + updateRecordBtnUI(); + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; // 识别中:三点加载动效 + armWorkTimeout(); // 工作状态续上兜底超时,防止服务端中途无响应卡死 + break; + case 'polishing': + setStatus(L.statusPolishing, 'work'); // 润色保留 ✨ + armWorkTimeout(); // 同上 + break; + case 'done': + awaitingResult = false; + updateRecordBtnUI(); + clearWorkTimeout(); // 正常收尾,解除兜底超时 + var n = typeof msg.insertedChars === 'number' ? msg.insertedChars : 0; + setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok'); + if (statusIcon) { + statusIcon.src = '/done.png'; + statusIcon.hidden = false; + } // 完成:对勾图 + setLevel(0); + scheduleReady(); + break; + case 'error': + awaitingResult = false; + updateRecordBtnUI(); + clearWorkTimeout(); // 服务端已明确报错,解除兜底超时 + if (recording || startSent) failRecording('❌ ' + (msg.message || L.errGeneric), true); + else { + resetRemoteStreamState(); + setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); + setLevel(0); + } + break; + default: + if (msg.message) setStatus(msg.message, null); + } + } + + // ============================================================ + // 屏幕状态判断辅助 + // ============================================================ + function isPinScreen() { + return screenPin.classList.contains('active'); + } + + function enterRecScreen() { + showPinError(''); + showScreen('rec'); + syncModeUI(); + updateRecordBtnUI(); + setStatus(L.ready, null); + setLevel(0); + sendInsertConfig(); // 进录音屏时把「电脑落字」开关同步给电脑 + } + + // ============================================================ + // PIN 屏交互 + // ============================================================ + pinInput.addEventListener('input', function () { + // 仅保留数字 + var v = pinInput.value.replace(/\D+/g, '').slice(0, 6); + if (v !== pinInput.value) pinInput.value = v; + showPinError(''); + }); + pinInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') doConnect(); + }); + btnConnect.addEventListener('click', doConnect); + + function doConnect() { + var pin = (pinInput.value || '').replace(/\D+/g, ''); + if (pin.length !== 6) { + showPinError(L.errPinFormat); + return; + } + showPinError(''); + btnConnect.disabled = true; + btnConnect.textContent = L.btnConnecting; + connect(pin); + } + + function showPinError(text) { + if (!text) { + pinError.hidden = true; + pinError.textContent = ''; + } else { + pinError.hidden = false; + pinError.textContent = text; + } + } + function resetConnectBtn() { + btnConnect.disabled = false; + btnConnect.textContent = L.btnConnect; + } + + // 重新连接 + btnReconnect.addEventListener('click', function () { + showScreen('pin'); + showPinError(''); + resetConnectBtn(); + var p = lastPin || readPin(); + if (p) { + pinInput.value = p; + doConnect(); // 有配对码直接重连,省去再点一次 + } + }); + + // 复制证书下载链接 —— 方便换个浏览器打开,或发给自己。 + function fallbackCopyText(text, cb) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (cb) cb(); + } catch (e) {} + } + if (copyCertBtn) { + copyCertBtn.addEventListener('click', function () { + var url = location.origin + '/cert.mobileconfig'; + var ok = function () { + copyCertBtn.textContent = L.helpCopied; + setTimeout(function () { + copyCertBtn.textContent = L.helpCopyLink; + }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(ok, function () { + fallbackCopyText(url, ok); + }); + } else { + fallbackCopyText(url, ok); + } + }); + } + + // 结果文字「一键复制」:优先 navigator.clipboard(需安全上下文,本页是 HTTPS), + // 失败或旧浏览器回退 execCommand(兼容性高,见 fallbackCopyText)。 + if (resultCopy) { + resultCopy.addEventListener('click', function () { + var text = resultText.textContent || ''; + if (!text) return; + var done = function () { + resultCopy.classList.add('copied'); + resultCopy.textContent = L.copied || '已复制 ✓'; + setTimeout(function () { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, function () { + fallbackCopyText(text, done); + }); + } else { + fallbackCopyText(text, done); + } + }); + } + + // ============================================================ + // 录音按钮交互(toggle / hold) + // ============================================================ + function updateRecordBtnUI() { + recordBtn.classList.toggle('recording', recording); + recordBtn.classList.toggle('busy', (busy || awaitingResult) && !recording); + recordBtn.disabled = (busy || awaitingResult) && !recording; + if (recording) { + recordLabel.textContent = mode === 'hold' ? L.labelHoldRec : L.labelToggleRec; + } else { + recordLabel.textContent = mode === 'hold' ? L.labelHoldIdle : L.labelToggleIdle; + } + } + + // toggle 模式:click 切换 + recordBtn.addEventListener('click', function () { + if (mode !== 'toggle') return; + if (!authed || busy || awaitingResult) return; + if (recording) stopRecording(); + else startRecording(); + }); + + // hold 模式:按下开始;松开/取消结束。 + // 关键:用 document 级监听兜底"松开"事件。移动端 setPointerCapture 在动画/重排/ + // 系统权限弹窗时可能丢失,导致 recordBtn 自身的 pointerup 收不到 —— 表现为"手已 + // 松开却还在录音,得再点一下才停"。改为按下时在 document 上挂一次性的 pointerup/ + // pointercancel,无论指针最终在哪释放都能结束录音。 + var holdEndHandler = null; + function attachHoldEnd() { + if (holdEndHandler) return; + holdEndHandler = function () { + if (recording) + stopRecording(); // stopRecording 内部会 detachHoldEnd + else detachHoldEnd(); + }; + document.addEventListener('pointerup', holdEndHandler, true); + document.addEventListener('pointercancel', holdEndHandler, true); + } + function detachHoldEnd() { + if (!holdEndHandler) return; + document.removeEventListener('pointerup', holdEndHandler, true); + document.removeEventListener('pointercancel', holdEndHandler, true); + holdEndHandler = null; + } + + recordBtn.addEventListener('pointerdown', function (e) { + if (mode !== 'hold') return; + if (!authed || busy || awaitingResult) return; + e.preventDefault(); + attachHoldEnd(); + if (!recording) startRecording(); + }); + + // ============================================================ + // 录音流程 + // ============================================================ + // 给可能"永久 pending"的 Promise 兜底超时。移动端 audioCtx.resume() / getUserMedia() + // 在息屏/切后台/被占用时可能既不 resolve 也不 reject,整条 ensureAudio 链就永久卡住 —— + // start 指令发不出去、电脑端不弹胶囊,H5 一直停在"正在准备麦克风…"。超时即判失败,复位 + // 状态并提示重试,而不是无限等待。 + function withTimeout(promise, ms, tag) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + var err = new Error(tag || 'TIMEOUT'); + err.name = tag || 'TIMEOUT'; + reject(err); + }, ms); + promise.then( + function (v) { + clearTimeout(timer); + resolve(v); + }, + function (e) { + clearTimeout(timer); + reject(e); + }, + ); + }); + } + + function startRecording() { + if (recording || startSent || awaitingResult) return; + if (!ws || ws.readyState !== 1) { + setStatus(L.connLost, 'error'); + return; + } + // 先乐观置态,保证 iOS 在手势同步栈内 resume() + recording = true; + clearRecoveryTimer(); + acquireWakeLock(); + resetRemoteStreamState(); + clearReadyTimer(); // 防止上一次 done 的回 ready 定时器迟到覆盖本次状态 + clearWorkTimeout(); // 新一次录音开始,作废上一轮的识别兜底超时 + updateRecordBtnUI(); + setStatus(L.preparingMic, 'work'); + clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 + + withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') + .then(function () { + if (!recording) { + // 期间已被取消/松手 + teardownAudioCapture(); + return; + } + wsSendJSON({ type: 'start' }); + startSent = true; // start 已发出,stopRecording 才需要配对发 stop + setStatus(L.preparingBackend, 'work'); + }) + .catch(function (err) { + recording = false; + resetRemoteStreamState(); + // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 + // 同一个坏 ctx 上;非超时错误只需停采集链。 + if (err && err.name === 'TIMEOUT') resetAudioContext(); + else teardownAudioCapture(); + updateRecordBtnUI(); + setStatus(err && err.name === 'TIMEOUT' ? L.micTimeout : micErrorText(err), 'error'); + }); + } + + function stopRecording() { + detachHoldEnd(); + if (!recording) return; + clearReadyTimer(); // 防止迟到的回 ready 定时器覆盖"识别中…" + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + // start 还没发出(hold 按下后立即松手,ensureAudio 尚未完成)→ 按本地取消处理: + // 不发孤立 stop,否则 PC 无对应会话、不回 done/error,UI 会永久卡在"识别中…"。 + if (!startSent) { + resetRemoteStreamState(); + setStatus(L.ready, null); + setLevel(0); + return; + } + awaitingResult = true; + updateRecordBtnUI(); + if (remoteSessionId) { + wsSendJSON({ type: 'stop' }); + resetRemoteStreamState(); + enterTranscribing(); + } else { + // ACK 未到:先保留首段 PCM,ACK 后按序 flush,再把 stop 排在音频帧之后。 + finishAfterStarted = 'stop'; + setStatus(L.preparingBackend, 'work'); + setLevel(0); + armWorkTimeout(); + } + } + + function cancelRecording() { + detachHoldEnd(); + awaitingResult = false; + clearRecoveryTimer(); + saveRecoverySession(''); + if (!recording && !startSent) { + teardownAudioCapture(); + resetRemoteStreamState(); + return; + } + clearReadyTimer(); + clearWorkTimeout(); + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + if (startSent) { + wsSendJSON({ type: 'cancel' }); + if (remoteSessionId) resetRemoteStreamState(); + else { + finishAfterStarted = 'cancel'; + clearPendingPcm(); + } + } else { + resetRemoteStreamState(); + } + setStatus(L.cancelled, null); + setLevel(0); + } + + function micErrorText(err) { + var name = err && err.name ? err.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') { + return L.micDenied; + } + if (name === 'NotFoundError' || name === 'OverconstrainedError') { + return L.micNotFound; + } + if (name === 'NotReadableError') { + return L.micBusy; + } + return fmt(L.micUnknown, { name: name ? '(' + name + ')' : '' }); + } + + // ============================================================ + // 音频:获取设备 + 建立采集链 + // ============================================================ + // 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。 + // 必须在用户手势调用栈内(startRecording 由手势触发)。 + function ensureAudio() { + // 不支持 getUserMedia + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + + // 1) AudioContext(iOS 需手势内 resume) + if (!audioCtx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + audioCtx = new AC(); + audioCtx.onstatechange = function () { + if (audioCtx && recording && startSent && audioCtx.state !== 'running') { + interruptRecording(); + } + }; + } + + // 注意:iOS Safari 来电/Siri 后 ctx 处于私有的 'interrupted' 状态,只判 'suspended' + // 不命中,会导致录音静默无声 —— 凡是非 running 都尝试 resume。 + var resumeP = + audioCtx.state !== 'running' ? audioCtx.resume().catch(function () {}) : Promise.resolve(); + + return resumeP + .then(function () { + // 2) 麦克风流(已存在则复用) + if (mediaStream) return mediaStream; + // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, + // 避免泄漏麦克风或覆盖重试成功的新流。 + var gen = audioGen; + return navigator.mediaDevices + .getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }) + .then(function (stream) { + if (gen !== audioGen) { + try { + stream.getTracks().forEach(function (t) { + t.stop(); + }); + } catch (e) {} + return null; // 交给下一步判空直接放弃 + } + mediaStream = stream; + stream.getTracks().forEach(function (track) { + track.onended = function () { + if (recording) interruptRecording(); + }; + }); + return stream; + }); + }) + .then(function (stream) { + // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext + // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 + if (sourceNode || !audioCtx || !stream) return; + sourceNode = audioCtx.createMediaStreamSource(stream); + return buildCaptureGraph(); + }); + } + + // 建立 AudioWorklet(优先)或 ScriptProcessor(兜底) + function buildCaptureGraph() { + var inSr = audioCtx.sampleRate || 48000; + + // 优先 AudioWorklet + if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') { + return loadWorklet() + .then(function () { + workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', { + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: 1, + processorOptions: { inSr: inSr, targetSr: TARGET_SR }, + }); + workletNode.port.onmessage = function (e) { + // e.data 是已转换好的 Int16 LE ArrayBuffer + sendAudio(e.data); + }; + sourceNode.connect(workletNode); + usingWorklet = true; + }) + .catch(function () { + // worklet 加载失败 → 回退 ScriptProcessor + usingWorklet = false; + buildScriptProcessor(inSr); + }); + } + + // 无 audioWorklet:直接兜底 + usingWorklet = false; + buildScriptProcessor(inSr); + return Promise.resolve(); + } + + // ---- AudioWorklet processor(字符串 → Blob URL 加载) ---- + function loadWorklet() { + if (workletUrl) return audioCtx.audioWorklet.addModule(workletUrl); + + var code = + 'class OlPcmWorklet extends AudioWorkletProcessor {' + + ' constructor(o){' + + ' super();' + + ' var p=(o&&o.processorOptions)||{};' + + ' this.inSr=p.inSr||sampleRate;' + + ' this.targetSr=p.targetSr||16000;' + + ' this.ratio=this.inSr/this.targetSr;' + + ' this.phase=0;' + // 当前小数相位 + ' this.last=0;' + // 上一块最后一个样本(用于跨块拼接) + ' this.hasLast=false;' + + ' }' + + ' process(inputs){' + + ' var ch=inputs[0]&&inputs[0][0];' + + ' if(!ch||ch.length===0){return true;}' + + ' var ratio=this.ratio;' + + ' var phase=this.phase;' + + ' var prev=this.last;' + + ' var hasPrev=this.hasLast;' + + ' var n=ch.length;' + + // 估算输出样本数上界 + ' var outCap=Math.ceil((n+1)/ratio)+2;' + + ' var pcm=new ArrayBuffer(outCap*2);' + + ' var dv=new DataView(pcm);' + + ' var oi=0;' + + // 线性插值:phase 以"输入样本"为单位推进,step=inSr/16000 + // i=floor(phase),frac=phase-i;a=样本[i],b=样本[i+1] + // 跨块时 i 可能为 -1,用 prev 作为 a。 + ' while(true){' + + ' var i=Math.floor(phase);' + + ' var frac=phase-i;' + + ' var a,b;' + + ' if(i+1>=n){break;}' + // 需要 i 和 i+1 都在块内(或 a 用 prev) + ' if(i<0){' + + ' if(!hasPrev){phase+=ratio;continue;}' + + ' a=prev;b=ch[0];' + + ' }else{' + + ' a=ch[i];b=ch[i+1];' + + ' }' + + ' var s=a+(b-a)*frac;' + + ' if(s>1)s=1;else if(s<-1)s=-1;' + + ' dv.setInt16(oi*2, (s*32767)|0, true);' + + ' oi++;' + + ' phase+=ratio;' + + ' }' + + // 保留余数:把 phase 拉回到相对下一块起点 + ' this.phase=phase-n;' + + ' this.last=ch[n-1];' + + ' this.hasLast=true;' + + ' if(oi>0){' + + ' var out=pcm.slice(0,oi*2);' + + ' this.port.postMessage(out,[out]);' + + ' }' + + ' return true;' + + ' }' + + '}' + + 'registerProcessor("ol-pcm-worklet", OlPcmWorklet);'; + + workletUrl = URL.createObjectURL(new Blob([code], { type: 'application/javascript' })); + return audioCtx.audioWorklet.addModule(workletUrl); + } + + // ---- ScriptProcessor 兜底 ---- + function buildScriptProcessor(inSr) { + scriptNode = audioCtx.createScriptProcessor(4096, 1, 1); + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + + scriptNode.onaudioprocess = function (e) { + if (!recording) return; + var input = e.inputBuffer.getChannelData(0); + var buf = resampleToInt16LE(input, inSr); + if (buf && buf.byteLength) sendAudio(buf); + }; + // ScriptProcessor 需连到 destination 才会触发(用静音增益避免回放) + sourceNode.connect(scriptNode); + var silent = audioCtx.createGain(); + silent.gain.value = 0; + scriptNode.connect(silent); + silent.connect(audioCtx.destination); + scriptNode._silentGain = silent; + } + + // 主线程线性插值重采样(给 ScriptProcessor 用),逻辑与 worklet 一致 + function resampleToInt16LE(ch, inSr) { + var ratio = inSr / TARGET_SR; + var phase = resampleState.phase; + var prev = resampleState.last; + var hasPrev = resampleState.hasLast; + var n = ch.length; + if (n === 0) return null; + + var outCap = Math.ceil((n + 1) / ratio) + 2; + var pcm = new ArrayBuffer(outCap * 2); + var dv = new DataView(pcm); + var oi = 0; + + while (true) { + var i = Math.floor(phase); + var frac = phase - i; + var a, b; + if (i + 1 >= n) break; + if (i < 0) { + if (!hasPrev) { + phase += ratio; + continue; + } + a = prev; + b = ch[0]; + } else { + a = ch[i]; + b = ch[i + 1]; + } + var s = a + (b - a) * frac; + if (s > 1) s = 1; + else if (s < -1) s = -1; + dv.setInt16(oi * 2, (s * 32767) | 0, true); + oi++; + phase += ratio; + } + + resampleState.phase = phase - n; + resampleState.last = ch[n - 1]; + resampleState.hasLast = true; + + return oi > 0 ? pcm.slice(0, oi * 2) : null; + } + + function clearPendingPcm() { + pendingPcm = []; + pendingPcmBytes = 0; + } + + function resetRemoteStreamState() { + startSent = false; + remoteSessionId = ''; + remoteSequence = 0; + finishAfterStarted = ''; + clearPendingPcm(); + } + + function enterTranscribing() { + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; + setLevel(0); + armWorkTimeout(); + } + + function failRecording(message, notifyBackend) { + var waitingForAck = startSent && !remoteSessionId; + recording = false; + awaitingResult = false; + detachHoldEnd(); + teardownAudioCapture(); + if (notifyBackend && startSent) wsSendJSON({ type: 'cancel' }); + if (waitingForAck) { + finishAfterStarted = 'cancel'; + clearPendingPcm(); + } else { + resetRemoteStreamState(); + } + updateRecordBtnUI(); + setStatus(message, 'error'); + setLevel(0); + } + + function sendRemoteFrame(buf) { + if (!ws || ws.readyState !== 1 || !remoteSessionId) return false; + try { + ws.send(buildAudioFrame(remoteSessionId, remoteSequence, buf)); + remoteSequence++; + return true; + } catch (e) { + return false; + } + } + + function flushPendingPcm() { + var queued = pendingPcm; + clearPendingPcm(); + for (var i = 0; i < queued.length; i++) { + if (!sendRemoteFrame(queued[i])) return false; + } + return true; + } + + function handleStarted(sessionId, key) { + if (!startSent) { + clearPendingPcm(); + return; + } + if (finishAfterStarted === 'cancel') { + resetRemoteStreamState(); + updateRecordBtnUI(); + return; + } + if (remoteSessionId) return; + if ( + typeof sessionId !== 'string' || + !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + sessionId, + ) + ) { + failRecording('❌ ' + L.errGeneric, true); + return; + } + + remoteSessionId = sessionId; + saveRecoverySession(sessionId, key); + remoteSequence = 0; + if (!flushPendingPcm()) { + interruptRecording(); + return; + } + if (finishAfterStarted === 'stop') { + wsSendJSON({ type: 'stop' }); + resetRemoteStreamState(); + enterTranscribing(); + } else if (recording) { + setStatus(stripLeadingIcon(L.statusRecording), 'work'); + } else { + wsSendJSON({ type: 'cancel' }); + resetRemoteStreamState(); + } + } + + // 发送二进制音频帧;start ACK 前最多缓存 128 KiB,避免冷启动吞掉首词。 + function sendAudio(buf) { + if (!recording || !buf || !buf.byteLength) return; + if (!ws || ws.readyState !== 1) { + interruptRecording(); + return; + } + if (remoteSessionId) { + if (!sendRemoteFrame(buf)) interruptRecording(); + else updateLocalLevel(buf); + return; + } + if (pendingPcmBytes + buf.byteLength > PCM_QUEUE_MAX_BYTES) { + failRecording(L.pcmQueueOverflow, true); + return; + } + pendingPcm.push(buf); + pendingPcmBytes += buf.byteLength; + updateLocalLevel(buf); + } + + function buildAudioFrame(sessionId, sequence, pcm) { + var hex = sessionId.replace(/-/g, ''); + if (!/^[0-9a-fA-F]{32}$/.test(hex)) throw new Error('invalid session id'); + var frame = new ArrayBuffer(28 + pcm.byteLength); + var view = new DataView(frame); + view.setUint8(0, 0x4f); + view.setUint8(1, 0x4c); + view.setUint8(2, 0x32); + view.setUint8(3, 0x30); + for (var i = 0; i < 16; i++) view.setUint8(4 + i, parseInt(hex.slice(i * 2, i * 2 + 2), 16)); + var high = Math.floor(sequence / 0x100000000); + var low = sequence >>> 0; + view.setUint32(20, high, false); + view.setUint32(24, low, false); + new Uint8Array(frame, 28).set(new Uint8Array(pcm)); + return frame; + } + + // 本地音量可视化:直接用即将上传的 Int16 PCM 算 RMS。远程模式下 PC 端没有麦克风 + // 电平源(不开本地 cpal),所以电平条由手机端自己的音频驱动 —— 实时,且不依赖后端事件。 + var lastLevelAt = 0; + function updateLocalLevel(buf) { + var now = window.performance && performance.now ? performance.now() : 0; + if (now && now - lastLevelAt < 50) return; // 限到 ~20Hz,避免过度刷新 DOM + lastLevelAt = now; + var n = buf.byteLength >> 1; + if (n === 0) return; + var dv = new DataView(buf); + var sum = 0; + for (var i = 0; i < n; i++) { + var s = dv.getInt16(i * 2, true) / 32768; + sum += s * s; + } + var rms = Math.sqrt(sum / n); + setLevel(Math.min(1, rms * 3.5)); // 适度放大,让正常说话有明显跳动 + } + + // ============================================================ + // 音频清理 + // ============================================================ + // 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。 + function teardownAudioCapture() { + releaseWakeLock(); + if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint; + try { + if (workletNode) { + workletNode.port.onmessage = null; + workletNode.disconnect(); + } + } catch (e) {} + workletNode = null; + + try { + if (scriptNode) { + scriptNode.onaudioprocess = null; + scriptNode.disconnect(); + if (scriptNode._silentGain) { + try { + scriptNode._silentGain.disconnect(); + } catch (e2) {} + } + } + } catch (e) {} + scriptNode = null; + + try { + if (sourceNode) sourceNode.disconnect(); + } catch (e) {} + // sourceNode 置空,下次 ensureAudio 重新从 stream 创建 + sourceNode = null; + + // 复位兜底重采样状态 + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + } + + // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 + function teardownAudio() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + // 不强行 close ctx(部分浏览器再次 new 较慢);仅在确实需要时挂起 + if (audioCtx && audioCtx.state === 'running') { + try { + audioCtx.suspend(); + } catch (e) {} + } + } + + // 准备超时后的硬复位:停麦克风轨道并彻底关闭 audioCtx,使下次 ensureAudio 从零重建。 + // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 + // (resume 永不 settle),保留它只会让下次继续卡。 + function resetAudioContext() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + if (audioCtx) { + try { + audioCtx.close(); + } catch (e) {} + audioCtx = null; + } + } + + // ============================================================ + // 息屏和切后台结束本段录音,保留电脑已收到的部分。 + // ============================================================ + function interruptRecording() { + var hadStarted = startSent; + if (recording) stopRecording(); + else if (startSent && remoteSessionId) { + wsSendJSON({ type: 'stop' }); + resetRemoteStreamState(); + awaitingResult = true; + teardownAudioCapture(); + updateRecordBtnUI(); + } + // 系统中断后释放旧轨道,下一次由用户开始录音时重新获取麦克风。 + teardownAudio(); + if (hadStarted) setStatus(L.interrupted, 'work'); + } + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + if (recording) interruptRecording(); + releaseWakeLock(); + clearRecoveryTimer(); + clearWorkTimeout(); + } else { + if (recording) acquireWakeLock(); + if (authed) requestRecovery(); + else if (!ws || ws.readyState > 1) { + var pin = readPin(); + if (pin) connect(pin); + } + } + }); + window.addEventListener('pagehide', function () { + if (recording) interruptRecording(); + releaseWakeLock(); + }); + + // ============================================================ + // 初始化 + // ============================================================ + function init() { + // iOS Safari 怪癖兜底:页面"首次加载"后,页面内 wss 的证书信任不生效 —— 首次连接 + // 会卡在 TLS 握手→超时,手动刷新一次就好(已用日志证实:首次 TCP 到了却不升级,刷新 + // 后立刻 WS 升级成功)。这里把那一下"刷新"自动化:每个浏览器会话首次加载时静默 + // reload 一次,之后再初始化+自动连接,wss 握手就能成功。sessionStorage 标记保证只刷 + // 一次、不会死循环;手动刷新(同标签)不会重复触发,新标签/重开才会再刷。 + var reloadedOnce = false; + try { + reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; + } catch (e) {} + if (!reloadedOnce) { + // 写后立即读回校验:sessionStorage 被禁用(写入抛异常/写不进去)时标记永远落不下, + // 若仍 reload 会无限循环刷新 —— 校验失败就放弃刷新,直接继续初始化。 + var marked = false; + try { + sessionStorage.setItem('ol_reloaded_once', '1'); + marked = sessionStorage.getItem('ol_reloaded_once') === '1'; + } catch (e) {} + if (marked) { + location.reload(); + return; + } + } + + applyStaticI18n(); + syncModeUI(); + initInsertSwitch(); + initWakeLockSwitch(); + showScreen('pin'); + showPinError(''); + // 上次成功的配对码 → 自动填充并重连,刷新/重开页面免再输一次 + var saved = readPin(); + if (saved) { + pinInput.value = saved; + doConnect(); + } else { + // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) + setTimeout(function () { + try { + pinInput.focus(); + } catch (e) {} + }, 200); + } + } + + init(); +})(); diff --git a/openless-all/app/assets/remote-input/done.png b/openless-all/app/assets/remote-input/done.png new file mode 100644 index 0000000000000000000000000000000000000000..dc11332334cbdae8fd65198a1e126b9be6a7f1fe GIT binary patch literal 5605 zcmVf8F-ru&=Kx8vlFt*^sM zp`~9!K*@3mp^?Y!;Q!0V)i+LGKYdyKe*e7s{nPhdx;MN}5}DWUHqkQ~I!f-?E1&Dm zapl}|nVi1fuUA`=DXB(w*~Qb8EEgdkD%K7z z(S&7N2A4@URMD@rB^gKsGJ!;&zO1%HT65i`%LD|JECa#Yl0!5FNWo-qgb-3-AP+3m zdv=k5NmPB@5eXy$ft(gn@(hLj0$GL@H^2N_a){R0BL~q9mABj|dB%5|lBESZA;>W~ zL~FEUE_>H0d0MCI|m#%`0%`vpn;leuJ;#B z7KvDi2H7V_b?v}e_?n4B9GPQZ2N*GN7LErkk}NBj8XUEfh?SUEN|3We07izMMcYVp zZJF9;8d=UFoZrD6NjQQfVch&=%v_?z$8p+bS)^G6SynJ3es3aKN)kVO6m7$#K?r>z z0fE_)W#^ADNzk!=hKQDOiU9jIjx_f-r-$dWXLe+9B-!~R^XaV2Tf(Gi{F<2t17<}Q z+}fceNlvLf(#*@J2}+h&-L`eyagS82O0}L#2ry~bJVWGhCO%EkWLdGgM>ta^($sOk zG`qKs5r9V3WaAv zNF!V&jyY?Z6*{q56j^X(vf6~NR3#1-mDwTy-_pE(DNW0yB#Q;E$G6oc_s^VFO)dsu zCLgP1H~>&ed$K2s4F{7FrIZmhR89^6!2c{k%VbR!F}t9$hjxJGFAD+qpAs?4dtN3> zvNUFw;T~7Bi$Pe%hy74IN0YHxmK|C4%&ujO5pCrm0vVfSS&;>{D{alLLkx9mK?sOh zmS40i8?spcr24p^A)!q@j{rx@F!P^}ACsIch=K@Fh}1tJT@Debt|=qOKPg#26ynJ# z21tp*An2zJ0TAZ(`ziy+KN(q8-|kt>zJ6zTlqd{>el8#Y$3HP)l90s`MJ5_AAav%i z9$Fp(Ak5eMb*hF(CyNp#RaeiXUXGT^^g@E>L?w&$QBLWHaJ;y9wkOM zF1(INgb7L(ota$Xp6XD;C{%+V}(r zFkyba2D=&8J6WtlpZ#1)f{xTGqaeT+>Bg$@UY&QcG!DIYLA5|dpf>^_On{KGSF)%> z-@7|CV`u~dIQ-tova++O>d=QC1X_Mi1pNCiUdUqo6#Sc3&vs}wMIZ(OFvmRJrR;?) zc$zIn+vC-tHO&SA5XO`LGMOwk^1v!@Ht1?YW`}@%8h3KDCXq$VFJ|s!DYO7ZAS?nm z_M$$ml1XIIF!40-_3VfU@I_rN?h~pUO_qkK8vU98zMdKxfr+#GqsSr*Wu``MYChGe zBxNGNV^+rQNa;tBg(fm|q^x?{oDeYPiK;j6h|EjWW=)u^988uKUSY%xjoLu1Y(5Ay zB&mOSysO`@U#NO}8<#K~xgB}2A50eeXQ%V&J8elM1dd74RGWk_e6b&6jtwG*yNlg0@+NC#HRI7A#weCMbz|iOV!{fV4B*A$yu1uomp9X4|-egg&g09tc0fCJ* zxgORR^2uR&q=T>UvXSD8>mK5}^dgI>Nm5@WMSm7kXI0(RJxngw9w7=*`bq5p+NBp+ zGy+DtbR)pp27S$@JV8z*!SuJqle|fwcDwUmVG-dsJ z!cTMd4wY`QRPs~YQ$0E##D7I-G zb1o-jQ4`GKwU}=)yM{-*96qxn3L4fC+&U#okI5O7r>S%VnuoJ4JeYN^m9r#@ZE&k8 zc0d+uf>qZxzkbgcflb}>;jAtUW=xc($){1V1G1^JJd! zkSv@>KV>6uR(kPI#)XAlxhvel!jpGyd$#L}PUc$<$)X`+n&&dzKLIX66l1>N_xw|J zZQYVxOSfb>KCsK8zY0fSvVVg1uW*y$Vu*qkBOUrJTe7IX0&r%Qn)#Edt+GU+=c(ls zcm9#zvLj1JY`M4ws=yY3s=C9A?d-<*@5K@YkX6;ejg>pH5S=(Qq)V9yRCHUvZ+(Hp zp!SGqUd;0-gPJxfvyeqV8FjjzlotYi9C@@!#zd~q%tOFvY3H{HS%_Y*qt&eyg+joK zBM;#kqAVopg%+Pldwz>0%TH1hjEY{EC=a*ZT44XMRL8^-pq4C0J9V=l1PpzyKlmd` zRxiNIp&P2*?O&EG8VSpxTZYR3NB);-GxGbDuD;%{d0RCKgRuA6;$g``wB13vlz{+< zGU1Nl<3_IJHWn)Hx%uOU1lSfIj?2ArKlrzPia@|$#LG2zwaD3d*ZMwd8xeDwrOz29 z=Gl;;&lLMlKEol!j(orWJ?SfgTr71ir59XG7R#Dmxl78#B!S~w!=|sQyBQFMiQ>+ylRLTV4O=$`1p!`n(K45mQ z$!lnw>Mc*;aelWm*EO4uBClupRucKD3VsouXFx6%XReWj$%0k!iOrP$j&J>b{Zi*{ zmI?8v-wb)&v(`^B>Zcq7(_{OJ8hflq-dwXVQN}sGHOw*|l-ZeOEED`J-+)`9yngpx z?)=NEW}sQvVXr6J^D-#KETau2UraAkarvQux1%+ zCw8XOX&Dfux)btFZ_-H#@)r<9>d@1;<-h;-KmW#LG1ucxPk5G?DgN#GTAcqBb25fC z%G=vuM8OQbzKmq@fMv2wE+b+A7*rH&P@y3huytJLpM5R_C)O{h-*>Wp3O)NScZuaO zS*-I?PUGJM8C1vZuf_Qfu4R}gZ2lcr?sHrH6fbE0>Bpb`oyp>*tVo40vCWTXxmrf3 zQJ!NL=bts2|N4RxB{M(8KZ21Sq2mzZBn=nDSKFuq!zKBxV_nos$Vp z6vCo@3Q+K#m@HKkhuOR2u0Rfn#w85QGIq)h4Sh}~3Q-y?bUww64^Qj=`_KRVCzHif zX(@|o2ouZkM^o!zb24#=@{P?e#scz{m-3hRicDz^eg~)+By4^}B@7+~fLUhDQ-@U8 z9gip+DMW>ud?R4Q`kIGk*kgRL(LtvM};I zU?LNSE2S2*EE-W(2on>jpF&|5CQJ4AuvMfe9fUz>`9A6Z+D6OREUPEp4c!+t(V|Af z#C`D^m#=Efuq*pxTp^jC#e-;trvf92DR(f#EQ?jAm|w9fOIS|^RkLQX&PxWegb^X~ zTuhiow3oV4^Q#>g-+K-HKZ<0s#JMpMl_g6YDZ~7Om@LVJ@LRF8g*B(&Qnj>m2wcHz zNcX_gIng{HW5VFC0A-MQE;>nZYJSCo{fgv{4c08QoCSp8K~_3p)clG&WF>IJRa&wb z66Vk{mgsXaK4BH2tNC>TX}BFbpWc!sTuqslTwTYWyz~ad&sTllmp3qn6=cbBbtT^Z zuv1IVT^?aXtH6{j9r}{wH`QOAxRxyQdCR?fY>fBlleB$GNp*nMgAcOV4XiaqpKkmHanSGx%q z9MmKTq#=yX)C>5@zK;AA$Z=S+Iksl1C^> zmm>tKc=wZOsQ3u`*qi3XDvrZ?Psw8JQ(=eMbjuNB}UAL0ny=yUZ%KL{KvjHc8Rve+jlt5#+;=jaLu82^Nk2}q)- zZ=@kzju2oI3~w7f+KbH-vS24w8Wj^9E`14M{^HMM(h0~*h=)Jk5(RUWwvt6>^Bp`r z);z#h9`0Dy3CJE;$H*dXNP9)CWRa|fmh`AGOV3QOr+XHU>NFq000G!NklTZnWgUKS5j8I{6o_;9; zsSz;d$=)P9T#!*@QSCAuXgwnb1k6kQ%i}1m0_jJQ1t(JV_LfGz*h0?NFGV0W0uwEw z(PU9)KXy-ReG?Jb)IDyNJXgnPvY=2~xjOs6LR01lRBuL`U#?}7$RdS|j4a5x`lSfO zK)}#vv4kepHJL0DSzuu%%Auz#0)Y^KQ~xmhQSTOMCX)qadN#*Uq$Wkc4+0X1@>7%- zvWO@ml=?4%g?{CMKt;Dc%rDm%Z)6dnCWhvmP`XwGCL_SEfw%~h3)&UnjVvfq%rO=c zzcncW2@#kaCT^46D_Ky15Jo2}0fnX{2(Ux{;ejX2{a5atEFxl1|3zxhFGZjofq+vP zLC7NhOLe7hg=z0j-5Ux4b^(0*Df^-ZAqy&yH}R{(4=6OnK_G~qvM+itvY-<2UzqLq ztw|9Gh`^=}XOglZ3-GXn zN&50Wh@mMd0&HD?DDNLONyTWXJS(z5KHT14YiJozEHSYopElxk4)pBE;)JbXtMU~_ zUlHpNnfV(2NhXeb7t-011p@EPGRjURlP;bRXw0t6{FAoEWla_cY|S!`vGbKjwG0rM zRm$4zIz~EsvN(ZjTzQg5%bYa2o{RvS*mdS+*Kr$*AqxbDSypeIe^%evsi`ysHdMVy zdfUoaz=|S^Yh*3Xzx>tN#_iK}?g;S3tG0L*(Utl+H9G zT^a;7#2UY5bGcNLt7*r$l~Ba zM{P+1Q$QbMcF=@c&zd9-v{{nHHBLywrU1b_Q_?tC{H=j0YjIqdfVIDZ^DJrBWO0y- zdB&uvb&keCGHop+;kgoEn&I8S(;C-(_9Do#-+Xa8K^ndy^8QWC2pZQ{@@GT1g8~zZ zLX!1M$&DE!`{KyrK!Vv&vm7yKr*<37HTo0r3YYsct zPTS~w4PQ4fKSHS9!K%T=2y>6Ko>L3Tq zP_I?ZxG*Kl$j&__&zpBtk<~up_fQHAwAqoOy4z|E_Q$O6@!OMGM9>9h^i#aE2^CW|th>7KrVgCrJ*@i|qNX_{Y#^v(ZjM|Zc>lB9^ zq(E9;c$We$N|u1&x}t?dJb5RfR}g8N9`EFekFe}EJ-(}<3EVaY|3P|K*Es(@$bhtd z0a0CDx(f3NO!}K(jbk1w9=hJHw+~W0@B@Z z`P~oquK&9C!#QW2wa(f5-SO`IJkNf@)!r)*;?dv%006?bin8iYPrv^z9IU5%m$>mD z0KmfZR#xhx+x)(_YlfcY+~Z|}h=>jR7aK^Ip&MTdi6?&Q+e=^BfC2+~?2K>qhqyJV)G@Bt}|X53RqaexGCh#hP{7 zKk~idGSf42OSA<5gIOb@s<`=)$XPJ$|NG+m*ipOL6&@D+w`0j1uDXPV1h?3M{K5nQ zM1EkHhcz*}EVHN%7@0ynY(Dtm&rSgMk7g=fz}UnDbX4N;?(h8kJPY95#pQVWg^DFD zQE+*ACcEFr!NEbT$YHY+KJ4B3Cr(_+7%VfRJb)YB($0>v2S0322RR#usG>#~$Ah43 zL0*RY9%U5OC^o1ybFM(xHW2E;AE($n5&9;@3W{FB^63<<&FB8cAu1{=D@Jx>E+aBf zi0Ym)FbqBO$|tr33@hcFpv^Nz7$l6A7}nL*EsK~9B%oU14KfUV601Ob%I+`2X)eX< zeaZ#SU-8gB2V+Vg!Jcineh}Sa{mUFNeRJwX*kQQ4|E0RR+8%16oWve(`g;yLEUX%(%T^G@ z{c%7_))c{5#+T?(=G!8n#ptg!EoSyK&%>-7RF2Rfj`~bs6YNW3DjhPkQ7A1 z1btWuB$od*x3^ArbgB#IQC~@p#9TN56QvHwqUY6&cq$%U1rm#c8KHD<@@;si{oFgl z2R0pv<)LwtQQRDWLWuaGAm6M5`2(ZApR{(Z=~Vd=obCY z;^J#|lDDc{`8H4t5Q#aW*jZR?6k8lN#HQWHlq==`nIJa-Pjc`vQlW7zsD7=24b~dX zjf|3o78XwKxFfhXaYOP{(Y$K%%gXkPZfryCwm~Z`_sYo>!3`tW%3z8MTdXqb2uLwkH_Gcr$Ak^-wLP{b%R4emlKI9$7FquzF3^}Jf9h2895`DgN_?PW_q5NO~@ zU7N*9h2ODBP?O0B)o&-P`C@N0Iaw|wIYf!= zML~AW#Ry3?|Cp}lvZX~?;O^k+ZK8D8YBAk9(qdmQ?(dHuKYoj~M~>TL?oQ_%da zBLKW*YkkUoM_PcsCgn=8_m|VZ!`S@%{H-Qi3W%x>szx2g66ar|cvFUDe1coR&a4tW z>-lQx!*XBZ5>#o|kKxZ_&p;k|jRifpOs$1%j_SzwzOp^fub$Lt`SO~Q!;6rU(+l`Yt2y|5cS*)m0sF+A3gN_s1wzZ< zw&##!uqXf^83eJZAbfE+X}vLB2Tg|&_m>OUq4dX6kjlq*%%tH)t~4^4oJK1`7eKcv zMUAxA{{CUr3DJnB2mo%wp&x2q_!%K~-u>8$v*5jOD9{@QlVzf-@l(7=R06}6RdEJS zbU!Z~b!&{SRP|fKG%GZ)NrVXa`4q+k$rUHUGiwzRcC>x+-jc-n9d$03}iImT7UN zUvJW}fuIA3h)#5{&sS?OBO!ne2WBV-QxGMqs)z25eBfCb+$scbC3D521pmF)?5n$MmYl+sx$@FD2 zN_h6zuI)Ez-LeDK!%?ro*mR=wQw&d8d${X%IC6tY%R@=|Os8Y;c9cUFx%Auw0hBfq zKMcSR`ENY7GQ_Cpg^p*@t+*h5uNbLCdkMcw7Is-q&n$pbaw%0vBmTFsw+4gu6re`lcy?bX4q-RNQ02^6KEyR!aOZ zHz$WE!@)w1!KCFj^?aU+-H_Fur5zXu`bs6z>A!+5jibo6f`iMLpGc7cnF`ouYGj0S zhV_^fl~nY0%2lktGHk(fT|E5^hP4ZYqCC`U$2{8xEEJGnQfVlulzuU7lc|WLzP`S* zT3j3Y-RR>0>vaf$ZS%FlRTljptT#ap>uK3y0DC0b7e6DZ{Sa0z)qyPr^n{XF*VDcx zAuv?&AP6*~j)MeaQpHZ3`1Cs9VU8F%ZfKBUB7d0Lpl5L3wJjVX<9qB@+FBiOSya;0 z+G~Frv;X^M+9_RG-2ALPnL~d@OiSq+pnV9-jK zAW^>a*tdQo=~Fj7mrZSlmD2m{1R4gSb!#FQ9zx$3IQ(THOyG^RU-nuDC!5@}q{0_i2LWxZ}RvbxW9CU545IwLI!fd9;9b*8loRSVSJR)gDC zAFv<%eA&H#4ACJW9`?q;iX51jTmIY;PPWqujB&$XjuRj6h%=lgssgQWHif%i%AwWL zQW_zi?<+6s_^W0(3fEfO%IJH4FDyJYzyzTqJ{uF6KCR{9Ef;AK_-)SO?{Cl8ib_h( zO>r<_XR!}{(p22;?3sAh$E)9O|G^)-7hRWQ%rm{|q?K;ju>_^1@o*WRr^FCMV+Lv( za<(3cX4rRzV%YrX=BkwPW?7)dH!=(TaoukCcz`t|w(ey;_a|h2Ue`j?!VESvaHFo6 zKWW*$MxyuBbw`QHt&mu^vYNVY)Pl(}jgFys%|~d5aA<}T#CZixaJwDj=!|D{wKnSk zQleEMqoI*!uFjEQ`j!@l?E_iV+J)gAR=6p;len{DJ5j{kbpevu=<{D-0-l$l)g^ax zQxmPzR%T?~k{5Q3%Ug>)GqmeCbtr%Mw=bz5UF~fW@T=(o^#%bg@YOpOB=<7)x6-8*TZ&v1>sY`|9m64N|_iXDs4^^A*w!nWa z6K$XLYqAoA>lMGJS+%6rg)@^nc&+bJoHx>v2j1dh38T&A>hvhA2q`*cLh_5$DgoO@2RasB0mg6rQi`ip?1u*Y7L-p3 z&)HfH8Wp{j4x><$*k?jOrbqHN@~YOcnd?jeTwb>e0@-CPWN|du^J?vfuUBx5u!^1W z1eV>zMugxTdNYK5_mZo+i6>1|w2!PoLF#x-2fxT#9~bTN8X9oROZG&)0P!v|v^KHt zSdO-{vgG0LCi6FR8EXVl?IIxj?esHa*NX1DQ2H*JHczmM%DPc?MRK}ic7l3tS=qB> zLm%8$`-e~dG<=|by0F9KfvwO;ORye)rDMl`Fr%cRk$?fC>|@`sHF_;5Ty|_Rv9l{i z^pkV#q#DcVGb}Xp#F$c_?}MA%hc}1*DQJCenWD-Egakq4+uPgih2q!Tdfq((SOm@B z=#NR?`aW=sp2rz2d;eJP+e28J?9tEdnfcZ9RF3q>A+tDD|FaZ z+JG3svX7l_CGK`enlA=SrdYnI%>%jd?k*8Lrf3kK2tYFC%%38h4urB~ChM^_PTZ9nz>*K| z1+c#*}f>h;=i)Kbtxj^XK|Mrl@ zg&7FdOhXFsz;w&5Ar}_{8*)`5OoZZ$SR1>9*h;b^|wp~V+gAXpk~Uz ztnjo&L{f2{PqsuzPwW$+VJ+}_%cP*K^cQA!BoU^A!uw)ri<5a3^7!gk?tZ`g(Mq@_ z%eG0~lakam*Do6sNIXyMshS?qoF2-Ai3tNC0t2O}G0=_YPU`%irr(}P<3JGaZnm=A zPN=yU19bix9MUQXF9tFZKZ1zF&Q{tLxF+5$)%-Y&ob55W6V4`t zFp4GD3@ax@W3CgF%#_l`z5Atag|>Re2GFVBrYO>jnci_<&Al*vlOp6qzgIh$ASuX> zy&K707;P$$XjOMPdMIS`Qdn3x?)_zUlgJZY;Z@(^ohat@@c5 zDhsXm@~vLMV%R^)Jx|vp^n4I5s7R;=dU8j$gpsvUgKMRXl4EXq4 zY2h+bs;ISdD@=OrPV@}){OnLn?aIq{g8qF(!O!5ZL94jd)~&`S4Mp=enId(*2g_H- z>tlaJ#jC$2a?)z!N=W%C!J3_`3_Qbz-|l(ZVIk%E>?xH>O-cn|snSlz>T@NQOi9zx zSa=|YL}`|EVyFeRhnN=6u~C=R&F&&3V)--@6jB zeOuc13DB@q$|Pg7Q2+K<$#81E_)6m{ zF%86u4(nRA@-%Rxi2)JBJJ=t5r!uJJt{Wd8$YY2$vS%LC>peP_VUFki(5_#r!5B*j zx8M$u)Ws0(tH-_^{1qq7PI+GBfAIo?o|nBO0}!(cDolCFa_=KU>GoMfU(fCIOQ~CO z9>?EV1}ZGY5itfQ#SX^j4EZ%^iv5fQ$s3*2^_AA`B#^2XGUmu^vG2V-ti7VF<3xXW z2ei)#8dhQfZW88kyNxaU9d80e2g5Hpr$JMf0wFj77^xW@$TB=`Y8hG5YE}$ITPA-@ zjC><|Oi?_ewr@j!g&ap#+(={)7-?V}NO9(v=kI^juEiE#)C9-#Tnjx!)$!~(%DAQ! z2A+h9L?x*Pj(qK-9P%XtBE!<4ST?~xewrp;1^abh{?!NbM#h4mH}eNpwm?z%H4~04 z{s-I+v~mo1_DT}Xys$ihyA4-Prag)~PLkGWp$()6B(+|Sd&dllLYCQOSc}oM$;Yff z8uW-7yu>B_I-Hl30BsX|;ZZC@Vz!XKbTixgT3QS@=VXa}A;G|*NeR0bYEeSJmjbNX z=;^;>rAq31i^cZ`4Ts^@jW7qqk}ek0Ie-1CIqYj_U9H-G$>4^@fC)=6vs(c;fVf|= zUST(nR%Qt~5R-wy4qP18KSjWZDm3Uc772aSGZQvRNL19heGR-Tzkl>{-9A6Cw)Sgt zoiR*mUWX50$o44d>*|`b8X|3Rpnz`qtJr>?Q)BnuuJ1vG&WxA5b-7LIV}j~#XJVrHTuJQE(T6Q>+xJ|hXWyVf}PpoFGom4s|&(AZ@c;{RGE#=6Ht|f zzL(OMf(DESnZAiG)^Mfe?<~3$s=l{G3sf2KP5$(o2vCoKMQ1vA_1ustIxslWK#C(- zfq9J}TLmGy=gEw&=}yStfp$K-uDTwlkSVJHW9l2E-ayDGMjsr|K|GEg>NnQ#vCier zF0Fq>Fi2_cVql8QdJgQ(Vdv%;LlWLdwPp~nSg=QEmY__oPXzFP`ou5~m{)iH4EX_h zQi+=HQA*wG4zG@QuozpEpq*08Yew@+p0;G1EcAr*4OvSqGX@&m-Z#7(j-R*;>VMlX z_OUb;J&vl@vf~3W?KQcb?kBo=Zd&0m+B`GS?r@<<&{7)!niF`wU-V9AV5_M8 z*3F5?uaRda?HE^}kJkIq`@^3qUycOIM>n66U6nnAS7(+uq#4EXnPCoX%|sY)tqlUFA_Wn2i1Lw({*NXH$kc&}WBhc{fAKmBWHj^IK zlBQS;MgsTFKi^cvBQKX0H_RTu;If<=yq$-jPuP3fTHXcwIA}#hV6nD~hd&L4Oj%Bz z(;eBov^nMFM2~kf>UR&sr-5lrJwKEaH7s&yI#^og7v|?fLh0Q}-*-zb4KEnT8RX!A z7}JMMV#L1oi1YlIYbPaufP_Jj^CSN-Ds%rGUUkl=I;nQ$za@LZP#6YOQAqpAVF~%q z@XwV_1Ls#!l;=31J=bsg?R-u_{5DJI9YWRoNq4V+HoOGX8>j9Nr!$Bwl&Jc^(Ab*y z1-Rnt`1sMrezexCSu)$zkY$#nf6ve2%I2 zt1Mi`O8arR+g^AfNlZ^^$$|_)oAM{&Z&FT9WJFm`MJlALJ*n$W_F0F)*An>uu;DQO z=OzEOz4fBlAnp ztF>^!^7X;v~6`7u&P<>vh+Gi=?4&oR;^XTY z7eO@kJ6f2>H^alLYyJA&%w=fmyH1zIfarqS4xo&yHWiV`27}ktb{D?YvxAjHX=PW` z75eH<);0NiiG6bG#YT!a#cp+UEuFTurx}QE=uWtm=}J;tdy^m;6JaC}i%0YR4Dh@C zvA?Wt9hG&D?s9%!3oIL_dz|qHR0gT7G5>_qgkRlcxEa{+u)JvZ>Bq15{Ev~PG9ab`M}o)_BB2sqE2UjoL`sv_;5MCQbnQz(`ylj{XR?*sR%^(;C<&10>=A3B8cC}kJwP@eDMIMM4y zn?L!k){;4T{^Ma$gi%}rH16ys9^(=QIC`HwX-7nEov!-Adx3o#2OJmPtRn2_L58wQ zpRLZvWX#uVv;H8gRb#aIre2lz-DyQ*Zdx(N^F2v9hHZCk4s!zK-Hk4XoemiQlFn;a zndgxqZS>Zc*NmUEeCxDC*`Bw94?I^culD|&9@VvRO3TQIC1};Ar&Kv{83+k>P}kPl z_HfgSL!P8BDJd+j5AKBgUE?SbQS>Vx^aAqtBKzcCTOshVxp6e2oy|WV(8_7i^O;B@ z6^`3wP96Rgj&^hUp2N@1&M;qpu0~0z1fV_RlSADcFAVZvReT`t?F_D1D|KoODk*QJ zOMORoEDLd;0j5M@@aE|HWjIyWxEhUw5+R%66?k9oj1G2fa+hpX^}7;K_W9kazF)Ch zy@y@nN=t&7#Mn|DDufaiQ;`7mc_QNmYa;p-aYFMUF+zHg>u!er>SR;cZ2XB2F(bX- zD*G23UP^X1>b8bUsphNBnb^{<)rZBhLDXVnpW^l@Cx#fft$ zif5Ig!M5wTmoAw8ZbhU>h2_CJMvP-;2^fI?p@K`n_H4Cn%Lu%Wvtl;bsSw zV^EA6E`t&DZJzX<%_0e#_TB(*NB>aqn2)qqTWWBdZGBfWGz`^YkFcml;(x44YH)7E zrWLU1Osm&z4_^SbbuAJq5}7{ymY&Pa=d6 z7xlv8p)q=ufsq?91;CKy8aLXYHW1A8Ja?Q%7RFbkjDk`|1{$rr47TQvwC0a_3N7^= z7U1_{7aW=dBnd=$3kUJwb+%H2gAx=A)MMk;ij>Je*O4r@A_s&vi#--=himtVFvHQ~=JA4$|d`bz}hM!Sh(fiU8d z-y&j_daR4wmp3$^WD|q?Yt+z%u!uPXWaq55kWuxfj=DYA{oXinl9OzCZaSEn=K|?6 zV-Va$^j-g>^0mqDL~Q)G6|$$>`#-%!LoC>^0jl?#74@qk0xBFH`cqHe;n%`Y( z#-gEK<4xZ}Z}Lz|((BK)iG9|11$%TV$c&Wl?c+-qD z^6zZmdD5qx&StYC2J=x$7p^o(mMP--<@1~ay45=gdcW4=Vv$67{`4M)Ub^tx!&{2- z=CwCd9t|;pe6>Y;G{O5v1hQWyf0+%|YE6gio+kZ|VzZXL0Na=IT_>HIcG zeb?pCCoBNjMcDwTAmMI*8q@pFzKq7&pwx~aUjmqiP4wn#mn393(q%Z3yK5jDjnIeim{@hIum)(z2YKY)w z2_-`xWV;dl=mIj-#PqwkAQFAJGUR$(m4F>jm~C(Gg)l*6^Z1_R@{}!iUdAABcI=Gb zUz0pSM_an5wE)4h*F1>{@f`Z_gcu4MFWsdL1WKqcod&2g;2A(QCOP+_}mXjB!*LU`En$fy)a@^?R(YWuV*PseHs2& z4o`HTk(E%OVx`emt!K)C8PY2yN)pO+yES-${#|ouMUxRzO78E`0*h_G;st(BEW>&L}5 zC>kSzTSYdvxOnHAnpRba<3MeKj15+au=+d2Tl?DExco9gsz5Lep5Kt^ zubC+Ft0GA~l2ev!$A&+Faf%zxFvK9(DTL>X5v@EU#VeVFbVq~t;{(udhWh{4)lkZM zz8jaa;SOBu;3GM{KHbVn9No%V$q+$sC*PDVT>$RX2l@#TU#VH97U0A%Ubl~KI;sw3 zNj#Q;V^>hPzjIG;75*w7tKCLr!u}j;twRw zctU;3Q67D_M6SeTHK?@zVdr=%2eL4E6bAsjNgWa`<0BzqE6^)Q7^h^C4u!&QGHI_@ zmnTz>mcNWGQ+OJ{rw={x^EH{_DZ#A9_Dq9Q2FLAxJj8Fb0-W&gD zX}PbLg&s5YI((T2i)g!lwQ6mt4BWSIqkD(J#Z3){$$1(bu-Nx7{63_ zC6WS$g@nmIWjRkIiNu|MYi&iEnC5zpKk=1s z!#yz)3`0p%+y2b?|2TezPtfJtN!{E|o;`bpt;`lfAr0vOr+0u^a2eyJ`!1&sW^|?C z-=UrIP!AdSeXjbyxsZ?$xm6X+?6oGJDNr^sG^{oA=aWCe{o#5`U9iM%!xamkOYgl^;mclHN<|=RJN~~mtp8)s%JW!%1VF7Y`J>tJx}RKQfVXn*Wy__F G0{#~aLb@aX literal 0 HcmV?d00001 diff --git a/openless-all/app/assets/remote-input/index.html b/openless-all/app/assets/remote-input/index.html new file mode 100644 index 000000000..d9add7ca9 --- /dev/null +++ b/openless-all/app/assets/remote-input/index.html @@ -0,0 +1,172 @@ + + + + + + + + OpenLess + + + + + +
+ +
+
+ OpenLess +

OpenLess 远程输入

+

在手机上录音,实时输入到电脑

+
+ +
+ + + + +
+ + +
+ 首次设置:信任此电脑 +

+ 先打开电脑上的 OpenLess 远程输入设置,保留「本机根证书 + SHA-256」。在手机系统证书详情中核对全部 64 + 个字符;不要使用本网页、描述文件名称或标识里的值作为证明。不一致或无法查看时,请停止并移除描述文件。描述文件必须只含一张根证书,不得有其他证书、VPN + 或管理配置。 +

+

+ iPhone / iPad:下载描述文件,在“设置 → 通用 → VPN 与设备管理”中打开它,选择“更多详细信息”中的证书并核对指纹。确认一致且没有额外配置后再安装,并到“通用 + → 关于本机 → 证书信任设置”开启完全信任。若只能在安装后查看详情,先保持完全信任关闭,核对后再开启。完成后返回 + Safari 刷新。 +

+

+ 安卓:下载 CA + 证书,在系统证书预览中核对完整 SHA-256 + 后再安装。若系统无法在信任前显示指纹,请勿从此页面安装,改用已有的可信文件传输渠道。菜单名称因设备而异。 +

+

+ 首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。 +

+ +
+
+ + +
+
+ OpenLess + OpenLess +
+ + +
+
+ +
+ + + + +
+ + + 准备就绪 +
+ + + +
+ +

点击大按钮开始录音,再次点击结束并识别。

+ + + +

+ + + +
+ + +
+
+
📵
+

连接已断开

+

与电脑的连接已中断。

+ +
+
+
+ + + + diff --git a/openless-all/app/assets/remote-input/mic.png b/openless-all/app/assets/remote-input/mic.png new file mode 100644 index 0000000000000000000000000000000000000000..1e0570ee8962c5088fc457260c5dabfe968a1385 GIT binary patch literal 4327 zcmX9?c|26__rG^Am@JcIOPaETD5a3CYsr>;7-NfM9cz?iFJlX3nPeGjB>OU!p=?>p z*dqJBWyuy1vgLRC{{Fc4xv%q_*ZVp5ea^YB_dVgdI_k_Q9uxoo%$gdiSa_xWyBLu0 zy~wyY4FJ%9rm7Oo3tUZol{i+(7Lqrn5u-JWv#Mcp6@Ba?__e5|Do%K-qai8Y=t4Ny z20l7Gc>>#F{RnxBuRrPFt>*E20=ri(0q_=%bkPWKV8blaVW-0ph`Df?YC$^Muzh&@ zT|*Q>KF{F8dx#&!Kvd%ul^l4EH>WndzZWNW}v(Cf^M2t#acEnl0B51S7cpQivJ?R6XEN- zkS@_YJ~gzhMn_9kYg}k!X4>Iey~N=Db#G0wyEH@rC5c+J8|`(UK+Ju8=5&RbMXI%v zr^TuKY$}V)aHjo;L2fM|g8n+4-h9=GeNIvS1hM2+<3jT#*E(ge>{}3$-lAk^n6&kK z4rBh6lVmmtW)x|sSd{N)st`fmO+QI6f zjg6h(_<5_^{2DdSY*oz4!7rAI+3TI2F8#qUrB?npr>JV_r5&8UM6SL5X^TPoUwSSs zO@n`)Ic^R5P2_N5Tx&jFm6>#DdGX%}#VK$5g#}_xhy9GEiQ(0t@rk9sF?Z!2u7Aof zYcJT>#v%imLBE(_RO@DL||T9P=fI=JR!ZqmOs z18N>b=l;DpRy<=jI0KgQSroWVi0rZ&w8j3NDQ*? zAy*X6DH@ON!gR~N!4vJ4e?RX|vtXVAR4uVPn5r z@aqohkcofKG44gQ@)lY(h8w?QB;CK=C(BaL)uV%vKt!@-I8jh#~dc)=VBkbWFd%o8S8SH3G#`t;b zckm(ayj6FCu@E8HEi@uLn*f;n4r7ZbBmmqa2aX5tU`V|Sf?RMjTr1lh*trc?!8=tg zi88pKwbu)NZoq&Wmtz9#L^04;+=Hi~Mlg`)YbJSp5OmAl&zGx3AOvb(wKL(0WnouY z+5A|Y(`ZbptYKF^99>>!rq&V;1bg$W2MYh+Aqv;7`|vbIrOGsNA|H-FpVZ5m1sOws zzQ5)$7xOD^_%-kxt!P?UFFtH6{n?HqY0FLn(x$R59FsW|O}y*^XoMNYX_7b-h~lkjD)u&oUp-D^Tskf@=D3;|DN-c4 zNaj#SwD7-dR<_&tGvd1DmKOvHvB{zSi{=%~lFkG_Pf z4J)6TocY(zvhS*{RkV~K`B3RzcUjb-K@gCbt;>>Vb+l!#FKZu?^eq$IJnsFgw?CRr zIse-|82cu3@!*m}`H0TeZ4usngK>JQnVc%kTw&C*iB|uPX6&YXQtV~2eGMLs8$R#+ zjCFN9xPF0Cwq9hKyHK9>uKe~S6M2t+lxdb*;bqP?B{poDl=SIoT-zB*nlC#^A#c3BIRyqy0b`z&5ti+p?4pQ7SD&-uE!Sen-I)Ltn}a87G{eK?@f!W|rSZ?wl#D)2PfzX&@m zFuX);y(OjVm2lJiNQLnn3;GF~;3ex&8ibvg-1Zcv_m{>GBs%SsH0F6Z;0I6puUmP0 z$~A#8_m&eK74#2nh``13iPyA$dpg$SH1e$}DDDm)UU$Vo@_n_t4jh41YFL!L#7Ddi z{Gm)qP^?ofCI(?OAZLNC%jK1aS!O9)DjM6-v~A}G@vijSoqXpLjND%m2^J)}?p?N@ zCM~i|Iug%stHAEmN-TBBV3*JQUUs;c%{jk4mx=gWZ|4u%9Lj?%+b^8wQnv_A$MrSseM>5I916R2cJf|ZOrK#l1X#2c3!okJA1q?qENyY5cw39-QAT| zLKrVh7Fz$Ywnz6RnUN8<${^Q1!8EZ$BlCASe%{h?U3<+$>(=%B!N?wh@#1@ZHm$J^ zvDv57HXag9S4R4j8+^X-VV~(zE&qg5BA%6qTe2aC@I7hDG;<~!Bzx|) zlPkpbE8(7T$7hD2ewWJZf*{&B zN1j})yE(ivZu7N8yXjg0OS3Z@BfB%m44gW> z?;Ismyuj;R4%yMNlOy?RNFpv4B!wUIA}gV_nv(H`xBSc>HSY2an-I7Eh;;!m@AIy1 z0<8&i8#w3GFdc`0#Ge|v04O|ShyGaHmyXlwU3RK=zpZ)Bh&%2YSu|S&(l$%^kKSzH ze}pDbyW|{{L-lu(4}zXghI1dMzz{& zPWPVj2oX4g-bTlySX5V*wwtlCpBT%PbX}CIpM^v+y3(7 zz##QG`+4#D#K^tcj4m)HBB#8^yVv${@OMuWmpa?mPwg!%nROQb+7ERCw6cevH^;3G zQi7iR4{GOTyz_DM^t2_iL)6MOgpv$4nSPzwOQvn{VSUukCa>aVyy!bO-(Rf$f%RZ~ zdiu=fy$;b;{Gfo#>J-> zZj7%nRySKoIkcFLkppNmS`h%6w8vQn#kI#NV`4ZU6$%!3B<92p z(P4xWFf+c$%T6!GPMuxRy-%=O1DNn?c;-<<{Y&k$rS4sVS8UTbn!s5-D|HRh-5=Vk zcg=s;@X2*kUv+U@N6);1Li14g1=v}I-HXo%`=J%M*tfW8#&)k&$;qJe zw3 z)i^VWmJZqwKw-jmUtD+rEjLc4?)o^_NUFTajq69$Jmx01S^Qv;X3JpuZ z$+whSadEP@Is|%&@qRbQ+bP-*i0-#d2tDh^NWFqZv{}Zb>FMnfFph!2uH(_c!TC=B z1fCOdmgf;e#GeLhd9&~*)H?zI2G-6AG>|U-@-dY(Rb=7_N!%ElVR?Pbquh=QDhot1N-{nWPP>t8qkr$^R!n~K6=3M)EnWCURC+9|17`+kGw%IkQ02;Gjcl(Ov8v`Fo0 z<)^=HK~|dfvk_N522)keTBl1`As9P~Ps$-5;%|N~4B!Y|K37pTV@IsAW`g13+4%#m z(Fav_3v&N8Bh-GQ`w7jIK8ohgZ}4i~-95)h^u3KAVq`b(YmR8br>VD;v(u*xk^`km zA}-i}G43%Uh%v;vUNk1%1wu$s@viF8iQvcsW68_4)~`nRtGmvxp2D1&cvNLJoFh>H zwDEGt&V1?hxWfX-J5zNxRJF=)2aF@$LzEO-mRG5Zwp`N^HN1?S3g{2>Z$<=!%gRyK zAXa$Xbfi$_)7Q%$X#tk;Gqp$=GRF8QmXX15AmmSv(VKfmD!d3Ab?FD6#9Ik3rfl0g zuEi^6Rh0RE46v}(7ix%&zajD+7MC_A{8?Pau;?t=8u)Ds3#2kNt*a%l-D0s|sRLF; zwvn+`HHe8hG-l!>mU%=47RjOcqUC4kXf>pZaVk5y5MebVzkO8=7MGEi)E%g$YwVcT z<)Tp@eTZ;Pc1`I0(6e2(0?QC{;QWPfAO;!J4uGM zkCGdFLtaI=-0agOkjlMHeV(7cekE{0ut%MKvfgymE~+wa9|!^jYR!_nwbr6j*O^yd zfGyJvZ4EO^>8=_w{p$Ag!dEgWJ`4~Oi<&}rFFy8fNp>o zBSMdTptpQIE@_+6G{31!^PA@e(+O%Wi&4!75eX^G7zMiw!m^h}_WoUSGW_pBj>EFR zvjgs)CC5!4IPE?~v2GB&>gX7iJ`>)!0*7>b+@8q020tMG#YO`Z)t8JHHu8_d7C`g1 Lj%u;8Md1GdX3F^% literal 0 HcmV?d00001 diff --git a/openless-all/app/assets/remote-input/style.css b/openless-all/app/assets/remote-input/style.css new file mode 100644 index 000000000..911b4f791 --- /dev/null +++ b/openless-all/app/assets/remote-input/style.css @@ -0,0 +1,740 @@ +/* ===== OpenLess 远程输入 — 移动端样式 ===== + * 配色 / 圆角 / 阴影 / 字体对齐 PC 端 design tokens(src/styles/tokens.css): + * 黑 + 白 + 电光蓝,浅色 glassy 风格(与桌面端保持一致)。 + */ + +:root { + /* 中性色 */ + --bg: #f7f7f8; + --surface: #ffffff; + --surface-2: #fafafa; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.14); + + /* 墨色文字 */ + --ink: #0a0a0b; + --ink-2: #2a2a2d; + --ink-3: rgba(10, 10, 11, 0.62); + --ink-4: rgba(10, 10, 11, 0.58); + + /* 蓝色强调 */ + --blue: #2563eb; + --blue-hover: #1d4ed8; + --blue-soft: #eff4ff; + --blue-ring: rgba(37, 99, 235, 0.22); + --on-accent: #ffffff; + --accent-solid-bg: var(--blue); + --accent-solid-bg-hover: var(--blue-hover); + --accent-solid-ink: var(--on-accent); + + /* 状态色 */ + --ok: #16a34a; + --ok-soft: #ecfdf5; + --warn: #d97706; + --danger: #dc2626; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(15, 17, 22, 0.04), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-md: + 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.1), + 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-lg: + 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.1), + 0 0 0 0.5px rgba(0, 0, 0, 0.06); + + /* 圆角 */ + --control-radius: 8px; + --r-sm: 6px; + --r-md: 10px; + --r-lg: 14px; + --r-xl: 18px; + --bubble-radius: var(--r-lg); + --modal-radius: var(--r-xl); + --r-2xl: 22px; + --r-pill: 999px; + + /* 字体 */ + --font-sans: + system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', Roboto, Helvetica, Arial, sans-serif; + + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +[data-ol-theme='dark'] { + --bg: #0b0e13; + --surface: #141922; + --surface-2: #1a202b; + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.16); + --ink: #f4f7fb; + --ink-2: #d8dfeb; + --ink-3: rgba(244, 247, 251, 0.74); + --ink-4: rgba(244, 247, 251, 0.58); + --blue: #60a5fa; + --blue-hover: #3b82f6; + --blue-soft: rgba(96, 165, 250, 0.14); + --blue-ring: rgba(96, 165, 250, 0.3); + --on-accent: #f8fbff; + --accent-solid-bg: #2563eb; + --accent-solid-bg-hover: #3b82f6; + --accent-solid-ink: #f8fbff; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +/* 关键:很多元素用 hidden 属性控制显隐,但元素自带 display(flex/inline-flex)会覆盖浏览器 + 默认的 [hidden]{display:none},导致空框照常显示。这条强制 hidden 优先(结果框/三点/图标都靠它)。 */ +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: + radial-gradient(120% 80% at 50% -10%, #eef2fb 0%, var(--bg) 55%) fixed, + var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + user-select: none; + -webkit-user-select: none; + overscroll-behavior: none; +} + +#app { + min-height: 100%; + display: flex; + flex-direction: column; + padding-bottom: calc(24px + var(--safe-bottom)); +} + +/* ===== 屏幕切换 ===== */ +.screen { + display: none; + flex: 1; + flex-direction: column; + padding: 24px 20px; + animation: fadeIn 0.25s ease; +} +.screen.active { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== 品牌头 ===== */ +.brand { + text-align: center; + margin: 28px 0 22px; +} +.brand-logo-img { + width: 72px; + height: 72px; + border-radius: var(--r-xl); + object-fit: cover; + box-shadow: var(--shadow-lg); +} +.brand-title { + font-size: 22px; + font-weight: 700; + margin: 16px 0 4px; + letter-spacing: 0.2px; + color: var(--ink); +} +.brand-sub { + margin: 0; + color: var(--ink-3); + font-size: 14px; +} + +/* ===== 卡片 ===== */ +.card { + background: var(--surface); + border: 0.5px solid var(--line); + border-radius: var(--r-2xl); + padding: 22px 20px; + box-shadow: var(--shadow-lg); +} +.card-center { + text-align: center; +} + +.field-label { + display: block; + font-size: 13px; + color: var(--ink-3); + margin-bottom: 10px; +} + +/* ===== PIN 输入 ===== */ +.pin-input { + width: 100%; + font-size: 30px; + letter-spacing: 14px; + text-align: center; + padding: 16px 12px; + color: var(--ink); + background: var(--surface-2); + border: 1.5px solid var(--line-strong); + border-radius: var(--r-lg); + outline: none; + font-variant-numeric: tabular-nums; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} +.pin-input::placeholder { + color: var(--ink-4); + letter-spacing: 14px; +} +.pin-input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px var(--blue-ring); +} + +/* ===== 按钮 ===== */ +.btn { + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + margin-top: 16px; + padding: 15px 18px; + font-size: 17px; + font-weight: 600; + color: var(--on-accent); + border: none; + border-radius: var(--r-lg); + cursor: pointer; + transition: + transform 0.08s ease, + background 0.15s ease, + opacity 0.15s ease; +} +.btn:active { + transform: scale(0.98); +} +.btn:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-primary { + background: var(--accent-solid-bg); + box-shadow: 0 6px 18px -6px var(--blue-ring); +} +.btn-primary:active { + background: var(--accent-solid-bg-hover); +} + +.hint-error { + color: var(--danger); + font-size: 13px; + margin: 12px 2px 0; + min-height: 1em; +} + +/* ===== 连接帮助(配对屏) ===== */ +.help { + margin-top: 18px; + padding: 16px 16px 18px; + border-radius: var(--r-xl); + background: var(--surface-2); + border: 0.5px solid var(--line); +} +.help-title { + font-size: 13.5px; + font-weight: 600; + color: var(--ink-2); + margin-bottom: 10px; + cursor: pointer; +} +.help:not([open]) .help-title { margin-bottom: 0; } +.help-step { + font-size: 12.5px; + color: var(--ink-3); + line-height: 1.65; + margin: 0 0 9px; +} +.help-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} +.help-verify { + color: var(--ink); + padding: 10px; + border-left: 3px solid var(--blue); + background: var(--surface); + border-radius: 4px; +} +.help-link { + display: inline-block; + padding: 9px 16px; + border-radius: var(--control-radius); + border: none; + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); + font-size: 13px; + font-weight: 600; + font-family: inherit; + text-decoration: none; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} +.help-link:active { + background: var(--accent-solid-bg-hover); +} +.help-link-ghost { + background: var(--surface); + color: var(--blue); + border: 1px solid var(--blue); +} +.help-link-ghost:active { + background: var(--blue-soft); +} + +/* ===== 录音屏头部 ===== */ +.rec-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 8px; +} +.app-icon { + width: 26px; + height: 26px; + border-radius: var(--control-radius); + flex: none; + box-shadow: var(--shadow-sm); +} +.rec-header-title { + font-weight: 700; + font-size: 16px; + letter-spacing: 0.2px; + color: var(--ink); +} +.mode-switch { + margin-left: auto; + display: inline-flex; + background: var(--surface-2); + border: 0.5px solid var(--line); + border-radius: var(--r-lg); + padding: 3px; + gap: 2px; +} +.mode-btn { + -webkit-appearance: none; + appearance: none; + border: none; + background: transparent; + color: var(--ink-3); + font-size: 13px; + font-weight: 600; + padding: 7px 14px; + border-radius: var(--control-radius); + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} +.mode-btn.active { + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); +} + +/* ===== 录音主区 ===== */ +.rec-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; +} + +/* 录音大按钮 —— 默认蓝色实心(对齐 PC 主操作蓝),录音中转红 */ +.record-btn { + position: relative; + width: 168px; + height: 168px; + border-radius: 50%; + border: none; + cursor: pointer; + color: var(--accent-solid-ink); + background: linear-gradient(180deg, var(--accent-solid-bg-hover) 0%, var(--accent-solid-bg) 100%); + box-shadow: + 0 16px 36px -10px rgba(37, 99, 235, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.25); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + transition: + transform 0.1s ease, + box-shadow 0.2s ease, + background 0.2s ease; + user-select: none; + -webkit-user-select: none; +} +.record-btn:active { + transform: scale(0.97); +} + +.record-btn-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid rgba(37, 99, 235, 0.35); + opacity: 0; + pointer-events: none; +} +.record-btn-icon { + width: 60px; + height: 60px; + object-fit: contain; + line-height: 1; + transition: transform 0.15s ease; +} +.record-btn-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); + letter-spacing: 0.3px; +} + +/* 录音中:红色 + 呼吸脉冲动画 */ +.record-btn.recording { + background: linear-gradient(180deg, #f87171 0%, #dc2626 100%); + box-shadow: 0 16px 36px -10px rgba(220, 38, 38, 0.5); + animation: breathe 1.6s ease-in-out infinite; +} +.record-btn.recording .record-btn-label { + color: var(--on-accent); +} +.record-btn.recording .record-btn-ring { + opacity: 1; + border-color: rgba(220, 38, 38, 0.4); + animation: pulseRing 1.6s ease-out infinite; +} + +@keyframes breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.04); + } +} +@keyframes pulseRing { + 0% { + transform: scale(1); + opacity: 0.7; + } + 70% { + transform: scale(1.28); + opacity: 0; + } + 100% { + transform: scale(1.28); + opacity: 0; + } +} + +/* 忙/禁用态 */ +.record-btn.busy { + opacity: 0.5; + cursor: default; + animation: none; +} + +/* ===== 音量条 ===== */ +.level-wrap { + width: 78%; + max-width: 320px; + height: 8px; + border-radius: var(--r-pill); + background: #e9ebf0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.07); + overflow: hidden; + /* 平时淡化成一个浅凹槽;录音时才高亮(下方规则),避免像一根无意义的白条 */ + opacity: 0.45; + transition: opacity 0.2s ease; +} +.record-btn.recording ~ .level-wrap { + opacity: 1; +} +.level-bar { + height: 100%; + width: 0%; + border-radius: var(--r-pill); + background: linear-gradient(90deg, var(--ok), var(--blue)); + transition: width 0.08s linear; +} + +/* ===== 状态条 ===== */ +.status-bar { + min-height: 28px; + padding: 8px 18px; + border-radius: var(--r-pill); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); + font-size: 15px; + font-weight: 600; + color: var(--ink); + text-align: center; + max-width: 90%; +} +.status-bar.is-error { + color: var(--danger); + border-color: rgba(220, 38, 38, 0.35); +} +.status-bar.is-ok { + color: var(--ok); + border-color: rgba(22, 163, 74, 0.35); +} +.status-bar.is-work { + color: var(--blue); + border-color: var(--blue-ring); +} + +/* 状态图标(如完成对勾) */ +.status-icon { + width: 18px; + height: 18px; + object-fit: contain; + vertical-align: -3px; + margin-right: 3px; +} + +/* 识别中三点加载动效(替代旋转 emoji) */ +.dots { + display: inline-flex; + align-items: center; + gap: 3px; + margin-right: 5px; + vertical-align: middle; + color: var(--blue); +} +.dots i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + display: inline-block; + animation: dotPulse 1.2s infinite ease-in-out both; +} +.dots i:nth-child(1) { + animation-delay: -0.32s; +} +.dots i:nth-child(2) { + animation-delay: -0.16s; +} +@keyframes dotPulse { + 0%, + 80%, + 100% { + transform: scale(0.5); + opacity: 0.35; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ===== 识别结果文字(电脑回传) ===== */ +.result-wrap { + max-width: 90%; + margin-top: 2px; + display: flex; + flex-direction: column; + gap: 8px; + animation: fadeIn 0.25s ease; +} +.result-text { + padding: 12px 16px; + border-radius: var(--r-lg); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); + font-size: 15px; + line-height: 1.55; + color: var(--ink); + text-align: left; + white-space: pre-wrap; + word-break: break-word; + /* 结果文字允许选中复制(其余 UI 默认禁选) */ + user-select: text; + -webkit-user-select: text; +} +.result-copy { + align-self: flex-end; + -webkit-appearance: none; + appearance: none; + border: 1px solid var(--blue); + background: var(--blue-soft); + color: var(--blue); + font-size: 13px; + font-weight: 600; + font-family: inherit; + padding: 8px 18px; + border-radius: var(--r-lg); + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} +.result-copy:active { + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); +} +.result-copy.copied { + background: var(--ok-soft); + border-color: var(--ok); + color: var(--ok); +} + +/* ===== 提示文字 ===== */ +.rec-tip { + text-align: center; + color: var(--ink-4); + font-size: 13px; + margin: 18px 0 0; +} + +/* ===== 电脑落字开关 ===== */ +.insert-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 14px 0 0; + font-size: 13px; + color: var(--ink-3); + cursor: pointer; +} +.insert-switch { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} +.insert-track { + position: relative; + width: 42px; + height: 24px; + border-radius: 999px; + background: var(--line-strong); + transition: background 0.2s ease; + flex: none; +} +.insert-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: transform 0.2s ease; +} +.insert-switch:checked ~ .insert-track { + background: var(--blue); +} +.insert-switch:checked ~ .insert-track::after { + transform: translateX(18px); +} +.insert-switch:focus-visible ~ .insert-track { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +.wake-lock-hint { + margin: 7px auto 0; + max-width: 340px; + color: var(--ink-3); + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +/* ===== 断线屏 ===== */ +.offline-icon { + font-size: 48px; +} +.offline-title { + font-size: 20px; + margin: 12px 0 6px; + color: var(--ink); +} +.offline-sub { + color: var(--ink-3); + font-size: 14px; + margin: 0 0 8px; +} + +/* ===== 底部证书提示(固定) ===== */ +.cert-tip { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px calc(12px + var(--safe-bottom)); + font-size: 12px; + line-height: 1.5; + color: var(--ink-4); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); + border-top: 0.5px solid var(--line); + text-align: center; +} + +/* 小屏微调 */ +@media (max-height: 640px) { + .brand { + margin: 14px 0; + } + .brand-logo-img { + width: 56px; + height: 56px; + } + .record-btn { + width: 148px; + height: 148px; + } + .record-btn-icon { + width: 52px; + height: 52px; + } +} diff --git a/openless-all/app/assets/vocab-presets.json b/openless-all/app/assets/vocab-presets.json new file mode 100644 index 000000000..704b6ad80 --- /dev/null +++ b/openless-all/app/assets/vocab-presets.json @@ -0,0 +1,36 @@ +[ + { + "id": "programmer", + "name": "程序员", + "phrases": [ + "PR", + "CI", + "tag", + "release", + "issue", + "Rust", + "TypeScript", + "Claude", + "Codex", + "Copilot", + "Cursor", + "Windsurf", + "Anthropic", + "OpenAI", + "GPT", + "ChatGPT", + "Gemini", + "DeepSeek" + ] + }, + { + "id": "chef", + "name": "厨师", + "phrases": ["出品", "备料", "火候", "刀工", "摆盘", "sous vide"] + }, + { + "id": "civil-servant", + "name": "公务员", + "phrases": ["公文", "批示", "督办", "政务", "会签", "材料"] + } +] diff --git a/openless-all/app/crates/openless-core/src/asr/bailian.rs b/openless-all/app/crates/openless-core/src/asr/bailian.rs index 9e2850e32..963d81480 100644 --- a/openless-all/app/crates/openless-core/src/asr/bailian.rs +++ b/openless-all/app/crates/openless-core/src/asr/bailian.rs @@ -16,6 +16,7 @@ use tokio::net::{lookup_host, TcpStream}; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify}; use tokio_tungstenite::client_async_tls; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::error::UrlError; use tokio_tungstenite::tungstenite::http::header::HeaderValue; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; @@ -46,15 +47,11 @@ const PER_ADDR_TCP_TIMEOUT: Duration = Duration::from_millis(1500); fn default_port_for_request( request: &tokio_tungstenite::tungstenite::handshake::client::Request, -) -> Result { +) -> Result { let default_port = match request.uri().scheme_str() { Some("ws") => 80, Some("wss") => 443, - _ => { - return Err(WsError::Url( - tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme, - )) - } + _ => return Err(UrlError::UnsupportedUrlScheme), }; Ok(request.uri().port_u16().unwrap_or(default_port)) } @@ -118,7 +115,7 @@ async fn connect_ws_prefer_ipv4( ), WsError, > { - let port = default_port_for_request(&request)?; + let port = default_port_for_request(&request).map_err(WsError::Url)?; let host = request.uri().host().unwrap_or("").to_string(); let addrs = lookup_host((host.as_str(), port)) .await @@ -870,15 +867,11 @@ mod tests { let explicit_port = "https://localhost:443/path".into_client_request().unwrap(); assert!(matches!( default_port_for_request(&request), - Err(WsError::Url( - tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme - )) + Err(UrlError::UnsupportedUrlScheme) )); assert!(matches!( default_port_for_request(&explicit_port), - Err(WsError::Url( - tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme - )) + Err(UrlError::UnsupportedUrlScheme) )); } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 83569a9be..bf8f159b6 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -162,7 +162,7 @@ impl CloudSyncService { .parse::() .is_ok_and(|ip| ip.is_loopback()) }); - if (!matches!(url.scheme(), "https") && !(url.scheme() == "http" && loopback)) + if !(url.scheme() == "https" || url.scheme() == "http" && loopback) || !url.username().is_empty() || url.password().is_some() { diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 4b73d88ec..1397aa38e 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/external_audio.rs b/openless-all/app/crates/openless-core/src/external_audio.rs index 24d0cbf8d..977ba1d44 100644 --- a/openless-all/app/crates/openless-core/src/external_audio.rs +++ b/openless-all/app/crates/openless-core/src/external_audio.rs @@ -299,8 +299,10 @@ mod tests { std::env::temp_dir().join(format!("openless-remote-archive-{}", uuid::Uuid::new_v4())); let recorder = ExternalAudioRecorder::with_recordings_directory(directory.clone()); let id = SessionId::new(); - let mut context = DictationContext::default(); - context.audio_source = DictationAudioSource::External; + let mut context = DictationContext { + audio_source: DictationAudioSource::External, + ..DictationContext::default() + }; context.recording.archive_enabled = true; let consumer = Arc::new(RecordingConsumer::default()); let recording = recorder @@ -356,8 +358,10 @@ mod tests { } let recorder = ExternalAudioRecorder::with_recordings_directory(directory.clone()); let id = SessionId::new(); - let mut context = DictationContext::default(); - context.audio_source = DictationAudioSource::External; + let mut context = DictationContext { + audio_source: DictationAudioSource::External, + ..DictationContext::default() + }; context.recording.archive_enabled = enabled; let consumer = Arc::new(RecordingConsumer::default()); let recording = recorder @@ -389,8 +393,10 @@ mod tests { std::fs::create_dir_all(&directory).unwrap(); std::fs::write(directory.join("user.wav"), b"keep").unwrap(); let recorder = ExternalAudioRecorder::with_recordings_directory(directory.clone()); - let mut context = DictationContext::default(); - context.audio_source = DictationAudioSource::External; + let mut context = DictationContext { + audio_source: DictationAudioSource::External, + ..DictationContext::default() + }; context.recording.archive_enabled = true; context.recording.max_entries = Some(2); for _ in 0..4 { diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 3dc2d6c42..e317e953c 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -355,4 +355,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_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 102f657dc..bf16e7e39 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -513,8 +513,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")); } diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 37d0f7ccf..6ca1579c6 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -52,6 +52,10 @@ pub struct HotkeyRuntimeTarget { pub selection_polish: Option, pub coding_agent_enabled: bool, pub coding_agent_voice: Option, + #[serde(default)] + pub coding_agent_panel: Option, + #[serde(default)] + pub coding_agent_quick: Option, pub style_packs: Vec, } @@ -67,6 +71,8 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { selection_polish: preferences.selection_polish_hotkey.clone(), coding_agent_enabled: preferences.coding_agent_enabled, coding_agent_voice: preferences.coding_agent_voice_hotkey.clone(), + coding_agent_panel: preferences.coding_agent_panel_hotkey.clone(), + coding_agent_quick: preferences.coding_agent_quick_hotkey.clone(), style_packs: preferences.style_pack_hotkeys.clone(), } } @@ -105,6 +111,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 +128,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 +136,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 +146,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..6e65d94ba 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless-linux-egui" -version = "0.1.0" +version = "2.0.0-Beta.1" license = "AGPL-3.0-only" description = "Linux host seam for the OpenLess egui frontend" edition = "2021" @@ -13,16 +13,27 @@ 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"] } [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -arboard = { version = "3", features = ["wayland-data-control"] } +x11rb = { version = "0.13.2", features = ["randr", "xinput"] } 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"] } +# PipeWire's PulseAudio protocol also supports the GNOME 42 / Ubuntu 22.04 +# baseline (native cpal PipeWire requires libpipewire >= 0.3.53). +cpal = { version = "0.18.2", default-features = false, features = ["pulseaudio"] } +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } +egui = "=0.33.3" +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" axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } diff --git a/openless-all/app/linux-egui/assets/ui-locales.json b/openless-all/app/linux-egui/assets/ui-locales.json new file mode 100644 index 000000000..b15ac5f09 --- /dev/null +++ b/openless-all/app/linux-egui/assets/ui-locales.json @@ -0,0 +1 @@ +{"zh-CN":{"cloudSync.title":"云同步","cloudSync.description":"使用 GitHub 账号,在设备之间同步词典、风格与个人偏好。","cloudSync.signIn":"使用 GitHub 登录","cloudSync.account":"同步账号","cloudSync.refresh":"刷新状态","cloudSync.loading":"正在读取云端状态…","cloudSync.noBackup":"云端尚无备份","cloudSync.available":"云端备份已就绪","cloudSync.summary":"{{dictionary}} 个词条 · {{corrections}} 条纠正规则 · {{stylePacks}} 个风格","cloudSync.updated":"更新于 {{time}}","cloudSync.upload":"备份到云端","cloudSync.restore":"从云端恢复","cloudSync.delete":"删除云端备份","cloudSync.working":"正在同步…","cloudSync.uploadSuccess":"已备份到云端","cloudSync.restoreSuccess":"已恢复云端配置","cloudSync.deleteSuccess":"云端备份已删除","cloudSync.failed":"同步失败:{{error}}","cloudSync.conflict":"云端已有更新。请刷新状态后,再决定备份或恢复。","cloudSync.unavailable":"官方同步服务暂不可用,请稍后重试。","cloudSync.signInRequired":"请先登录 GitHub。","cloudSync.restoreTitle":"恢复云端备份?","cloudSync.restoreDescription":"云端的词典、纠正规则、风格和同步偏好将覆盖本机对应内容。API 密钥、设备目录与权限保持本机设置。","cloudSync.deleteTitle":"删除云端备份?","cloudSync.deleteDescription":"仅删除这个 GitHub 账号的云端备份,本机数据会保留。","cloudSync.confirmRestore":"恢复并替换","cloudSync.confirmDelete":"删除备份","cloudSync.scope":"同步词典、纠正规则、风格图标与常用偏好。API 密钥、登录凭据及设备专属设置保留在本机。","macDictationKey.Changed":"保存期间快捷键已改变,请重试。","macDictationKey.label":"Mac 听写键","macDictationKey.description":"用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。","macDictationKey.Permission":"请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。","macDictationKey.Busy":"请先结束当前听写,再更改快捷键。","macDictationKey.Unavailable":"无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。","app.name":"OpenLess","app.tagline":"自然说话,完美书写","common.loading":"加载中…","common.retry":"重试","common.settingsLoadFailed":"设置加载失败","common.refresh":"刷新","common.clear":"清空","common.copy":"复制","common.delete":"删除","common.later":"稍后","common.cancel":"取消","common.close":"关闭","common.show":"显示","common.hide":"隐藏","common.saved":"已保存","common.saving":"保存中","common.experimental":"实验性","common.copied":"已复制","common.operationFailed":"操作失败","common.add":"添加","common.durationSeconds":"{{value}} 秒","common.durationMillis":"{{value}} 毫秒","common.durationMinutes":"{{value}} 分钟","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"已取消","capsule.error":"出错了","capsule.inserted":"已插入 {{count}}","capsule.translating":"正在翻译","capsule.selectionPolish.polishing":"正在润色...","capsule.selectionPolish.replaced":"已替换","capsule.selectionPolish.noSelection":"未选中内容","capsule.selectionPolish.failed":"润色失败,请重试","selectionPolishPreview.title":"选区润色预览","selectionPolishPreview.subtitle":"可直接编辑;点击确认后才会替换原选区。","selectionPolishPreview.cancel":"取消","selectionPolishPreview.resultLabel":"润色结果","selectionPolishPreview.sourcePrefix":"原文:","selectionPolishPreview.applyError":"未能应用:","selectionPolishPreview.confirmReplace":"确认并替换","selectionVoiceIntent.title":"你想做什么?","selectionVoiceIntent.subtitle":"已识别你的语音指令,请选择处理方式。","selectionVoiceIntent.loading":"加载中…","selectionVoiceIntent.sourcePrefix":"选区:","selectionVoiceIntent.errorPrefix":"未能继续:","selectionVoiceIntent.question":"提问","selectionVoiceIntent.edit":"编辑选区","selectionVoiceIntent.cancel":"取消","qa.title":"划词追问","qa.headerHint":"随时提问","qa.thinking":"思考中…","qa.error":"出错了,请稍后再试。","qa.errorRetry":"重试","qa.errorRetryHint":"请再试一次。","qa.pinTooltip":"固定(不自动关闭)","qa.unpinTooltip":"取消固定","qa.closeTooltip":"关闭","qa.micLabel":"语音提问","qa.micStop":"结束录音","qa.selectionPreview":"基于选中文本:","qa.emptyTitle":"有什么可以帮你?","qa.emptyDesc":"选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。","qa.recordingHint":"录音中…再按一次 {{recordHotkey}} 结束并提问","qa.mobileRecordLabel":"录音按钮","qa.mobileRecordStart":"开始录音","qa.mobileRecordStop":"结束并提交","qa.composerPlaceholder":"输入问题,Enter 发送","qa.composerSend":"发送","qa.statusIdle":"按 {{recordHotkey}} 提问","qa.statusRecording":"录音中","qa.statusThinking":"思考中","qa.statusError":"出错了","qa.jumpToLatest":"跳到最新","qa.editApplyReplace":"预览并确认插入","qa.editApplyUnavailable":"没有可替换的编辑结果","qa.editRevertPrevious":"保留上一版本","qa.editInstructionMode":"编辑指令","lessComputer.title":"Less Computer","lessComputer.subtitle":"想让电脑做什么?","lessComputer.you":"你","lessComputer.working":"正在操控电脑…","lessComputer.tool":"调用了 {{name}}","lessComputer.compaction":"上下文已压缩","lessComputer.done":"完成","lessComputer.cost":"${{cost}}","lessComputer.error":"失败,请重试。","lessComputer.closeTooltip":"关闭","lessComputer.jumpToLatest":"跳到最新","lessComputer.inputPlaceholder":"输入指令,Enter 发送","lessComputer.send":"发送","lessComputer.approvalTitle":"执行被拦截的命令?","lessComputer.approvalRerunWarning":"注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用","lessComputer.approve":"允许","lessComputer.deny":"拒绝","lessComputer.approved":"已允许","lessComputer.denied":"已拒绝","nav.overview":"概览","nav.history":"历史","nav.vocab":"词典","nav.style":"风格","nav.marketplace":"风格市场","nav.translation":"翻译","nav.selectionAsk":"划词追问","nav.corrections":"纠正规则","nav.polishMode":"润色模式","nav.group.style":"风格","nav.group.tools":"工具","nav.localAsr":"模型设置","nav.more":"更多","marketplace.kicker":"风格市场","marketplace.title":"风格包市场","marketplace.desc":"浏览、安装和分享社区风格包。","marketplace.searchPlaceholder":"搜索名称 / 描述 / 标签…","marketplace.sortPopular":"按热度","marketplace.sortNew":"最新","marketplace.uploadBtn":"上传","marketplace.uploadDisabledHint":"请先在 设置 → 风格市场 配置 GitHub 用户名","marketplace.refreshBtn":"刷新","marketplace.empty":"还没有风格包","marketplace.emptyHint":"换个搜索词,或自己上传一个分享给社区","marketplace.loadFailed":"加载失败:{{err}}","marketplace.noDescription":"(暂无描述)","marketplace.installBtn":"安装到本地","marketplace.installingBtn":"安装中…","marketplace.downloadZipBtn":"下载 ZIP","marketplace.downloadingZipBtn":"下载中…","marketplace.downloadAria":"下载「{{name}}」ZIP","marketplace.likeBtn":"点赞","marketplace.installed":"已安装「{{name}}」到本地风格包","marketplace.downloaded":"已下载「{{name}}」ZIP","marketplace.uploaded":"上传成功,等待审核","marketplace.uploadTitle":"选择要上传的风格包","marketplace.uploadHint":"上传以 {{login}} 身份登录。包内容会发到云端审核队列。","marketplace.uploadNoLocal":"本地没有可上传的风格包","marketplace.errors.detail":"加载详情失败:{{err}}","marketplace.errors.install":"安装失败:{{err}}","marketplace.errors.download":"下载 ZIP 失败:{{err}}","marketplace.errors.like":"点赞失败:{{err}}","marketplace.errors.upload":"上传失败:{{err}}","marketplace.errors.loadLocal":"加载本地风格包失败:{{err}}","marketplace.sortLiked":"我赞过的","marketplace.likedEmpty":"你还没有赞过任何风格包","marketplace.likedEmptyHint":"点开任一风格包,红色星星点亮后会出现在这里","marketplace.derivativeBadge":"衍生自 @{{login}}","marketplace.detail.withdrawBtn":"撤回发布","marketplace.detail.withdrawConfirm":"确认从风格市场撤回「{{name}}」?本地副本不会被删除。","marketplace.detail.withdrawSuccess":"已从风格市场撤回","marketplace.detail.withdrawFailed":"撤回失败:{{err}}","marketplace.myPacks.buttonLabel":"我的发布","marketplace.myPacks.buttonTitle":"查看 {{login}} 的发布","marketplace.myPacks.buttonTitleEmpty":"先在 Settings → 风格市场 填写发布身份","marketplace.myPacks.searchPlaceholder":"搜索名称、标签","marketplace.myPacks.notLoggedIn":"请先在 Settings → 风格市场 填写发布身份","marketplace.myPacks.emptyTitle":"你还没有发布过风格包","marketplace.myPacks.emptyHint":"在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。","marketplace.myPacks.noMatch":"没有匹配的风格包","marketplace.myPacks.summary":"已发布 {{count}} 个风格包","marketplace.myPacks.summaryPending":"已发布 {{count}} 个风格包 · {{pending}} 个审核中","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"更新","marketplace.myPacks.actions.withdraw":"下架","marketplace.myPacks.loadFailed":"我的发布加载失败:{{err}}","marketplace.myPacks.loadingTitle":"正在拉取,请稍后…","marketplace.myPacks.loadingHint":"从风格市场获取你最新发布的风格包。","marketplace.myPacks.loadErrorTitle":"加载失败","marketplace.myPacks.loadErrorRetry":"重试","marketplace.upload.confirmBtn":"确定上传","marketplace.upload.updateTitle":"更新「{{name}}」","marketplace.upload.updateHint":"选中要上传的本地新版本风格包,下方点「确定上传」。同名包默认预选。","marketplace.upload.recommendedBadge":"建议更新","marketplace.state.pending":"审核中","marketplace.state.approved":"已上架","marketplace.state.rejected":"未通过","marketplace.state.withdrawn":"已下架","marketplace.state.superseded":"已被新版替换","marketplace.state.unknown":"未知","marketplace.oauth.title":"用 GitHub 登录","marketplace.oauth.generating":"正在生成设备验证码…","marketplace.oauth.browserHint":"在浏览器中打开 {{uri}} 并输入下方代码:","marketplace.oauth.copyBtn":"复制","marketplace.oauth.copied":"已复制设备码","marketplace.oauth.copyFailed":"复制失败:{{err}}","marketplace.oauth.openBrowserBtn":"打开浏览器","marketplace.oauth.cancelBtn":"取消","marketplace.oauth.waiting":"等待你在浏览器中授权…","marketplace.oauth.successAs":"已登录为 @{{login}}","marketplace.oauth.retryBtn":"重试","marketplace.oauth.closeBtn":"关闭","marketplace.oauth.loginBtn":"登录","marketplace.oauth.loginTooltip":"点击用 GitHub 登录","marketplace.oauth.reloginTooltip":"点击重新登录 / 切换账号(当前 @{{login}})","marketplace.modal.loggedIn":"当前登录身份 —— 在 Settings → 录音 → 风格市场 修改","marketplace.modal.notLoggedIn":"未登录 —— 去 Settings → 录音 → 风格市场 填一个发布者名","marketplace.modal.notLoggedInLabel":"未登录","shell.shortcutLabel":"录音快捷键","shell.shortcutHint":"开始 / 停止","shell.betaTag":"BETA","shell.betaNote":"本地存储,可选云端备份","shell.navHint.overview":"状态总览:用量统计、提供商与权限健康检查","shell.navHint.history":"听写历史:搜索、回放与复制过往转写","shell.navHint.vocab":"词典:自定义热词,提升专有名词识别率","shell.navHint.style":"润色风格:管理输出风格与自定义提示词","shell.navHint.translation":"翻译:按住 Shift 说话,译成目标语言插入","shell.navHint.selectionAsk":"划词追问:选中文字后语音提问","shell.navHint.settings":"偏好设置:快捷键、提供商、隐私与更新","shell.footer.account":"账户","shell.footer.feedback":"反馈","shell.footer.settings":"设置","shell.footer.help":"帮助","shell.footer.version":"版本 {{version}}","shell.footer.helpPopover.tagline":"本地驱动的语音输入层","shell.footer.helpPopover.releaseNotes":"查看发布日志 ↗","shell.footer.helpPopover.docs":"帮助中心 ↗","shell.providerPrompt.title":"设置语音提供商","shell.providerPrompt.body":"还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。","shell.providerPrompt.later":"稍后","shell.providerPrompt.openSettings":"去设置","shell.hotkeyModePrompt.title":"检查录音方式","shell.hotkeyModePrompt.body":"默认已改为切换式。如果之前改过触发方式,请到录音设置确认一次。","shell.hotkeyModePrompt.later":"稍后提醒","shell.hotkeyModePrompt.openSettings":"去录音设置","onboarding.welcome":"欢迎使用 OpenLess","onboarding.intro":"本地说出,本地落字。开始前需要两个系统权限。","onboarding.accessibilityTitle":"辅助功能","onboarding.hotkeyTitle":"全局快捷键","onboarding.accessibilityDesc":"用于监听全局快捷键(默认 {{trigger}})并把识别结果写入光标位置。","onboarding.hotkeyDesc":"用于确认全局快捷键监听可用。","onboarding.micTitle":"麦克风","onboarding.micDesc":"用于捕获你的语音输入。","onboarding.actionNotApplicable":"无需授权","onboarding.actionGranted":"已授权","onboarding.actionOpenSystem":"打开系统设置","onboarding.actionRestart":"重置授权并重启 OpenLess","onboarding.actionGrant":"授权","onboarding.actionRequestMic":"弹出授权","onboarding.micNoDeviceHint":"未检测到麦克风,请连接并启用麦克风后重试。","onboarding.accessibilityHint":"授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。","onboarding.footerHint":"授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。","onboarding.continueToSettings":"仅进入设置(语音与全局快捷键暂不可用)","onboarding.androidContinue":"先进入应用","onboarding.androidFooterHint":"听写需要麦克风权限。可点击上方「弹出授权」,或先进入应用后在概览页继续授权。","onboarding.androidTitle":"配置 OpenLess","onboarding.androidIntro":"按步骤完成移动端权限和服务配置。","onboarding.androidStepCounter":"第 {{current}} / {{total}} 项","onboarding.androidBack":"上一步","onboarding.androidNext":"下一步","onboarding.androidFinish":"完成并进入","onboarding.androidSteps.microphoneTitle":"麦克风权限","onboarding.androidSteps.microphoneDesc":"调用 Android 系统授权卡片,允许 OpenLess 录制语音。","onboarding.androidSteps.accessibilityTitle":"无障碍服务","onboarding.androidSteps.accessibilityDesc":"用于把识别结果粘贴回当前输入框,并辅助检测输入环境。","onboarding.androidSteps.overlayPermissionTitle":"悬浮窗权限","onboarding.androidSteps.overlayPermissionDesc":"允许 OpenLess 在其他应用上显示录音控制按钮。","onboarding.androidSteps.overlayConfigTitle":"悬浮窗配置","onboarding.androidSteps.overlayConfigDesc":"设置悬浮窗显示时机、触发方式、滑动动作和按钮大小。","onboarding.androidSteps.asrTitle":"ASR 云服务","onboarding.androidSteps.asrDesc":"配置语音转文字服务的供应商、密钥、接口地址和模型。","onboarding.androidSteps.llmTitle":"LLM 服务","onboarding.androidSteps.llmDesc":"配置文本润色、翻译和问答使用的语言模型服务。","overview.refresh":"刷新状态","overview.servicesTitle":"当前语音服务","overview.statsTitle":"使用记录","overview.omniKind":"多模态语音","overview.omniName":"当前 Omni 模型","overview.statusLoading":"正在读取服务配置…","overview.configureProvider":"去配置","overview.manageProvider":"管理服务","overview.recentEmptyHint":"还没有听写记录。跟着上方的引导试一次,结果会显示在这里。","overview.providerHelp.asr":"将语音转成文字。","overview.providerHelp.llm":"按你的风格整理和润色文字。","overview.providerHelp.omni":"由一个模型完成语音识别和文字处理。","overview.actions.refresh":"重新读取","overview.actions.services":"AI 服务与模型","overview.actions.general":"录音与输入","overview.actions.shortcuts":"快捷键","overview.actions.privacy":"权限与数据","overview.guide.nextStep":"下一步","overview.guide.loadingTitle":"正在读取你的配置","overview.guide.loadingDesc":"稍等一下,马上显示当前服务和下一步操作。","overview.guide.unavailableTitle":"暂时无法读取服务状态","overview.guide.unavailableDesc":"重新读取,或进入 AI 服务查看配置。","overview.guide.servicesTitle":"先配置语音服务","overview.guide.servicesDesc":"推荐从这里开始:选择语音识别和文字处理服务;使用 Omni 时,只需配置当前多模态模型。","overview.guide.permissionsTitle":"先检查快捷键状态","overview.guide.permissionsDesc":"当前快捷键适配器不可用。打开权限与数据,查看状态和可用的处理方式。","overview.guide.shortcutsTitle":"设置一个录音快捷键","overview.guide.shortcutsDesc":"选择顺手的快捷键,之后就能在输入时发起听写。","overview.guide.recordingTitle":"确认你的录音方式","overview.guide.recordingDesc":"服务配置已保存。打开录音设置,选择麦克风和适合你的录音方式。","overview.guide.tryDictationTitle":"试一次听写","overview.guide.tryDictationDesc":"把光标放到要输入的地方。{{shortcut}}","overview.guide.permissionsHint":"录音或快捷键没有反应?在「权限与数据」中查看权限、麦克风和快捷键状态。","overview.kicker":"概览","overview.title":"今日概览","overview.desc":"今日口述统计与系统状态。","overview.pressPrefix":"按","overview.pressSuffix":"开始录音","overview.asrKind":"语音识别","overview.llmKind":"文字处理","overview.asrName":"火山引擎","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI 兼容","overview.llmConfigured":"已配置 active LLM","overview.llmNotConfigured":"未配置","overview.statusConfigured":"已配置","overview.statusNotConfigured":"未配置","overview.statusUnknown":"无法读取","overview.credentialsLoadError":"无法读取凭据状态","overview.metricChars":"今日字数","overview.metricSegments":"{{count}} 段","overview.metricDuration":"今日总时长","overview.metricAvg":"平均段落","overview.metricAvgTrend":"今日均值","overview.metricNoData":"暂无数据","overview.historyLoadError":"历史读取失败","overview.metricTotal":"累计记录","overview.metricTotalTrend":"本机存档 (上限 200)","overview.activityTitle":"年度活动","overview.activityCount":"{{count}} 次听写","overview.activityLoadError":"活动数据读取失败","overview.period.ariaLabel":"统计周期","overview.period.last7Days":"近 7 天","overview.period.last30Days":"近 30 天","overview.period.dailyAverage":"日均 {{value}}","overview.period.minutes":"{{value}} 分钟","overview.period.hoursMinutes":"{{hours}} 小时 {{minutes}} 分","overview.metricName.ariaLabel":"统计指标","overview.metricName.count":"条数","overview.metricName.chars":"字数","overview.metricName.duration":"时长","overview.recentTitle":"最近识别","overview.recentAll":"全部记录 →","overview.recentEmpty":"还没有记录。按 {{trigger}} 开始第一次录音。","overview.recentLoadFailed":"无法读取最近识别,请重试。","overview.historyRetry":"重试","overview.weekDays.0":"日","overview.weekDays.1":"一","overview.weekDays.2":"二","overview.weekDays.3":"三","overview.weekDays.4":"四","overview.weekDays.5":"五","overview.weekDays.6":"六","overview.inAppDictation.title":"应用内录音","overview.inAppDictation.start":"开始录音","overview.inAppDictation.stop":"停止录音","overview.inAppDictation.idle":"点击开始录音","overview.inAppDictation.recording":"录音中…","overview.inAppDictation.processing":"处理中…","overview.androidMicBanner.title":"需要麦克风权限","overview.androidMicBanner.desc":"授权麦克风后可使用应用内录音与语音输入。","overview.androidMicBanner.grant":"弹出授权","overview.androidMicBanner.openSettings":"打开系统设置","history.exportError":"导出录音失败,请重试。","history.kicker":"历史记录","history.title":"历史记录","history.desc":"本机保存的识别记录。","history.filterAll":"全部","history.summary":"共 {{total}} 条 · 显示 {{shown}}","history.searchPlaceholder":"搜索转写内容…({{shortcut}})","history.searchNoMatch":"没有匹配「{{query}}」的记录。","history.empty":"还没有历史记录。按 {{trigger}} 录一段试试。","history.loadFailed":"加载历史失败:{{err}}","history.retry":"重试","history.clearFailed":"清空失败:{{err}}","history.deleteFailed":"删除失败:{{err}}","history.copyFailed":"复制失败:{{err}}","history.playRecording":"播放录音","history.audioLoading":"加载中…","history.audioDecodeFailed":"音频解码失败:{{err}}","history.exportRecording":"导出录音","history.exportFailed":"导出失败:{{err}}","history.retranscribe":"重新转录","history.retranscribing":"转录中…","history.retranscribeFailed":"重新转录失败:{{err}}","history.rawLabel":"原文","history.rawEmpty":"(空)","history.selectHint":"左侧选一条查看详情。","history.recorded":"录音 {{duration}}","history.stepAsr":"识别","history.multimodalPipeline":"多模态","history.stepAsrHint":"松键后等待识别结果的耗时。流式识别边录边转,此值通常远小于录音时长。","history.stepPolish":"润色","history.stepInsert":"插入","history.chars":"{{count}} 字","history.vocabHits":"{{count}} 个热词","history.inserted":"已插入","history.pasteSent":"已尝试粘贴","history.copiedFallback":"已复制(需 {{shortcut}})","history.insertFailed":"插入失败","history.confirmClear":"确定清空全部 {{count}} 条记录?此操作不可恢复。","history.backToList":"返回列表","history.repolish.title":"重新润色","history.repolish.hint":"基于上面的原文再跑一次润色。结果只在本次查看时显示,不写回这条记录。原风格包已删除或旧记录时,重试将使用当前风格。","history.repolish.retry":"用原风格重试","history.repolish.retrying":"重试中…","history.repolish.apply":"应用","history.repolish.applying":"润色中…","history.repolish.pickStyle":"选择风格包","history.repolish.noPacks":"没有可用的风格包。","history.repolish.packsLoadFailed":"读取风格包失败:{{err}}","history.repolish.failed":"重新润色失败:{{err}}","history.repolish.timeout":"当前 LLM 提供商 30 秒内没有返回结果。换个更快的提供商,或稍后重试 —— 免费模型池经常排队。","history.repolish.resultTitle":"{{name}} 的结果","history.repolish.retryResultTitle":"重试结果","history.repolish.empty":"(模型返回了空结果)","history.repolish.clear":"清除结果","vocabCard.title":"要记住这个词吗?","vocabCard.accept":"记住","vocabCard.reject":"不用","insertFallbackCard.copy":"复制","insertFallbackCard.copied":"已复制","insertFallbackCard.copyFailed":"复制失败","insertFallbackCard.dismiss":"关闭","vocab.selectAllVisible":"选择当前结果","vocab.selectedCount":"已选择 {{count}} 个词","vocab.selectWord":"选择「{{phrase}}」","vocab.deleteSelected":"删除已选({{count}})","vocab.batchDeleteFailed":"{{count}} 个词条删除失败,已保留选中,可重试。","vocab.kicker":"词典","vocab.title":"词典","vocab.desc":"添加生词或专业术语,提高识别准确率。","vocab.sectionTitle":"词条","vocab.placeholder":"输入词语,按 Enter 或点添加…","vocab.tip":"支持中英混合 · 数字开头按字面识别 · 命中次数自动计数","vocab.loadFailed":"加载失败:{{err}}","vocab.empty":"还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。","vocab.tipDisabled":"点击禁用此词条","vocab.tipEnabled":"点击启用此词条","vocab.removeAria":"删除","vocab.edit":"编辑","vocab.editTitle":"编辑词汇","vocab.editSave":"保存","vocab.editEmpty":"词条不能为空。","vocab.filter.all":"所有","vocab.filter.auto":"自动添加","vocab.filter.manual":"手动添加","vocab.searchPlaceholder":"搜索","vocab.searchEmpty":"没有匹配的词条。","vocab.newWord":"新词","vocab.newWordTitle":"添加新词","vocab.newWordDesc":"直接输入新词,或从预设模板批量导入。","vocab.newWordInputPlaceholder":"输入词语,按 Enter 添加…","vocab.newWordTemplates":"预设模板","vocab.newWordTemplateCount":"{{count}} 词","vocab.newWordAddSelected":"添加所选","vocab.learnedSection":"自动收集({{count}})","vocab.removeAllLearned":"全部删除","vocab.corrections.title":"纠正规则","vocab.corrections.tip":"修正常见 ASR 误识别,支持 {num} 数字通配。","vocab.corrections.patternPlaceholder":"误识别写法,如 {num}粒","vocab.corrections.replacementPlaceholder":"目标写法,如 {num}例","vocab.corrections.empty":"还没有纠正规则。","vocab.corrections.invalid":"仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。","vocab.corrections.tipDisabled":"点击禁用此规则","vocab.corrections.tipEnabled":"点击启用此规则","vocab.corrections.removeAria":"删除纠正规则","vocab.corrections.learnedBadge":"自动","vocab.corrections.learnedTip":"从你的手改中自动收集。可以随时删掉。","vocab.corrections.onlyLearned":"只看自动收集的({{count}})","vocab.corrections.removeAllLearned":"删除全部自动收集的","vocab.corrections.suggestTitle":"要记住这个改法吗?","vocab.corrections.suggestAccept":"记住","vocab.corrections.suggestDismiss":"不用","vocab.presets.title":"场景预设","vocab.presets.tip":"可多选批量启用,支持编辑和新建。","vocab.presets.create":"新建预设","vocab.presets.apply":"启用所选","vocab.presets.save":"保存预设","vocab.presets.edit":"编辑 {{name}}","vocab.presets.newPreset":"新预设","vocab.presets.namePlaceholder":"预设名称","vocab.presets.wordsPlaceholder":"词条(用逗号或换行分隔)","style.kicker":"风格","style.title":"输出风格","style.desc":"选择录音的默认输出风格。","style.masterToggle":"整体启用","style.currentDefault":"当前默认","style.ariaSetDefault":"设为默认","style.saveFailed":"保存失败:{{error}}","style.customPromptTitle":"自定义提示词","style.customPromptPlaceholder":"可选,追加到这个风格的内置 system prompt 末尾。","style.customPromptHint":"留空则保持当前行为不变。保存后会在该风格的润色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。","style.customPromptSave":"保存提示词","style.customPromptDirty":"未保存","style.systemPromptMovedHint":"完整 System Prompt 已移到 设置 -> Providers 页面统一编辑。这里现在只负责风格启停和默认风格。","style.modes.raw.name":"原文","style.modes.raw.desc":"只补标点和必要分句,不改写不扩写。","style.modes.raw.sample":"保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。","style.modes.light.name":"轻度润色","style.modes.light.desc":"去口癖、补标点,整理为可发送的自然文字。","style.modes.light.sample":"让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。","style.modes.structured.name":"清晰结构","style.modes.structured.desc":"面向编程协作、技术排障和产品反馈,准确保留术语并梳理结构。","style.modes.structured.sample":"1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点","style.modes.formal.name":"正式表达","style.modes.formal.desc":"工作沟通和邮件场景,更专业更完整。","style.modes.formal.sample":"邮件场景自动识别问候 / 落款;不引入空泛客套。","style.pack.builtinTags.minimalEdits":"最小改写","style.pack.builtinTags.strongCorrection":"强纠错","style.pack.builtinTags.communication":"沟通","style.pack.builtinTags.natural":"自然","style.pack.builtinTags.organized":"条理","style.pack.builtinTags.workplaceCommunication":"工作沟通","style.pack.builtinTags.aiCoding":"AI 编程","style.pack.builtinTags.technicalStructure":"技术结构化","style.pack.newName":"未命名风格","style.pack.newDescription":"简短描述这个风格的使用场景。","style.pack.uploadIcon":"为「{{name}}」上传 SVG 图标","style.pack.resetIcon":"恢复默认图标","style.pack.iconSaved":"图标已保存","style.pack.iconInvalid":"请选择不含外部资源的有效 SVG 图标(最大 256 KB)。","style.pack.iconSaveFailed":"图标保存失败,请重试。","style.pack.selectionListTitle":"选区书面润色风格","style.pack.selectionListDesc":"用于无需 ASR 的已选文字:单纯语法、清晰度和格式润色。可为它单独选择风格与 Prompt。","style.pack.dictationTab":"录音 / ASR 风格","style.pack.selectionTab":"选区润色","style.pack.current":"当前","style.pack.useForSelection":"用于选区","style.pack.writtenPolish":"书面润色","style.pack.selectionPromptTitle":"选区润色 Prompt(无 ASR)","style.pack.selectionPromptHint":"用于用户主动选中的书面文字;不经过 ASR,不把内容当成转写,也不回答其中的问题。","style.pack.selectionPromptEditorDesc":"当前编辑选区润色 Prompt;输入对象是用户主动选中的书面文字,不经过 ASR。","style.pack.dictationPromptEditorDesc":"当前编辑录音 / ASR 风格 Prompt;输入对象是语音识别后的转写文本。","style.pack.dictationPromptTitle":"录音 / ASR Prompt","style.pack.dictationPromptHint":"用于录音转写后的 ASR 文本;这里可以写口语整理、ASR 错字纠正和专有名词还原规则。","style.pack.selectionPromptFallback":"尚未配置书面润色 Prompt;将使用安全默认规则。","style.pack.selectionActivated":"已将「{{name}}」用于选区润色","style.pack.selectionActivateFailed":"选区润色风格切换失败:{{err}}","style.pack.selectionChars":"{{count}} 字符","style.pack.kicker":"风格包","style.pack.title":"风格包","style.pack.desc":"管理本地风格包。","style.pack.marketplaceBtn":"风格市场","style.pack.loadFailed":"加载风格包失败:{{err}}","style.pack.importZip":"导入 ZIP","style.pack.exportZip":"导出 ZIP","style.pack.exportShort":"导出","style.pack.publishMarketplace":"发布到风格市场","style.pack.updateMarketplace":"更新到风格市场新版本","style.pack.publishDisabledHint":"请先在 设置 → 风格市场 配置 GitHub 用户名","style.pack.publishSuccess":"发布成功,等待 marketplace 审核","style.pack.publishFailed":"发布失败:{{err}}","style.pack.publishBuiltinRejected":"内置风格包不能直接发布,请先编辑生成一份导入版。","style.pack.builtin":"内置","style.pack.imported":"导入","style.pack.active":"当前","style.pack.activate":"激活","style.pack.edit":"编辑","style.pack.closeEditor":"关闭","style.pack.unsaved":"未保存","style.pack.listTitle":"本地风格包","style.pack.listDesc":"浏览和切换风格包。","style.pack.listCount":"{{count}} 个风格包","style.pack.addPackTileTitle":"新建风格包","style.pack.addPackTileHint":"从空白模板开始。","style.pack.createSuccess":"已创建新风格包","style.pack.createFailed":"创建风格包失败:{{err}}","style.pack.save":"保存","style.pack.revert":"撤销","style.pack.saveSuccess":"风格包已保存","style.pack.saveFailed":"保存风格包失败:{{err}}","style.pack.activateSuccess":"已将\"{{name}}\"设为当前风格","style.pack.activateFailed":"设为当前风格失败:{{err}}","style.pack.importSuccess":"已导入\"{{name}}\"","style.pack.importFailed":"导入 ZIP 失败:{{err}}","style.pack.exportSuccess":"已导出到 {{path}}","style.pack.exportFailed":"导出 ZIP 失败:{{err}}","style.pack.exportDirtyFirst":"请先保存当前风格包,再导出 ZIP。","style.pack.resetBuiltin":"重置","style.pack.resetSuccess":"已重置\"{{name}}\"","style.pack.resetFailed":"重置风格包失败:{{err}}","style.pack.deleteImported":"删除","style.pack.deleteConfirm":"确定删除\"{{name}}\"吗?删除后无法恢复。","style.pack.deleteSuccess":"已删除\"{{name}}\"","style.pack.deleteFailed":"删除风格包失败:{{err}}","style.pack.summaryCurrentEmpty":"还没有选中风格包","style.pack.editorTitle":"编辑风格","style.pack.editorDesc":"编辑当前风格包。","style.pack.metaTitle":"安装信息","style.pack.metaSource":"来源","style.pack.metaBaseMode":"基础模式","style.pack.metaUpdatedAt":"更新时间","style.pack.fieldName":"名称","style.pack.fieldAuthor":"作者","style.pack.fieldAuthorPlaceholder":"可选,方便标注来源","style.pack.fieldVersion":"版本","style.pack.fieldTags":"标签","style.pack.fieldTagsPlaceholder":"用英文逗号分隔,例如 community, voiceover, formal","style.pack.fieldDescription":"描述","style.pack.fieldModel":"推荐模型(仅元数据)","style.pack.fieldModelPlaceholder":"可选,例如 gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"仅作说明,不会切换实际模型。","style.pack.fieldCompatibility":"兼容版本","style.pack.fieldCompatibilityPlaceholder":"可选,例如 >=1.3.0","style.pack.fullPromptTitle":"System Prompt","style.pack.fullPromptHint":"这就是这套风格包自己的 Prompt。","style.pack.promptChars":"{{count}} 字符","style.pack.runtimeTitle":"OpenLess 运行时附加指令","style.pack.runtimeDesc":"只读的运行时辅助项。","style.pack.runtimeContextTitle":"上下文前提","style.pack.runtimeContextDesc":"来自语言与应用上下文","style.pack.runtimeContextEmpty":"当前不会附加","style.pack.runtimeHotwordTitle":"热词提示段","style.pack.runtimeHotwordDesc":"来自已启用热词","style.pack.runtimeHotwordEmpty":"当前不会附加","style.pack.runtimeHistoryTitle":"多轮历史保护段","style.pack.runtimeHistoryDesc":"仅用于实时多轮 polish","style.pack.runtimeHistoryEmpty":"只有存在 prior turns 时才会附加","style.pack.runtimeActive":"当前生效","style.pack.runtimeInactive":"当前未生效","style.pack.runtimePreviewFailed":"生成运行时预览失败:{{err}}","style.pack.runtimePreviewOmittedFrontApp":"预览已省略前台 app 标签。","style.pack.examplesTitle":"效果示例","style.pack.examplesDesc":"会随风格包一起导出。","style.pack.addExample":"新增示例","style.pack.examplesEmpty":"还没有示例。","style.pack.exampleTitlePlaceholder":"示例 {{index}} 标题","style.pack.exampleInput":"输入","style.pack.exampleOutput":"输出","style.pack.examplesCount":"{{count}} 个示例","style.pack.discardCloseConfirm":"关闭编辑面板前要放弃未保存修改吗?","style.pack.discardSwitchConfirm":"要放弃当前未保存修改,并切换到\"{{name}}\"吗?","style.pack.derivativeBadge":"衍生自 @{{login}}","translation.searchLanguages":"搜索语言…","translation.noMatchingLanguages":"没有匹配的语言","translation.selectedLanguages":"已选择 {{count}} 种语言","translation.languageSupportHint":"语音服务支持的语种可能不同;翻译目标不受界面语言限制。","translation.kicker":"翻译","translation.title":"翻译","translation.desc":"录音后自动翻译为目标语言再插入。","translation.statusEnabled":"已启用","translation.statusDisabled":"未启用","translation.working.title":"工作语言","translation.working.desc":"勾选日常使用的语言,影响润色与翻译效果。","translation.target.title":"翻译目标语言","translation.target.desc":"录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。","translation.target.disabled":"不启用(Shift 按下不触发翻译)","translation.target.sameAsWorking":"目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。","translation.style.title":"翻译风格","translation.style.desc":"自动继承「风格」页当前激活的风格包。","translation.style.unavailable":"暂不可用","translation.save.workingFailed":"工作语言保存失败,请重试。","translation.save.targetFailed":"翻译目标语言保存失败,请重试。","translation.save.hotkeyRegisterFailed":"翻译快捷键注册失败,未继续保存。","translation.save.hotkeySaveFailed":"翻译快捷键保存失败,请重试。","translation.howto.title":"使用方法","translation.howto.step1":"在任意输入框聚焦光标。","translation.howto.step2":"按 {{trigger}} 开始录音。","translation.howto.step3":"录音中按一下 {{shortcut}} 激活翻译。","translation.howto.step4":"再按 {{trigger}} 停止录音。","translation.howto.step5":"翻译结果自动插入到光标位置。","translation.howto.indicatorTitle":"翻译模式指示","translation.howto.indicatorDesc":"按 Shift 后屏幕底部会显示蓝色「正在翻译」标识。","translation.howto.fallbackTitle":"安全兜底","translation.howto.fallbackDesc":"翻译失败时回退为插入原始转写,不会丢字。","selectionAsk.title":"划词追问","selectionAsk.desc":"选中文字后语音提问,支持多轮追问。","selectionAsk.shortcutSettings":"快捷键设置","selectionAsk.guide.openTitle":"打开追问浮窗","selectionAsk.guide.openDesc":"按 {{hotkey}},开始一轮对话。","selectionAsk.guide.unsetDesc":"先在快捷键设置中,为划词追问设置一个快捷键。","selectionAsk.guide.selectTitle":"选中想了解的内容","selectionAsk.guide.askTitle":"开口说出问题","selectionAsk.guide.askDesc":"按 {{recordHotkey}} 录音,再按一次提交。","selectionAsk.guide.followup":"继续使用录音快捷键,即可多轮追问。","selectionAsk.guide.dismiss":"关闭浮窗,结束本次对话","selectionAsk.hotkey.title":"弹出浮窗的快捷键","selectionAsk.save.historySaveFailed":"Q&A 历史保存设置保存失败,请重试。","selectionAsk.history.title":"保存历史","selectionAsk.history.desc":"开启后在本地保存问答记录,默认关闭。","selectionAsk.howto.title":"使用方法","selectionAsk.howto.step2":"在任意 app 选中文字。","settings.selectionWorkspace.title":"选区助手","settings.selectionWorkspace.hint":"选中文字后按同一快捷键:关闭语音编辑时直接润色;开启后口述指令,说完再选择「提问」或「编辑选区」。","settings.selectionWorkspace.polishHotkey":"选区助手快捷键","settings.selectionWorkspace.polishHotkeyDesc":"关闭语音编辑时直接润色;开启语音编辑时按住口述指令(录音方式跟随全局设置)。","settings.selectionWorkspace.polishDelivery":"结果处理","settings.selectionWorkspace.voiceDeliveryDesc":"语音编辑完成后:直接替换选区,或在华词面板中预览后再确认。","settings.selectionWorkspace.voiceEnable":"语音编辑","settings.selectionWorkspace.voiceEnableDesc":"与上方同一快捷键;录音方式跟随全局设置(当前:{{recordingLabel}})。","settings.selectionWorkspace.autoIntent":"自动判断意图","settings.selectionWorkspace.autoIntentDesc":"开启后默认用服务配置的模型判断问句 vs 编辑;模型不可用或解析失败时回退到问句启发式。","settings.selectionWorkspace.editKeywords":"额外问句线索","settings.selectionWorkspace.editKeywordsDesc":"关闭自动判断时生效;每行一个,指令中包含则视为提问,否则仍按问句启发式(?/吗/什么…)判定。","settings.selectionPolish.title":"选区润色","settings.selectionPolish.hotkey":"触发快捷键","settings.selectionPolish.hotkeyDesc":"录制后立即生效;与录音、追问等全局快捷键冲突时会被拒绝。","settings.selectionPolish.delivery":"结果处理方式","settings.selectionPolish.hint":"选择任意文字后触发。它不依赖麦克风或 ASR,使用当前风格包与独立的选区 Prompt。","settings.selectionPolish.directReplace":"直接覆盖","settings.selectionPolish.directReplaceHint":"模型完成后安全替换原选区。","settings.selectionPolish.previewConfirm":"预览确认","settings.selectionPolish.previewConfirmHint":"在可编辑弹窗中核对结果,再确认覆盖原选区。","settings.kicker":"设置","settings.title":"设置","settings.desc":"录音、提供商、快捷键与权限配置。","settings.network.title":"网络","settings.network.useSystemProxyLabel":"使用系统代理","settings.network.useSystemProxyDesc":"开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流与 Less Computer 不受此开关影响。","settings.dataStorage.title":"数据存储","settings.dataStorage.desc":"本机保留的历史会话与对话上下文。","settings.dataStorage.cursorContextLabel":"光标上下文(实验)","settings.dataStorage.cursorContextDesc":"润色时读取你正在写的那篇文档中光标附近的原文,帮模型判断同音词、专名和代词该怎么写。开启后这段文字会随请求发送给你配置的 LLM 服务商;关闭时一个字都不读。密码输入框、Secure Input、密码管理器与终端始终不读。仅 macOS。","settings.codingConsole.title":"Claude 控制台","settings.codingConsole.desc":"检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。","settings.codingConsole.guardNote":"默认放行可恢复操作;rm -rf / sudo / 强制推送等高风险命令被拦截;若工作目录是 git 仓库,运行前自动生成快照可回滚。","settings.codingConsole.status":"状态","settings.codingConsole.detect":"检测","settings.codingConsole.detecting":"检测中…","settings.codingConsole.installed":"已检测到 Claude","settings.codingConsole.notInstalled":"未检测到 claude","settings.codingConsole.notInstalledHint":"请先安装 Claude Code(参见 docs.anthropic.com/claude-code),或在下方填写其可执行文件完整路径。","settings.codingConsole.mcpServers":"已配置 {{count}} 个 MCP 服务","settings.codingConsole.computerUsePresent":"已配置桌面控制(computer use)MCP","settings.codingConsole.computerUseAbsent":"未配置桌面控制 MCP(复制/粘贴等轻动作用 Bash 即可,无需此项)","settings.codingConsole.exePath":"可执行文件","settings.codingConsole.workdir":"工作目录","settings.codingConsole.workdirDesc":"可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。","settings.codingConsole.workdirPlaceholder":"留空则在临时目录运行","settings.codingConsole.permissionMode":"权限模式","settings.codingConsole.mode.acceptEdits":"放行(可恢复操作)","settings.codingConsole.mode.plan":"只读 / 计划","settings.codingConsole.mode.default":"默认(逐项确认)","settings.codingConsole.mode.bypassPermissions":"完全放行(高风险)","settings.codingConsole.promptPlaceholder":"让 Claude 做点什么,例如:把当前目录的文件名列出来","settings.codingConsole.run":"运行","settings.codingConsole.running":"运行中…","settings.codingConsole.cancel":"取消","settings.codingConsole.clear":"清空","settings.codingConsole.riskWarn":"检测到高风险意图:{{reason}}。护栏会在执行层拦截高风险命令。","settings.codingConsole.toolUse":"调用工具 {{name}}","settings.codingConsole.done":"完成","settings.codingConsole.doneCost":"完成 · 用量 ${{cost}}","settings.codingConsole.cancelled":"已取消","settings.codingConsole.outputPlaceholder":"输出会流式显示在这里…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。","settings.codingAgent.enable":"启用 Less Computer","settings.codingAgent.comingSoonNote":"配置即时保存;热键触发与执行链路随后续版本生效。","settings.codingAgent.hotkeyHint":"开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。","settings.codingAgent.voiceHotkey":"按住说话键","settings.codingAgent.voiceHotkeyDesc":"按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。","settings.codingAgent.provider":"Agent 后端","settings.codingAgent.opencodeReady":"已检测到 OpenCode v{{version}}。","settings.codingAgent.opencodeMissing":"未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。","settings.codingAgent.cliReady":"已检测到 {{name}} v{{version}}。","settings.codingAgent.cliMissing":"未检测到 {{name}} 命令。请先安装并登录,或在下方「可执行文件」里填它的绝对路径。","settings.codingAgent.sandboxGuardHint":"该后端只有粗粒度沙箱档位,没有逐命令的高风险清单:撞到限制时会直接如实报错,不会弹出「批准这条命令」的卡片。","settings.codingAgent.codexModelHint":"填 Codex 的模型名(如 gpt-5);留空则用 ~/.codex/config.toml 里的设置。","settings.codingAgent.codexBudgetHint":"Codex 没有单次美元预算上限;费用取决于你配置的服务商。","settings.codingAgent.codexMode.plan":"只读 / 计划","settings.codingAgent.codexMode.workspaceWrite":"允许工作区写入","settings.codingAgent.codexModelPlaceholder":"留空 = 用 Codex 自己的默认","settings.codingAgent.dshModelHint":"dsh 的 headless 配置里没有模型开关:模型由 dsh 自己的 profile 决定,在这里改不了。","settings.codingAgent.panelHotkey":"面板键(语音 Agent)","settings.codingAgent.panelHotkeyDesc":"录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。","settings.codingAgent.quickHotkey":"快取用键","settings.codingAgent.quickHotkeyDesc":"拿当前选中文本 → Claude → 结果回插光标处。不开面板、更快。","settings.codingAgent.model":"模型","settings.codingAgent.modelPlaceholder":"默认 sonnet","settings.codingAgent.modelDefault":"默认(自动 sonnet)","settings.codingAgent.modelHint":"Haiku 最快 · Sonnet 均衡 · Opus 最强","settings.codingAgent.opencodeModelDefault":"使用 OpenCode 默认模型","settings.codingAgent.opencodeModelHint":"自动拉取 OpenCode 当前账号可用的 provider/model;选择后立即保存。","settings.codingAgent.opencodeModelsRefresh":"重新拉取模型","settings.codingAgent.opencodeModelsRefreshing":"正在拉取 OpenCode 模型…","settings.codingAgent.opencodeModelsLoaded":"已拉取 {{count}} 个模型。","settings.codingAgent.opencodeModelsEmpty":"没有返回可用模型,请先完成 OpenCode 登录或配置模型提供商。","settings.codingAgent.opencodeModelsError":"拉取模型失败:{{message}}","settings.codingAgent.exe":"可执行文件路径","settings.codingAgent.openPanel":"文字测试","settings.codingAgent.openPanelHint":"直接打开 Less Computer 浮窗,用文字验证当前 Agent 与模型配置。","settings.codingAgent.openPanelAction":"打开 Less Computer","settings.debug.cursorLabel":"光标","settings.debug.title":"调试工具","settings.debug.desc":"排查识别问题时使用,平时无需开启。","settings.debug.cursorProbeLabel":"光标上下文探针","settings.debug.cursorProbeDesc":"点一下,然后在倒计时内切到目标 app 并点进输入框——探针会读那里的光标附近原文,用来确认哪些 app 读得到、哪些被安全闸门拦住。只读一次,不发给任何服务商。","settings.debug.cursorProbeBtn":"探测(5 秒后)","settings.debug.cursorProbeCountdown":"{{n}} 秒后读取…","settings.marketplace.title":"扩展市场","settings.marketplace.desc":"风格市场的上传身份。浏览与安装风格在「风格」页内完成。","settings.marketplace.github.signIn":"用 GitHub 账号登录","settings.marketplace.github.signedIn":"已通过 GitHub 登录","settings.marketplace.github.signedOut":"登录后即可上传风格、给风格点赞。","settings.marketplace.github.signOut":"退出登录","settings.marketplace.github.starting":"正在发起登录…","settings.marketplace.github.codeHint":"在打开的 GitHub 页面输入这个验证码:","settings.marketplace.github.openGithub":"打开 GitHub","settings.marketplace.github.waiting":"已打开 GitHub,完成授权后会自动登录…","settings.marketplace.github.failed":"登录失败,请重试","settings.recording.title":"录音与输入","settings.recording.desc":"全局录音的快捷键与触发方式。","settings.recording.hotkeyLabel":"录音快捷键","settings.recording.hotkeyDescAcc":"按下开始捕获语音,全局生效(需辅助功能权限)。","settings.recording.hotkeyDescNoAcc":"按下开始捕获语音,全局生效。","settings.recording.modeLabel":"录音方式","settings.recording.modeDesc":"切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。","settings.recording.modeToggle":"切换式","settings.recording.modeHold":"按住说话","settings.recording.modeAuto":"自动","settings.recording.silenceAutoStopLabel":"静音后自动停止","settings.recording.silenceAutoStopDesc":"仅切换模式生效。检测到语音后,连续静音达到所选时长即自动结束并提交;一直没说话则 10 秒后取消。默认关闭;第二次按键停止和 Esc 取消仍然有效。","settings.recording.silenceAutoStopSecondsLabel":"静音时长","settings.recording.silenceAutoStopSecondsValue":"{{value}} 秒","settings.recording.migrationNoticeTitle":"默认已改为切换式说话","settings.recording.migrationNoticeDesc":"本次更新调整了默认值,如果习惯按住说话,请在此处切回。","settings.recording.microphoneLabel":"首选麦克风","settings.recording.microphoneDesc":"选择优先输入设备。设备断开时自动切到系统默认。","settings.recording.microphoneDefault":"系统默认麦克风","settings.recording.microphoneDefaultDesc":"使用系统默认输入设备","settings.recording.microphoneSystemDefault":"系统默认","settings.recording.microphoneUnavailable":"不可用","settings.recording.microphoneLoadError":"麦克风列表读取失败:{{message}}","settings.recording.microphoneDialogTitle":"麦克风","settings.recording.microphoneDialogDesc":"选择能捕捉到您声音的麦克风。","settings.recording.microphoneMonitorError":"输入电平监听失败:{{message}}","settings.recording.capsuleLabel":"录音胶囊","settings.recording.capsuleDesc":"录音 / 转写时显示屏幕底部胶囊。","settings.recording.capsuleStyleTypeless":"Typeless 传统风格","settings.recording.capsuleStyleLabel":"胶囊样式","settings.recording.capsuleStyleSiri":"流光 Siri 风格","settings.recording.capsuleStyleClassic":"Openless 默认风格","settings.recording.muteDuringRecordingLabel":"录音时静音","settings.recording.muteDuringRecordingDesc":"录音期间临时静音系统输出,避免扬声器回音。","settings.recording.audioCueLabel":"录音提示音","settings.recording.audioCueDesc":"按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。","settings.recording.audioCuePreview":"试听","settings.recording.insertGroupTitle":"插入与剪贴板","settings.recording.restoreClipboardLabel":"插入后恢复剪贴板","settings.recording.restoreClipboardDesc":"粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。","settings.recording.pasteShortcutLabel":"模拟粘贴快捷键","settings.recording.pasteShortcutDesc":"插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。","settings.recording.pasteShortcutCtrlV":"Ctrl+V(默认 / 多数应用)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert(xterm / urxvt)","settings.recording.comboRecordLabel":"录制快捷键","settings.recording.comboRecordDesc":"点击后按下想要的快捷键组合(如 ⌘⇧D)。","settings.recording.comboRecordBtn":"录制快捷键","settings.recording.comboResetBtn":"重置","settings.recording.comboMenuToggle":"更多操作","settings.recording.comboDisableHint":"核心快捷键不可停用,录音必须绑定一个热键","settings.recording.comboRecordHint":"请按下快捷键组合…","settings.recording.comboNeedKey":"请配组合键(如 ⌘⇧J),不支持单独的修饰键","settings.recording.comboRecorded":"已录制","settings.recording.comboClear":"清除","settings.recording.comboConflict":"该快捷键组合不可用","settings.recording.allowNonTsfFallbackLabel":"允许非 TSF 兜底","settings.recording.allowNonTsfFallbackDesc":"Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。","settings.recording.windowsInsertionModeLabel":"Windows 插入方式","settings.recording.windowsInsertionModeDesc":"听写结果如何插入到当前光标位置。剪贴板粘贴模式使用上方「模拟粘贴快捷键」,可完整保留换行。","settings.recording.windowsInsertionModeTsf":"TSF 输入法(默认)","settings.recording.windowsInsertionModeSendInput":"SendInput 逐字模拟","settings.recording.windowsInsertionModePaste":"剪贴板粘贴(Ctrl+V 等)","settings.recording.macosNewlineModeLabel":"换行怎么落","settings.recording.macosNewlineModeDesc":"自动会在已知终端应用中使用 Line Feed(U+000A / Ctrl+J),其他应用使用 Shift+Return;Return 会直接发送。","settings.recording.macosNewlineModeAuto":"自动(终端使用 Line Feed)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return(聊天框换行)","settings.recording.macosNewlineModeLineFeed":"Line Feed(终端 CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return(拆成多条消息)","settings.recording.windowsSendInputNewlineModeLabel":"SendInput 换行模拟","settings.recording.windowsSendInputNewlineModeDesc":"SendInput 模式下如何把换行符模拟成按键。聊天框通常选 Shift+Enter;记事本 / VS Code 等选 Enter。","settings.recording.windowsSendInputNewlineModeEnter":"Enter(多数编辑器)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter(聊天输入框)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"在键盘列表中显示 OpenLess","settings.recording.windowsShowOpenlessInKeyboardListDesc":"关闭后 Win+Space 切换输入法时不会出现 OpenLess;SendInput 与剪贴板粘贴插入不受影响。重新开启本项可恢复显示。","settings.recording.windowsShowOpenlessInKeyboardListError":"无法更新键盘列表:系统拒绝更改 OpenLess 语言配置文件。","settings.recording.historyGroupTitle":"历史与上下文","settings.recording.historyRetentionLabel":"历史保留天数","settings.recording.historyRetentionDesc":"超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。","settings.recording.historyMaxEntriesLabel":"历史条数上限","settings.recording.historyMaxEntriesDesc":"本地保留会话上限,留空 = 200。范围 5–200。","settings.recording.polishContextWindowLabel":"对话上下文窗口(分钟)","settings.recording.polishContextWindowDesc":"把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。","settings.recording.recordAudioForDebugLabel":"保留原始录音(调试)","settings.recording.recordAudioForDebugDesc":"保存原始麦克风音频为 wav,便于排查识别问题。","settings.recording.audioRecordingMaxEntriesLabel":"原始录音保留条数","settings.recording.audioRecordingMaxEntriesDesc":"本地保留 wav 文件数上限,留空 = 200。","settings.recording.startupGroupTitle":"启动","settings.recording.startMinimizedLabel":"启动时静默运行","settings.recording.startMinimizedDesc":"所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。","settings.recording.autoUpdateCheckLabel":"自动检查更新","settings.recording.autoUpdateCheckDesc":"启动时及每 60 分钟自动检查更新。","settings.recording.marketplaceGroupTitle":"风格市场","settings.recording.marketplaceBaseUrlLabel":"云端服务地址","settings.recording.marketplaceBaseUrlDesc":"风格市场后端 URL,留空使用默认值。","settings.recording.marketplaceDevLoginLabel":"GitHub 用户名(上传身份)","settings.recording.marketplaceDevLoginDesc":"标识上传者身份,为空时无法上传或点赞。","settings.recording.startupAtBoot":"开机自启","settings.recording.startupAtBootDesc":"登录系统时自动启动 OpenLess。","settings.recording.startupAtBootError":"开机自启切换失败:{{message}}","settings.channels.backToList":"返回渠道列表","settings.channels.done":"完成","settings.channels.llmTitle":"文字处理渠道","settings.channels.asrTitle":"语音识别渠道","settings.channels.current":"当前使用","settings.channels.enabled":"启用","settings.channels.disabled":"已停用","settings.channels.enabledFor":"启用 {{name}}","settings.channels.modelNotSet":"模型未单独设置","settings.channels.localModelManaged":"模型由系统或「本地模型」页管理","settings.channels.lastCheck":"上次验证","settings.channels.verifying":"正在验证…","settings.channels.notVerified":"尚未验证","settings.channels.passed":"验证通过","settings.channels.failed":"验证失败 · {{reason}}","settings.channels.elapsed":"耗时 {{ms}} ms","settings.channels.staleResult":"结果已超过 24 小时","settings.channels.connectionTitle":"服务连接","settings.channels.modelTitle":"模型设置","settings.channels.modelHint":"直接输入模型名称,或拉取并选择供应商的可用模型。","settings.channels.availableModels":"可用模型","settings.channels.validationTitle":"连接验证","settings.channels.validationHint":"手动发起一次真实请求,检查当前配置;可能消耗服务额度。保存设置不会自动验证。","settings.channels.autoSaveHint":"字段修改后自动保存;完成配置后,可手动验证连接。","settings.channels.nameHint":"名称仅用于区分同一供应商的多个渠道,不影响模型或连接。","settings.channels.errModel":"模型","settings.channels.verify":"验证","settings.channels.verifyHint":"点一下真实调用一次接口,确认这张卡现在能用","settings.channels.errTimeout":"超时","settings.channels.errNetwork":"网络","settings.channels.errEndpoint":"地址","settings.channels.errGeneric":"失败","settings.channels.dragHint":"按住拖动可调整优先级","settings.channels.orderHint":"列表中第一个启用的渠道用于请求。拖动调整顺序;停用的渠道移到末尾。","settings.channels.empty":"还没有渠道。点击「添加渠道」,连接你的第一个服务。","settings.channels.add":"添加渠道","settings.channels.edit":"编辑","settings.channels.createTitle":"添加渠道","settings.channels.editTitle":"编辑渠道","settings.channels.providerLabel":"供应商","settings.channels.nameLabel":"渠道名称(可选)","settings.channels.namePlaceholder":"例如:硅基流动-主号","settings.channels.create":"创建","settings.channels.delete":"删除渠道","settings.channels.deleteConfirm":"删除后该渠道保存的密钥也会一并清除。","settings.channels.confirmDelete":"确认删除","settings.channels.justNow":"刚刚","settings.channels.minutesAgo":"{{count}} 分钟前","settings.channels.hoursAgo":"{{count}} 小时前","settings.channels.daysAgo":"{{count}} 天前","settings.channels.localEngineModelHint":"可在「AI 服务与模型 → 本地模型」中下载和切换本地模型。","settings.providers.localEngineNoCredentials":"本地引擎无需 API Key 与地址。","settings.providers.localModelLabel":"本地模型","settings.providers.localModelEmpty":"尚未下载本地模型","settings.providers.appleSpeechLocalNote":"Apple 语音识别使用系统内置引擎,无需选择模型。","settings.providers.localEngineNote":"已下载的本地模型在上方下拉里直接选择;更多模型在「本地模型」看板下载与管理。","settings.providers.localTag":"本地","settings.providers.llmTitle":"LLM 模型(润色)","settings.providers.llmDesc":"OpenAI 兼容协议,支持多家供应商切换。","settings.providers.providerLabel":"供应商","settings.providers.llmProviderDesc":"选择后将自动填入 Base URL 默认值。","settings.providers.credentialStorageNotice":"凭据保存在系统凭据库中。","settings.providers.codexOAuthNotice":"Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。","settings.providers.asrProviderDesc":"切换后将自动选用对应凭据。","settings.providers.asrTitle":"ASR 语音(转写)","settings.providers.asrDesc":"用于将录制的语音转写为文本。","settings.providers.omniTitle":"多模态模型","settings.providers.omniDesc":"一个模型直接接收「提示词 + 音频」一步输出最终文本(实验性管线)。","settings.providers.pipelineModeLabel":"识别管线","settings.providers.pipelineModeHint":"传统 = ASR 转写 + LLM 润色两段式;多模态 = 单个多模态模型一次完成。","settings.providers.pipelineModeTraditional":"传统模式","settings.providers.pipelineModeMultimodal":"多模态模式","settings.providers.pipelineIsolationNotice":"两种模式使用完全独立的凭据配置。切换模式不会删除另一套配置,只是暂时停用;切回即恢复。","settings.providers.presets.ark":"ARK(火山方舟)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"硅基流动","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"小米 MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter(免费模型)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"阿里云 Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax(M3)","settings.providers.presets.stepfun":"StepFun(阶跃星辰)","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"腾讯云 TokenHub","settings.providers.presets.customChatCompletions":"自定义 · Chat Completions","settings.providers.presets.customResponses":"自定义 · Responses","settings.providers.presets.customMessages":"自定义 · Messages","settings.providers.presets.custom":"自定义","settings.providers.presets.asrVolcengine":"火山引擎 bigasr","settings.providers.presets.asrBailian":"阿里云百炼实时 ASR","settings.providers.presets.asrBailianQwen3":"阿里云百炼 Qwen3 实时 ASR","settings.providers.presets.asrBailianFunAsrFlash":"阿里云百炼 Fun-ASR-Flash(录音文件)","settings.providers.presets.asrSiliconflow":"硅基流动 SenseVoice","settings.providers.presets.asrStepfun":"阶跃星辰 StepAudio","settings.providers.presets.asrZhipu":"智谱 GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper(兼容)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"自定义 OpenAI 兼容","settings.providers.presets.asrXiaomiMimo":"小米 MiMo ASR","settings.providers.presets.asrIflytek":"讯飞实时语音转写","settings.providers.presets.asrTencentCloud":"腾讯云混元实时 ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"本地 sherpa-onnx(实验性)","settings.providers.presets.asrFoundryLocalWhisper":"本地 Whisper(Foundry Local)","settings.providers.presets.asrLocalWhisper":"本地 Whisper(批量解码)","settings.providers.presets.asrLocalQwen3":"本地 Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"本地 Qwen3-ASR(MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"本地 Qwen3-ASR(C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple 语音(macOS)","settings.providers.presets.omniOpenai":"OpenAI(支持音频)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"阿里云百炼 Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs 会将录音上传到所配置的端点进行批量转写。","settings.providers.zenmuxVocabularyNote":"ZenMux 走 JSON 转写协议,不发送词典热词(prompt/hotwords);词典仍会进入润色链路,但不会参与语音识别偏置。","settings.providers.asrAdvancedNote":"以下高级选项仅影响「自定义 OpenAI 兼容」与「ZenMux」预设;其余命名厂商预设保持内置行为。","settings.providers.asrAdvancedVerboseJsonLabel":"分段指标 (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"服务端支持时返回 segments 指标,用于幻听过滤;自建服务若不支持请保持关闭。","settings.providers.asrAdvancedChunkLabel":"分片时长 (ms)","settings.providers.asrAdvancedChunkHint":"0 = 不分片,整段发送;按片段多次请求,适合长录音或服务端单次请求时长受限。","settings.providers.asrAdvancedEnableItnLabel":"数字归一化 (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"把口语数字/单位归一化为阿拉伯数字(如“二零二六年”→“2026年”)。关闭后保留原始文字。","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API Key","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"鉴权模式","settings.providers.volcengineAuthModeAppIdToken":"旧版应用(APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"新版控制台 API Key","settings.providers.volcengineMappingNote":"Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。","settings.providers.volcengineApiKeyNote":"使用新版语音控制台创建的 API Key 鉴权,无需 APP ID。API Key 在语音控制台「API Key 管理」中创建:console.volcengine.com/speech/new/setting/apikeys。Resource ID 默认使用 volc.seedasr.sauc.duration。","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API Key","settings.providers.xfyunNote":"在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。","settings.providers.tencentCloudAppIdLabel":"腾讯云 AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"使用腾讯云「语音识别」服务的 API 密钥。默认 Hy-ASR-3.0-preview 支持中英与 20 种方言;Preview 仅支持 60 秒以内的 16kHz 单声道 PCM,暂不支持上下文或热词增强。","settings.providers.tencentTokenHubNote":"仅显示当前在线的语言模型。部分模型始终启用思考;关闭思考开关时将沿用该模型的固定行为。","settings.providers.localAsrActiveNotice":"当前已启用「{{name}}」,可在「高级」中切换或禁用。","settings.providers.localAsrTakeoverHint":"启动「{{name}}」后,ASR 提供商将被接管。","settings.providers.asrProviderTakenOver":"当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「服务 → 本地模型」里管理。","settings.providers.localAsrHint":"在本机运行,无需 API Key。从 HuggingFace 下载模型即可使用。","settings.providers.foundryLocalAsrHint":"在本机运行,无需 ASR API Key。首次使用需下载运行组件和模型。","settings.providers.localAsrPerformanceWarning":"本地推理比云端慢,中文准确率可能更低。适合离线或隐私敏感场景。","settings.providers.localAsrReady":"{{model}} 已下载","settings.providers.localAsrNotReady":"{{model}} 未下载","settings.providers.localAsrGoDownload":"前往模型设置下载","settings.providers.localAsrManage":"前往模型设置","settings.providers.localAsrDownloadedTitle":"已下载模型","settings.providers.localAsrDelete":"删除","settings.providers.fillDefault":"填入默认值","settings.providers.readFailed":"读取失败","settings.providers.apiKeyLabel":"API 密钥","settings.providers.baseUrlLabel":"接口地址","settings.providers.modelLabel":"模型","settings.providers.customModelLabel":"自定义模型…","settings.providers.presetListLabel":"返回预设列表","settings.providers.searchModels":"搜索模型…","settings.providers.noMatchingModels":"没有匹配的模型","settings.providers.orcarouterCatalogHint":"模型来自 OrcaRouter /models;此供应商只允许从目录中选择,不支持手动填写模型 ID。","settings.providers.orcarouterAsrCatalogHint":"模型来自 OrcaRouter /models,并仅显示兼容音频输入的 Gemini;不支持手动填写模型 ID。","settings.providers.temperatureLabel":"Temperature","settings.providers.temperaturePlaceholder":"留空则不发送;范围 0~2(含边界),例如 0.3","settings.providers.extraHeadersLabel":"额外 Headers","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"思考","settings.providers.thinkingModeOn":"开启","settings.providers.thinkingModeOff":"关闭","settings.providers.requestFormatLabel":"请求格式","settings.providers.messagesThinkingLabel":"思考方式","settings.providers.thinkingAdaptive":"自适应","settings.providers.thinkingBudget":"固定预算","settings.providers.maxTokensLabel":"最大输出 tokens","settings.providers.thinkingBudgetLabel":"思考预算 tokens","settings.providers.responsesThinkingHint":"部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。","settings.providers.messagesThinkingHint":"旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。","settings.providers.llmRequestFormatInvalid":"请求格式无效,请重新选择。","settings.providers.llmThinkingModeInvalid":"思考方式无效,请重新选择。","settings.providers.llmTokenLimitInvalid":"Token 上限必须为正整数。","settings.providers.llmThinkingBudgetInvalid":"思考预算至少为 1024,且固定预算必须小于最大输出。","settings.providers.llmResponseIncomplete":"响应未完整结束或达到输出上限;已输出正文会保留。","settings.providers.llmProtocolHeaderConflict":"Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。","settings.providers.llmStreamError":"服务端返回流式错误,请检查模型和请求参数。","settings.providers.saveProtocol":"保存协议设置","settings.providers.thinkingModeHint":"按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。","settings.providers.bailianVocabularyIdLabel":"热词 Vocabulary ID(可选)","settings.providers.bailianVocabularyIdNote":"如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。","settings.providers.bailianModelRealtimeHint":"实时模型 · 边说边出字。","settings.providers.bailianModelSyncFileHint":"同步录音模型 · 说完后整段转写(单条 ≤ 5 分钟)。","settings.providers.bailianModelAsyncFileHint":"异步文件模型 · 录音上传后等待转写任务完成。","settings.providers.appIdLabel":"App ID(应用 ID)","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"资源 ID","settings.providers.toolsLabel":"连接检查","settings.providers.toolsDesc":"先保存上方配置,再验证当前模型连通性或拉取模型;失败时仍可手动填写模型 ID。","settings.providers.validate":"验证","settings.providers.validating":"验证中…","settings.providers.fetchModels":"拉取模型","settings.providers.loadingModels":"拉取模型中…","settings.providers.modelMissing":"未配置模型,请先填写模型 ID。","settings.providers.modelsEmpty":"鉴权成功,但没有返回可用模型。","settings.providers.modelsLoaded":"已拉取 {{count}} 个模型。","settings.providers.selectModel":"选择一个模型写入上方字段","settings.providers.modelSaved":"已保存模型 {{model}}。","settings.providers.validateSuccess":"连接检查通过。","settings.providers.validateFailed":"连接检查未通过。","settings.providers.providerHttpStatus":"供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。","settings.providers.endpointMustUseHttps":"允许使用 HTTP Endpoint,但请注意:API Key 和音频内容可能在传输中泄漏。","settings.providers.endpointHttpWarning":"允许使用 HTTP Endpoint,但请注意:API Key 和请求内容可能在传输中泄漏。","settings.providers.endpointInvalid":"Endpoint 格式不合法。","settings.providers.bailianEndpointSchemeInvalid":"百炼实时 ASR 走 DashScope WebSocket 网关,接口地址必须以 wss:// 开头(默认 wss://dashscope.aliyuncs.com/api-ws/v1/inference/);https:// 的兼容模式地址在此不可用。","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 实时 ASR 走 DashScope Realtime WebSocket 网关,接口地址必须以 wss:// 开头(默认 wss://dashscope.aliyuncs.com/api-ws/v1/realtime);https:// 地址在此不可用。","settings.providers.responseTooLarge":"供应商响应过大,已停止验证以保证安全。","settings.providers.asrInvalidJson":"ASR 响应不是有效 JSON。","settings.providers.asrMissingTextField":"ASR 响应缺少 text 字段。","settings.providers.apiKeyMissing":"API Key 为空。","settings.providers.endpointMissing":"Endpoint 为空。","settings.providers.volcengineAppIdMissing":"APP ID 为空。","settings.providers.volcengineAccessTokenMissing":"Access Token 为空。","settings.providers.requestTimeout":"请求超时,请稍后重试。","settings.shortcuts.title":"快捷键设置","settings.shortcuts.descAcc":"所有快捷键全局生效,需要在权限设置中开启辅助功能。","settings.shortcuts.descNoAcc":"所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。","settings.shortcuts.startStop":"开始 / 停止录音","settings.shortcuts.cancel":"取消本次录音","settings.shortcuts.confirm":"胶囊确认插入","settings.shortcuts.switchStyle":"切换到上一个风格","settings.shortcuts.openApp":"打开 OpenLess","settings.shortcuts.stylePackTitle":"风格直达快捷键","settings.shortcuts.stylePackDesc":"为常用风格包各配一个快捷键,按下直接切换;停用中的包会自动启用。","settings.shortcuts.stylePackAdd":"添加风格快捷键","settings.shortcuts.stylePackSelect":"选择风格包","settings.shortcuts.stylePackDisabledSuffix":"(已停用)","settings.shortcuts.stylePackRemove":"移除","settings.shortcuts.agentPolish":"选中文本润色","settings.shortcuts.agentPolishDesc":"选中文本 → 按键 → Claude 润色 → 替换选区。","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"按住自定义按键 → 说话 → Claude 执行任务 → 结果弹胶囊显示。","settings.shortcuts.agentVoiceHint":"在「高级 → Less Computer」里设置按住说话键。","settings.shortcuts.agentVoiceTrigger":"Less Computer 按住说话键","settings.shortcuts.enable":"启用","settings.shortcuts.disable":"停用","settings.shortcuts.confirmHint":"点击右侧 ✓","settings.shortcuts.notSupported":"暂未支持","settings.shortcuts.androidReadOnly":"Android 不支持全局快捷键,请在概览页使用录音按钮。","settings.permissions.title":"权限","settings.permissions.descAcc":"OpenLess 需要以下系统权限。授权后通常要完全退出 App 重启一次才生效。","settings.permissions.descNoAcc":"麦克风必需;全局快捷键状态用来检测 native hook 是否运行。","settings.permissions.micLabel":"麦克风","settings.permissions.micDesc":"用于捕获你的语音输入。","settings.permissions.accLabel":"辅助功能","settings.permissions.accDesc":"监听全局快捷键并把识别结果写入光标。","settings.permissions.hotkeyLabel":"全局快捷键","settings.permissions.hotkeyDescWithAdapter":"适配器:{{adapter}}。","settings.permissions.hotkeyDescPlain":"判断快捷键监听是否已安装。","settings.permissions.networkLabel":"网络","settings.permissions.networkDesc":"云端 ASR / LLM 必需,本地模式可关。","settings.permissions.networkOk":"可用","settings.permissions.networkOffline":"不可用","settings.permissions.checking":"检查中…","settings.permissions.granted":"已授权","settings.permissions.notApplicable":"无需授权","settings.permissions.denied":"未授权","settings.permissions.indeterminate":"未确定","settings.permissions.micNoDevice":"未检测到麦克风","settings.permissions.openSystem":"打开系统设置","settings.permissions.restart":"重置授权并重启","settings.permissions.grant":"授权","settings.permissions.rerunAndroidSetup":"重新运行设置向导","settings.permissions.hotkeyInstalled":"已安装","settings.permissions.hotkeyStarting":"安装中…","settings.permissions.hotkeyFailed":"监听失败","settings.permissions.windowsImeLabel":"Windows 输入法后端","settings.permissions.windowsImeDesc":"语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。","settings.permissions.windowsImeInstalled":"已安装","settings.permissions.windowsImeUnavailable":"不可用","settings.permissions.androidImeLabel":"输入法 (IME)","settings.permissions.androidImeSelected":"已选中","settings.permissions.androidImeEnabled":"已启用","settings.permissions.androidImeDisabled":"未启用","settings.permissions.androidOverlayLabel":"悬浮窗","settings.permissions.androidAccessibilityLabel":"无障碍服务","settings.permissions.androidAccessibilityImpact":"开启后可在不切换键盘的情况下把结果输出到当前输入框;不开启时仍会复制到剪贴板,需要手动粘贴。","settings.permissions.androidAccessibilityGrantedStale":"已授权,未连接","settings.permissions.androidAccessibilityMessages.not_android":"无障碍状态仅在 Android 上可用。","settings.permissions.androidAccessibilityMessages.not_enabled":"请在系统无障碍设置中启用 OpenLess。","settings.permissions.androidAccessibilityMessages.operational":"无障碍服务正在运行。","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"无障碍已授权但未连接,请在系统设置中重新开启 OpenLess。","settings.permissions.androidAccessibilityMessages.status_read_failed":"无法读取无障碍状态。","settings.permissions.androidShizukuLabel":"Shizuku 增强模式","settings.permissions.androidShizukuHint":"可选功能,在部分机型无法手动开启无障碍时尽力恢复;无法完全消除跨应用竞态。设备重启后可能需要重新启动 Shizuku。","settings.permissions.androidShizukuOpenApp":"打开 Shizuku","settings.permissions.androidShizukuRequestPermission":"请求授权","settings.permissions.androidShizukuRecover":"恢复无障碍服务","settings.permissions.androidShizukuRecoverConfirm":"是否通过 Shizuku 尝试重新启用 OpenLess 无障碍服务?写入时会合并当时已启用的服务。若全局开关为关闭,启用后可能同时启动列表中已登记的其他无障碍服务。","settings.permissions.androidShizukuYes":"是","settings.permissions.androidShizukuNo":"否","settings.permissions.androidShizukuAccessibilityOperational":"无障碍服务已注册且正在运行。","settings.permissions.androidShizukuAccessibilityRegistered":"已注册:{{registered}} · 运行中:{{operational}}","settings.permissions.androidShizukuState.notInstalled":"未安装","settings.permissions.androidShizukuState.notRunning":"未运行","settings.permissions.androidShizukuState.notAuthorized":"未授权","settings.permissions.androidShizukuState.authorized":"已授权","settings.permissions.androidShizukuState.binderDead":"连接断开","settings.permissions.androidShizukuState.notAndroid":"不可用","settings.permissions.androidShizukuMessages.not_android":"Shizuku 仅在 Android 上可用。","settings.permissions.androidShizukuMessages.not_installed":"未安装 Shizuku 或 Sui 后端。","settings.permissions.androidShizukuMessages.unsupported_backend":"当前 Shizuku 后端版本过旧,请更新 Shizuku 或 Sui 至 v11 及以上。","settings.permissions.androidShizukuMessages.not_running":"Shizuku 未运行,请先启动 Shizuku 或 Sui。","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku 未授权,请授予 OpenLess 权限。","settings.permissions.androidShizukuMessages.binder_dead":"Shizuku 连接已断开,请重新启动 Shizuku。","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku 已授权,无障碍服务运行正常。","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku 已授权,无障碍服务已注册但未运行。","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku 已授权,可尝试恢复无障碍服务。","settings.permissions.androidShizukuMessages.operational":"无障碍服务已注册且正在运行。","settings.permissions.androidShizukuMessages.registered_stale":"无障碍服务已注册,但服务当前不可用。","settings.permissions.androidShizukuMessages.not_registered":"无障碍服务未在系统设置中启用。","settings.permissions.androidShizukuMessages.already_granted":"Shizuku 权限已授予。","settings.permissions.androidShizukuMessages.binder_unavailable":"请求授权时 Shizuku 服务不可用。","settings.permissions.androidShizukuMessages.request_cancelled":"已取消 Shizuku 授权请求。","settings.permissions.androidShizukuMessages.granted":"Shizuku 权限已授予。","settings.permissions.androidShizukuMessages.denied":"Shizuku 权限被拒绝。","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Shizuku 授权已被阻止。请打开 Shizuku 并手动允许 OpenLess。","settings.permissions.androidShizukuMessages.launched":"已打开 Shizuku 授权界面。","settings.permissions.androidShizukuMessages.launch_failed":"无法打开 Shizuku 授权界面。","settings.permissions.androidShizukuMessages.open_shizuku":"已打开 Shizuku 管理器。","settings.permissions.androidShizukuMessages.jni_error":"无法连接 Android Shizuku 后端。","settings.permissions.androidShizukuMessages.status_parse_failed":"无法解析 Shizuku 状态。","settings.permissions.androidShizukuMessages.user_not_confirmed":"需要用户确认后才能恢复无障碍服务。","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku 未授权或不可用。","settings.permissions.androidShizukuMessages.invalid_component":"无效的无障碍服务组件 ID。","settings.permissions.androidShizukuMessages.service_connect_failed":"无法连接 Shizuku 特权服务。","settings.permissions.androidShizukuMessages.recovery_in_progress":"已有恢复操作正在进行,请稍后再试。","settings.permissions.androidShizukuMessages.parse_failed":"无法解析恢复结果。","settings.permissions.androidShizukuMessages.service_not_bound":"设置已写入,但无障碍服务尚未运行。","settings.permissions.androidShizukuMessages.success":"无障碍服务已恢复。","settings.permissions.androidShizukuMessages.read_failed":"无法读取无障碍服务设置。","settings.permissions.androidShizukuMessages.read_enabled_failed":"无法读取无障碍总开关。","settings.permissions.androidShizukuMessages.merge_failed":"无法合并无障碍服务列表。","settings.permissions.androidShizukuMessages.write_services_failed":"无法写入已启用无障碍服务列表。","settings.permissions.androidShizukuMessages.write_enabled_failed":"无法启用无障碍总开关。","settings.permissions.androidShizukuMessages.readback_failed":"写入后无法验证无障碍设置。","settings.permissions.androidShizukuMessages.oem_rollback":"厂商系统回滚了无障碍写入。","settings.permissions.androidShizukuMessages.concurrent_change":"恢复过程中无障碍设置被其他应用修改。","settings.permissions.androidShizukuMessages.partial_rollback":"恢复失败,且设置只能部分回滚。请检查系统无障碍设置。","settings.permissions.androidShizukuMessages.manual_required":"全局开关关闭且列表中已有其他无障碍服务时,无法安全自动恢复。请前往系统设置手动操作。","settings.permissions.androidShizukuMessages.max_retries":"多次尝试后恢复失败。","settings.permissions.androidShizukuMessages.internal_error":"恢复因内部错误失败。","settings.permissions.androidShizukuMessages.unknown":"未知 Shizuku 状态。","settings.permissions.androidInsertStrategyLabel":"文本插入策略","settings.permissions.androidOverlayTriggerLabel":"悬浮窗显示时机","settings.permissions.androidOverlayActivationModeLabel":"悬浮窗激活方式","settings.permissions.androidOverlayLeftSwipeActionLabel":"左滑动作","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"取消录音滑向","settings.permissions.androidOverlaySizeLabel":"悬浮窗大小","settings.permissions.androidOverlaySizeHint":"调整悬浮按钮直径,保存后在当前悬浮窗上生效并保留位置。","settings.permissions.androidInsertStrategy.accessibility":"自动输出到输入框","settings.permissions.androidInsertStrategy.clipboard":"仅剪贴板","settings.permissions.androidInsertStrategyHint.accessibility":"需要开启无障碍服务;不可用时会复制到剪贴板。","settings.permissions.androidInsertStrategyHint.clipboard":"不需要无障碍权限,结果只复制到剪贴板,由你手动粘贴。","settings.permissions.androidOverlayTrigger.background":"应用退到后台","settings.permissions.androidOverlayTrigger.keyboard":"弹出键盘时","settings.permissions.androidOverlayTrigger.always":"始终显示","settings.permissions.androidOverlayTriggerHint.background":"省电、实现简单;其他 App 输入时不会自动出现。","settings.permissions.androidOverlayTriggerHint.keyboard":"该模式已暂缓,历史配置会自动改为“应用退到后台”。","settings.permissions.androidOverlayTriggerHint.always":"入口始终可见,但会一直占屏。","settings.permissions.androidOverlayTriggerDisabled.keyboard":"“弹出键盘时”暂缓开放,后续将以悬浮窗手势替代键盘检测。","settings.permissions.androidOverlayActivationMode.tap":"点按激活","settings.permissions.androidOverlayActivationMode.long_press":"长按激活","settings.permissions.androidOverlayActivationModeHint.tap":"第一次点按进入激活态,第二次点按开始普通听写。","settings.permissions.androidOverlayActivationModeHint.long_press":"按住进入激活态;松开时结束当前录音或问答轮次。","settings.permissions.androidOverlayLeftSwipeAction.translation":"翻译听写","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"切换风格包","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"激活态左滑后按翻译模式录音。","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"激活态左滑后切换到上一个风格包。","settings.permissions.androidOverlayCancelSwipeDirection.up":"向上滑","settings.permissions.androidOverlayCancelSwipeDirection.down":"向下滑","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"录音中向上滑取消本次听写,不转写、不插入。","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"录音中向下滑取消本次听写,不转写、不插入。","settings.permissions.windowsIme.installed":"已安装,按需切到 OpenLess 输入法。","settings.permissions.windowsIme.notInstalled":"未安装,走剪贴板 / WM_PASTE 兜底。","settings.permissions.windowsIme.registrationBroken":"注册损坏,请重装 OpenLess 输入法。","settings.permissions.windowsIme.notWindows":"仅 Windows 可用。","settings.advanced.multimodalPipelineTitle":"多模态识别管线","settings.advanced.multimodalPipelineTitleHint":"用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。","settings.advanced.multimodalPipelineLabel":"启用多模态识别管线","settings.advanced.multimodalPipelineHint":"开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。","settings.advanced.streamingInsertTitle":"流式输入","settings.advanced.streamingInsertTitleLinux":"流式输入(实验性)","settings.advanced.streamingInsertDesc":"逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。","settings.advanced.streamingInsertLabel":"流式输入","settings.advanced.streamingInsertHintMac":"临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。","settings.advanced.streamingInsertHintWindows":"SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。","settings.advanced.streamingInsertHintLinux":"通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。","settings.advanced.streamingInsertSaveClipboardLabel":"同步到剪贴板","settings.advanced.streamingInsertSaveClipboardHint":"插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。","settings.advanced.localAsrTitle":"本地 ASR 模型","settings.advanced.localAsrDesc":"把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。","settings.advanced.localAsrWarningShort":"本地推理较慢,配置不足时可能吞字。","settings.advanced.qwen3Desc":"启动之后,ASR 提供商将被接管。","settings.advanced.sherpaDesc":"启动之后,ASR 提供商将被接管。","settings.advanced.foundryDesc":"启动之后,ASR 提供商将被接管。","settings.advanced.notSupportedHere":"本平台暂不支持,未集成推理模块。","settings.advanced.enable":"启用","settings.advanced.alreadyActive":"已启用","settings.advanced.disableLocalLabel":"禁用本地 ASR","settings.advanced.disableLocalDesc":"切回云端 ASR(默认火山引擎 bigasr)。","settings.advanced.disable":"禁用","settings.advanced.platformNotSupported":"该平台暂未支持本地 ASR 模型集成。","settings.advanced.confirmEnableLocalTitle":"启用本地 ASR?","settings.advanced.confirmEnableLocalBody":"启用后转写会比云端慢,准确率可能更低。","settings.advanced.confirm":"确认启用","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"界面语言","settings.language.desc":"切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。","settings.language.label":"语言","settings.language.labelDesc":"选择「跟随系统」时按操作系统当前语言显示。","settings.language.followSystem":"跟随系统","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"部分原生菜单(系统托盘等)可能需要重启 App 才会切换。","settings.layout.title":"布局","settings.theme.title":"外观","settings.theme.label":"主题","settings.theme.activityHeatmapLabel":"概览页显示年度活动热力图","settings.theme.stackedRowLayoutLabel":"易读布局(防溢出换行)","settings.theme.stackedRowLayoutDesc":"小屏或大字时,同一行放不下的按钮和选项会自动换到下一行,避免横向挤出屏幕或文字被压扁。","settings.theme.conservativeLayoutLabel":"保守排版","settings.theme.conservativeLayoutDesc":"除首页、顶栏与底栏外,设置与功能页改为单列满宽,最大程度避免横向溢出。","settings.theme.system":"跟随系统","settings.theme.light":"浅色","settings.theme.dark":"深色","settings.remoteInput.title":"远程输入","settings.remoteInput.enableLabel":"启用远程输入","settings.remoteInput.enableDesc":"手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)","settings.remoteInput.portLabel":"监听端口","settings.remoteInput.defaultModeLabel":"默认录音方式","settings.remoteInput.modeToggle":"点击切换","settings.remoteInput.modeHold":"按住说话","settings.remoteInput.urlLabel":"访问网址","settings.remoteInput.pinLabel":"配对码","settings.remoteInput.regeneratePin":"重新生成","settings.remoteInput.portInUse":"端口 {{port}} 被占用,请更换","settings.remoteInput.startError":"远程输入服务启动失败:{{reason}}","settings.remoteInput.securityHint":"仅同一局域网可访问,需输入配对码;不用时建议关闭。","settings.remoteInput.certHint":"首次连接需核对根证书指纹后再信任。升级旧版需设置一次;以后重启和换 IP 会保留信任。","settings.remoteInput.certFingerprintLabel":"本机根证书 SHA-256","settings.remoteInput.certFingerprintCopy":"复制完整指纹","settings.remoteInput.certFingerprintCopied":"指纹已复制","settings.remoteInput.certFingerprintUnavailable":"完整指纹不可用。请勿安装或信任下载的证书。","settings.remoteInput.certVerifyHint":"在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。","settings.remoteInput.certProfileHint":"描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。","settings.remoteInput.certTrustWarning":"首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。","settings.remoteInput.certSetupLink":"复制 iPhone 证书链接","settings.remoteInput.waitingStart":"服务尚未启动。请关闭开关再打开一次,不要重启软件。","settings.remoteInput.starting":"正在启动远程输入服务…","settings.remoteInput.urlsStale":"这些地址来自上次运行,可能已经过期。","settings.about.tagline":"自然说话,完美书写","settings.about.checkUpdate":"检查更新","settings.about.checkUpdateBtn":"检查","settings.about.checkStableUpdateBtn":"检查正式版更新","settings.about.checkBetaUpdateBtn":"检查 Beta 更新","settings.about.checkingUpdate":"检查中…","settings.about.upToDate":"当前已是最新版本。","settings.about.updateError":"检查或更新失败,请稍后重试。","settings.about.retryBtn":"重试","settings.about.openReleases":"打开 Releases","settings.about.source":"源码","settings.about.docs":"文档","settings.about.feedback":"反馈","settings.about.qq":"社区 QQ 群","settings.about.qqDesc":"使用 QQ 搜索群号加入,或扫码进群。","settings.about.copyQq":"复制群号","settings.about.privacy":"隐私","settings.about.privacyDesc":"录音可能会发送到你配置的云端服务商进行转写。","settings.about.localFirst":"本地优先","settings.about.linksTitle":"文档链接","settings.about.betaChannelLabel":"加入 Beta 渠道","settings.about.betaChannelToggleLabel":"启用 Beta 渠道","settings.about.betaChannelDesc":"开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。","settings.about.autoUpdateSectionTitle":"自动更新","settings.about.autoUpdateCheckLabelAndroid":"自动检查并下载更新","settings.about.autoUpdateCheckDescAndroid":"启动后及每 60 分钟自动检查更新;发现新版本后自动下载并打开系统安装器。渠道跟随上方 Beta 开关。","settings.about.betaChannelFetching":"正在获取最新 Beta 版本…","settings.about.betaChannelFetchBtn":"查询最新 Beta","settings.about.betaChannelLatestPrefix":"最新 Beta:","settings.about.betaChannelDownloadBtn":"前往下载","settings.about.betaChannelRefresh":"重新查询","settings.about.betaChannelNoBeta":"暂无已发布的 Beta 版。","settings.about.betaChannelFetchError":"获取 Beta 版本信息失败,请稍后重试。","settings.about.betaChannelUpToDate":"已是最新","settings.about.betaChannelUpdateNow":"立即更新","settings.about.betaChannelUpdateNowTitle":"检查并下载最新 Beta,然后弹出更新对话框","settings.about.betaChannelChecking":"检查中…","settings.about.updateDialog.available.title":"发现新版本","settings.about.updateDialog.available.desc":"发现 OpenLess {{version}},是否现在更新?","settings.about.updateDialog.stableChannelSwitch.title":"切换到正式版","settings.about.updateDialog.stableChannelSwitch.desc":"当前版本:OpenLess {{currentVersion}}\n目标版本:OpenLess {{version}}\n这是从 Beta 渠道切换到正式版,是否继续?","settings.about.updateDialog.downloading.title":"正在下载更新","settings.about.updateDialog.downloading.desc":"正在下载 OpenLess {{version}},请保持应用打开。","settings.about.updateDialog.downloaded.title":"更新已准备好","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} 已安装完成。是否现在自动重启以应用更新?","settings.about.updateDialog.installing.title":"正在安装更新","settings.about.updateDialog.installing.desc":"正在安装 OpenLess {{version}},请保持应用打开。","settings.about.updateDialog.install":"现在更新","settings.about.updateDialog.androidInstall":"下载并打开安装器","settings.about.updateDialog.androidInstalled.title":"系统安装器已打开","settings.about.updateDialog.androidInstalled.desc":"请按系统提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。","settings.about.updateDialog.downloadingLabel":"下载中…","settings.about.updateDialog.installingLabel":"安装中…","settings.about.updateDialog.later":"稍后手动重启","settings.about.updateDialog.restartNow":"现在重启","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"已下载 {{downloaded}}","settings.about.updateDialog.installError.title":"更新失败","settings.about.updateDialog.installError.desc":"自动更新没能完成:{{error}}。你可以前往下载页手动下载安装最新版本。","settings.about.updateDialog.manualDownload":"手动下载","startup.loading":"正在启动 OpenLess…","startup.loadingDesc":"正在连接本地服务并检查兼容性。","startup.failed":"OpenLess 暂时无法启动","startup.recovery":"请重新检查。如果仍然失败,请完全退出后重开应用;升级后出现此问题时,确认已安装完整的同一版本。","startup.retry":"重新检查","startup.details":"查看错误详情","modal.serviceViews.label":"服务设置分类","modal.serviceViews.llm":"语言模型","modal.serviceViews.asr":"语音识别","modal.serviceViews.omni":"多模态模型","modal.serviceViews.models":"本地模型","modal.serviceViews.connections":"连接与扩展","modal.serviceViews.statusConfigured":"已配置","modal.serviceViews.statusMissing":"未配置","modal.searchPlaceholder":"查找设置分类…","modal.clearSearch":"清除搜索","modal.categoriesLabel":"设置分类","modal.searchResults":"查找结果","modal.searchCount":"找到 {{count}} 个相关分类","modal.noResults":"没有找到相关分类。试试“麦克风”“模型”或“主题”。","modal.autoSaveHint":"修改后自动保存","modal.backToAdvanced":"返回实验与扩展","modal.advancedPages.lessComputer":"选择 Agent,配置模型、权限与工作目录。","modal.advancedPages.claudeConsole":"检测 Claude Code,并查看测试任务的运行输出。","modal.advancedPages.multimodal":"管理多模态识别的实验性开关。","modal.advancedPages.debug":"保留调试录音、探测光标上下文和导出日志。","modal.descriptions.general":"选择麦克风、设置录音方式与文字输入,也可连接手机输入。","modal.descriptions.shortcuts":"设置各功能的触发方式,以及选中文字后的操作。","modal.descriptions.services":"选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。","modal.descriptions.appearance":"调整主题、页面排版和界面语言,让阅读更舒服。","modal.descriptions.privacy":"检查系统权限与连接状态,管理历史、录音和本地数据。","modal.descriptions.advanced":"按需配置 Less Computer、多模态与调试功能。","modal.descriptions.about":"查看当前版本、更新渠道与自动更新设置。","modal.searchKeywords.general":"麦克风 录音 输入 手机 远程 局域网 PIN 胶囊 静音 自启 开机","modal.searchKeywords.shortcuts":"快捷键 热键 组合键 选区 润色 语音编辑","modal.searchKeywords.services":"ASR LLM API 渠道 模型 云 本地 网络 代理 市场","modal.searchKeywords.appearance":"主题 深色 浅色 暗色 语言 字号 排版 布局 热力图","modal.searchKeywords.privacy":"权限 麦克风 辅助功能 历史 录音 存储 隐私 导出","modal.searchKeywords.advanced":"Less Computer Claude Agent 多模态 Omni 调试 日志 实验","modal.searchKeywords.about":"版本 Beta 稳定 更新 升级","modal.sections.appearance":"外观与语言","modal.sections.shortcuts":"快捷键与选区","modal.sections.general":"录音与输入","modal.sections.services":"AI 服务与模型","modal.sections.privacy":"权限与数据","modal.sections.advanced":"实验与扩展","modal.sections.personalize":"个性化","modal.sections.about":"关于与更新","modal.sections.helpCenter":"帮助中心","modal.sections.releaseNotes":"发布日志","modal.personalize.font":"字体大小","modal.personalize.fontDesc":"整体缩放界面字号,立即生效。","modal.personalize.fontSmall":"小","modal.personalize.fontMedium":"中","modal.personalize.fontLarge":"大","modal.personalize.blur":"毛玻璃强度","modal.personalize.blurDesc":"影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。","modal.about.tagline":"自然说话,完美书写","modal.about.checkUpdate":"检查更新","modal.about.checkUpdateBtn":"检查","modal.about.docs":"文档","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"反馈渠道","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"源码","modal.about.qq":"社区 QQ 群","modal.about.qqDesc":"使用 QQ 搜索群号加入,或扫码进群。","modal.about.copyQq":"复制群号","modal.about.exportErrorLog":"导出错误日志","modal.about.exportErrorLogDesc":"把当前会话的运行日志保存到本地,便于排查问题或反馈给我们。","modal.about.exportErrorLogBtn":"导出","modal.about.exporting":"导出中…","modal.about.exportSuccess":"已保存","modal.about.exportFailed":"导出失败","modal.about.privacy":"隐私","modal.about.privacyDesc":"识别结果保存在本机;所配置的云端服务商可能接收录音以完成转写。","modal.about.localFirst":"本地优先","windowChrome.restore":"还原","windowChrome.minimize":"最小化","windowChrome.maximize":"最大化","windowChrome.close":"关闭","hotkey.triggers.rightOption":"右 Option","hotkey.triggers.leftOption":"左 Option","hotkey.triggers.rightControl":"右 Control","hotkey.triggers.leftControl":"左 Control","hotkey.triggers.rightCommand":"右 Command","hotkey.triggers.leftCommand":"左 Command","hotkey.triggers.leftShift":"左 Shift","hotkey.triggers.rightShift":"右 Shift","hotkey.triggers.fn":"Fn (地球键)","hotkey.triggers.rightAlt":"右 Alt","hotkey.triggers.mediaPlayPause":"⏯ 媒体播放/暂停","hotkey.triggers.custom":"自定义组合键…","hotkey.fallback":"全局快捷键","hotkey.modeHoldSuffix":"(按住说话)","hotkey.modeToggleSuffix":"(开始 / 停止)","hotkey.modeAutoSuffix":"(自动识别)","hotkey.usageHold":"按住 {{trigger}} 说话,松开结束。","hotkey.usageToggle":"按 {{trigger}} 开始录音,再按一次结束。","hotkey.usageAuto":"短按 {{trigger}} 切换开始 / 停止,按住则说完松开即停。","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows 低层键盘 hook","hotkey.adapter.fcitx5":"fcitx5 输入法插件","hotkey.adapter.unavailable":"不可用","localAsr.kicker":"本地 ASR","localAsr.title":"模型设置","localAsr.desc":"管理本机语音识别模型。","localAsr.storageTitle":"模型存储位置","localAsr.storageBaseDir":"选择的父目录","localAsr.storageModelsRoot":"实际模型目录","localAsr.storageDefault":"系统默认目录","localAsr.storageChoose":"更改目录","localAsr.storageReset":"恢复默认","localAsr.storageReveal":"打开模型总目录","localAsr.storageDesc":"自定义目录会在所选位置下创建 OpenLess/models,并自动迁移现有模型;迁移前会取消下载和释放已加载模型。","localAsr.storageChooseTitle":"选择本地模型存储父目录","localAsr.storageChangeConfirm":"将把现有本地模型迁移到 {{path}}/OpenLess/models。迁移前会自动取消下载并释放已加载模型。是否继续?","localAsr.storageResetConfirm":"将把现有本地模型迁回系统默认目录。当前目录:{{path}}。是否继续?","localAsr.modelDir":"模型目录","localAsr.revealDir":"打开目录","localAsr.deleteConfirm":"确定删除 {{name}} 的本地模型文件吗?此操作无法撤销。","localAsr.appleSpeechTitle":"Apple 语音识别(macOS)","localAsr.appleSpeechDesc":"macOS 系统自带的语音识别,在本机把语音转成文字:不用下载模型、不用填 API Key,主流语言可完全离线、音频不出本机。适合作云端 ASR 网络不稳时的本地兜底;首次使用会弹出系统语音识别授权。","localAsr.appleSpeechUse":"使用 Apple 语音","localAsr.qwenTitle":"Qwen3-ASR 模型管理","localAsr.qwenExperimentalBadge":"实验性","localAsr.engineUnavailable":"当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。","localAsr.qwenUnavailableOnWindows":"Windows 暂不支持 Qwen3-ASR,请使用上方 Foundry Local Whisper。","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"在本机识别语音,无需 ASR API Key。首次使用需下载运行组件和模型。","localAsr.foundryAvailable":"Windows 可用","localAsr.foundryUnavailable":"仅 Windows 可用","localAsr.foundryRuntimeReady":"运行组件已下载","localAsr.foundryRuntimeMissing":"运行组件未下载","localAsr.foundryRuntimeSourceLabel":"运行组件下载源","localAsr.foundryRuntimeSourceAuto":"自动(NuGet 优先)","localAsr.foundryRuntimeSourceNuget":"NuGet 官方源","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly 源","localAsr.foundryRuntimeSourceDesc":"首次使用前需下载运行组件。","localAsr.foundrySelectedModel":"选择模型","localAsr.foundryActiveModel":"当前默认 alias","localAsr.foundryLoadedModel":"已加载模型","localAsr.foundryNotLoaded":"未加载","localAsr.foundryError":"Foundry 状态","localAsr.foundrySetDefault":"设为默认 / 启用 Windows 本地 ASR","localAsr.foundryEnabling":"正在启用…","localAsr.foundryPrepare":"准备 / 下载 / 加载","localAsr.foundryPreparing":"正在准备…","localAsr.foundryReleasing":"正在释放…","localAsr.foundryRetryPrepare":"继续准备 / 重试","localAsr.foundryCancelPrepare":"取消准备","localAsr.foundryCancelRequested":"已请求取消","localAsr.foundryCancelling":"正在取消…","localAsr.foundryCancelBestEffort":"已请求取消,会在当前步骤完成后停止。可稍后重试。","localAsr.foundryPrepareRuntime":"准备运行时组件","localAsr.foundryPrepareModel":"下载模型","localAsr.foundryPrepareLoad":"加载模型","localAsr.foundryPrepareModelSkipped":"模型已下载,跳过下载阶段","localAsr.foundryPrepareDone":"已完成","localAsr.foundryPrepareWaiting":"等待中","localAsr.foundryApproxSizeMb":"约 {{mb}} MB","localAsr.foundryLanguageLabel":"识别语言","localAsr.foundryLanguageAuto":"自动","localAsr.foundryLanguageZh":"中文 zh","localAsr.foundryLanguageEn":"英文 en","localAsr.foundryLanguageDesc":"中文听写选中文,中英混用选自动。","localAsr.foundryModelSmall":"Whisper Small(默认 / 平衡)","localAsr.foundryModelSmallDesc":"默认平衡选项,兼顾质量与资源占用。","localAsr.foundryModelMedium":"Whisper Medium(更高质量)","localAsr.foundryModelMediumDesc":"更高准确率,适合性能更强、可接受更大下载和更慢推理的设备。","localAsr.foundryModelLarge":"Whisper Large V3 Turbo(最高质量)","localAsr.foundryModelLargeDesc":"更高质量的大模型选项,适合高配设备和质量优先场景。","localAsr.foundryModelBase":"Whisper Base(更快 / 更省资源)","localAsr.foundryModelBaseDesc":"更快、资源占用更低,适合日常轻量使用。","localAsr.foundryModelTiny":"Whisper Tiny(最快 / 冒烟测试)","localAsr.foundryModelTinyDesc":"最快的检查选项,适合确认 Foundry 路径可用。","localAsr.sherpaTitle":"Windows sherpa-onnx Local(实验性)","localAsr.sherpaDesc":"Windows 使用 sherpa-onnx 在本机离线批量识别,无需 ASR API Key。","localAsr.sherpaRuntimeReady":"模型已加载","localAsr.sherpaRuntimeMissing":"模型未加载","localAsr.sherpaSetDefault":"设为默认 / 启用 sherpa-onnx","localAsr.sherpaPrepare":"检查本地文件 / 加载","localAsr.sherpaPreparing":"加载中…","localAsr.sherpaPrepareLocalFiles":"检查本地模型文件","localAsr.sherpaModelDir":"模型目录","localAsr.sherpaRevealDir":"打开模型目录","localAsr.sherpaError":"sherpa-onnx 状态","localAsr.sherpaLanguageJa":"日语 ja","localAsr.sherpaLanguageKo":"韩语 ko","localAsr.sherpaLanguageYue":"粤语 yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small(默认 / 中文优先)","localAsr.sherpaModelSenseVoiceDesc":"默认实验模型,适合中文与中英混合听写。","localAsr.sherpaModelParaformer":"Paraformer 中文","localAsr.sherpaModelParaformerDesc":"面向中文的实验模型。","localAsr.sherpaModelWhisper":"Whisper Small 多语言","localAsr.sherpaModelWhisperDesc":"与 Whisper 系列行为一致的多语言实验兜底模型。","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3(多语)","localAsr.sherpaModelWhisperLargeV3Desc":"开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。","localAsr.sherpaModelZipformer":"Zipformer 流式(中英)","localAsr.sherpaModelZipformerDesc":"边说边出的流式中英模型,延迟最低,适合实时听写。","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。","localAsr.modelSelectTitle":"本机模型","localAsr.modelSelectDesc":"查看下载状态、管理文件,或加载模型进行测试。","localAsr.modelSelectPlaceholder":"选择已下载的模型…","localAsr.modelSelectEmpty":"还没有已下载的模型,先到「下载与管理」下载一个。","localAsr.groupDownload":"下载与管理","localAsr.groupOther":"其他","localAsr.mirrorLabel":"下载镜像源","localAsr.mirrorDesc":"官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。","localAsr.mirrorHuggingface":"HuggingFace 官方 (huggingface.co)","localAsr.mirrorHfMirror":"国内镜像 (hf-mirror.com)","localAsr.activeBadge":"当前使用","localAsr.downloadedBadge":"已下载","localAsr.notDownloadedBadge":"未下载","localAsr.download":"下载","localAsr.resume":"继续下载","localAsr.cancel":"取消","localAsr.delete":"删除","localAsr.setActive":"设为默认","localAsr.failed":"失败","localAsr.cancelled":"已取消","localAsr.files":"文件","localAsr.sizeLoading":"正在查询尺寸…","localAsr.sizeUnknown":"尺寸未知","localAsr.performanceWarning":"本地 ASR 适合离线或隐私敏感场景,首次使用需下载模型。","localAsr.test":"加载并测试","localAsr.testRunning":"测试中…","localAsr.testHeading":"内置音频测试","localAsr.testExpected":"原文","localAsr.testActual":"识别","localAsr.testStats":"音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}","localAsr.testFailed":"测试失败","localAsr.engineStatusLabel":"内存中的引擎","localAsr.engineLoaded":"已加载:{{model}}","localAsr.engineUnloaded":"未加载(首次听写需先加载模型)","localAsr.loadNow":"立即加载","localAsr.releaseNow":"立即释放","localAsr.keepLoadedLabel":"保持加载多久","localAsr.keepLoadedDesc":"决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。","localAsr.keepImmediate":"说完话立即释放","localAsr.keep1min":"上次使用后 1 分钟","localAsr.keep5min":"上次使用后 5 分钟(默认)","localAsr.keep30min":"上次使用后 30 分钟","localAsr.keepForever":"不释放(始终保留)","localAsr.sidebarTitle":"已下载与下载中","localAsr.activePill":"当前使用","localAsr.setDefault":"设为默认","localAsr.downloading":"下载中","localAsr.startDownload":"开始下载","localAsr.downloadNewModel":"下载新模型","localAsr.activeModelLabel":"使用中的模型","localAsr.pickerNoModelDownloaded":"还没有已下载的模型,请先在本地模型页下载。","localAsr.partialDownloadsLabel":"未完成下载","localAsr.partialDownloadsDesc":"存在中断下载的临时残留,可一键清理,不影响已安装模型。","localAsr.cleanupIncomplete":"清理未完成下载","localAsr.languagesLabel":"语言","localAsr.partialBytesLabel":"残留文件","localAsr.downloadDialogTitle":"下载模型","localAsr.downloadDialogAlreadyHave":"模型文件已下载。可回到模型页加载并测试,或在「ASR 语音转写」中选择对应供应商。","localAsr.downloadDialogDesc":"查看模型大小与简介,选择后开始下载。下载完成后,在「语音识别」中选择对应的本地服务。","localAsr.detailRepo":"模型仓库","localAsr.hfDownloads":"下载量","localAsr.hfLikes":"收藏数","localAsr.hfDescription":"模型简介","localAsr.hfNoDescription":"暂无简介","localAsr.hfCardFailed":"模型信息获取失败","localAsr.detailFiles":"个文件","localAsr.detailDownloaded":"已下载","localAsr.detailEmpty":"选择一个模型查看详情","localAsr.foundryLanguage":"语言","localAsr.foundryRuntimeSource":"运行时来源","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"保持加载","localAsr.downloadSettingsTitle":"下载与存储设置","localAsr.downloadSettingsDesc":"镜像源 · 模型存储位置 · 内存引擎","localAsr.libraryEmptyTitle":"还没有本地模型","localAsr.libraryEmptyDesc":"下载一个语音识别模型,让音频在本机处理。已有模型却没有显示时,可重新读取目录。","localAsr.catalogTitle":"模型目录","localAsr.catalogEmpty":"当前没有可显示的模型。重新读取目录后再试。","localAsr.reloadCatalog":"重新读取","localAsr.engineLabel":"识别引擎","localAsr.sizeLabel":"模型大小","localAsr.allEngines":"全部","localAsr.backToCatalog":"返回模型目录","localAsr.detailsTitle":"模型详情","localAsr.testActivateHint":"「加载并测试」会将此模型设为当前使用,再运行内置音频测试。","localAsr.downloadProgressHint":"开始后返回模型页查看进度,也可随时取消下载。","localAsr.errorDetails":"错误详情"},"zh-TW":{"cloudSync.title":"雲端同步","cloudSync.description":"使用 GitHub 帳號,在裝置之間同步詞典、風格與個人偏好。","cloudSync.signIn":"使用 GitHub 登入","cloudSync.account":"同步帳號","cloudSync.refresh":"重新整理狀態","cloudSync.loading":"正在讀取雲端狀態…","cloudSync.noBackup":"雲端尚無備份","cloudSync.available":"雲端備份已就緒","cloudSync.summary":"{{dictionary}} 個詞條 · {{corrections}} 條修正规則 · {{stylePacks}} 個風格","cloudSync.updated":"更新於 {{time}}","cloudSync.upload":"備份至雲端","cloudSync.restore":"從雲端還原","cloudSync.delete":"刪除雲端備份","cloudSync.working":"正在同步…","cloudSync.uploadSuccess":"已備份至雲端","cloudSync.restoreSuccess":"已還原雲端設定","cloudSync.deleteSuccess":"已刪除雲端備份","cloudSync.failed":"同步失敗:{{error}}","cloudSync.conflict":"雲端已有更新。請重新整理狀態後,再決定備份或還原。","cloudSync.unavailable":"官方同步服務暫時無法使用,請稍後重試。","cloudSync.signInRequired":"請先登入 GitHub。","cloudSync.restoreTitle":"還原雲端備份?","cloudSync.restoreDescription":"雲端的詞典、修正规則、風格和同步偏好將覆蓋本機對應內容。API 金鑰、裝置目錄與權限維持本機設定。","cloudSync.deleteTitle":"刪除雲端備份?","cloudSync.deleteDescription":"僅刪除此 GitHub 帳號的雲端備份,本機資料會保留。","cloudSync.confirmRestore":"還原並取代","cloudSync.confirmDelete":"刪除備份","cloudSync.scope":"同步詞典、修正规則、風格圖示與常用偏好。API 金鑰、登入憑據與裝置專屬設定保留在本機。","macDictationKey.Changed":"儲存期間快捷鍵已變更,請重試。","macDictationKey.label":"Mac 聽寫鍵","macDictationKey.description":"用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。","macDictationKey.Permission":"請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。","macDictationKey.Busy":"請先結束目前的聽寫,再變更快捷鍵。","macDictationKey.Unavailable":"無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。","app.name":"OpenLess","app.tagline":"自然說話,完美書寫","common.loading":"加載中…","common.retry":"重試","common.settingsLoadFailed":"設置加載失敗","common.refresh":"刷新","common.clear":"清空","common.copy":"複製","common.delete":"刪除","common.later":"稍後","common.cancel":"取消","common.close":"關閉","common.show":"顯示","common.hide":"隱藏","common.saved":"已保存","common.saving":"保存中","common.experimental":"實驗性","common.copied":"已複製","common.operationFailed":"操作失敗","common.add":"添加","common.durationSeconds":"{{value}} 秒","common.durationMillis":"{{value}} 毫秒","common.durationMinutes":"{{value}} 分鐘","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"已取消","capsule.error":"出錯了","capsule.inserted":"已插入 {{count}}","capsule.translating":"正在翻譯","capsule.selectionPolish.polishing":"正在潤色...","capsule.selectionPolish.replaced":"已替換","capsule.selectionPolish.noSelection":"未選中內容","capsule.selectionPolish.failed":"潤色失敗,請重試","selectionPolishPreview.title":"選區潤色預覽","selectionPolishPreview.subtitle":"可直接編輯;點擊確認後才會替換原選區。","selectionPolishPreview.cancel":"取消","selectionPolishPreview.resultLabel":"潤色結果","selectionPolishPreview.sourcePrefix":"原文:","selectionPolishPreview.applyError":"未能應用:","selectionPolishPreview.confirmReplace":"確認並替換","selectionVoiceIntent.title":"你想做什麼?","selectionVoiceIntent.subtitle":"已識別你的語音指令,請選擇處理方式。","selectionVoiceIntent.loading":"載入中…","selectionVoiceIntent.sourcePrefix":"選區:","selectionVoiceIntent.errorPrefix":"未能繼續:","selectionVoiceIntent.question":"提問","selectionVoiceIntent.edit":"編輯選區","selectionVoiceIntent.cancel":"取消","qa.title":"劃詞追問","qa.headerHint":"隨時提問","qa.thinking":"思考中…","qa.error":"出錯了,請稍後再試。","qa.errorRetry":"重試","qa.errorRetryHint":"請再試一次。","qa.pinTooltip":"固定(不自動關閉)","qa.unpinTooltip":"取消固定","qa.closeTooltip":"關閉","qa.micLabel":"語音提問","qa.micStop":"結束錄音","qa.selectionPreview":"基於選中文本:","qa.emptyTitle":"有什麼可以幫你?","qa.emptyDesc":"選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。","qa.recordingHint":"錄音中…再按一次 {{recordHotkey}} 結束並提問","qa.mobileRecordLabel":"錄音按鈕","qa.mobileRecordStart":"開始錄音","qa.mobileRecordStop":"結束並提交","qa.composerPlaceholder":"輸入問題,Enter 發送","qa.composerSend":"發送","qa.statusIdle":"按 {{recordHotkey}} 提問","qa.statusRecording":"錄音中","qa.statusThinking":"思考中","qa.statusError":"出錯了","qa.jumpToLatest":"跳到最新","qa.editApplyReplace":"確認並替換選區","qa.editApplyUnavailable":"沒有可替換的編輯結果","qa.editRevertPrevious":"保留上一版本","qa.editInstructionMode":"編輯指令","lessComputer.title":"Less Computer","lessComputer.subtitle":"想讓電腦做什麼?","lessComputer.you":"你","lessComputer.working":"正在操控電腦…","lessComputer.tool":"呼叫了 {{name}}","lessComputer.compaction":"上下文已壓縮","lessComputer.done":"完成","lessComputer.cost":"${{cost}}","lessComputer.error":"失敗,請重試。","lessComputer.closeTooltip":"關閉","lessComputer.jumpToLatest":"跳到最新","lessComputer.inputPlaceholder":"輸入指令,Enter 傳送","lessComputer.send":"傳送","lessComputer.approvalTitle":"執行被攔截的指令?","lessComputer.approvalRerunWarning":"注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用","lessComputer.approve":"允許","lessComputer.deny":"拒絕","lessComputer.approved":"已允許","lessComputer.denied":"已拒絕","nav.overview":"概覽","nav.history":"歷史","nav.vocab":"詞典","nav.style":"風格","nav.marketplace":"風格市場","nav.translation":"翻譯","nav.selectionAsk":"劃詞追問","nav.corrections":"糾正規則","nav.polishMode":"潤色模式","nav.group.style":"風格","nav.group.tools":"工具","nav.localAsr":"模型設置","nav.more":"更多","marketplace.kicker":"風格市場","marketplace.title":"風格包市場","marketplace.desc":"瀏覽、安裝和分享社區風格包。","marketplace.searchPlaceholder":"搜尋名稱 / 描述 / 標籤…","marketplace.sortPopular":"按熱度","marketplace.sortNew":"最新","marketplace.uploadBtn":"上傳","marketplace.uploadDisabledHint":"請先在 設定 → 風格市場 配置 GitHub 使用者名稱","marketplace.refreshBtn":"重新整理","marketplace.empty":"還沒有風格包","marketplace.emptyHint":"換個搜尋詞,或自己上傳一個分享給社群","marketplace.loadFailed":"載入失敗:{{err}}","marketplace.noDescription":"(暫無描述)","marketplace.installBtn":"安裝到本機","marketplace.installingBtn":"安裝中…","marketplace.downloadZipBtn":"下載 ZIP","marketplace.downloadingZipBtn":"下載中…","marketplace.downloadAria":"下載「{{name}}」ZIP","marketplace.likeBtn":"點讚","marketplace.installed":"已安裝「{{name}}」到本機風格包","marketplace.downloaded":"已下載「{{name}}」ZIP","marketplace.uploaded":"上傳成功,等待審核","marketplace.uploadTitle":"選擇要上傳的風格包","marketplace.uploadHint":"以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。","marketplace.uploadNoLocal":"本機沒有可上傳的風格包","marketplace.errors.detail":"載入詳情失敗:{{err}}","marketplace.errors.install":"安裝失敗:{{err}}","marketplace.errors.download":"下載 ZIP 失敗:{{err}}","marketplace.errors.like":"點讚失敗:{{err}}","marketplace.errors.upload":"上傳失敗:{{err}}","marketplace.errors.loadLocal":"載入本機風格包失敗:{{err}}","marketplace.sortLiked":"我讚過的","marketplace.likedEmpty":"你還沒有讚過任何風格包","marketplace.likedEmptyHint":"點開任一風格包,紅色星星點亮後會出現在這裡","marketplace.derivativeBadge":"衍生自 @{{login}}","marketplace.detail.withdrawBtn":"撤回發布","marketplace.detail.withdrawConfirm":"確認從風格市場撤回「{{name}}」?本機副本不會被刪除。","marketplace.detail.withdrawSuccess":"已從風格市場撤回","marketplace.detail.withdrawFailed":"撤回失敗:{{err}}","marketplace.myPacks.buttonLabel":"我的發布","marketplace.myPacks.buttonTitle":"查看 {{login}} 的發布","marketplace.myPacks.buttonTitleEmpty":"先在 Settings → 風格市場 填寫發布身份","marketplace.myPacks.searchPlaceholder":"搜尋名稱、標籤","marketplace.myPacks.notLoggedIn":"請先在 Settings → 風格市場 填寫發布身份","marketplace.myPacks.emptyTitle":"你還沒有發布過風格包","marketplace.myPacks.emptyHint":"在「風格」頁面編輯後點「發布到風格市場」,或點擊右上角上傳本機風格包。","marketplace.myPacks.noMatch":"沒有符合的風格包","marketplace.myPacks.summary":"已發布 {{count}} 個風格包","marketplace.myPacks.summaryPending":"已發布 {{count}} 個風格包 · {{pending}} 個審核中","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"更新","marketplace.myPacks.actions.withdraw":"下架","marketplace.myPacks.loadFailed":"我的發布載入失敗:{{err}}","marketplace.myPacks.loadingTitle":"正在拉取,請稍後…","marketplace.myPacks.loadingHint":"從風格市場獲取你最新發布的風格包。","marketplace.myPacks.loadErrorTitle":"載入失敗","marketplace.myPacks.loadErrorRetry":"重試","marketplace.upload.confirmBtn":"確定上傳","marketplace.upload.updateTitle":"更新「{{name}}」","marketplace.upload.updateHint":"選中要上傳的本機新版本風格包,下方點「確定上傳」。同名包預設預選。","marketplace.upload.recommendedBadge":"建議更新","marketplace.state.pending":"審核中","marketplace.state.approved":"已上架","marketplace.state.rejected":"未通過","marketplace.state.withdrawn":"已下架","marketplace.state.superseded":"已被新版替換","marketplace.state.unknown":"未知","marketplace.oauth.title":"用 GitHub 登入","marketplace.oauth.generating":"正在產生裝置驗證碼…","marketplace.oauth.browserHint":"在瀏覽器中開啟 {{uri}} 並輸入下方代碼:","marketplace.oauth.copyBtn":"複製","marketplace.oauth.copied":"已複製裝置碼","marketplace.oauth.copyFailed":"複製失敗:{{err}}","marketplace.oauth.openBrowserBtn":"開啟瀏覽器","marketplace.oauth.cancelBtn":"取消","marketplace.oauth.waiting":"等待你在瀏覽器中授權…","marketplace.oauth.successAs":"已登入為 @{{login}}","marketplace.oauth.retryBtn":"重試","marketplace.oauth.closeBtn":"關閉","marketplace.oauth.loginBtn":"登入","marketplace.oauth.loginTooltip":"點擊用 GitHub 登入","marketplace.oauth.reloginTooltip":"點擊重新登入 / 切換帳號(目前 @{{login}})","marketplace.modal.loggedIn":"目前登入身份 —— 在 Settings → 錄音 → 風格市場 修改","marketplace.modal.notLoggedIn":"未登入 —— 去 Settings → 錄音 → 風格市場 填一個發布者名","marketplace.modal.notLoggedInLabel":"未登入","shell.shortcutLabel":"錄音快捷鍵","shell.shortcutHint":"開始 / 停止","shell.betaTag":"BETA","shell.betaNote":"本機儲存,可選雲端備份","shell.navHint.overview":"狀態總覽:用量統計、提供商與權限健康檢查","shell.navHint.history":"聽寫歷史:搜尋、回放與複製過往轉寫","shell.navHint.vocab":"詞典:自訂熱詞,提升專有名詞辨識率","shell.navHint.style":"潤色風格:管理輸出風格與自訂提示詞","shell.navHint.translation":"翻譯:按住 Shift 說話,譯成目標語言插入","shell.navHint.selectionAsk":"劃詞追問:選取文字後語音提問","shell.navHint.settings":"偏好設定:快捷鍵、提供商、隱私與更新","shell.footer.account":"賬戶","shell.footer.feedback":"反饋","shell.footer.settings":"設置","shell.footer.help":"幫助","shell.footer.version":"版本 {{version}}","shell.footer.helpPopover.tagline":"本地驅動的語音輸入層","shell.footer.helpPopover.releaseNotes":"查看發佈日誌 ↗","shell.footer.helpPopover.docs":"幫助中心 ↗","shell.providerPrompt.title":"設置語音提供商","shell.providerPrompt.body":"還沒有配置 ASR 或 LLM 提供商,語音輸入和潤色暫時無法正常工作。","shell.providerPrompt.later":"稍後","shell.providerPrompt.openSettings":"去設置","shell.hotkeyModePrompt.title":"檢查錄音方式","shell.hotkeyModePrompt.body":"預設已改為切換式。如果之前改過觸發方式,請到錄音設定確認一次。","shell.hotkeyModePrompt.later":"稍後提醒","shell.hotkeyModePrompt.openSettings":"去錄音設置","onboarding.welcome":"歡迎使用 OpenLess","onboarding.intro":"本地說出,本地落字。開始前需要兩個系統權限。","onboarding.accessibilityTitle":"輔助功能","onboarding.hotkeyTitle":"全局快捷鍵","onboarding.accessibilityDesc":"用於監聽全局快捷鍵(默認 {{trigger}})並把識別結果寫入光標位置。","onboarding.hotkeyDesc":"用於確認全局快捷鍵監聽可用。","onboarding.micTitle":"麥克風","onboarding.micDesc":"用於捕獲你的語音輸入。","onboarding.actionNotApplicable":"無需授權","onboarding.actionGranted":"已授權","onboarding.actionOpenSystem":"打開系統設置","onboarding.actionRestart":"重置授權並重新啟動 OpenLess","onboarding.actionGrant":"授權","onboarding.actionRequestMic":"彈出授權","onboarding.micNoDeviceHint":"未偵測到麥克風,請連接並啟用麥克風後重試。","onboarding.accessibilityHint":"授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。","onboarding.footerHint":"授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。","onboarding.continueToSettings":"僅進入設定(語音與全域快速鍵暫不可用)","onboarding.androidContinue":"先進入應用","onboarding.androidFooterHint":"聽寫需要麥克風權限。可點擊上方「彈出授權」,或先進入應用後在概覽頁繼續授權。","onboarding.androidTitle":"配置 OpenLess","onboarding.androidIntro":"按步驟完成移動端權限和服務配置。","onboarding.androidStepCounter":"第 {{current}} / {{total}} 項","onboarding.androidBack":"上一步","onboarding.androidNext":"下一步","onboarding.androidFinish":"完成並進入","onboarding.androidSteps.microphoneTitle":"麥克風權限","onboarding.androidSteps.microphoneDesc":"調用 Android 系統授權卡片,允許 OpenLess 錄製語音。","onboarding.androidSteps.accessibilityTitle":"無障礙服務","onboarding.androidSteps.accessibilityDesc":"用於把識別結果貼回當前輸入框,並輔助檢測輸入環境。","onboarding.androidSteps.overlayPermissionTitle":"懸浮窗權限","onboarding.androidSteps.overlayPermissionDesc":"允許 OpenLess 在其他應用上顯示錄音控制按鈕。","onboarding.androidSteps.overlayConfigTitle":"懸浮窗配置","onboarding.androidSteps.overlayConfigDesc":"設置懸浮窗顯示時機、觸發方式、滑動動作和按鈕大小。","onboarding.androidSteps.asrTitle":"ASR 雲服務","onboarding.androidSteps.asrDesc":"配置語音轉文字服務的供應商、密鑰、接口地址和模型。","onboarding.androidSteps.llmTitle":"LLM 服務","onboarding.androidSteps.llmDesc":"配置文本潤色、翻譯和問答使用的語言模型服務。","overview.refresh":"重新整理狀態","overview.servicesTitle":"目前的語音服務","overview.statsTitle":"使用紀錄","overview.omniKind":"多模態語音","overview.omniName":"目前的 Omni 模型","overview.statusLoading":"正在讀取服務設定…","overview.configureProvider":"前往設定","overview.manageProvider":"管理服務","overview.recentEmptyHint":"還沒有聽寫紀錄。依照上方引導試一次,結果就會顯示在這裡。","overview.providerHelp.asr":"將語音轉成文字。","overview.providerHelp.llm":"依照你的風格整理和潤飾文字。","overview.providerHelp.omni":"由一個模型完成語音辨識和文字處理。","overview.actions.refresh":"重新讀取","overview.actions.services":"AI 服務與模型","overview.actions.general":"錄音與輸入","overview.actions.shortcuts":"快捷鍵","overview.actions.privacy":"權限與資料","overview.guide.nextStep":"下一步","overview.guide.loadingTitle":"正在讀取你的設定","overview.guide.loadingDesc":"請稍候,馬上顯示目前的服務和下一步操作。","overview.guide.unavailableTitle":"暫時無法讀取服務狀態","overview.guide.unavailableDesc":"重新讀取,或前往 AI 服務檢查設定。","overview.guide.servicesTitle":"先設定語音服務","overview.guide.servicesDesc":"建議從這裡開始:選擇語音辨識和文字處理服務;使用 Omni 時,只需設定目前的多模態模型。","overview.guide.permissionsTitle":"先檢查快捷鍵狀態","overview.guide.permissionsDesc":"目前無法使用快捷鍵介面。請開啟權限與資料,查看狀態和可用的處理方式。","overview.guide.shortcutsTitle":"設定一個錄音快捷鍵","overview.guide.shortcutsDesc":"選擇順手的快捷鍵,之後就能在輸入時開始聽寫。","overview.guide.recordingTitle":"確認你的錄音方式","overview.guide.recordingDesc":"服務設定已儲存。開啟錄音設定,選擇麥克風和適合你的錄音方式。","overview.guide.tryDictationTitle":"試一次聽寫","overview.guide.tryDictationDesc":"將游標放到要輸入的位置。{{shortcut}}","overview.guide.permissionsHint":"錄音或快捷鍵沒有反應?在「權限與資料」中查看權限、麥克風和快捷鍵狀態。","overview.kicker":"概覽","overview.title":"今日概覽","overview.desc":"今日口述統計與系統狀態。","overview.pressPrefix":"按","overview.pressSuffix":"開始錄音","overview.asrKind":"語音辨識","overview.llmKind":"文字處理","overview.asrName":"火山引擎","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI 兼容","overview.llmConfigured":"已配置 active LLM","overview.llmNotConfigured":"未配置","overview.statusConfigured":"已配置","overview.statusNotConfigured":"未配置","overview.statusUnknown":"無法讀取","overview.credentialsLoadError":"無法讀取憑據狀態","overview.metricChars":"今日字數","overview.metricSegments":"{{count}} 段","overview.metricDuration":"今日總時長","overview.metricAvg":"平均段落","overview.metricAvgTrend":"今日均值","overview.metricNoData":"暫無數據","overview.historyLoadError":"歷史讀取失敗","overview.metricTotal":"累計記錄","overview.metricTotalTrend":"本機存檔 (上限 200)","overview.activityTitle":"年度活動","overview.activityCount":"{{count}} 次聽寫","overview.activityLoadError":"活動數據讀取失敗","overview.period.ariaLabel":"統計週期","overview.period.last7Days":"近 7 天","overview.period.last30Days":"近 30 天","overview.period.dailyAverage":"日均 {{value}}","overview.period.minutes":"{{value}} 分鐘","overview.period.hoursMinutes":"{{hours}} 小時 {{minutes}} 分","overview.metricName.ariaLabel":"統計指標","overview.metricName.count":"條數","overview.metricName.chars":"字數","overview.metricName.duration":"時長","overview.recentTitle":"最近識別","overview.recentAll":"全部記錄 →","overview.recentEmpty":"還沒有記錄。按 {{trigger}} 開始第一次錄音。","overview.recentLoadFailed":"無法讀取最近識別,請重試。","overview.historyRetry":"重試","overview.weekDays.0":"日","overview.weekDays.1":"一","overview.weekDays.2":"二","overview.weekDays.3":"三","overview.weekDays.4":"四","overview.weekDays.5":"五","overview.weekDays.6":"六","overview.inAppDictation.title":"應用內錄音","overview.inAppDictation.start":"開始錄音","overview.inAppDictation.stop":"停止錄音","overview.inAppDictation.idle":"點擊開始錄音","overview.inAppDictation.recording":"錄音中…","overview.inAppDictation.processing":"處理中…","overview.androidMicBanner.title":"需要麥克風權限","overview.androidMicBanner.desc":"授權麥克風後可使用應用內錄音與語音輸入。","overview.androidMicBanner.grant":"彈出授權","overview.androidMicBanner.openSettings":"打開系統設置","history.exportError":"匯出錄音失敗,請重試。","history.kicker":"歷史記錄","history.title":"歷史記錄","history.desc":"本機保存的識別記錄。","history.filterAll":"全部","history.summary":"共 {{total}} 條 · 顯示 {{shown}}","history.searchPlaceholder":"搜尋轉寫內容…({{shortcut}})","history.searchNoMatch":"沒有符合「{{query}}」的記錄。","history.empty":"還沒有歷史記錄。按 {{trigger}} 錄一段試試。","history.loadFailed":"加載歷史失敗:{{err}}","history.retry":"重試","history.clearFailed":"清空失敗:{{err}}","history.deleteFailed":"刪除失敗:{{err}}","history.copyFailed":"複製失敗:{{err}}","history.playRecording":"播放錄音","history.audioLoading":"載入中…","history.audioDecodeFailed":"音訊解碼失敗:{{err}}","history.exportRecording":"匯出錄音","history.exportFailed":"匯出失敗:{{err}}","history.retranscribe":"重新轉錄","history.retranscribing":"轉錄中…","history.retranscribeFailed":"重新轉錄失敗:{{err}}","history.rawLabel":"原文","history.rawEmpty":"(空)","history.selectHint":"左側選一條查看詳情。","history.recorded":"錄音 {{duration}}","history.stepAsr":"辨識","history.multimodalPipeline":"多模態","history.stepAsrHint":"放開按鍵後等待辨識結果的耗時。串流辨識邊錄邊轉,此值通常遠小於錄音時長。","history.stepPolish":"潤飾","history.stepInsert":"插入","history.chars":"{{count}} 字","history.vocabHits":"{{count}} 個熱詞","history.inserted":"已插入","history.pasteSent":"已嘗試粘貼","history.copiedFallback":"已複製(需 {{shortcut}})","history.insertFailed":"插入失敗","history.confirmClear":"確定清空全部 {{count}} 條記錄?此操作不可恢復。","history.backToList":"返回列表","history.repolish.title":"重新潤色","history.repolish.hint":"基於上面的原文再跑一次潤色。結果只在本次查看時顯示,不寫回這條記錄。原風格包已刪除或舊記錄時,重試將使用當前風格。","history.repolish.retry":"用原風格重試","history.repolish.retrying":"重試中…","history.repolish.apply":"套用","history.repolish.applying":"潤色中…","history.repolish.pickStyle":"選擇風格包","history.repolish.noPacks":"沒有可用的風格包。","history.repolish.packsLoadFailed":"讀取風格包失敗:{{err}}","history.repolish.failed":"重新潤色失敗:{{err}}","history.repolish.timeout":"當前 LLM 提供商 30 秒內沒有返回結果。換個更快的提供商,或稍後重試 —— 免費模型池經常排隊。","history.repolish.resultTitle":"{{name}} 的結果","history.repolish.retryResultTitle":"重試結果","history.repolish.empty":"(模型返回了空結果)","history.repolish.clear":"清除結果","vocabCard.title":"要記住這個詞嗎?","vocabCard.accept":"記住","vocabCard.reject":"不用","insertFallbackCard.copy":"複製","insertFallbackCard.copied":"已複製","insertFallbackCard.copyFailed":"複製失敗","insertFallbackCard.dismiss":"關閉","vocab.selectAllVisible":"選取目前結果","vocab.selectedCount":"已選取 {{count}} 個詞","vocab.selectWord":"選取「{{phrase}}」","vocab.deleteSelected":"刪除已選({{count}})","vocab.batchDeleteFailed":"{{count}} 個詞條刪除失敗,已保留選取,可重試。","vocab.kicker":"詞典","vocab.title":"詞典","vocab.desc":"添加生詞或專業術語,提高識別準確率。","vocab.sectionTitle":"詞條","vocab.placeholder":"輸入詞語,按 Enter 或點添加…","vocab.tip":"支持中英混合 · 數字開頭按字面識別 · 命中次數自動計數","vocab.loadFailed":"加載失敗:{{err}}","vocab.empty":"還沒有詞條。在上面輸入一個生詞或專業術語,讓模型在聽寫時優先匹配。","vocab.tipDisabled":"點擊禁用此詞條","vocab.tipEnabled":"點擊啓用此詞條","vocab.removeAria":"刪除","vocab.edit":"編輯","vocab.editTitle":"編輯詞彙","vocab.editSave":"儲存","vocab.editEmpty":"詞條不能為空。","vocab.filter.all":"所有","vocab.filter.auto":"自動新增","vocab.filter.manual":"手動新增","vocab.searchPlaceholder":"搜尋","vocab.searchEmpty":"沒有符合的詞條。","vocab.newWord":"新詞","vocab.newWordTitle":"新增新詞","vocab.newWordDesc":"直接輸入新詞,或從預設範本批次匯入。","vocab.newWordInputPlaceholder":"輸入詞語,按 Enter 新增…","vocab.newWordTemplates":"預設範本","vocab.newWordTemplateCount":"{{count}} 詞","vocab.newWordAddSelected":"新增所選","vocab.learnedSection":"自動收集({{count}})","vocab.removeAllLearned":"全部刪除","vocab.corrections.title":"糾正規則","vocab.corrections.tip":"修正常見 ASR 誤識別,支援 {num} 數字通配。","vocab.corrections.patternPlaceholder":"誤識別寫法,如 {num}粒","vocab.corrections.replacementPlaceholder":"目標寫法,如 {num}例","vocab.corrections.empty":"還沒有糾正規則。","vocab.corrections.invalid":"僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。","vocab.corrections.tipDisabled":"點擊停用此規則","vocab.corrections.tipEnabled":"點擊啟用此規則","vocab.corrections.removeAria":"刪除糾正規則","vocab.corrections.learnedBadge":"自動","vocab.corrections.learnedTip":"從你的手動修改中自動收集。可以隨時刪掉。","vocab.corrections.onlyLearned":"只看自動收集的({{count}})","vocab.corrections.removeAllLearned":"刪除全部自動收集的","vocab.corrections.suggestTitle":"要記住這個改法嗎?","vocab.corrections.suggestAccept":"記住","vocab.corrections.suggestDismiss":"不用","vocab.presets.title":"場景預設","vocab.presets.tip":"可多選批量啟用,支援編輯和新建。","vocab.presets.create":"新建預設","vocab.presets.apply":"啓用所選","vocab.presets.save":"保存預設","vocab.presets.edit":"編輯 {{name}}","vocab.presets.newPreset":"新預設","vocab.presets.namePlaceholder":"預設名稱","vocab.presets.wordsPlaceholder":"詞條(用逗號或換行分隔)","style.kicker":"風格","style.title":"輸出風格","style.desc":"選擇錄音的預設輸出風格。","style.masterToggle":"整體啓用","style.currentDefault":"當前默認","style.ariaSetDefault":"設爲默認","style.saveFailed":"保存失敗:{{error}}","style.customPromptTitle":"自定義提示詞","style.customPromptPlaceholder":"可選,追加到這個風格的內建 system prompt 末尾。","style.customPromptHint":"留空則保持當前行為不變。保存後會在該風格的潤色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。","style.customPromptSave":"保存提示詞","style.customPromptDirty":"未保存","style.systemPromptMovedHint":"完整 System Prompt 已移到 設定 -> Providers 頁面統一編輯。這裡現在只負責風格啟停和預設風格。","style.modes.raw.name":"原文","style.modes.raw.desc":"只補標點和必要分句,不改寫不擴寫。","style.modes.raw.sample":"保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。","style.modes.light.name":"輕度潤色","style.modes.light.desc":"去口癖、補標點,整理爲可發送的自然文字。","style.modes.light.sample":"讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。","style.modes.structured.name":"清晰結構","style.modes.structured.desc":"面向程式協作、技術排障和產品回饋,準確保留術語並整理結構。","style.modes.structured.sample":"1. 主題一\na. 要點\nb. 要點\n2. 主題二\na. 要點\nb. 要點","style.modes.formal.name":"正式表達","style.modes.formal.desc":"工作溝通和郵件場景,更專業更完整。","style.modes.formal.sample":"郵件場景自動識別問候 / 落款;不引入空泛客套。","style.pack.builtinTags.minimalEdits":"最小改寫","style.pack.builtinTags.strongCorrection":"強糾錯","style.pack.builtinTags.communication":"溝通","style.pack.builtinTags.natural":"自然","style.pack.builtinTags.organized":"條理","style.pack.builtinTags.workplaceCommunication":"工作溝通","style.pack.builtinTags.aiCoding":"AI 程式開發","style.pack.builtinTags.technicalStructure":"技術結構化","style.pack.newName":"未命名風格","style.pack.newDescription":"簡短描述這個風格的使用情境。","style.pack.uploadIcon":"為「{{name}}」上傳 SVG 圖示","style.pack.resetIcon":"還原預設圖示","style.pack.iconSaved":"圖示已儲存","style.pack.iconInvalid":"請選擇不含外部資源的有效 SVG 圖示(最大 256 KB)。","style.pack.iconSaveFailed":"圖示儲存失敗,請再試一次。","style.pack.selectionListTitle":"選區書面潤色風格","style.pack.selectionListDesc":"用於無需 ASR 的已選文字:單純語法、清晰度和格式潤色。可為它單獨選擇風格與 Prompt。","style.pack.dictationTab":"錄音 / ASR 風格","style.pack.selectionTab":"選區潤色","style.pack.current":"目前","style.pack.useForSelection":"用於選區","style.pack.writtenPolish":"書面潤色","style.pack.selectionPromptTitle":"選區潤色 Prompt(無 ASR)","style.pack.selectionPromptHint":"用於使用者主動選中的書面文字;不經過 ASR,不把內容當成轉寫,也不回答其中的問題。","style.pack.selectionPromptEditorDesc":"目前編輯選區潤色 Prompt;輸入對象是使用者主動選中的書面文字,不經過 ASR。","style.pack.dictationPromptEditorDesc":"目前編輯錄音 / ASR 風格 Prompt;輸入對象是語音辨識後的轉寫文本。","style.pack.dictationPromptTitle":"錄音 / ASR Prompt","style.pack.dictationPromptHint":"用於錄音轉寫後的 ASR 文本;這裡可以寫口語整理、ASR 錯字糾正和專有名詞還原規則。","style.pack.selectionPromptFallback":"尚未配置書面潤色 Prompt;將使用安全預設規則。","style.pack.selectionActivated":"已將「{{name}}」用於選區潤色","style.pack.selectionActivateFailed":"選區潤色風格切換失敗:{{err}}","style.pack.selectionChars":"{{count}} 字元","style.pack.kicker":"風格包","style.pack.title":"風格包","style.pack.desc":"管理本機風格包。","style.pack.marketplaceBtn":"風格市場","style.pack.loadFailed":"載入風格包失敗:{{err}}","style.pack.importZip":"匯入 ZIP","style.pack.exportZip":"匯出 ZIP","style.pack.exportShort":"匯出","style.pack.publishMarketplace":"發布到風格市場","style.pack.updateMarketplace":"更新到風格市場新版本","style.pack.publishDisabledHint":"請先在 設定 → 風格市場 設定 GitHub 使用者名稱","style.pack.publishSuccess":"發布成功,等待 marketplace 審核","style.pack.publishFailed":"發布失敗:{{err}}","style.pack.publishBuiltinRejected":"內建風格包不能直接發布,請先編輯產生一份匯入版。","style.pack.builtin":"內建","style.pack.imported":"匯入","style.pack.active":"目前","style.pack.activate":"啟用","style.pack.edit":"編輯","style.pack.closeEditor":"關閉","style.pack.unsaved":"未儲存","style.pack.listTitle":"本機風格包","style.pack.listDesc":"瀏覽和切換風格包。","style.pack.listCount":"{{count}} 個風格包","style.pack.addPackTileTitle":"新建風格包","style.pack.addPackTileHint":"從空白範本開始。","style.pack.createSuccess":"已建立新風格包","style.pack.createFailed":"建立風格包失敗:{{err}}","style.pack.save":"儲存","style.pack.revert":"還原","style.pack.saveSuccess":"風格包已儲存","style.pack.saveFailed":"儲存風格包失敗:{{err}}","style.pack.activateSuccess":"已將\"{{name}}\"設為目前風格","style.pack.activateFailed":"設為目前風格失敗:{{err}}","style.pack.importSuccess":"已匯入\"{{name}}\"","style.pack.importFailed":"匯入 ZIP 失敗:{{err}}","style.pack.exportSuccess":"已匯出到 {{path}}","style.pack.exportFailed":"匯出 ZIP 失敗:{{err}}","style.pack.exportDirtyFirst":"請先儲存目前風格包,再匯出 ZIP。","style.pack.resetBuiltin":"重設","style.pack.resetSuccess":"已重設\"{{name}}\"","style.pack.resetFailed":"重設風格包失敗:{{err}}","style.pack.deleteImported":"刪除","style.pack.deleteConfirm":"確定刪除\"{{name}}\"嗎?刪除後無法復原。","style.pack.deleteSuccess":"已刪除\"{{name}}\"","style.pack.deleteFailed":"刪除風格包失敗:{{err}}","style.pack.summaryCurrentEmpty":"還沒有選中風格包","style.pack.editorTitle":"編輯風格","style.pack.editorDesc":"編輯目前風格包。","style.pack.metaTitle":"安裝資訊","style.pack.metaSource":"來源","style.pack.metaBaseMode":"基礎模式","style.pack.metaUpdatedAt":"更新時間","style.pack.fieldName":"名稱","style.pack.fieldAuthor":"作者","style.pack.fieldAuthorPlaceholder":"可選,方便標註來源","style.pack.fieldVersion":"版本","style.pack.fieldTags":"標籤","style.pack.fieldTagsPlaceholder":"用英文逗號分隔,例如 community, voiceover, formal","style.pack.fieldDescription":"描述","style.pack.fieldModel":"建議模型(僅元資料)","style.pack.fieldModelPlaceholder":"可選,例如 gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"僅作說明,不會切換實際模型。","style.pack.fieldCompatibility":"相容版本","style.pack.fieldCompatibilityPlaceholder":"可選,例如 >=1.3.0","style.pack.fullPromptTitle":"System Prompt","style.pack.fullPromptHint":"這就是這套風格包自己的 Prompt。","style.pack.promptChars":"{{count}} 字元","style.pack.runtimeTitle":"OpenLess 執行時附加指令","style.pack.runtimeDesc":"只讀的執行時輔助項。","style.pack.runtimeContextTitle":"上下文前提","style.pack.runtimeContextDesc":"來自語言與應用上下文","style.pack.runtimeContextEmpty":"目前不會附加","style.pack.runtimeHotwordTitle":"熱詞提示段","style.pack.runtimeHotwordDesc":"來自已啟用熱詞","style.pack.runtimeHotwordEmpty":"目前不會附加","style.pack.runtimeHistoryTitle":"多輪歷史保護段","style.pack.runtimeHistoryDesc":"僅用於即時多輪 polish","style.pack.runtimeHistoryEmpty":"只有存在 prior turns 時才會附加","style.pack.runtimeActive":"目前生效","style.pack.runtimeInactive":"目前未生效","style.pack.runtimePreviewFailed":"產生執行時預覽失敗:{{err}}","style.pack.runtimePreviewOmittedFrontApp":"預覽已省略前台 app 標籤。","style.pack.examplesTitle":"效果範例","style.pack.examplesDesc":"會隨風格包一起匯出。","style.pack.addExample":"新增範例","style.pack.examplesEmpty":"還沒有範例。","style.pack.exampleTitlePlaceholder":"範例 {{index}} 標題","style.pack.exampleInput":"輸入","style.pack.exampleOutput":"輸出","style.pack.examplesCount":"{{count}} 個範例","style.pack.discardCloseConfirm":"關閉編輯面板前要捨棄未儲存修改嗎?","style.pack.discardSwitchConfirm":"要捨棄目前未儲存修改,並切換到\"{{name}}\"嗎?","style.pack.derivativeBadge":"衍生自 @{{login}}","translation.searchLanguages":"搜尋語言…","translation.noMatchingLanguages":"沒有符合的語言","translation.selectedLanguages":"已選擇 {{count}} 種語言","translation.languageSupportHint":"語音服務支援的語種可能不同;翻譯目標不受介面語言限制。","translation.kicker":"翻譯","translation.title":"翻譯","translation.desc":"錄音後自動翻譯為目標語言再插入。","translation.statusEnabled":"已啓用","translation.statusDisabled":"未啓用","translation.working.title":"工作語言","translation.working.desc":"勾選日常使用的語言,影響潤色與翻譯效果。","translation.target.title":"翻譯目標語言","translation.target.desc":"錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。","translation.target.disabled":"不啓用(Shift 按下不觸發翻譯)","translation.target.sameAsWorking":"目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。","translation.style.title":"翻譯風格","translation.style.desc":"自動沿用「風格」頁目前啓用的風格包。","translation.style.unavailable":"暫時無法取得","translation.save.workingFailed":"工作語言保存失敗,請重試。","translation.save.targetFailed":"翻譯目標語言保存失敗,請重試。","translation.save.hotkeyRegisterFailed":"翻譯快捷鍵註冊失敗,未繼續保存。","translation.save.hotkeySaveFailed":"翻譯快捷鍵保存失敗,請重試。","translation.howto.title":"使用方法","translation.howto.step1":"在任意輸入框聚焦游標。","translation.howto.step2":"按 {{trigger}} 開始錄音。","translation.howto.step3":"錄音中按一下 {{shortcut}} 啟動翻譯。","translation.howto.step4":"再按 {{trigger}} 停止錄音。","translation.howto.step5":"翻譯結果自動插入到游標位置。","translation.howto.indicatorTitle":"怎麼知道翻譯模式生效了","translation.howto.indicatorDesc":"按 Shift 後螢幕底部會顯示藍色「正在翻譯」標識。","translation.howto.fallbackTitle":"安全兜底","translation.howto.fallbackDesc":"翻譯失敗時回退為插入原始轉寫,不會丟字。","selectionAsk.title":"劃詞追問","selectionAsk.desc":"選中文字後語音提問,支援多輪追問。","selectionAsk.shortcutSettings":"快捷鍵設定","selectionAsk.guide.openTitle":"開啟追問浮窗","selectionAsk.guide.openDesc":"按 {{hotkey}},開始一輪對話。","selectionAsk.guide.unsetDesc":"先到快捷鍵設定中,為劃詞追問設定快捷鍵。","selectionAsk.guide.selectTitle":"選取想了解的內容","selectionAsk.guide.askTitle":"開口說出問題","selectionAsk.guide.askDesc":"按 {{recordHotkey}} 錄音,再按一次提交。","selectionAsk.guide.followup":"繼續使用錄音快捷鍵,即可多輪追問。","selectionAsk.guide.dismiss":"關閉浮窗,結束本次對話","selectionAsk.hotkey.title":"彈出浮窗的快捷鍵","selectionAsk.save.historySaveFailed":"Q&A 歷史保存設置保存失敗,請重試。","selectionAsk.history.title":"保存歷史","selectionAsk.history.desc":"開啟後在本地保存問答記錄,預設關閉。","selectionAsk.howto.title":"使用方法","selectionAsk.howto.step2":"在任意 app 選中文字。","settings.selectionWorkspace.title":"選區助手","settings.selectionWorkspace.hint":"選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。","settings.selectionWorkspace.polishHotkey":"選區助手快捷鍵","settings.selectionWorkspace.polishHotkeyDesc":"關閉語音編輯時直接潤色;開啟語音編輯時按住口述指令(錄音方式跟隨全域設定)。","settings.selectionWorkspace.polishDelivery":"結果處理","settings.selectionWorkspace.voiceDeliveryDesc":"語音編輯完成後:直接替換選區,或在華詞面板中預覽後再確認。","settings.selectionWorkspace.voiceEnable":"語音編輯","settings.selectionWorkspace.voiceEnableDesc":"與上方同一快捷鍵;錄音方式跟隨全域設定(目前:{{recordingLabel}})。","settings.selectionWorkspace.autoIntent":"自動判斷意圖","settings.selectionWorkspace.autoIntentDesc":"開啟後預設用服務配置的模型判斷問句 vs 編輯;模型不可用或解析失敗時回退到問句啟發式。","settings.selectionWorkspace.editKeywords":"額外問句線索","settings.selectionWorkspace.editKeywordsDesc":"關閉自動判斷時生效;每行一個,指令含則視為提問,否則仍按問句啟發式判定。","settings.selectionPolish.title":"選區潤色","settings.selectionPolish.hotkey":"觸發快捷鍵","settings.selectionPolish.hotkeyDesc":"錄製後立即生效;與錄音、追問等全域快捷鍵衝突時會被拒絕。","settings.selectionPolish.delivery":"結果處理方式","settings.selectionPolish.hint":"選取任意文字後觸發。它不依賴麥克風或 ASR,使用目前風格包與獨立的選區 Prompt。","settings.selectionPolish.directReplace":"直接覆蓋","settings.selectionPolish.directReplaceHint":"模型完成後安全替換原選區。","settings.selectionPolish.previewConfirm":"預覽確認","settings.selectionPolish.previewConfirmHint":"在可編輯彈窗中核對結果,再確認覆蓋原選區。","settings.kicker":"設定","settings.title":"設置","settings.desc":"錄音、提供商、快捷鍵與權限配置。","settings.network.title":"網路","settings.network.useSystemProxyLabel":"使用系統代理","settings.network.useSystemProxyDesc":"開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。","settings.dataStorage.title":"資料儲存","settings.dataStorage.desc":"本機保留的歷史會話與對話上下文。","settings.dataStorage.cursorContextLabel":"游標上下文(實驗)","settings.dataStorage.cursorContextDesc":"潤稿時讀取你正在寫的那篇文件中游標附近的原文,幫模型判斷同音詞、專有名詞與代詞該怎麼寫。開啟後這段文字會隨請求送給你設定的 LLM 服務商;關閉時一個字都不讀。密碼輸入框、Secure Input、密碼管理器與終端機始終不讀。僅 macOS。","settings.codingConsole.title":"Claude 主控台","settings.codingConsole.desc":"偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。","settings.codingConsole.guardNote":"預設放行可復原操作;rm -rf / sudo / 強制推送等高風險指令會被攔截;若工作目錄為 git 儲存庫,執行前自動建立快照可回滾。","settings.codingConsole.status":"狀態","settings.codingConsole.detect":"偵測","settings.codingConsole.detecting":"偵測中…","settings.codingConsole.installed":"已偵測到 Claude","settings.codingConsole.notInstalled":"未偵測到 claude","settings.codingConsole.notInstalledHint":"請先安裝 Claude Code(參見 docs.anthropic.com/claude-code),或在下方填入其執行檔完整路徑。","settings.codingConsole.mcpServers":"已設定 {{count}} 個 MCP 服務","settings.codingConsole.computerUsePresent":"已設定桌面控制(computer use)MCP","settings.codingConsole.computerUseAbsent":"未設定桌面控制 MCP(複製/貼上等輕動作用 Bash 即可,無需此項)","settings.codingConsole.exePath":"執行檔","settings.codingConsole.workdir":"工作目錄","settings.codingConsole.workdirDesc":"選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。","settings.codingConsole.workdirPlaceholder":"留空則於暫存目錄執行","settings.codingConsole.permissionMode":"權限模式","settings.codingConsole.mode.acceptEdits":"放行(可復原操作)","settings.codingConsole.mode.plan":"唯讀 / 計畫","settings.codingConsole.mode.default":"預設(逐項確認)","settings.codingConsole.mode.bypassPermissions":"完全放行(高風險)","settings.codingConsole.promptPlaceholder":"讓 Claude 做點什麼,例如:列出目前目錄的檔名","settings.codingConsole.run":"執行","settings.codingConsole.running":"執行中…","settings.codingConsole.cancel":"取消","settings.codingConsole.clear":"清空","settings.codingConsole.riskWarn":"偵測到高風險意圖:{{reason}}。護欄會在執行層攔截高風險指令。","settings.codingConsole.toolUse":"呼叫工具 {{name}}","settings.codingConsole.done":"完成","settings.codingConsole.doneCost":"完成 · 用量 ${{cost}}","settings.codingConsole.cancelled":"已取消","settings.codingConsole.outputPlaceholder":"輸出會串流顯示在這裡…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。","settings.codingAgent.enable":"啟用 Less Computer","settings.codingAgent.comingSoonNote":"設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。","settings.codingAgent.hotkeyHint":"開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。","settings.codingAgent.voiceHotkey":"按住說話鍵","settings.codingAgent.voiceHotkeyDesc":"按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。","settings.codingAgent.provider":"Agent 後端","settings.codingAgent.opencodeReady":"已偵測到 OpenCode v{{version}}。","settings.codingAgent.opencodeMissing":"未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。","settings.codingAgent.cliReady":"已偵測到 {{name}} v{{version}}。","settings.codingAgent.cliMissing":"未偵測到 {{name}} 指令。請先安裝並登入,或在下方「執行檔」欄填它的絕對路徑。","settings.codingAgent.sandboxGuardHint":"此後端只有粗粒度沙箱層級,沒有逐指令的高風險清單:碰到限制時會直接據實回報錯誤,不會跳出「核准這條指令」的卡片。","settings.codingAgent.codexModelHint":"填 Codex 的模型名稱(如 gpt-5);留空則使用 ~/.codex/config.toml 的設定。","settings.codingAgent.codexBudgetHint":"Codex 沒有單次美元預算上限;費用取決於你設定的服務商。","settings.codingAgent.codexMode.plan":"唯讀 / 計畫","settings.codingAgent.codexMode.workspaceWrite":"允許工作目錄寫入","settings.codingAgent.codexModelPlaceholder":"留空 = 使用 Codex 自己的預設","settings.codingAgent.dshModelHint":"dsh 的 headless 設定沒有模型開關:模型由 dsh 自己的 profile 決定,這裡改不了。","settings.codingAgent.panelHotkey":"面板鍵(語音 Agent)","settings.codingAgent.panelHotkeyDesc":"錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。","settings.codingAgent.quickHotkey":"快取用鍵","settings.codingAgent.quickHotkeyDesc":"取目前選取文字 → Claude → 結果回插游標處。不開面板、更快。","settings.codingAgent.model":"模型","settings.codingAgent.modelPlaceholder":"預設 sonnet","settings.codingAgent.modelDefault":"預設(自動 sonnet)","settings.codingAgent.modelHint":"Haiku 最快 · Sonnet 均衡 · Opus 最強","settings.codingAgent.opencodeModelDefault":"使用 OpenCode 預設模型","settings.codingAgent.opencodeModelHint":"自動拉取 OpenCode 目前帳號可用的 provider/model;選取後立即儲存。","settings.codingAgent.opencodeModelsRefresh":"重新拉取模型","settings.codingAgent.opencodeModelsRefreshing":"正在拉取 OpenCode 模型…","settings.codingAgent.opencodeModelsLoaded":"已拉取 {{count}} 個模型。","settings.codingAgent.opencodeModelsEmpty":"沒有回傳可用模型,請先完成 OpenCode 登入或設定模型提供商。","settings.codingAgent.opencodeModelsError":"拉取模型失敗:{{message}}","settings.codingAgent.exe":"可執行檔路徑","settings.codingAgent.openPanel":"文字測試","settings.codingAgent.openPanelHint":"直接開啟 Less Computer 浮窗,以文字驗證目前的 Agent 與模型設定。","settings.codingAgent.openPanelAction":"開啟 Less Computer","settings.debug.cursorLabel":"游標","settings.debug.title":"除錯工具","settings.debug.desc":"排查辨識問題時使用,平時無需開啟。","settings.debug.cursorProbeLabel":"游標上下文探針","settings.debug.cursorProbeDesc":"點一下,然後在倒數內切到目標 app 並點進輸入框——探針會讀那裡的游標附近原文,用來確認哪些 app 讀得到、哪些被安全閘門擋住。只讀一次,不送給任何服務商。","settings.debug.cursorProbeBtn":"探測(5 秒後)","settings.debug.cursorProbeCountdown":"{{n}} 秒後讀取…","settings.marketplace.title":"擴充市集","settings.marketplace.desc":"風格市集的上傳身份。瀏覽與安裝風格在「風格」頁內完成。","settings.marketplace.github.signIn":"用 GitHub 帳號登入","settings.marketplace.github.signedIn":"已透過 GitHub 登入","settings.marketplace.github.signedOut":"登入後即可上傳風格、為風格按讚。","settings.marketplace.github.signOut":"登出","settings.marketplace.github.starting":"正在發起登入…","settings.marketplace.github.codeHint":"在開啟的 GitHub 頁面輸入這個驗證碼:","settings.marketplace.github.openGithub":"開啟 GitHub","settings.marketplace.github.waiting":"已開啟 GitHub,完成授權後會自動登入…","settings.marketplace.github.failed":"登入失敗,請重試","settings.recording.title":"錄音與輸入","settings.recording.desc":"定義全局錄音的快捷鍵與觸發方式。","settings.recording.hotkeyLabel":"錄音快捷鍵","settings.recording.hotkeyDescAcc":"按下即開始捕獲語音,全局生效。需要授予輔助功能權限。","settings.recording.hotkeyDescNoAcc":"按下即開始捕獲語音,全局生效。無需額外輔助功能授權。","settings.recording.modeLabel":"錄音方式","settings.recording.modeDesc":"切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。","settings.recording.modeToggle":"切換式","settings.recording.modeHold":"按住說話","settings.recording.modeAuto":"自動","settings.recording.silenceAutoStopLabel":"靜音後自動停止","settings.recording.silenceAutoStopDesc":"僅切換模式生效。偵測到語音後,連續靜音達到所選時長即自動結束並提交;一直沒說話則 10 秒後取消。預設關閉;第二次按鍵停止和 Esc 取消仍然有效。","settings.recording.silenceAutoStopSecondsLabel":"靜音時長","settings.recording.silenceAutoStopSecondsValue":"{{value}} 秒","settings.recording.migrationNoticeTitle":"默認已改爲切換式說話","settings.recording.migrationNoticeDesc":"如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。","settings.recording.comboRecordLabel":"錄製快捷鍵","settings.recording.comboRecordDesc":"點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。","settings.recording.comboRecordBtn":"錄製快捷鍵","settings.recording.comboResetBtn":"重置","settings.recording.comboMenuToggle":"更多操作","settings.recording.comboDisableHint":"核心快捷鍵不可停用,錄音必須綁定一個快捷鍵","settings.recording.comboRecordHint":"請按下快捷鍵組合…","settings.recording.comboNeedKey":"請設定組合鍵(如 ⌘⇧J),不支援單獨的修飾鍵","settings.recording.comboRecorded":"已錄製","settings.recording.comboClear":"清除","settings.recording.comboConflict":"此快捷鍵組合不可用","settings.recording.microphoneLabel":"首選麥克風","settings.recording.microphoneDesc":"選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。","settings.recording.microphoneDefault":"系統默認麥克風","settings.recording.microphoneDefaultDesc":"使用系統默認輸入設備","settings.recording.microphoneSystemDefault":"系統默認","settings.recording.microphoneUnavailable":"不可用","settings.recording.microphoneLoadError":"麥克風列表讀取失敗:{{message}}","settings.recording.microphoneDialogTitle":"麥克風","settings.recording.microphoneDialogDesc":"選擇能捕捉到您聲音的麥克風。如果指示條沒有移動,請嘗試其他麥克風。","settings.recording.microphoneMonitorError":"輸入電平監聽失敗:{{message}}","settings.recording.capsuleLabel":"錄音膠囊","settings.recording.capsuleDesc":"錄音 / 轉寫時在屏幕底部顯示半透明膠囊。","settings.recording.capsuleStyleTypeless":"Typeless 傳統風格","settings.recording.capsuleStyleLabel":"膠囊樣式","settings.recording.capsuleStyleSiri":"流光 Siri 風格","settings.recording.capsuleStyleClassic":"Openless 預設風格","settings.recording.muteDuringRecordingLabel":"錄音時靜音","settings.recording.muteDuringRecordingDesc":"錄音期間臨時靜音系統輸出,避免揚聲器回音。","settings.recording.audioCueLabel":"錄音提示音","settings.recording.audioCueDesc":"按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。","settings.recording.audioCuePreview":"試聽","settings.recording.insertGroupTitle":"插入與剪貼板","settings.recording.restoreClipboardLabel":"插入後恢復剪貼板","settings.recording.restoreClipboardDesc":"粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。","settings.recording.pasteShortcutLabel":"模擬粘貼快捷鍵","settings.recording.pasteShortcutDesc":"插入時模擬按下的粘貼鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。","settings.recording.pasteShortcutCtrlV":"Ctrl+V(默認 / 多數應用)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert(xterm / urxvt)","settings.recording.allowNonTsfFallbackLabel":"允許非 TSF 兜底","settings.recording.allowNonTsfFallbackDesc":"Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。","settings.recording.windowsInsertionModeLabel":"Windows 插入方式","settings.recording.windowsInsertionModeDesc":"聽寫結果如何插入到目前游標位置。剪貼簿貼上模式使用上方「模擬粘貼快捷鍵」,可完整保留換行。","settings.recording.windowsInsertionModeTsf":"TSF 輸入法(預設)","settings.recording.windowsInsertionModeSendInput":"SendInput 逐字模擬","settings.recording.windowsInsertionModePaste":"剪貼簿貼上(Ctrl+V 等)","settings.recording.macosNewlineModeLabel":"換行怎麼落","settings.recording.macosNewlineModeDesc":"自動會在已知終端應用中使用 Line Feed(U+000A / Ctrl+J),其他應用使用 Shift+Return;Return 會直接發送。","settings.recording.macosNewlineModeAuto":"自動(終端使用 Line Feed)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return(聊天框換行)","settings.recording.macosNewlineModeLineFeed":"Line Feed(終端 CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return(拆成多條訊息)","settings.recording.windowsSendInputNewlineModeLabel":"SendInput 換行模擬","settings.recording.windowsSendInputNewlineModeDesc":"SendInput 模式下如何把換行符模擬成按鍵。聊天框通常選 Shift+Enter;記事本 / VS Code 等選 Enter。","settings.recording.windowsSendInputNewlineModeEnter":"Enter(多數編輯器)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter(聊天輸入框)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"在鍵盤列表中顯示 OpenLess","settings.recording.windowsShowOpenlessInKeyboardListDesc":"關閉後 Win+Space 切換輸入法時不會出現 OpenLess;SendInput 與剪貼簿貼上插入不受影響。重新開啟本項可恢復顯示。","settings.recording.windowsShowOpenlessInKeyboardListError":"無法更新鍵盤列表:系統拒絕更改 OpenLess 語言設定檔。","settings.recording.historyGroupTitle":"歷史與上下文","settings.recording.historyRetentionLabel":"歷史保留天數","settings.recording.historyRetentionDesc":"超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。","settings.recording.historyMaxEntriesLabel":"歷史條數上限","settings.recording.historyMaxEntriesDesc":"本地保留會話上限,留空 = 200。範圍 5–200。","settings.recording.polishContextWindowLabel":"對話上下文窗口(分鐘)","settings.recording.polishContextWindowDesc":"把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。","settings.recording.recordAudioForDebugLabel":"保留原始錄音(除錯)","settings.recording.recordAudioForDebugDesc":"保存原始麥克風音訊為 wav,便於排查識別問題。","settings.recording.audioRecordingMaxEntriesLabel":"原始錄音保留條數","settings.recording.audioRecordingMaxEntriesDesc":"本地保留 wav 檔案數上限,留空 = 200。","settings.recording.startupGroupTitle":"啟動","settings.recording.startMinimizedLabel":"啓動時靜默運行","settings.recording.startMinimizedDesc":"所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。","settings.recording.autoUpdateCheckLabel":"自動檢查更新","settings.recording.autoUpdateCheckDesc":"啟動時及每 60 分鐘自動檢查更新。","settings.recording.marketplaceGroupTitle":"風格市場","settings.recording.marketplaceBaseUrlLabel":"雲端服務位址","settings.recording.marketplaceBaseUrlDesc":"風格市場後端 URL,留空使用預設值。","settings.recording.marketplaceDevLoginLabel":"GitHub 使用者名稱(上傳身份)","settings.recording.marketplaceDevLoginDesc":"標識上傳者身分,為空時無法上傳或按讚。","settings.recording.startupAtBoot":"開機自啓","settings.recording.startupAtBootDesc":"登錄系統時自動啓動 OpenLess。","settings.recording.startupAtBootError":"開機自啓切換失敗:{{message}}","settings.channels.backToList":"返回渠道列表","settings.channels.done":"完成","settings.channels.llmTitle":"文字處理渠道","settings.channels.asrTitle":"語音辨識渠道","settings.channels.current":"目前使用","settings.channels.enabled":"啟用","settings.channels.disabled":"已停用","settings.channels.enabledFor":"啟用 {{name}}","settings.channels.modelNotSet":"未單獨設定模型","settings.channels.localModelManaged":"模型由系統或「本地模型」頁管理","settings.channels.lastCheck":"上次驗證","settings.channels.verifying":"正在驗證…","settings.channels.notVerified":"尚未驗證","settings.channels.passed":"驗證通過","settings.channels.failed":"驗證失敗 · {{reason}}","settings.channels.elapsed":"耗時 {{ms}} ms","settings.channels.staleResult":"結果已超過 24 小時","settings.channels.connectionTitle":"服務連線","settings.channels.modelTitle":"模型設定","settings.channels.modelHint":"直接輸入模型名稱,或取得並選擇供應商的可用模型。","settings.channels.availableModels":"可用模型","settings.channels.validationTitle":"連線驗證","settings.channels.validationHint":"手動發出一次實際請求,檢查目前設定;可能消耗服務額度。儲存設定不會自動驗證。","settings.channels.autoSaveHint":"修改欄位後會自動儲存;完成設定後,可手動驗證連線。","settings.channels.nameHint":"名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。","settings.channels.errModel":"模型","settings.channels.verify":"驗證","settings.channels.verifyHint":"點一下會真實呼叫一次介面,確認這張卡現在可用","settings.channels.errTimeout":"逾時","settings.channels.errNetwork":"網路","settings.channels.errEndpoint":"網址","settings.channels.errGeneric":"失敗","settings.channels.dragHint":"按住拖曳可調整優先順序","settings.channels.orderHint":"請求會使用列表中第一個啟用的渠道。拖曳可調整順序;停用的渠道會移到末尾。","settings.channels.empty":"還沒有渠道。點選「新增渠道」,連接你的第一個服務。","settings.channels.add":"新增渠道","settings.channels.edit":"編輯","settings.channels.createTitle":"新增渠道","settings.channels.editTitle":"編輯渠道","settings.channels.providerLabel":"供應商","settings.channels.nameLabel":"渠道名稱(選填)","settings.channels.namePlaceholder":"例如:矽基流動-主帳號","settings.channels.create":"建立","settings.channels.delete":"刪除渠道","settings.channels.deleteConfirm":"刪除後該渠道儲存的金鑰也會一併清除。","settings.channels.confirmDelete":"確認刪除","settings.channels.justNow":"剛剛","settings.channels.minutesAgo":"{{count}} 分鐘前","settings.channels.hoursAgo":"{{count}} 小時前","settings.channels.daysAgo":"{{count}} 天前","settings.channels.localEngineModelHint":"可在「AI 服務與模型 → 本地模型」中下載和切換本地模型。","settings.providers.localEngineNoCredentials":"本機引擎不需要 API Key 與網址。","settings.providers.localModelLabel":"本地模型","settings.providers.localModelEmpty":"尚未下載本地模型","settings.providers.appleSpeechLocalNote":"Apple 語音辨識使用系統內建引擎,無需選擇模型。","settings.providers.localEngineNote":"已下載的本地模型在上方下拉中直接選擇;更多模型在「本地模型」看板下載與管理。","settings.providers.localTag":"本地","settings.providers.llmTitle":"LLM 模型(潤色)","settings.providers.llmDesc":"OpenAI 兼容協議,支持多家供應商切換。","settings.providers.providerLabel":"供應商","settings.providers.llmProviderDesc":"選擇後將自動填入 Base URL 默認值。","settings.providers.credentialStorageNotice":"憑據保存在系統憑據庫中。","settings.providers.codexOAuthNotice":"Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。","settings.providers.asrProviderDesc":"切換後將自動選用對應憑據。","settings.providers.asrTitle":"ASR 語音(轉寫)","settings.providers.asrDesc":"用於將錄製的語音轉寫為文字。","settings.providers.omniTitle":"多模態模型","settings.providers.omniDesc":"一個模型直接接收「提示詞 + 音訊」一步輸出最終文字(實驗性管線)。","settings.providers.pipelineModeLabel":"識別管線","settings.providers.pipelineModeHint":"傳統 = ASR 轉寫 + LLM 潤色兩段式;多模態 = 單一多模態模型一次完成。","settings.providers.pipelineModeTraditional":"傳統模式","settings.providers.pipelineModeMultimodal":"多模態模式","settings.providers.pipelineIsolationNotice":"兩種模式使用完全獨立的憑證設定。切換模式不會刪除另一套設定,只是暫時停用;切回即恢復。","settings.providers.presets.ark":"ARK(火山方舟)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"硅基流動","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"小米 MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter(免費模型)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"阿里雲 Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax(M3)","settings.providers.presets.stepfun":"StepFun(階躍星辰)","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"騰訊雲 TokenHub","settings.providers.presets.customChatCompletions":"自訂 · Chat Completions","settings.providers.presets.customResponses":"自訂 · Responses","settings.providers.presets.customMessages":"自訂 · Messages","settings.providers.presets.custom":"自定義","settings.providers.presets.asrVolcengine":"火山引擎 bigasr","settings.providers.presets.asrBailian":"阿里雲百煉即時 ASR","settings.providers.presets.asrBailianQwen3":"阿里雲百煉 Qwen3 即時 ASR","settings.providers.presets.asrBailianFunAsrFlash":"阿里雲百煉 Fun-ASR-Flash(錄音檔)","settings.providers.presets.asrSiliconflow":"硅基流動 SenseVoice","settings.providers.presets.asrStepfun":"階躍星辰 StepAudio","settings.providers.presets.asrZhipu":"智譜 GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper(兼容)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"自訂 OpenAI 相容","settings.providers.presets.asrXiaomiMimo":"小米 MiMo ASR","settings.providers.presets.asrIflytek":"訊飛即時語音轉寫","settings.providers.presets.asrTencentCloud":"騰訊雲混元即時 ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"本地 sherpa-onnx(實驗性)","settings.providers.presets.asrFoundryLocalWhisper":"本地 Whisper(Foundry Local)","settings.providers.presets.asrLocalWhisper":"本地 Whisper(批次解碼)","settings.providers.presets.asrLocalQwen3":"本地 Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"本地 Qwen3-ASR(MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"本地 Qwen3-ASR(C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple 語音(macOS)","settings.providers.presets.omniOpenai":"OpenAI(支援音訊)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"阿里雲百煉 Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs 會將錄音上傳至已設定的端點進行批次轉寫。","settings.providers.zenmuxVocabularyNote":"ZenMux 走 JSON 轉寫協定,不傳送詞典熱詞(prompt/hotwords);詞典仍會進入潤色鏈路,但不會參與語音辨識偏置。","settings.providers.asrAdvancedNote":"以下進階選項僅影響「自訂 OpenAI 相容」與「ZenMux」預設;其餘具名廠商預設維持內建行為。","settings.providers.asrAdvancedVerboseJsonLabel":"分段指標 (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"服務端支援時回傳 segments 指標,用於幻聽過濾;自建服務若不支援請保持關閉。","settings.providers.asrAdvancedChunkLabel":"分片時長 (ms)","settings.providers.asrAdvancedChunkHint":"0 = 不分片,整段傳送;按片段多次請求,適合長錄音或服務端單次請求時長受限。","settings.providers.asrAdvancedEnableItnLabel":"數字正規化 (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"把口語數字/單位正規化為阿拉伯數字(如「二零二六年」→「2026年」)。關閉後保留原始文字。","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API Key","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"鑑權模式","settings.providers.volcengineAuthModeAppIdToken":"舊版應用(APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"新版控制台 API Key","settings.providers.volcengineMappingNote":"Secret Key 當前無需填寫。Resource ID 默認使用 volc.seedasr.sauc.duration。","settings.providers.volcengineApiKeyNote":"使用新版語音控制台建立的 API Key 鑑權,無需 APP ID。API Key 可在語音控制台「API Key 管理」建立:console.volcengine.com/speech/new/setting/apikeys。Resource ID 預設使用 volc.seedasr.sauc.duration。","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API Key","settings.providers.xfyunNote":"在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。","settings.providers.tencentCloudAppIdLabel":"騰訊雲 AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"使用騰訊雲「語音辨識」服務的 API 金鑰。預設 Hy-ASR-3.0-preview 支援中英與 20 種方言;Preview 僅支援 60 秒內的 16kHz 單聲道 PCM,暫不支援上下文或熱詞增強。","settings.providers.tencentTokenHubNote":"僅顯示目前在線的語言模型。部分模型始終啟用思考;關閉思考開關時將沿用該模型的固定行為。","settings.providers.localAsrActiveNotice":"當前已啓用「{{name}}」,可在「高級」中切換或停用。","settings.providers.localAsrTakeoverHint":"啓動「{{name}}」後,ASR 提供商將被接管。","settings.providers.asrProviderTakenOver":"目前使用的是本地引擎,在上方下拉直接選其他供應商即可切換(本地引擎會自動停用);本地模型在「服務 → 本地模型」裡管理。","settings.providers.localAsrHint":"在本機運行,無需 API Key。從 HuggingFace 下載模型即可使用。","settings.providers.foundryLocalAsrHint":"在本機運行,無需 ASR API Key。首次使用需下載運行元件和模型。","settings.providers.localAsrPerformanceWarning":"本地推理比雲端慢,中文準確率可能更低。適合離線或隱私敏感場景。","settings.providers.localAsrReady":"{{model}} 已下載","settings.providers.localAsrNotReady":"{{model}} 未下載","settings.providers.localAsrGoDownload":"前往模型設置下載","settings.providers.localAsrManage":"前往模型設置","settings.providers.localAsrDownloadedTitle":"已下載模型","settings.providers.localAsrDelete":"刪除","settings.providers.fillDefault":"填入默認值","settings.providers.readFailed":"讀取失敗","settings.providers.apiKeyLabel":"API 密鑰","settings.providers.baseUrlLabel":"接口地址","settings.providers.modelLabel":"模型","settings.providers.customModelLabel":"自訂模型…","settings.providers.presetListLabel":"返回預設清單","settings.providers.searchModels":"搜尋模型…","settings.providers.noMatchingModels":"沒有符合的模型","settings.providers.orcarouterCatalogHint":"模型來自 OrcaRouter /models;此供應商只允許從目錄中選擇,不支援手動填寫模型 ID。","settings.providers.orcarouterAsrCatalogHint":"模型來自 OrcaRouter /models,並僅顯示相容音訊輸入的 Gemini;不支援手動填寫模型 ID。","settings.providers.temperatureLabel":"Temperature","settings.providers.temperaturePlaceholder":"留空則不發送;範圍 0~2(含邊界),例如 0.3","settings.providers.extraHeadersLabel":"額外 Headers","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"思考","settings.providers.thinkingModeOn":"開啟","settings.providers.thinkingModeOff":"關閉","settings.providers.requestFormatLabel":"請求格式","settings.providers.messagesThinkingLabel":"思考方式","settings.providers.thinkingAdaptive":"自適應","settings.providers.thinkingBudget":"固定預算","settings.providers.maxTokensLabel":"最大輸出 tokens","settings.providers.thinkingBudgetLabel":"思考預算 tokens","settings.providers.responsesThinkingHint":"部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度參數。","settings.providers.messagesThinkingHint":"舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度參數。","settings.providers.llmRequestFormatInvalid":"請求格式無效,請重新選擇。","settings.providers.llmThinkingModeInvalid":"思考方式無效,請重新選擇。","settings.providers.llmTokenLimitInvalid":"Token 上限必須為正整數。","settings.providers.llmThinkingBudgetInvalid":"思考預算至少為 1024,且固定預算必須小於最大輸出。","settings.providers.llmResponseIncomplete":"回應未完整結束或達到輸出上限;已輸出正文會保留。","settings.providers.llmProtocolHeaderConflict":"Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。","settings.providers.llmStreamError":"伺服器回傳串流錯誤,請檢查模型和請求參數。","settings.providers.saveProtocol":"儲存協定設定","settings.providers.thinkingModeHint":"依所選請求格式與模型支援的參數啟用、關閉或降低思考,不在提示詞注入控制指令。","settings.providers.bailianVocabularyIdLabel":"熱詞 Vocabulary ID(可選)","settings.providers.bailianVocabularyIdNote":"如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。","settings.providers.bailianModelRealtimeHint":"即時模型 · 邊說邊出字。","settings.providers.bailianModelSyncFileHint":"同步錄音模型 · 說完後整段轉寫(單條 ≤ 5 分鐘)。","settings.providers.bailianModelAsyncFileHint":"非同步檔案模型 · 錄音上傳後等待轉寫工作完成。","settings.providers.appIdLabel":"App ID(應用 ID)","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"資源 ID","settings.providers.toolsLabel":"連接檢查","settings.providers.toolsDesc":"先保存上方配置,再驗證當前模型連通性或拉取模型;失敗時仍可手動填寫模型 ID。","settings.providers.validate":"驗證","settings.providers.validating":"驗證中…","settings.providers.fetchModels":"拉取模型","settings.providers.loadingModels":"拉取模型中…","settings.providers.modelMissing":"未配置模型,請先填寫模型 ID。","settings.providers.modelsEmpty":"鑑權成功,但沒有返回可用模型。","settings.providers.modelsLoaded":"已拉取 {{count}} 個模型。","settings.providers.selectModel":"選擇一個模型寫入上方字段","settings.providers.modelSaved":"已保存模型 {{model}}。","settings.providers.validateSuccess":"連接檢查通過。","settings.providers.validateFailed":"連接檢查未通過。","settings.providers.providerHttpStatus":"供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。","settings.providers.endpointMustUseHttps":"允許使用 HTTP Endpoint,但請注意:API Key 和音訊內容可能在傳輸中外洩。","settings.providers.endpointHttpWarning":"允許使用 HTTP Endpoint,但請注意:API Key 和請求內容可能在傳輸中外洩。","settings.providers.endpointInvalid":"Endpoint 格式不合法。","settings.providers.bailianEndpointSchemeInvalid":"百煉即時 ASR 走 DashScope WebSocket 閘道,接口地址必須以 wss:// 開頭(預設 wss://dashscope.aliyuncs.com/api-ws/v1/inference/);https:// 的相容模式地址在此不可用。","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 即時 ASR 走 DashScope Realtime WebSocket 閘道,接口地址必須以 wss:// 開頭(預設 wss://dashscope.aliyuncs.com/api-ws/v1/realtime);https:// 地址在此不可用。","settings.providers.responseTooLarge":"供應商響應過大,已停止驗證以保證安全。","settings.providers.asrInvalidJson":"ASR 響應不是有效 JSON。","settings.providers.asrMissingTextField":"ASR 響應缺少 text 字段。","settings.providers.apiKeyMissing":"API Key 爲空。","settings.providers.endpointMissing":"Endpoint 爲空。","settings.providers.volcengineAppIdMissing":"APP ID 爲空。","settings.providers.volcengineAccessTokenMissing":"Access Token 爲空。","settings.providers.requestTimeout":"請求超時,請稍後重試。","settings.shortcuts.title":"快捷鍵設定","settings.shortcuts.descAcc":"所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。","settings.shortcuts.descNoAcc":"所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。","settings.shortcuts.startStop":"開始 / 停止錄音","settings.shortcuts.cancel":"取消本次錄音","settings.shortcuts.confirm":"膠囊確認插入","settings.shortcuts.switchStyle":"切換到上一個風格","settings.shortcuts.openApp":"打開 OpenLess","settings.shortcuts.stylePackTitle":"風格直達快捷鍵","settings.shortcuts.stylePackDesc":"為常用風格包各配一個快捷鍵,按下直接切換;停用中的包會自動啟用。","settings.shortcuts.stylePackAdd":"新增風格快捷鍵","settings.shortcuts.stylePackSelect":"選擇風格包","settings.shortcuts.stylePackDisabledSuffix":"(已停用)","settings.shortcuts.stylePackRemove":"移除","settings.shortcuts.agentPolish":"選取文字潤色","settings.shortcuts.agentPolishDesc":"選取文字 → 按鍵 → Claude 潤色 → 取代選取。","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"按住自訂按鍵 → 說話 → Claude 執行任務 → 結果彈膠囊顯示。","settings.shortcuts.agentVoiceHint":"在「進階 → Less Computer」裡設定按住說話鍵。","settings.shortcuts.agentVoiceTrigger":"Less Computer 按住說話鍵","settings.shortcuts.enable":"啟用","settings.shortcuts.disable":"停用","settings.shortcuts.confirmHint":"點擊右側 ✓","settings.shortcuts.notSupported":"暫未支持","settings.shortcuts.androidReadOnly":"Android 不支援全域快捷鍵,請在概覽頁使用錄音按鈕。","settings.permissions.title":"權限","settings.permissions.descAcc":"OpenLess 需要以下系統權限才能正常工作。授權後通常需要完全退出 App 重啓一次才生效。","settings.permissions.descNoAcc":"OpenLess 需要麥克風可用,並依賴全局快捷鍵監聽狀態判斷 native hook 是否正常工作。","settings.permissions.micLabel":"麥克風","settings.permissions.micDesc":"用於捕獲你的語音輸入。","settings.permissions.accLabel":"輔助功能","settings.permissions.accDesc":"用於監聽全局快捷鍵並將識別結果寫入光標位置。","settings.permissions.hotkeyLabel":"全局快捷鍵","settings.permissions.hotkeyDescWithAdapter":"當前適配器:{{adapter}}。用於判斷快捷鍵監聽是否已經安裝。","settings.permissions.hotkeyDescPlain":"用於判斷快捷鍵監聽是否已經安裝。","settings.permissions.networkLabel":"網絡","settings.permissions.networkDesc":"雲端 ASR / LLM 調用所必需。本地模式可關閉。","settings.permissions.networkOk":"可用","settings.permissions.networkOffline":"不可用","settings.permissions.checking":"檢查中…","settings.permissions.granted":"已授權","settings.permissions.notApplicable":"無需授權","settings.permissions.denied":"未授權","settings.permissions.indeterminate":"未確定","settings.permissions.micNoDevice":"未偵測到麥克風","settings.permissions.openSystem":"打開系統設置","settings.permissions.restart":"重置授權並重新啟動","settings.permissions.grant":"授權","settings.permissions.rerunAndroidSetup":"重新執行設定向導","settings.permissions.hotkeyInstalled":"已安裝","settings.permissions.hotkeyStarting":"安裝中…","settings.permissions.hotkeyFailed":"監聽失敗","settings.permissions.windowsImeLabel":"Windows 輸入法後端","settings.permissions.windowsImeDesc":"用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。","settings.permissions.windowsImeInstalled":"已安裝","settings.permissions.windowsImeUnavailable":"不可用","settings.permissions.androidImeLabel":"輸入法 (IME)","settings.permissions.androidImeSelected":"已選中","settings.permissions.androidImeEnabled":"已啟用","settings.permissions.androidImeDisabled":"未啟用","settings.permissions.androidOverlayLabel":"懸浮窗","settings.permissions.androidAccessibilityLabel":"無障礙服務","settings.permissions.androidAccessibilityImpact":"開啟後可在不切換鍵盤的情況下把結果輸出到目前輸入框;未開啟時仍會複製到剪貼簿,需要手動貼上。","settings.permissions.androidAccessibilityGrantedStale":"已授權,未連線","settings.permissions.androidAccessibilityMessages.not_android":"無障礙狀態僅在 Android 上可用。","settings.permissions.androidAccessibilityMessages.not_enabled":"請在系統無障礙設定中啟用 OpenLess。","settings.permissions.androidAccessibilityMessages.operational":"無障礙服務正在執行。","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"無障礙已授權但未連線,請在系統設定中重新開啟 OpenLess。","settings.permissions.androidAccessibilityMessages.status_read_failed":"無法讀取無障礙狀態。","settings.permissions.androidShizukuLabel":"Shizuku 增強模式","settings.permissions.androidShizukuHint":"可選功能,在部分機型無法手動開啟無障礙時盡力恢復;無法完全消除跨應用競態。裝置重啟後可能需要重新啟動 Shizuku。","settings.permissions.androidShizukuOpenApp":"開啟 Shizuku","settings.permissions.androidShizukuRequestPermission":"請求授權","settings.permissions.androidShizukuRecover":"恢復無障礙服務","settings.permissions.androidShizukuRecoverConfirm":"是否透過 Shizuku 嘗試重新啟用 OpenLess 無障礙服務?寫入時會合併當時已啟用的服務。若全域開關為關閉,啟用後可能同時啟動清單中已登記的其他無障礙服務。","settings.permissions.androidShizukuYes":"是","settings.permissions.androidShizukuNo":"否","settings.permissions.androidShizukuAccessibilityOperational":"無障礙服務已註冊且正在執行。","settings.permissions.androidShizukuAccessibilityRegistered":"已註冊:{{registered}} · 執行中:{{operational}}","settings.permissions.androidShizukuState.notInstalled":"未安裝","settings.permissions.androidShizukuState.notRunning":"未執行","settings.permissions.androidShizukuState.notAuthorized":"未授權","settings.permissions.androidShizukuState.authorized":"已授權","settings.permissions.androidShizukuState.binderDead":"連線中斷","settings.permissions.androidShizukuState.notAndroid":"不可用","settings.permissions.androidShizukuMessages.not_android":"Shizuku 僅在 Android 上可用。","settings.permissions.androidShizukuMessages.not_installed":"未安裝 Shizuku 或 Sui 後端。","settings.permissions.androidShizukuMessages.unsupported_backend":"目前的 Shizuku 後端版本過舊,請更新 Shizuku 或 Sui 至 v11 以上。","settings.permissions.androidShizukuMessages.not_running":"Shizuku 未執行,請先啟動 Shizuku 或 Sui。","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku 未授權,請授予 OpenLess 權限。","settings.permissions.androidShizukuMessages.binder_dead":"Shizuku 連線已中斷,請重新啟動 Shizuku。","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku 已授權,無障礙服務執行正常。","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku 已授權,無障礙服務已註冊但未執行。","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku 已授權,可嘗試恢復無障礙服務。","settings.permissions.androidShizukuMessages.operational":"無障礙服務已註冊且正在執行。","settings.permissions.androidShizukuMessages.registered_stale":"無障礙服務已註冊,但服務目前無法使用。","settings.permissions.androidShizukuMessages.not_registered":"無障礙服務未在系統設定中啟用。","settings.permissions.androidShizukuMessages.already_granted":"Shizuku 權限已授予。","settings.permissions.androidShizukuMessages.binder_unavailable":"請求授權時 Shizuku 服務不可用。","settings.permissions.androidShizukuMessages.request_cancelled":"已取消 Shizuku 授權請求。","settings.permissions.androidShizukuMessages.granted":"Shizuku 權限已授予。","settings.permissions.androidShizukuMessages.denied":"Shizuku 權限被拒絕。","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Shizuku 授權已被阻止。請開啟 Shizuku 並手動允許 OpenLess。","settings.permissions.androidShizukuMessages.launched":"已開啟 Shizuku 授權介面。","settings.permissions.androidShizukuMessages.launch_failed":"無法開啟 Shizuku 授權介面。","settings.permissions.androidShizukuMessages.open_shizuku":"已開啟 Shizuku 管理器。","settings.permissions.androidShizukuMessages.jni_error":"無法連線 Android Shizuku 後端。","settings.permissions.androidShizukuMessages.status_parse_failed":"無法解析 Shizuku 狀態。","settings.permissions.androidShizukuMessages.user_not_confirmed":"需要使用者確認後才能恢復無障礙服務。","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku 未授權或不可用。","settings.permissions.androidShizukuMessages.invalid_component":"無效的無障礙服務元件 ID。","settings.permissions.androidShizukuMessages.service_connect_failed":"無法連線 Shizuku 特權服務。","settings.permissions.androidShizukuMessages.recovery_in_progress":"已有恢復操作正在進行,請稍後再試。","settings.permissions.androidShizukuMessages.parse_failed":"無法解析恢復結果。","settings.permissions.androidShizukuMessages.service_not_bound":"設定已寫入,但無障礙服務尚未執行。","settings.permissions.androidShizukuMessages.success":"無障礙服務已恢復。","settings.permissions.androidShizukuMessages.read_failed":"無法讀取無障礙服務設定。","settings.permissions.androidShizukuMessages.read_enabled_failed":"無法讀取無障礙總開關。","settings.permissions.androidShizukuMessages.merge_failed":"無法合併無障礙服務清單。","settings.permissions.androidShizukuMessages.write_services_failed":"無法寫入已啟用無障礙服務清單。","settings.permissions.androidShizukuMessages.write_enabled_failed":"無法啟用無障礙總開關。","settings.permissions.androidShizukuMessages.readback_failed":"寫入後無法驗證無障礙設定。","settings.permissions.androidShizukuMessages.oem_rollback":"廠商系統回滾了無障礙寫入。","settings.permissions.androidShizukuMessages.concurrent_change":"恢復過程中無障礙設定被其他應用修改。","settings.permissions.androidShizukuMessages.partial_rollback":"恢復失敗,且設定只能部分回滾。請檢查系統無障礙設定。","settings.permissions.androidShizukuMessages.manual_required":"全域開關關閉且清單中已有其他無障礙服務時,無法安全自動恢復。請前往系統設定手動操作。","settings.permissions.androidShizukuMessages.max_retries":"多次嘗試後恢復失敗。","settings.permissions.androidShizukuMessages.internal_error":"恢復因內部錯誤失敗。","settings.permissions.androidShizukuMessages.unknown":"未知 Shizuku 狀態。","settings.permissions.androidInsertStrategyLabel":"文字插入策略","settings.permissions.androidOverlayTriggerLabel":"懸浮窗顯示時機","settings.permissions.androidOverlayActivationModeLabel":"懸浮窗啟用方式","settings.permissions.androidOverlayLeftSwipeActionLabel":"左滑動作","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"取消錄音滑向","settings.permissions.androidOverlaySizeLabel":"懸浮窗大小","settings.permissions.androidOverlaySizeHint":"調整懸浮按鈕直徑,儲存後在目前懸浮窗上生效並保留位置。","settings.permissions.androidInsertStrategy.accessibility":"自動輸出到輸入框","settings.permissions.androidInsertStrategy.clipboard":"僅剪貼簿","settings.permissions.androidInsertStrategyHint.accessibility":"需要開啟無障礙服務;不可用時會複製到剪貼簿。","settings.permissions.androidInsertStrategyHint.clipboard":"不需要無障礙權限,只複製到剪貼簿,由你手動貼上。","settings.permissions.androidOverlayTrigger.background":"退到背景","settings.permissions.androidOverlayTrigger.keyboard":"鍵盤彈出時","settings.permissions.androidOverlayTrigger.always":"常駐","settings.permissions.androidOverlayTriggerHint.background":"省電","settings.permissions.androidOverlayTriggerHint.keyboard":"此模式已暫緩,既有設定會改回退到背景。","settings.permissions.androidOverlayTriggerHint.always":"一直佔屏","settings.permissions.androidOverlayTriggerDisabled.keyboard":"「鍵盤彈出時」暫緩開放,後續將以懸浮窗手勢取代鍵盤偵測。","settings.permissions.androidOverlayActivationMode.tap":"點按啟用","settings.permissions.androidOverlayActivationMode.long_press":"長按啟用","settings.permissions.androidOverlayActivationModeHint.tap":"第一次點按進入啟用狀態,第二次點按開始普通聽寫。","settings.permissions.androidOverlayActivationModeHint.long_press":"按住進入啟用狀態;放開時結束目前錄音或問答輪次。","settings.permissions.androidOverlayLeftSwipeAction.translation":"翻譯聽寫","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"切換風格包","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"啟用狀態左滑後按翻譯模式錄音。","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"啟用狀態左滑後切換到上一個風格包。","settings.permissions.androidOverlayCancelSwipeDirection.up":"向上滑","settings.permissions.androidOverlayCancelSwipeDirection.down":"向下滑","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"錄音中向上滑取消本次聽寫,不轉寫、不插入。","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"錄音中向下滑取消本次聽寫,不轉寫、不插入。","settings.permissions.windowsIme.installed":"已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。","settings.permissions.windowsIme.notInstalled":"未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。","settings.permissions.windowsIme.registrationBroken":"註冊已損壞。請重新安裝 OpenLess 輸入法。","settings.permissions.windowsIme.notWindows":"僅 Windows 可用。","settings.advanced.multimodalPipelineTitle":"多模態辨識管線","settings.advanced.multimodalPipelineTitleHint":"用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。","settings.advanced.multimodalPipelineLabel":"啟用多模態辨識管線","settings.advanced.multimodalPipelineHint":"開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。","settings.advanced.streamingInsertTitle":"流式輸入","settings.advanced.streamingInsertTitleLinux":"流式輸入(實驗性)","settings.advanced.streamingInsertDesc":"逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。","settings.advanced.streamingInsertLabel":"流式輸入","settings.advanced.streamingInsertHintMac":"臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。","settings.advanced.streamingInsertHintWindows":"SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。","settings.advanced.streamingInsertHintLinux":"通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。","settings.advanced.streamingInsertSaveClipboardLabel":"同步到剪貼簿","settings.advanced.streamingInsertSaveClipboardHint":"插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。","settings.advanced.localAsrTitle":"本地 ASR 模型","settings.advanced.localAsrDesc":"把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。","settings.advanced.localAsrWarningShort":"本地推理較慢,配置不足時可能吞字。","settings.advanced.qwen3Desc":"啓動之後,ASR 提供商將被接管。","settings.advanced.sherpaDesc":"啟用後,ASR 提供商將被接管。","settings.advanced.foundryDesc":"啓動之後,ASR 提供商將被接管。","settings.advanced.notSupportedHere":"本平臺暫不支持,未集成推理模塊。","settings.advanced.enable":"啓用","settings.advanced.alreadyActive":"已啓用","settings.advanced.disableLocalLabel":"停用本地 ASR","settings.advanced.disableLocalDesc":"切回雲端 ASR(默認火山引擎 bigasr)。","settings.advanced.disable":"停用","settings.advanced.platformNotSupported":"該平臺暫未支持本地 ASR 模型集成。","settings.advanced.confirmEnableLocalTitle":"啓用本地 ASR?","settings.advanced.confirmEnableLocalBody":"啟用後轉寫會比雲端慢,準確率可能更低。","settings.advanced.confirm":"確認啓用","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"界面語言","settings.language.desc":"切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。","settings.language.label":"語言","settings.language.labelDesc":"選擇「跟隨系統」時按操作系統當前語言顯示。","settings.language.followSystem":"跟隨系統","settings.language.zh":"簡體中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。","settings.layout.title":"布局","settings.theme.title":"外觀","settings.theme.label":"主題","settings.theme.activityHeatmapLabel":"概覽頁顯示年度活動熱力圖","settings.theme.stackedRowLayoutLabel":"易讀布局(防溢出換行)","settings.theme.stackedRowLayoutDesc":"小螢幕或大字時,同一行放不下的按鈕和選項會自動換到下一行,避免橫向擠出螢幕或文字被壓扁。","settings.theme.conservativeLayoutLabel":"保守排版","settings.theme.conservativeLayoutDesc":"除首頁、頂欄與底欄外,設定與功能頁改為單列滿寬,最大程度避免橫向溢出。","settings.theme.system":"跟隨系統","settings.theme.light":"淺色","settings.theme.dark":"深色","settings.remoteInput.title":"遠端輸入","settings.remoteInput.enableLabel":"啟用遠端輸入","settings.remoteInput.enableDesc":"手機/平板瀏覽器連到電腦錄音,語音即時落到電腦游標處(需 HTTPS,首次存取要信任憑證)","settings.remoteInput.portLabel":"監聽連接埠","settings.remoteInput.defaultModeLabel":"預設錄音方式","settings.remoteInput.modeToggle":"點擊切換","settings.remoteInput.modeHold":"按住說話","settings.remoteInput.urlLabel":"存取網址","settings.remoteInput.pinLabel":"配對碼","settings.remoteInput.regeneratePin":"重新產生","settings.remoteInput.portInUse":"連接埠 {{port}} 被佔用,請更換","settings.remoteInput.startError":"遠端輸入服務啟動失敗:{{reason}}","settings.remoteInput.securityHint":"僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。","settings.remoteInput.certHint":"首次連線需核對根憑證指紋後再信任。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。","settings.remoteInput.certFingerprintLabel":"本機根憑證 SHA-256","settings.remoteInput.certFingerprintCopy":"複製完整指紋","settings.remoteInput.certFingerprintCopied":"已複製指紋","settings.remoteInput.certFingerprintUnavailable":"完整指紋無法取得。請勿安裝或信任下載的憑證。","settings.remoteInput.certVerifyHint":"在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。","settings.remoteInput.certProfileHint":"描述檔應只包含一張根憑證。若有其他憑證、VPN 或裝置管理設定,請勿安裝。","settings.remoteInput.certTrustWarning":"首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。","settings.remoteInput.certSetupLink":"複製 iPhone 憑證連結","settings.remoteInput.waitingStart":"服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。","settings.remoteInput.starting":"正在啟動遠端輸入服務…","settings.remoteInput.urlsStale":"這些地址來自上次執行,可能已經過期。","settings.about.tagline":"自然說話,完美書寫","settings.about.checkUpdate":"檢查更新","settings.about.checkUpdateBtn":"檢查","settings.about.checkStableUpdateBtn":"檢查正式版更新","settings.about.checkBetaUpdateBtn":"檢查 Beta 更新","settings.about.checkingUpdate":"檢查中…","settings.about.upToDate":"當前已是最新版本。","settings.about.updateError":"檢查或更新失敗,請稍後重試。","settings.about.retryBtn":"重試","settings.about.openReleases":"打開 Releases","settings.about.source":"源碼","settings.about.docs":"文檔","settings.about.feedback":"反饋","settings.about.qq":"社區 QQ 羣","settings.about.qqDesc":"使用 QQ 搜索羣號加入,或掃碼進羣。","settings.about.copyQq":"複製羣號","settings.about.privacy":"隱私","settings.about.privacyDesc":"錄音可能會傳送至你設定的雲端服務商進行轉寫。","settings.about.localFirst":"本地優先","settings.about.linksTitle":"文件連結","settings.about.betaChannelLabel":"加入 Beta 渠道","settings.about.betaChannelToggleLabel":"啟用 Beta 渠道","settings.about.betaChannelDesc":"開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。","settings.about.autoUpdateSectionTitle":"自動更新","settings.about.autoUpdateCheckLabelAndroid":"自動檢查並下載更新","settings.about.autoUpdateCheckDescAndroid":"啟動後及每 60 分鐘自動檢查更新;發現新版本後自動下載並開啟系統安裝器。渠道跟隨上方 Beta 開關。","settings.about.betaChannelFetching":"正在獲取最新 Beta 版本…","settings.about.betaChannelFetchBtn":"查詢最新 Beta","settings.about.betaChannelLatestPrefix":"最新 Beta:","settings.about.betaChannelDownloadBtn":"前往下載","settings.about.betaChannelRefresh":"重新查詢","settings.about.betaChannelNoBeta":"尚未發佈過 Beta 版。","settings.about.betaChannelFetchError":"獲取 Beta 版本資訊失敗,請稍後重試。","settings.about.betaChannelUpToDate":"已是最新","settings.about.betaChannelUpdateNow":"立即更新","settings.about.betaChannelUpdateNowTitle":"檢查並下載最新 Beta,然後彈出更新對話框","settings.about.betaChannelChecking":"檢查中…","settings.about.updateDialog.available.title":"發現新版本","settings.about.updateDialog.available.desc":"發現 OpenLess {{version}},是否現在更新?","settings.about.updateDialog.stableChannelSwitch.title":"切換到正式版","settings.about.updateDialog.stableChannelSwitch.desc":"目前版本:OpenLess {{currentVersion}}\n目標版本:OpenLess {{version}}\n這是從 Beta 頻道切換到正式版,是否繼續?","settings.about.updateDialog.downloading.title":"正在下載更新","settings.about.updateDialog.downloading.desc":"正在下載 OpenLess {{version}},請保持應用打開。","settings.about.updateDialog.downloaded.title":"更新已準備好","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} 已安裝完成。是否現在自動重啓以應用更新?","settings.about.updateDialog.installing.title":"正在安裝更新","settings.about.updateDialog.installing.desc":"正在安裝 OpenLess {{version}},請保持應用打開。","settings.about.updateDialog.install":"現在更新","settings.about.updateDialog.androidInstall":"下載並開啟安裝器","settings.about.updateDialog.androidInstalled.title":"系統安裝器已開啟","settings.about.updateDialog.androidInstalled.desc":"請依系統提示完成安裝。安裝後重新開啟 OpenLess 即可使用 {{version}}。","settings.about.updateDialog.downloadingLabel":"下載中…","settings.about.updateDialog.installingLabel":"安裝中…","settings.about.updateDialog.later":"稍後手動重啓","settings.about.updateDialog.restartNow":"現在重啓","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"已下載 {{downloaded}}","settings.about.updateDialog.installError.title":"更新失敗","settings.about.updateDialog.installError.desc":"自動更新未能完成:{{error}}。你可以前往下載頁手動下載安裝最新版本。","settings.about.updateDialog.manualDownload":"手動下載","startup.loading":"正在啟動 OpenLess…","startup.loadingDesc":"正在連接本機服務並檢查相容性。","startup.failed":"OpenLess 暫時無法啟動","startup.recovery":"請重新檢查。如果仍然失敗,請完全結束後重開應用程式;升級後出現此問題時,確認已安裝完整的同一版本。","startup.retry":"重新檢查","startup.details":"查看錯誤詳情","modal.serviceViews.label":"服務設定分類","modal.serviceViews.llm":"語言模型","modal.serviceViews.asr":"語音辨識","modal.serviceViews.omni":"多模態模型","modal.serviceViews.models":"本機模型","modal.serviceViews.connections":"連線與擴充","modal.serviceViews.statusConfigured":"已設定","modal.serviceViews.statusMissing":"未設定","modal.searchPlaceholder":"尋找設定分類…","modal.clearSearch":"清除搜尋","modal.categoriesLabel":"設定分類","modal.searchResults":"搜尋結果","modal.searchCount":"找到 {{count}} 個相關分類","modal.noResults":"找不到相關分類。試試「麥克風」「模型」或「主題」。","modal.autoSaveHint":"修改後自動儲存","modal.backToAdvanced":"返回實驗與擴充","modal.advancedPages.lessComputer":"選擇 Agent,設定模型、權限與工作目錄。","modal.advancedPages.claudeConsole":"偵測 Claude Code,並查看測試工作的執行輸出。","modal.advancedPages.multimodal":"管理多模態辨識的實驗性開關。","modal.advancedPages.debug":"保留偵錯錄音、探測游標上下文與匯出日誌。","modal.descriptions.general":"選擇麥克風、設定錄音方式與文字輸入,也可連接手機輸入。","modal.descriptions.shortcuts":"設定各功能的觸發方式,以及選取文字後的操作。","modal.descriptions.services":"選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。","modal.descriptions.appearance":"調整主題、頁面排版和介面語言,讓閱讀更舒服。","modal.descriptions.privacy":"檢查系統權限與連線狀態,管理歷史、錄音和本機資料。","modal.descriptions.advanced":"按需設定 Less Computer、多模態與除錯功能。","modal.descriptions.about":"查看目前版本、更新管道與自動更新設定。","modal.searchKeywords.general":"麥克風 錄音 輸入 手機 遠端 區域網路 PIN 膠囊 靜音 開機","modal.searchKeywords.shortcuts":"快捷鍵 熱鍵 組合鍵 選取 潤飾 語音編輯","modal.searchKeywords.services":"ASR LLM API 管道 模型 雲端 本機 網路 代理 市場","modal.searchKeywords.appearance":"主題 深色 淺色 暗色 語言 字體 排版 版面 熱圖","modal.searchKeywords.privacy":"權限 麥克風 輔助功能 歷史 錄音 儲存 隱私 匯出","modal.searchKeywords.advanced":"Less Computer Claude Agent 多模態 Omni 除錯 日誌 實驗","modal.searchKeywords.about":"版本 Beta 穩定 更新 升級","modal.sections.appearance":"外觀與語言","modal.sections.shortcuts":"快捷鍵與選取文字","modal.sections.general":"錄音與輸入","modal.sections.services":"AI 服務與模型","modal.sections.privacy":"權限與資料","modal.sections.advanced":"實驗與擴充","modal.sections.personalize":"個性化","modal.sections.about":"關於與更新","modal.sections.helpCenter":"幫助中心","modal.sections.releaseNotes":"發佈日誌","modal.personalize.font":"字體大小","modal.personalize.fontDesc":"整體縮放界面字號,立即生效。","modal.personalize.fontSmall":"小","modal.personalize.fontMedium":"中","modal.personalize.fontLarge":"大","modal.personalize.blur":"毛玻璃強度","modal.personalize.blurDesc":"影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。","modal.about.tagline":"自然說話,完美書寫","modal.about.checkUpdate":"檢查更新","modal.about.checkUpdateBtn":"檢查","modal.about.docs":"文檔","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"反饋渠道","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"原始碼","modal.about.qq":"社群 QQ 群","modal.about.qqDesc":"使用 QQ 搜尋群號加入,或掃碼進群。","modal.about.copyQq":"複製群號","modal.about.exportErrorLog":"匯出錯誤日誌","modal.about.exportErrorLogDesc":"把當前會話的執行日誌儲存到本地,便於排查問題或反饋給我們。","modal.about.exportErrorLogBtn":"匯出","modal.about.exporting":"匯出中…","modal.about.exportSuccess":"已儲存","modal.about.exportFailed":"匯出失敗","modal.about.privacy":"隱私","modal.about.privacyDesc":"識別結果保存在本機;已設定的雲端服務商可能接收錄音以完成轉寫。","modal.about.localFirst":"本地優先","windowChrome.restore":"還原","windowChrome.minimize":"最小化","windowChrome.maximize":"最大化","windowChrome.close":"關閉","hotkey.triggers.rightOption":"右 Option","hotkey.triggers.leftOption":"左 Option","hotkey.triggers.rightControl":"右 Control","hotkey.triggers.leftControl":"左 Control","hotkey.triggers.rightCommand":"右 Command","hotkey.triggers.leftCommand":"左 Command","hotkey.triggers.leftShift":"左 Shift","hotkey.triggers.rightShift":"右 Shift","hotkey.triggers.fn":"Fn (地球鍵)","hotkey.triggers.rightAlt":"右 Alt","hotkey.triggers.mediaPlayPause":"⏯ 媒體播放/暫停","hotkey.triggers.custom":"自訂組合…","hotkey.fallback":"全局快捷鍵","hotkey.modeHoldSuffix":"(按住說話)","hotkey.modeToggleSuffix":"(開始 / 停止)","hotkey.modeAutoSuffix":"(自動識別)","hotkey.usageHold":"按住 {{trigger}} 說話,鬆開結束。","hotkey.usageToggle":"按 {{trigger}} 開始錄音,再按一次結束。","hotkey.usageAuto":"短按 {{trigger}} 切換開始 / 停止,按住則說完鬆開即停。","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows 低層鍵盤 hook","hotkey.adapter.fcitx5":"fcitx5 輸入法插件","hotkey.adapter.unavailable":"不可用","localAsr.kicker":"本地 ASR","localAsr.title":"模型設置","localAsr.desc":"管理本機語音識別模型。","localAsr.storageTitle":"模型儲存位置","localAsr.storageBaseDir":"選擇的父目錄","localAsr.storageModelsRoot":"實際模型目錄","localAsr.storageDefault":"系統預設目錄","localAsr.storageChoose":"更改目錄","localAsr.storageReset":"恢復預設","localAsr.storageReveal":"開啟模型總目錄","localAsr.storageDesc":"自訂目錄會在所選位置下建立 OpenLess/models,並自動遷移現有模型;遷移前會取消下載和釋放已載入模型。","localAsr.storageChooseTitle":"選擇本地模型儲存父目錄","localAsr.storageChangeConfirm":"將把現有本地模型遷移到 {{path}}/OpenLess/models。遷移前會自動取消下載並釋放已載入模型。是否繼續?","localAsr.storageResetConfirm":"將把現有本地模型遷回系統預設目錄。當前目錄:{{path}}。是否繼續?","localAsr.modelDir":"模型目錄","localAsr.revealDir":"開啟目錄","localAsr.deleteConfirm":"確定刪除 {{name}} 的本地模型檔案嗎?此操作無法復原。","localAsr.appleSpeechTitle":"Apple 語音辨識(macOS)","localAsr.appleSpeechDesc":"macOS 系統內建的語音辨識,在本機把語音轉成文字:不用下載模型、不用填 API Key,主流語言可完全離線、音訊不出本機。適合作雲端 ASR 網路不穩時的本地後備;首次使用會跳出系統語音辨識授權。","localAsr.appleSpeechUse":"使用 Apple 語音","localAsr.qwenTitle":"Qwen3-ASR 模型管理","localAsr.qwenExperimentalBadge":"實驗性","localAsr.engineUnavailable":"當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。","localAsr.qwenUnavailableOnWindows":"Windows 暫不支援 Qwen3-ASR,請使用上方的 Foundry Local Whisper。","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"在本機識別語音,無需 ASR API Key。首次使用需下載運行元件和模型。","localAsr.foundryAvailable":"Windows 可用","localAsr.foundryUnavailable":"僅 Windows 可用","localAsr.foundryRuntimeReady":"運行組件已下載","localAsr.foundryRuntimeMissing":"運行組件未下載","localAsr.foundryRuntimeSourceLabel":"運行組件下載源","localAsr.foundryRuntimeSourceAuto":"自動(NuGet 優先)","localAsr.foundryRuntimeSourceNuget":"NuGet 官方源","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly 源","localAsr.foundryRuntimeSourceDesc":"首次使用前需下載運行元件。","localAsr.foundrySelectedModel":"選擇模型","localAsr.foundryActiveModel":"當前默認 alias","localAsr.foundryLoadedModel":"已加載模型","localAsr.foundryNotLoaded":"未加載","localAsr.foundryError":"Foundry 狀態","localAsr.foundrySetDefault":"設為默認 / 啟用 Windows 本地 ASR","localAsr.foundryEnabling":"正在啟用…","localAsr.foundryPrepare":"準備 / 下載 / 加載","localAsr.foundryPreparing":"正在準備…","localAsr.foundryReleasing":"正在釋放…","localAsr.foundryRetryPrepare":"繼續準備 / 重試","localAsr.foundryCancelPrepare":"取消準備","localAsr.foundryCancelRequested":"已請求取消","localAsr.foundryCancelling":"正在取消…","localAsr.foundryCancelBestEffort":"已請求取消,會在當前步驟完成後停止。可稍後重試。","localAsr.foundryPrepareRuntime":"準備運行時組件","localAsr.foundryPrepareModel":"下載模型","localAsr.foundryPrepareLoad":"加載模型","localAsr.foundryPrepareModelSkipped":"模型已下載,跳過下載階段","localAsr.foundryPrepareDone":"已完成","localAsr.foundryPrepareWaiting":"等待中","localAsr.foundryApproxSizeMb":"約 {{mb}} MB","localAsr.foundryLanguageLabel":"識別語言","localAsr.foundryLanguageAuto":"自動","localAsr.foundryLanguageZh":"中文 zh","localAsr.foundryLanguageEn":"英文 en","localAsr.foundryLanguageDesc":"中文聽寫選中文,中英混用選自動。","localAsr.foundryModelSmall":"Whisper Small(默認 / 平衡)","localAsr.foundryModelSmallDesc":"默認平衡選項,兼顧質量與資源佔用。","localAsr.foundryModelMedium":"Whisper Medium(更高質量)","localAsr.foundryModelMediumDesc":"更高準確率,適合性能更強、可接受更大下載和更慢推理的設備。","localAsr.foundryModelLarge":"Whisper Large V3 Turbo(最高質量)","localAsr.foundryModelLargeDesc":"更高質量的大模型選項,適合高配設備和質量優先場景。","localAsr.foundryModelBase":"Whisper Base(更快 / 更省資源)","localAsr.foundryModelBaseDesc":"更快、資源佔用更低,適合日常輕量使用。","localAsr.foundryModelTiny":"Whisper Tiny(最快 / 冒煙測試)","localAsr.foundryModelTinyDesc":"最快的檢查選項,適合確認 Foundry 路徑可用。","localAsr.sherpaTitle":"Windows sherpa-onnx Local(實驗性)","localAsr.sherpaDesc":"Windows 使用 sherpa-onnx 在本機離線批次識別,無需 ASR API Key。","localAsr.sherpaRuntimeReady":"模型已載入","localAsr.sherpaRuntimeMissing":"模型未載入","localAsr.sherpaSetDefault":"設為預設 / 啟用 sherpa-onnx","localAsr.sherpaPrepare":"檢查本地檔案 / 載入","localAsr.sherpaPreparing":"載入中…","localAsr.sherpaPrepareLocalFiles":"檢查本地模型檔案","localAsr.sherpaModelDir":"模型目錄","localAsr.sherpaRevealDir":"開啟模型目錄","localAsr.sherpaError":"sherpa-onnx 狀態","localAsr.sherpaLanguageJa":"日語 ja","localAsr.sherpaLanguageKo":"韓語 ko","localAsr.sherpaLanguageYue":"粵語 yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small(預設 / 中文優先)","localAsr.sherpaModelSenseVoiceDesc":"預設實驗模型,適合中文與中英混合聽寫。","localAsr.sherpaModelParaformer":"Paraformer 中文","localAsr.sherpaModelParaformerDesc":"面向中文的實驗模型。","localAsr.sherpaModelWhisper":"Whisper Small 多語言","localAsr.sherpaModelWhisperDesc":"與 Whisper 系列行為一致的多語言實驗兜底模型。","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3(多語)","localAsr.sherpaModelWhisperLargeV3Desc":"開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。","localAsr.sherpaModelZipformer":"Zipformer 串流(中英)","localAsr.sherpaModelZipformerDesc":"邊說邊出的串流中英模型,延遲最低,適合即時聽寫。","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。","localAsr.modelSelectTitle":"本機模型","localAsr.modelSelectDesc":"查看下載狀態、管理檔案,或載入模型進行測試。","localAsr.modelSelectPlaceholder":"選擇已下載的模型…","localAsr.modelSelectEmpty":"還沒有已下載的模型,先到「下載與管理」下載一個。","localAsr.groupDownload":"下載與管理","localAsr.groupOther":"其他","localAsr.mirrorLabel":"下載鏡像源","localAsr.mirrorDesc":"官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。","localAsr.mirrorHuggingface":"HuggingFace 官方 (huggingface.co)","localAsr.mirrorHfMirror":"國內鏡像 (hf-mirror.com)","localAsr.activeBadge":"當前使用","localAsr.downloadedBadge":"已下載","localAsr.notDownloadedBadge":"未下載","localAsr.download":"下載","localAsr.resume":"繼續下載","localAsr.cancel":"取消","localAsr.delete":"刪除","localAsr.setActive":"設為默認","localAsr.failed":"失敗","localAsr.cancelled":"已取消","localAsr.files":"文件","localAsr.sizeLoading":"正在查詢尺寸…","localAsr.sizeUnknown":"尺寸未知","localAsr.performanceWarning":"本地 ASR 適合離線或隱私敏感場景,首次使用需下載模型。","localAsr.test":"加載並測試","localAsr.testRunning":"測試中…","localAsr.testHeading":"內置音頻測試","localAsr.testExpected":"原文","localAsr.testActual":"識別","localAsr.testStats":"音頻時長 {{audio}}s · 加載 {{load}}s · 推理 {{transcribe}}s · 後端 {{backend}}","localAsr.testFailed":"測試失敗","localAsr.engineStatusLabel":"內存中的引擎","localAsr.engineLoaded":"已加載:{{model}}","localAsr.engineUnloaded":"未加載(首次聽寫需先加載模型)","localAsr.loadNow":"立即加載","localAsr.releaseNow":"立即釋放","localAsr.keepLoadedLabel":"保持加載多久","localAsr.keepLoadedDesc":"決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。","localAsr.keepImmediate":"說完話立即釋放","localAsr.keep1min":"上次使用後 1 分鐘","localAsr.keep5min":"上次使用後 5 分鐘(默認)","localAsr.keep30min":"上次使用後 30 分鐘","localAsr.keepForever":"不釋放(始終保留)","localAsr.sidebarTitle":"已下載與下載中","localAsr.activePill":"目前使用","localAsr.setDefault":"設為預設","localAsr.downloading":"下載中","localAsr.startDownload":"開始下載","localAsr.downloadNewModel":"下載新模型","localAsr.activeModelLabel":"使用中的模型","localAsr.pickerNoModelDownloaded":"尚無已下載的模型,請先在本機模型頁下載。","localAsr.partialDownloadsLabel":"未完成下載","localAsr.partialDownloadsDesc":"存在中斷下載的暫存殘留,可一鍵清理,不影響已安裝模型。","localAsr.cleanupIncomplete":"清理未完成下載","localAsr.languagesLabel":"語言","localAsr.partialBytesLabel":"殘留檔案","localAsr.downloadDialogTitle":"下載模型","localAsr.downloadDialogAlreadyHave":"模型檔案已下載。可返回模型頁載入並測試,或在「ASR 語音轉寫」中選擇對應供應商。","localAsr.downloadDialogDesc":"查看模型大小與簡介,選擇後開始下載。下載完成後,在「語音辨識」中選擇對應的本機服務。","localAsr.detailRepo":"模型倉庫","localAsr.hfDownloads":"下載量","localAsr.hfLikes":"收藏數","localAsr.hfDescription":"模型簡介","localAsr.hfNoDescription":"暫無簡介","localAsr.hfCardFailed":"模型資訊取得失敗","localAsr.detailFiles":"個檔案","localAsr.detailDownloaded":"已下載","localAsr.detailEmpty":"選擇一個模型查看詳情","localAsr.foundryLanguage":"語言","localAsr.foundryRuntimeSource":"執行時來源","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"保持載入","localAsr.downloadSettingsTitle":"下載與儲存設定","localAsr.downloadSettingsDesc":"鏡像源 · 模型儲存位置 · 記憶體引擎","localAsr.libraryEmptyTitle":"還沒有本機模型","localAsr.libraryEmptyDesc":"下載一個語音辨識模型,讓音訊在本機處理。已有模型卻未顯示時,可重新讀取目錄。","localAsr.catalogTitle":"模型目錄","localAsr.catalogEmpty":"目前沒有可顯示的模型。請重新讀取目錄後再試。","localAsr.reloadCatalog":"重新讀取","localAsr.engineLabel":"辨識引擎","localAsr.sizeLabel":"模型大小","localAsr.allEngines":"全部","localAsr.backToCatalog":"返回模型目錄","localAsr.detailsTitle":"模型詳情","localAsr.testActivateHint":"「載入並測試」會將此模型設為目前使用,再執行內建音訊測試。","localAsr.downloadProgressHint":"開始後返回模型頁查看進度,也可隨時取消下載。","localAsr.errorDetails":"錯誤詳情"},"en":{"cloudSync.title":"Cloud sync","cloudSync.description":"Use your GitHub account to sync your dictionary, styles, and preferences across devices.","cloudSync.signIn":"Sign in with GitHub","cloudSync.account":"Sync account","cloudSync.refresh":"Refresh status","cloudSync.loading":"Checking cloud status…","cloudSync.noBackup":"No cloud backup yet","cloudSync.available":"Cloud backup available","cloudSync.summary":"{{dictionary}} words · {{corrections}} corrections · {{stylePacks}} styles","cloudSync.updated":"Updated {{time}}","cloudSync.upload":"Back up to cloud","cloudSync.restore":"Restore from cloud","cloudSync.delete":"Delete cloud backup","cloudSync.working":"Syncing…","cloudSync.uploadSuccess":"Cloud backup saved","cloudSync.restoreSuccess":"Cloud settings restored","cloudSync.deleteSuccess":"Cloud backup deleted","cloudSync.failed":"Sync failed: {{error}}","cloudSync.conflict":"The cloud copy has changed. Refresh its status before choosing to back up or restore.","cloudSync.unavailable":"The official sync service is currently unavailable. Try again later.","cloudSync.signInRequired":"Sign in with GitHub first.","cloudSync.restoreTitle":"Restore cloud backup?","cloudSync.restoreDescription":"Cloud dictionary entries, corrections, styles, and synced preferences will replace their local equivalents. API keys, device paths, and permissions stay on this device.","cloudSync.deleteTitle":"Delete cloud backup?","cloudSync.deleteDescription":"This removes only the cloud backup for this GitHub account. Local data is kept.","cloudSync.confirmRestore":"Restore and replace","cloudSync.confirmDelete":"Delete backup","cloudSync.scope":"Sync dictionary entries, corrections, style icons, and common preferences. API keys, credentials, and device settings stay on this device.","macDictationKey.Changed":"The shortcut changed while saving. Please try again.","macDictationKey.label":"Mac Dictation key","macDictationKey.description":"Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.","macDictationKey.Permission":"Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.","macDictationKey.Busy":"Finish the current dictation before changing its shortcut.","macDictationKey.Unavailable":"Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.","app.name":"OpenLess","app.tagline":"Speak naturally, write perfectly","common.loading":"Loading…","common.retry":"Retry","common.settingsLoadFailed":"Settings load failed","common.refresh":"Refresh","common.clear":"Clear","common.copy":"Copy","common.delete":"Delete","common.later":"Later","common.cancel":"Cancel","common.close":"Close","common.show":"Show","common.hide":"Hide","common.saved":"Saved","common.saving":"Saving…","common.experimental":"Experimental","common.copied":"Copied","common.operationFailed":"Operation failed","common.add":"Add","common.durationSeconds":"{{value}}s","common.durationMillis":"{{value}}ms","common.durationMinutes":"{{value}}m","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"Cancelled","capsule.error":"Something went wrong","capsule.inserted":"Inserted {{count}}","capsule.translating":"Translating","capsule.selectionPolish.polishing":"Polishing…","capsule.selectionPolish.replaced":"Replaced","capsule.selectionPolish.noSelection":"Nothing selected","capsule.selectionPolish.failed":"Polish failed, try again","selectionPolishPreview.title":"Selection Polish Preview","selectionPolishPreview.subtitle":"Editable; the original selection is replaced only after you confirm.","selectionPolishPreview.cancel":"Cancel","selectionPolishPreview.resultLabel":"Polished result","selectionPolishPreview.sourcePrefix":"Original: ","selectionPolishPreview.applyError":"Could not apply: ","selectionPolishPreview.confirmReplace":"Confirm & replace","selectionVoiceIntent.title":"What would you like to do?","selectionVoiceIntent.subtitle":"Your voice instruction was recognized. Choose how to proceed.","selectionVoiceIntent.loading":"Loading…","selectionVoiceIntent.sourcePrefix":"Selection: ","selectionVoiceIntent.errorPrefix":"Could not continue: ","selectionVoiceIntent.question":"Ask a question","selectionVoiceIntent.edit":"Edit selection","selectionVoiceIntent.cancel":"Cancel","qa.title":"Ask","qa.headerHint":"Ask anytime","qa.thinking":"Thinking…","qa.error":"Something went wrong. Please try again.","qa.errorRetry":"Retry","qa.errorRetryHint":"Please try again.","qa.pinTooltip":"Pin (stay open)","qa.unpinTooltip":"Unpin","qa.closeTooltip":"Close","qa.micLabel":"Ask by voice","qa.micStop":"Stop recording","qa.selectionPreview":"From selected text:","qa.emptyTitle":"How can I help?","qa.emptyDesc":"Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.","qa.recordingHint":"Recording… press {{recordHotkey}} again to submit","qa.mobileRecordLabel":"record button","qa.mobileRecordStart":"Start recording","qa.mobileRecordStop":"Stop and submit","qa.composerPlaceholder":"Type a question. Enter to send","qa.composerSend":"Send","qa.statusIdle":"Press {{recordHotkey}} to ask","qa.statusRecording":"Recording","qa.statusThinking":"Thinking","qa.statusError":"Error","qa.jumpToLatest":"Jump to latest","qa.editApplyReplace":"Preview and confirm insert","qa.editApplyUnavailable":"No edit result to apply","qa.editRevertPrevious":"Keep previous version","qa.editInstructionMode":"Edit instruction","lessComputer.title":"Less Computer","lessComputer.subtitle":"What should your computer do?","lessComputer.you":"You","lessComputer.working":"Operating…","lessComputer.tool":"Used {{name}}","lessComputer.compaction":"Context compacted","lessComputer.done":"Done","lessComputer.cost":"${{cost}}","lessComputer.error":"Failed. Try again.","lessComputer.closeTooltip":"Close","lessComputer.jumpToLatest":"Jump to latest","lessComputer.inputPlaceholder":"Type a command, Enter to send","lessComputer.send":"Send","lessComputer.approvalTitle":"Run blocked command?","lessComputer.approvalRerunWarning":"Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.","lessComputer.approve":"Approve","lessComputer.deny":"Deny","lessComputer.approved":"Approved","lessComputer.denied":"Denied","nav.overview":"Overview","nav.history":"History","nav.vocab":"Dictionary","nav.style":"Style","nav.marketplace":"Marketplace","nav.translation":"Translation","nav.selectionAsk":"Ask","nav.corrections":"Corrections","nav.polishMode":"Polish mode","nav.group.style":"Style","nav.group.tools":"Tools","nav.localAsr":"Models","nav.more":"More","marketplace.kicker":"MARKETPLACE","marketplace.title":"Style Pack Marketplace","marketplace.desc":"Browse, install, and share community style packs.","marketplace.searchPlaceholder":"Search name / description / tags…","marketplace.sortPopular":"Popular","marketplace.sortNew":"Newest","marketplace.uploadBtn":"Upload","marketplace.uploadDisabledHint":"Set your GitHub login in Settings → Marketplace first","marketplace.refreshBtn":"Refresh","marketplace.empty":"No style packs yet","marketplace.emptyHint":"Try a different keyword, or upload your own","marketplace.loadFailed":"Load failed: {{err}}","marketplace.noDescription":"(no description)","marketplace.installBtn":"Install","marketplace.installingBtn":"Installing…","marketplace.downloadZipBtn":"Download ZIP","marketplace.downloadingZipBtn":"Downloading…","marketplace.downloadAria":"Download \"{{name}}\" ZIP","marketplace.likeBtn":"Like","marketplace.installed":"Installed \"{{name}}\" locally","marketplace.downloaded":"Downloaded \"{{name}}\" ZIP","marketplace.uploaded":"Uploaded — waiting for review","marketplace.uploadTitle":"Pick a style pack to upload","marketplace.uploadHint":"Uploading as {{login}}. Content goes to the cloud review queue.","marketplace.uploadNoLocal":"No local style packs to upload","marketplace.errors.detail":"Detail load failed: {{err}}","marketplace.errors.install":"Install failed: {{err}}","marketplace.errors.download":"ZIP download failed: {{err}}","marketplace.errors.like":"Like failed: {{err}}","marketplace.errors.upload":"Upload failed: {{err}}","marketplace.errors.loadLocal":"Load local packs failed: {{err}}","marketplace.sortLiked":"Liked","marketplace.likedEmpty":"You have not liked any style packs yet","marketplace.likedEmptyHint":"Open any pack and tap the star — liked packs appear here","marketplace.derivativeBadge":"Derived from @{{login}}","marketplace.detail.withdrawBtn":"Withdraw","marketplace.detail.withdrawConfirm":"Withdraw \"{{name}}\" from the marketplace? Your local copy is kept.","marketplace.detail.withdrawSuccess":"Withdrawn from marketplace","marketplace.detail.withdrawFailed":"Withdraw failed: {{err}}","marketplace.myPacks.buttonLabel":"My Packs","marketplace.myPacks.buttonTitle":"View {{login}}'s publications","marketplace.myPacks.buttonTitleEmpty":"Set publisher identity in Settings → Marketplace first","marketplace.myPacks.searchPlaceholder":"Search name or tags","marketplace.myPacks.notLoggedIn":"Set publisher identity in Settings → Marketplace first","marketplace.myPacks.emptyTitle":"You have not published any style packs yet","marketplace.myPacks.emptyHint":"Edit a pack in the Style page and click \"Publish to Marketplace\", or upload a local pack from the top-right.","marketplace.myPacks.noMatch":"No matching style packs","marketplace.myPacks.summary":"{{count}} published","marketplace.myPacks.summaryPending":"{{count}} published · {{pending}} pending review","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"Update","marketplace.myPacks.actions.withdraw":"Withdraw","marketplace.myPacks.loadFailed":"Failed to load my packs: {{err}}","marketplace.myPacks.loadingTitle":"Loading…","marketplace.myPacks.loadingHint":"Fetching your latest publications from the marketplace.","marketplace.myPacks.loadErrorTitle":"Load failed","marketplace.myPacks.loadErrorRetry":"Retry","marketplace.upload.confirmBtn":"Confirm upload","marketplace.upload.updateTitle":"Update \"{{name}}\"","marketplace.upload.updateHint":"Pick the local newer version, then click \"Confirm upload\". A same-name pack is pre-selected.","marketplace.upload.recommendedBadge":"Recommended","marketplace.state.pending":"Pending","marketplace.state.approved":"Published","marketplace.state.rejected":"Rejected","marketplace.state.withdrawn":"Withdrawn","marketplace.state.superseded":"Superseded","marketplace.state.unknown":"Unknown","marketplace.oauth.title":"Sign in with GitHub","marketplace.oauth.generating":"Generating device code…","marketplace.oauth.browserHint":"Open {{uri}} in your browser and enter this code:","marketplace.oauth.copyBtn":"Copy","marketplace.oauth.copied":"Device code copied","marketplace.oauth.copyFailed":"Copy failed: {{err}}","marketplace.oauth.openBrowserBtn":"Open browser","marketplace.oauth.cancelBtn":"Cancel","marketplace.oauth.waiting":"Waiting for browser authorization…","marketplace.oauth.successAs":"Signed in as @{{login}}","marketplace.oauth.retryBtn":"Retry","marketplace.oauth.closeBtn":"Close","marketplace.oauth.loginBtn":"Sign in","marketplace.oauth.loginTooltip":"Sign in with GitHub","marketplace.oauth.reloginTooltip":"Click to re-sign-in / switch account (current @{{login}})","marketplace.modal.loggedIn":"Current sign-in identity — change in Settings → Recording → Marketplace","marketplace.modal.notLoggedIn":"Not signed in — go to Settings → Recording → Marketplace to set publisher name","marketplace.modal.notLoggedInLabel":"Not signed in","shell.shortcutLabel":"Recording shortcut","shell.shortcutHint":"Start / Stop","shell.betaTag":"BETA","shell.betaNote":"Local storage, optional cloud backup","shell.navHint.overview":"Status overview: usage stats, provider & permission health","shell.navHint.history":"Dictation history: search, replay and copy past transcripts","shell.navHint.vocab":"Dictionary: custom hotwords for better proper-noun accuracy","shell.navHint.style":"Polish styles: manage output styles and custom prompts","shell.navHint.translation":"Translation: hold Shift while speaking to insert in a target language","shell.navHint.selectionAsk":"Selection ask: select text, then ask about it by voice","shell.navHint.settings":"Preferences: shortcuts, providers, privacy and updates","shell.footer.account":"Account","shell.footer.feedback":"Feedback","shell.footer.settings":"Settings","shell.footer.help":"Help","shell.footer.version":"Version {{version}}","shell.footer.helpPopover.tagline":"Local-first voice input layer","shell.footer.helpPopover.releaseNotes":"Release notes ↗","shell.footer.helpPopover.docs":"Help center ↗","shell.providerPrompt.title":"Set up speech providers","shell.providerPrompt.body":"No ASR or LLM provider is configured yet. Voice input and polishing will not work until you add credentials.","shell.providerPrompt.later":"Later","shell.providerPrompt.openSettings":"Open Settings","shell.hotkeyModePrompt.title":"Review your recording mode","shell.hotkeyModePrompt.body":"Default is now Toggle. If you changed the trigger mode before, please confirm it in Recording settings.","shell.hotkeyModePrompt.later":"Remind me later","shell.hotkeyModePrompt.openSettings":"Open Recording","onboarding.welcome":"Welcome to OpenLess","onboarding.intro":"Speak locally, type locally. Two system permissions are needed before you start.","onboarding.accessibilityTitle":"Accessibility","onboarding.hotkeyTitle":"Global hotkey","onboarding.accessibilityDesc":"Used to listen to the global hotkey (default {{trigger}}) and write transcripts at the cursor.","onboarding.hotkeyDesc":"Used to confirm that the global hotkey listener is available.","onboarding.micTitle":"Microphone","onboarding.micDesc":"Used to capture your voice input.","onboarding.actionNotApplicable":"Not required","onboarding.actionGranted":"Granted","onboarding.actionOpenSystem":"Open System Settings","onboarding.actionRestart":"Reset Accessibility and Restart OpenLess","onboarding.actionGrant":"Grant","onboarding.actionRequestMic":"Request access","onboarding.micNoDeviceHint":"No microphone detected. Connect and enable a microphone, then retry.","onboarding.accessibilityHint":"After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).","onboarding.footerHint":"This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.","onboarding.continueToSettings":"Open settings only (voice and global shortcuts unavailable)","onboarding.androidContinue":"Continue to app","onboarding.androidFooterHint":"Microphone access is required for dictation. Tap Request access above, or continue and grant it later from Overview.","onboarding.androidTitle":"Set up OpenLess","onboarding.androidIntro":"Complete mobile permissions and services step by step.","onboarding.androidStepCounter":"Step {{current}} of {{total}}","onboarding.androidBack":"Back","onboarding.androidNext":"Next","onboarding.androidFinish":"Finish and enter","onboarding.androidSteps.microphoneTitle":"Microphone permission","onboarding.androidSteps.microphoneDesc":"Show the Android system permission sheet and allow OpenLess to record voice.","onboarding.androidSteps.accessibilityTitle":"Accessibility service","onboarding.androidSteps.accessibilityDesc":"Paste recognition results back into the active input field and help detect the input context.","onboarding.androidSteps.overlayPermissionTitle":"Floating window permission","onboarding.androidSteps.overlayPermissionDesc":"Allow OpenLess to show the recording control over other apps.","onboarding.androidSteps.overlayConfigTitle":"Floating window settings","onboarding.androidSteps.overlayConfigDesc":"Configure visibility, activation, swipe actions, and button size.","onboarding.androidSteps.asrTitle":"ASR cloud service","onboarding.androidSteps.asrDesc":"Configure the speech-to-text provider, key, endpoint, and model.","onboarding.androidSteps.llmTitle":"LLM service","onboarding.androidSteps.llmDesc":"Configure the language model used for polishing, translation, and Q&A.","overview.refresh":"Refresh status","overview.servicesTitle":"Current voice services","overview.statsTitle":"Your activity","overview.omniKind":"Multimodal voice","overview.omniName":"Current Omni model","overview.statusLoading":"Reading service configuration…","overview.configureProvider":"Configure","overview.manageProvider":"Manage service","overview.recentEmptyHint":"No dictations yet. Follow the guide above to try one; your result will appear here.","overview.providerHelp.asr":"Turns your speech into text.","overview.providerHelp.llm":"Organizes and polishes text in your style.","overview.providerHelp.omni":"One model handles both speech recognition and text processing.","overview.actions.refresh":"Try again","overview.actions.services":"AI services & models","overview.actions.general":"Recording & input","overview.actions.shortcuts":"Shortcuts","overview.actions.privacy":"Permissions & data","overview.guide.nextStep":"Next step","overview.guide.loadingTitle":"Reading your configuration","overview.guide.loadingDesc":"Your current services and next step will appear shortly.","overview.guide.unavailableTitle":"Service status is unavailable","overview.guide.unavailableDesc":"Try reading it again, or open AI services to review your configuration.","overview.guide.servicesTitle":"Set up your voice services","overview.guide.servicesDesc":"Start here: choose services for speech recognition and text processing. In Omni mode, only the active multimodal model needs configuration.","overview.guide.permissionsTitle":"Check your shortcut status","overview.guide.permissionsDesc":"The shortcut adapter is unavailable. Open Permissions & data to see its status and available options.","overview.guide.shortcutsTitle":"Choose a recording shortcut","overview.guide.shortcutsDesc":"Pick a shortcut that feels natural so you can start dictating while you type.","overview.guide.recordingTitle":"Choose how you record","overview.guide.recordingDesc":"Your service configuration is saved. Open recording settings to choose your microphone and recording mode.","overview.guide.tryDictationTitle":"Try a dictation","overview.guide.tryDictationDesc":"Place the cursor where you want to type. {{shortcut}}","overview.guide.permissionsHint":"Recording or shortcuts not responding? Check permissions, microphone access, and shortcut status in Permissions & data.","overview.kicker":"DASHBOARD","overview.title":"Today's overview","overview.desc":"Today's dictation stats and system status.","overview.pressPrefix":"Press","overview.pressSuffix":"to start","overview.asrKind":"Speech recognition","overview.llmKind":"Text processing","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI-compatible","overview.llmConfigured":"Active LLM configured","overview.llmNotConfigured":"Not configured","overview.statusConfigured":"Configured","overview.statusNotConfigured":"Not configured","overview.statusUnknown":"Unavailable","overview.credentialsLoadError":"Could not read credential status","overview.metricChars":"Characters today","overview.metricSegments":"{{count}} segments","overview.metricDuration":"Total duration today","overview.metricAvg":"Avg per segment","overview.metricAvgTrend":"Today's average","overview.metricNoData":"No data","overview.historyLoadError":"History load failed","overview.metricTotal":"Total records","overview.metricTotalTrend":"Local archive (max 200)","overview.activityTitle":"Annual activity","overview.activityCount":"{{count}} dictation(s)","overview.activityLoadError":"Activity data load failed","overview.period.ariaLabel":"Reporting period","overview.period.last7Days":"Last 7 days","overview.period.last30Days":"Last 30 days","overview.period.dailyAverage":"{{value}} / day","overview.period.minutes":"{{value}} min","overview.period.hoursMinutes":"{{hours}} h {{minutes}} min","overview.metricName.ariaLabel":"Metric","overview.metricName.count":"Count","overview.metricName.chars":"Characters","overview.metricName.duration":"Duration","overview.recentTitle":"Recent transcripts","overview.recentAll":"View all →","overview.recentEmpty":"No records yet. Press {{trigger}} to start your first recording.","overview.recentLoadFailed":"Could not load recent transcripts. Please retry.","overview.historyRetry":"Retry","overview.weekDays.0":"Sun","overview.weekDays.1":"Mon","overview.weekDays.2":"Tue","overview.weekDays.3":"Wed","overview.weekDays.4":"Thu","overview.weekDays.5":"Fri","overview.weekDays.6":"Sat","overview.inAppDictation.title":"In-app dictation","overview.inAppDictation.start":"Start recording","overview.inAppDictation.stop":"Stop recording","overview.inAppDictation.idle":"Tap to start recording","overview.inAppDictation.recording":"Recording…","overview.inAppDictation.processing":"Processing…","overview.androidMicBanner.title":"Microphone permission needed","overview.androidMicBanner.desc":"Grant microphone access to use in-app dictation and voice input.","overview.androidMicBanner.grant":"Request access","overview.androidMicBanner.openSettings":"Open settings","history.exportError":"Failed to export the recording. Please try again.","history.kicker":"HISTORY","history.title":"History","history.desc":"Locally stored transcripts.","history.filterAll":"All","history.summary":"{{total}} total · showing {{shown}}","history.searchPlaceholder":"Search transcripts… ({{shortcut}})","history.searchNoMatch":"No entries match “{{query}}”.","history.empty":"No history yet. Press {{trigger}} to record one.","history.loadFailed":"Failed to load history: {{err}}","history.retry":"Retry","history.clearFailed":"Failed to clear history: {{err}}","history.deleteFailed":"Failed to delete entry: {{err}}","history.copyFailed":"Failed to copy: {{err}}","history.playRecording":"Play recording","history.audioLoading":"Loading…","history.audioDecodeFailed":"Audio decode failed: {{err}}","history.exportRecording":"Export recording","history.exportFailed":"Failed to export: {{err}}","history.retranscribe":"Retranscribe","history.retranscribing":"Transcribing…","history.retranscribeFailed":"Retranscribe failed: {{err}}","history.rawLabel":"Raw","history.rawEmpty":"(empty)","history.selectHint":"Select an entry on the left to see details.","history.recorded":"Recorded {{duration}}","history.stepAsr":"Transcribe","history.multimodalPipeline":"Multimodal","history.stepAsrHint":"Time spent waiting for the transcript after key release. Streaming ASR transcribes while you speak, so this is usually much shorter than the recording.","history.stepPolish":"Polish","history.stepInsert":"Insert","history.chars":"{{count}} chars","history.vocabHits":"{{count}} vocab hits","history.inserted":"Inserted","history.pasteSent":"Paste sent","history.copiedFallback":"Copied (use {{shortcut}})","history.insertFailed":"Insert failed","history.confirmClear":"Delete all {{count}} history entries? This cannot be undone.","history.backToList":"Back to list","history.repolish.title":"Re-polish","history.repolish.hint":"Run polish again on the transcript above. Results are shown for this visit only and are not written back to the record. When the original style pack was deleted or the record predates style packs, retry uses the current style.","history.repolish.retry":"Retry with same style","history.repolish.retrying":"Retrying…","history.repolish.apply":"Apply","history.repolish.applying":"Polishing…","history.repolish.pickStyle":"Pick a style pack","history.repolish.noPacks":"No style packs available.","history.repolish.packsLoadFailed":"Failed to load style packs: {{err}}","history.repolish.failed":"Re-polish failed: {{err}}","history.repolish.timeout":"The current LLM provider did not respond within 30 seconds. Switch to a faster provider, or try again later — free model pools often queue.","history.repolish.resultTitle":"Result from {{name}}","history.repolish.retryResultTitle":"Retry result","history.repolish.empty":"(the model returned an empty result)","history.repolish.clear":"Clear results","vocabCard.title":"Remember this word?","vocabCard.accept":"Remember","vocabCard.reject":"Skip","insertFallbackCard.copy":"Copy","insertFallbackCard.copied":"Copied","insertFallbackCard.copyFailed":"Copy failed","insertFallbackCard.dismiss":"Dismiss","vocab.selectAllVisible":"Select current results","vocab.selectedCount":"{{count}} words selected","vocab.selectWord":"Select “{{phrase}}”","vocab.deleteSelected":"Delete selected ({{count}})","vocab.batchDeleteFailed":"Could not delete {{count}} words. They remain selected so you can retry.","vocab.kicker":"DICTIONARY","vocab.title":"Dictionary","vocab.desc":"Add terms or jargon to improve recognition accuracy.","vocab.sectionTitle":"Entries","vocab.placeholder":"Type a word, press Enter or click Add…","vocab.tip":"Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically","vocab.loadFailed":"Load failed: {{err}}","vocab.empty":"No entries yet. Add a new term or piece of jargon above so the model can prioritize it.","vocab.tipDisabled":"Click to disable this entry","vocab.tipEnabled":"Click to enable this entry","vocab.removeAria":"Remove","vocab.edit":"Edit","vocab.editTitle":"Edit Word","vocab.editSave":"Save","vocab.editEmpty":"Word cannot be empty.","vocab.filter.all":"All","vocab.filter.auto":"Auto-Added","vocab.filter.manual":"Manually Added","vocab.searchPlaceholder":"Search","vocab.searchEmpty":"No matching words.","vocab.newWord":"New Word","vocab.newWordTitle":"Add New Words","vocab.newWordDesc":"Type a word directly, or import preset templates in bulk.","vocab.newWordInputPlaceholder":"Type a word, press Enter to add…","vocab.newWordTemplates":"Preset Templates","vocab.newWordTemplateCount":"{{count}} words","vocab.newWordAddSelected":"Add Selected","vocab.learnedSection":"Auto-collected ({{count}})","vocab.removeAllLearned":"Remove all","vocab.corrections.title":"Correction rules","vocab.corrections.tip":"Fix common ASR mistakes. Supports {num} number wildcard.","vocab.corrections.patternPlaceholder":"Mistaken text, e.g. {num}粒","vocab.corrections.replacementPlaceholder":"Target text, e.g. {num}例","vocab.corrections.empty":"No correction rules yet.","vocab.corrections.invalid":"Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.","vocab.corrections.tipDisabled":"Click to disable this rule","vocab.corrections.tipEnabled":"Click to enable this rule","vocab.corrections.removeAria":"Remove correction rule","vocab.corrections.learnedBadge":"auto","vocab.corrections.learnedTip":"Collected automatically from your own edits. Delete it any time.","vocab.corrections.onlyLearned":"Only auto-collected ({{count}})","vocab.corrections.removeAllLearned":"Delete all auto-collected","vocab.corrections.suggestTitle":"Remember this correction?","vocab.corrections.suggestAccept":"Remember","vocab.corrections.suggestDismiss":"No thanks","vocab.presets.title":"Scenario presets","vocab.presets.tip":"Multi-select to apply in batch. Supports edit and create.","vocab.presets.create":"New preset","vocab.presets.apply":"Apply selected","vocab.presets.save":"Save preset","vocab.presets.edit":"Edit {{name}}","vocab.presets.newPreset":"New preset","vocab.presets.namePlaceholder":"Preset name","vocab.presets.wordsPlaceholder":"Terms (comma or newline separated)","style.kicker":"STYLE","style.title":"Output style","style.desc":"Choose the default output style for recording.","style.masterToggle":"Master switch","style.currentDefault":"Current default","style.ariaSetDefault":"Set as default","style.saveFailed":"Save failed: {{error}}","style.customPromptTitle":"Custom prompt","style.customPromptPlaceholder":"Optional. Appended to this style’s built-in system prompt.","style.customPromptHint":"Leave empty to preserve current behavior. After saving, it applies to both this style’s live polish path and repolish. Press Ctrl/Cmd+Enter to save as well.","style.customPromptSave":"Save prompt","style.customPromptDirty":"Unsaved","style.systemPromptMovedHint":"Full system prompt editing has moved to Settings -> Providers. This page now only controls which styles are enabled and which one is the default.","style.modes.raw.name":"Raw","style.modes.raw.desc":"Only adds punctuation and natural breaks — no rewriting or expansion.","style.modes.raw.sample":"Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact.","style.modes.light.name":"Light polish","style.modes.light.desc":"Drops fillers, adds punctuation, and produces sendable natural prose.","style.modes.light.sample":"Makes the transcript flow well without sounding scripted — your tone and habits remain.","style.modes.structured.name":"Structured","style.modes.structured.desc":"Organize coding discussions, troubleshooting and product feedback with precise terminology.","style.modes.structured.sample":"1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point","style.modes.formal.name":"Formal","style.modes.formal.desc":"Email and workplace tone — more complete, more professional.","style.modes.formal.sample":"Detects greetings/sign-offs in email contexts; avoids empty pleasantries.","style.pack.builtinTags.minimalEdits":"Minimal edits","style.pack.builtinTags.strongCorrection":"Strong corrections","style.pack.builtinTags.communication":"Communication","style.pack.builtinTags.natural":"Natural","style.pack.builtinTags.organized":"Organized","style.pack.builtinTags.workplaceCommunication":"Work communication","style.pack.builtinTags.aiCoding":"AI coding","style.pack.builtinTags.technicalStructure":"Technical structure","style.pack.newName":"Untitled style","style.pack.newDescription":"Briefly describe when to use this style.","style.pack.uploadIcon":"Upload an SVG icon for {{name}}","style.pack.resetIcon":"Restore default icon","style.pack.iconSaved":"Icon saved","style.pack.iconInvalid":"Choose a valid SVG icon with no external resources (up to 256 KB).","style.pack.iconSaveFailed":"Could not save the icon. Please try again.","style.pack.selectionListTitle":"Selection polish styles","style.pack.selectionListDesc":"For selected written text without ASR: grammar, clarity and formatting polish. Pick a style and prompt for it separately.","style.pack.dictationTab":"Recording / ASR styles","style.pack.selectionTab":"Selection polish","style.pack.current":"Current","style.pack.useForSelection":"Use for selection","style.pack.writtenPolish":"Written polish","style.pack.selectionPromptTitle":"Selection polish prompt (no ASR)","style.pack.selectionPromptHint":"For user-selected written text; not ASR output. Do not treat it as a transcript or answer its questions.","style.pack.selectionPromptEditorDesc":"Editing the selection polish prompt; input is written text the user actively selected, without ASR.","style.pack.dictationPromptEditorDesc":"Editing the recording / ASR style prompt; input is ASR transcript text after dictation.","style.pack.dictationPromptTitle":"Recording / ASR prompt","style.pack.dictationPromptHint":"For ASR text after dictation; write spoken-language cleanup, ASR typo fixes and term restoration rules here.","style.pack.selectionPromptFallback":"No written polish prompt configured yet; a safe default will be used.","style.pack.selectionActivated":"Set \"{{name}}\" for selection polish.","style.pack.selectionActivateFailed":"Failed to switch selection polish style: {{err}}","style.pack.selectionChars":"{{count}} chars","style.pack.kicker":"STYLE PACKS","style.pack.title":"Style Packs","style.pack.desc":"Manage local style packs.","style.pack.marketplaceBtn":"Marketplace","style.pack.loadFailed":"Failed to load style packs: {{err}}","style.pack.importZip":"Import ZIP","style.pack.exportZip":"Export ZIP","style.pack.exportShort":"Export","style.pack.publishMarketplace":"Publish to Marketplace","style.pack.updateMarketplace":"Update Marketplace version","style.pack.publishDisabledHint":"Configure your GitHub login in Settings → Marketplace first","style.pack.publishSuccess":"Published — pending review on marketplace","style.pack.publishFailed":"Publish failed: {{err}}","style.pack.publishBuiltinRejected":"Built-in packs cannot be published. Clone first via edit.","style.pack.builtin":"Built-in","style.pack.imported":"Imported","style.pack.active":"Active","style.pack.activate":"Activate","style.pack.edit":"Edit","style.pack.closeEditor":"Close","style.pack.unsaved":"Unsaved","style.pack.listTitle":"Local Packs","style.pack.listDesc":"Browse and switch packs.","style.pack.listCount":"{{count}} packs","style.pack.addPackTileTitle":"New Pack","style.pack.addPackTileHint":"Start from a blank template.","style.pack.createSuccess":"New pack created.","style.pack.createFailed":"Failed to create pack: {{err}}","style.pack.save":"Save","style.pack.revert":"Revert","style.pack.saveSuccess":"Style pack saved.","style.pack.saveFailed":"Failed to save style pack: {{err}}","style.pack.activateSuccess":"Set \"{{name}}\" as current.","style.pack.activateFailed":"Failed to set current style pack: {{err}}","style.pack.importSuccess":"Imported \"{{name}}\".","style.pack.importFailed":"Failed to import ZIP: {{err}}","style.pack.exportSuccess":"Exported to {{path}}","style.pack.exportFailed":"Failed to export ZIP: {{err}}","style.pack.exportDirtyFirst":"Save this pack before exporting ZIP.","style.pack.resetBuiltin":"Reset","style.pack.resetSuccess":"Reset \"{{name}}\".","style.pack.resetFailed":"Failed to reset pack: {{err}}","style.pack.deleteImported":"Delete","style.pack.deleteConfirm":"Delete \"{{name}}\"? This cannot be undone.","style.pack.deleteSuccess":"Deleted \"{{name}}\".","style.pack.deleteFailed":"Failed to delete pack: {{err}}","style.pack.summaryCurrentEmpty":"No pack selected yet","style.pack.editorTitle":"Edit Pack","style.pack.editorDesc":"Edit this pack.","style.pack.metaTitle":"Installation Info","style.pack.metaSource":"Source","style.pack.metaBaseMode":"Base Mode","style.pack.metaUpdatedAt":"Updated","style.pack.fieldName":"Name","style.pack.fieldAuthor":"Author","style.pack.fieldAuthorPlaceholder":"Optional source label","style.pack.fieldVersion":"Version","style.pack.fieldTags":"Tags","style.pack.fieldTagsPlaceholder":"Comma-separated tags, e.g. community, voiceover, formal","style.pack.fieldDescription":"Description","style.pack.fieldModel":"Recommended Model (Metadata)","style.pack.fieldModelPlaceholder":"Optional, e.g. gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"Metadata only. Does not switch model.","style.pack.fieldCompatibility":"Compatible App Version","style.pack.fieldCompatibilityPlaceholder":"Optional, e.g. >=1.3.0","style.pack.fullPromptTitle":"System Prompt","style.pack.fullPromptHint":"The prompt owned by this pack.","style.pack.promptChars":"{{count}} chars","style.pack.runtimeTitle":"OpenLess Runtime Directives","style.pack.runtimeDesc":"Read-only runtime helpers.","style.pack.runtimeContextTitle":"Context premise","style.pack.runtimeContextDesc":"From language and app context","style.pack.runtimeContextEmpty":"Not added in the current preview.","style.pack.runtimeHotwordTitle":"Hotword block","style.pack.runtimeHotwordDesc":"From enabled hotwords","style.pack.runtimeHotwordEmpty":"Not added in the current preview.","style.pack.runtimeHistoryTitle":"Multi-turn history guardrail","style.pack.runtimeHistoryDesc":"Only for live multi-turn polish","style.pack.runtimeHistoryEmpty":"Only added when prior turns exist.","style.pack.runtimeActive":"Active","style.pack.runtimeInactive":"Inactive","style.pack.runtimePreviewFailed":"Failed to build runtime preview: {{err}}","style.pack.runtimePreviewOmittedFrontApp":"Preview omits the front-app label.","style.pack.examplesTitle":"Effect Examples","style.pack.examplesDesc":"Exported with the pack.","style.pack.addExample":"Add Example","style.pack.examplesEmpty":"No examples yet.","style.pack.exampleTitlePlaceholder":"Example {{index}} title","style.pack.exampleInput":"Input","style.pack.exampleOutput":"Output","style.pack.examplesCount":"{{count}} examples","style.pack.discardCloseConfirm":"Discard unsaved changes and close the editor?","style.pack.discardSwitchConfirm":"Discard unsaved changes and switch to \"{{name}}\"?","style.pack.derivativeBadge":"Derived from @{{login}}","translation.searchLanguages":"Search languages…","translation.noMatchingLanguages":"No matching languages","translation.selectedLanguages":"{{count}} languages selected","translation.languageSupportHint":"Available speech languages depend on your provider. Translation targets are independent of the app language.","translation.kicker":"TRANSLATION","translation.title":"Translation","translation.desc":"Auto-translate recordings into a target language before insertion.","translation.statusEnabled":"Enabled","translation.statusDisabled":"Disabled","translation.working.title":"Working languages","translation.working.desc":"Select languages you use regularly to improve polish and translation.","translation.target.title":"Translation target language","translation.target.desc":"Press Shift during recording to trigger translation. \"Disabled\" makes Shift a no-op.","translation.target.disabled":"Disabled (Shift does nothing)","translation.target.sameAsWorking":"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.","translation.style.title":"Translation style","translation.style.desc":"Automatically inherits the active style pack from the Style page.","translation.style.unavailable":"Unavailable","translation.save.workingFailed":"Failed to save working languages. Please try again.","translation.save.targetFailed":"Failed to save translation target. Please try again.","translation.save.hotkeyRegisterFailed":"Failed to register the translation shortcut. The preference was not saved.","translation.save.hotkeySaveFailed":"Failed to save the translation shortcut. Please try again.","translation.howto.title":"How to use","translation.howto.step1":"Place cursor in any text field.","translation.howto.step2":"Press {{trigger}} to start recording.","translation.howto.step3":"Press {{shortcut}} once during recording to activate translation.","translation.howto.step4":"Press {{trigger}} again to stop.","translation.howto.step5":"Translated text is inserted at the cursor.","translation.howto.indicatorTitle":"How to confirm translation mode is on","translation.howto.indicatorDesc":"A blue \"Translating\" indicator appears at the bottom of the screen after pressing Shift.","translation.howto.fallbackTitle":"Safety fallbacks","translation.howto.fallbackDesc":"If translation fails, the raw transcript is inserted instead.","selectionAsk.title":"Selection Ask","selectionAsk.desc":"Select text and ask questions by voice, with multi-turn follow-ups.","selectionAsk.shortcutSettings":"Shortcut settings","selectionAsk.guide.openTitle":"Open the panel","selectionAsk.guide.openDesc":"Press {{hotkey}} to start a conversation.","selectionAsk.guide.unsetDesc":"Assign a Selection Ask shortcut in Shortcut settings first.","selectionAsk.guide.selectTitle":"Select something to explore","selectionAsk.guide.askTitle":"Say your question","selectionAsk.guide.askDesc":"Press {{recordHotkey}} to record, then press again to submit.","selectionAsk.guide.followup":"Use the recording shortcut again to ask a follow-up.","selectionAsk.guide.dismiss":"Close the panel and end this conversation","selectionAsk.hotkey.title":"Hotkey to open the panel","selectionAsk.save.historySaveFailed":"Failed to save the Q&A history setting. Please try again.","selectionAsk.history.title":"Save history","selectionAsk.history.desc":"Save Q&A records locally when enabled. Off by default.","selectionAsk.howto.title":"How to use","selectionAsk.howto.step2":"Select text in any app.","settings.selectionWorkspace.title":"Selection Assistant","settings.selectionWorkspace.hint":"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.","settings.selectionWorkspace.polishHotkey":"Selection assistant shortcut","settings.selectionWorkspace.polishHotkeyDesc":"Polishes directly when voice edit is off; hold to speak when voice edit is on (recording follows global settings).","settings.selectionWorkspace.polishDelivery":"Result handling","settings.selectionWorkspace.voiceDeliveryDesc":"After voice edit: replace selection directly, or preview in Ask panel then confirm.","settings.selectionWorkspace.voiceEnable":"Voice edit","settings.selectionWorkspace.voiceEnableDesc":"Uses the same shortcut above; recording follows global settings (current: {{recordingLabel}}).","settings.selectionWorkspace.autoIntent":"Auto-classify intent","settings.selectionWorkspace.autoIntentDesc":"When on, the configured model classifies question vs edit by default; falls back to question-word heuristics if the model fails.","settings.selectionWorkspace.editKeywords":"Extra question cues","settings.selectionWorkspace.editKeywordsDesc":"Only when auto-classify is off; one cue per line forces Ask; otherwise use ? / question-word heuristics.","settings.selectionPolish.title":"Selection Polish","settings.selectionPolish.hotkey":"Trigger shortcut","settings.selectionPolish.hotkeyDesc":"Recorded shortcuts take effect immediately; conflicts with recording, Q&A or other global shortcuts are rejected.","settings.selectionPolish.delivery":"Result handling","settings.selectionPolish.hint":"Trigger after selecting any text. It does not need a microphone or ASR, and uses the current style pack with its dedicated selection prompt.","settings.selectionPolish.directReplace":"Replace directly","settings.selectionPolish.directReplaceHint":"Safely replaces the original selection after the model finishes.","settings.selectionPolish.previewConfirm":"Preview & confirm","settings.selectionPolish.previewConfirmHint":"Review the result in an editable window, then confirm to replace the original selection.","settings.kicker":"SETTINGS","settings.title":"Settings","settings.desc":"Recording, providers, shortcuts, and permissions.","settings.network.title":"Network","settings.network.useSystemProxyLabel":"Use system proxy","settings.network.useSystemProxyDesc":"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.","settings.dataStorage.title":"Data storage","settings.dataStorage.desc":"Conversation history and context kept on this device.","settings.dataStorage.cursorContextLabel":"Cursor context (experimental)","settings.dataStorage.cursorContextDesc":"While polishing, read the text around your cursor in the document you are writing, so the model can tell homophones, proper nouns and pronouns apart. When on, that text is sent to your configured LLM provider with the request; when off, nothing is read at all. Password fields, Secure Input, password managers and terminals are never read. macOS only.","settings.codingConsole.title":"Claude Console","settings.codingConsole.desc":"Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.","settings.codingConsole.guardNote":"Reversible actions are allowed by default; high-risk commands (rm -rf, sudo, force push) are blocked; if the working dir is a git repo, a snapshot is taken before each run for rollback.","settings.codingConsole.status":"Status","settings.codingConsole.detect":"Detect","settings.codingConsole.detecting":"Detecting…","settings.codingConsole.installed":"Claude detected","settings.codingConsole.notInstalled":"claude not found","settings.codingConsole.notInstalledHint":"Install Claude Code first (see docs.anthropic.com/claude-code), or enter the full path to its executable below.","settings.codingConsole.mcpServers":"{{count}} MCP server(s) configured","settings.codingConsole.computerUsePresent":"Desktop-control (computer use) MCP configured","settings.codingConsole.computerUseAbsent":"No desktop-control MCP (light actions like copy/paste work via Bash — not required)","settings.codingConsole.exePath":"Executable","settings.codingConsole.workdir":"Working directory","settings.codingConsole.workdirDesc":"Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.","settings.codingConsole.workdirPlaceholder":"Empty = run in a temp dir","settings.codingConsole.permissionMode":"Permission mode","settings.codingConsole.mode.acceptEdits":"Allow (reversible)","settings.codingConsole.mode.plan":"Read-only / plan","settings.codingConsole.mode.default":"Default (ask each)","settings.codingConsole.mode.bypassPermissions":"Full bypass (risky)","settings.codingConsole.promptPlaceholder":"Ask Claude to do something, e.g. list files in the current directory","settings.codingConsole.run":"Run","settings.codingConsole.running":"Running…","settings.codingConsole.cancel":"Cancel","settings.codingConsole.clear":"Clear","settings.codingConsole.riskWarn":"High-risk intent detected: {{reason}}. The guardrail blocks high-risk commands at execution time.","settings.codingConsole.toolUse":"tool {{name}}","settings.codingConsole.done":"Done","settings.codingConsole.doneCost":"Done · cost ${{cost}}","settings.codingConsole.cancelled":"Cancelled","settings.codingConsole.outputPlaceholder":"Output streams here…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"Hold a key, speak, and your selected agent operates your computer. macOS only.","settings.codingAgent.enable":"Enable Less Computer","settings.codingAgent.comingSoonNote":"Config is saved now; hotkey triggering and the execution flow land in a later version.","settings.codingAgent.hotkeyHint":"When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.","settings.codingAgent.voiceHotkey":"Hold-to-talk key","settings.codingAgent.voiceHotkeyDesc":"Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.","settings.codingAgent.provider":"Agent backend","settings.codingAgent.opencodeReady":"OpenCode v{{version}} detected.","settings.codingAgent.opencodeMissing":"opencode command not found. Install it (npm i -g opencode-ai) and sign in with opencode auth login before use.","settings.codingAgent.cliReady":"Detected {{name}} v{{version}}.","settings.codingAgent.cliMissing":"{{name}} command not found. Install and sign in first, or enter its absolute path under Executable below.","settings.codingAgent.sandboxGuardHint":"This backend only offers coarse sandbox levels, not a per-command high-risk list: when it hits a limit it reports the failure as-is instead of showing an \"approve this command\" card.","settings.codingAgent.codexModelHint":"Enter a Codex model name (e.g. gpt-5); leave empty to use the setting in ~/.codex/config.toml.","settings.codingAgent.codexBudgetHint":"Codex has no per-run USD budget cap; charges depend on your configured provider.","settings.codingAgent.codexMode.plan":"Read-only / plan","settings.codingAgent.codexMode.workspaceWrite":"Allow workspace writes","settings.codingAgent.codexModelPlaceholder":"Empty = Codex default","settings.codingAgent.dshModelHint":"dsh's headless profile has no model switch: the model is decided by dsh's own profile and cannot be changed here.","settings.codingAgent.panelHotkey":"Panel hotkey (voice agent)","settings.codingAgent.panelHotkeyDesc":"Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.","settings.codingAgent.quickHotkey":"Quick-take hotkey","settings.codingAgent.quickHotkeyDesc":"Take selected text → Claude → result back at the cursor. No panel, faster.","settings.codingAgent.model":"Model","settings.codingAgent.modelPlaceholder":"Default: sonnet","settings.codingAgent.modelDefault":"Default (auto sonnet)","settings.codingAgent.modelHint":"Haiku = fastest · Sonnet = balanced · Opus = strongest","settings.codingAgent.opencodeModelDefault":"Use OpenCode default model","settings.codingAgent.opencodeModelHint":"Automatically fetches provider/model choices available to the current OpenCode account and saves your selection immediately.","settings.codingAgent.opencodeModelsRefresh":"Refresh models","settings.codingAgent.opencodeModelsRefreshing":"Fetching OpenCode models…","settings.codingAgent.opencodeModelsLoaded":"Fetched {{count}} models.","settings.codingAgent.opencodeModelsEmpty":"No models were returned. Sign in to OpenCode or configure a model provider first.","settings.codingAgent.opencodeModelsError":"Failed to fetch models: {{message}}","settings.codingAgent.exe":"Executable path","settings.codingAgent.openPanel":"Text test","settings.codingAgent.openPanelHint":"Open the Less Computer panel and verify the current agent and model with text.","settings.codingAgent.openPanelAction":"Open Less Computer","settings.debug.cursorLabel":"Cursor","settings.debug.title":"Debug tools","settings.debug.desc":"For troubleshooting recognition issues; off by default.","settings.debug.cursorProbeLabel":"Cursor context probe","settings.debug.cursorProbeDesc":"Click, then switch to the target app and click into a text field before the countdown ends. The probe reads the text around your cursor there, so you can see which apps are readable and which the safety gate blocks. One read, sent to no provider.","settings.debug.cursorProbeBtn":"Probe (in 5s)","settings.debug.cursorProbeCountdown":"Reading in {{n}}s…","settings.marketplace.title":"Marketplace","settings.marketplace.desc":"Upload identity for the style marketplace. Browse and install styles on the Styles page.","settings.marketplace.github.signIn":"Sign in with GitHub","settings.marketplace.github.signedIn":"Signed in with GitHub","settings.marketplace.github.signedOut":"Sign in to upload styles and like packs.","settings.marketplace.github.signOut":"Sign out","settings.marketplace.github.starting":"Starting sign-in…","settings.marketplace.github.codeHint":"Enter this code on the GitHub page that just opened:","settings.marketplace.github.openGithub":"Open GitHub","settings.marketplace.github.waiting":"GitHub opened — you’ll be signed in once you authorize…","settings.marketplace.github.failed":"Sign-in failed, please retry","settings.recording.title":"Recording & input","settings.recording.desc":"Global recording hotkey and trigger mode.","settings.recording.hotkeyLabel":"Recording hotkey","settings.recording.hotkeyDescAcc":"Press to capture voice globally (requires Accessibility permission).","settings.recording.hotkeyDescNoAcc":"Press to capture voice globally.","settings.recording.modeLabel":"Trigger mode","settings.recording.modeDesc":"Toggle = tap once to start, again to stop. Push-to-talk = hold to record.","settings.recording.modeToggle":"Toggle","settings.recording.modeHold":"Push-to-talk","settings.recording.modeAuto":"Auto","settings.recording.silenceAutoStopLabel":"Auto-stop after silence","settings.recording.silenceAutoStopDesc":"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.","settings.recording.silenceAutoStopSecondsLabel":"Silence duration","settings.recording.silenceAutoStopSecondsValue":"{{value}}s","settings.recording.migrationNoticeTitle":"Default recording mode is now Toggle","settings.recording.migrationNoticeDesc":"This update changes the default; if you prefer push-to-talk, switch it back here.","settings.recording.microphoneLabel":"Preferred microphone","settings.recording.microphoneDesc":"Choose the preferred input device; falls back to system default when unavailable.","settings.recording.microphoneDefault":"System default microphone","settings.recording.microphoneDefaultDesc":"Use the system default input device","settings.recording.microphoneSystemDefault":"system default","settings.recording.microphoneUnavailable":"unavailable","settings.recording.microphoneLoadError":"Failed to load microphones: {{message}}","settings.recording.microphoneDialogTitle":"Microphone","settings.recording.microphoneDialogDesc":"Choose a microphone that can pick up your voice.","settings.recording.microphoneMonitorError":"Failed to monitor input level: {{message}}","settings.recording.capsuleLabel":"Recording capsule","settings.recording.capsuleDesc":"Show a translucent capsule at the bottom of the screen while recording.","settings.recording.capsuleStyleTypeless":"Typeless compact style","settings.recording.capsuleStyleLabel":"Capsule style","settings.recording.capsuleStyleSiri":"Shimmer Siri style","settings.recording.capsuleStyleClassic":"OpenLess default style","settings.recording.muteDuringRecordingLabel":"Mute while recording","settings.recording.muteDuringRecordingDesc":"Temporarily mute system output during voice input to avoid speaker echo.","settings.recording.audioCueLabel":"Recording start sound","settings.recording.audioCueDesc":"Play a short synthesized chime when you press the hotkey to start recording. Plays even when the capsule is hidden.","settings.recording.audioCuePreview":"Preview","settings.recording.insertGroupTitle":"Insertion & clipboard","settings.recording.restoreClipboardLabel":"Restore clipboard after insert","settings.recording.restoreClipboardDesc":"Restore your original clipboard after a successful paste (Windows / Linux only).","settings.recording.pasteShortcutLabel":"Simulated paste shortcut","settings.recording.pasteShortcutDesc":"Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).","settings.recording.pasteShortcutCtrlV":"Ctrl+V (default / most apps)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert (xterm / urxvt)","settings.recording.comboRecordLabel":"Record shortcut","settings.recording.comboRecordDesc":"Click, then press your desired key combination (e.g. ⌘⇧D). Supports Toggle and Push-to-talk modes.","settings.recording.comboRecordBtn":"Record shortcut","settings.recording.comboResetBtn":"Reset","settings.recording.comboMenuToggle":"More options","settings.recording.comboDisableHint":"Core hotkey cannot be disabled — recording needs a hotkey","settings.recording.comboRecordHint":"Press your shortcut combination…","settings.recording.comboNeedKey":"Use a key combo (e.g. ⌘⇧J); a lone modifier will not work","settings.recording.comboRecorded":"Recorded","settings.recording.comboClear":"Clear","settings.recording.comboConflict":"This shortcut combination is not available","settings.recording.allowNonTsfFallbackLabel":"Allow non-TSF fallback","settings.recording.allowNonTsfFallbackDesc":"Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.","settings.recording.windowsInsertionModeLabel":"Windows insertion method","settings.recording.windowsInsertionModeDesc":"How dictation output is inserted at the cursor. Clipboard paste uses the simulated paste shortcut above and preserves line breaks.","settings.recording.windowsInsertionModeTsf":"TSF IME (default)","settings.recording.windowsInsertionModeSendInput":"SendInput keystroke simulation","settings.recording.windowsInsertionModePaste":"Clipboard paste (Ctrl+V, etc.)","settings.recording.macosNewlineModeLabel":"Line breaks","settings.recording.macosNewlineModeDesc":"Auto uses Line Feed (U+000A / Ctrl+J) in known terminal apps and Shift+Return elsewhere. Plain Return sends the message.","settings.recording.macosNewlineModeAuto":"Auto (Line Feed in terminals)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return (newline in chat)","settings.recording.macosNewlineModeLineFeed":"Line Feed (terminal CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return (split into messages)","settings.recording.windowsSendInputNewlineModeLabel":"SendInput newline simulation","settings.recording.windowsSendInputNewlineModeDesc":"How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.","settings.recording.windowsSendInputNewlineModeEnter":"Enter (most editors)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter (chat input boxes)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"Show OpenLess in keyboard list","settings.recording.windowsShowOpenlessInKeyboardListDesc":"When off, Win+Space will not cycle to OpenLess. SendInput and clipboard-paste insertion are unaffected. Turn this back on to restore the entry.","settings.recording.windowsShowOpenlessInKeyboardListError":"Could not update the keyboard list: the system rejected changing the OpenLess language profile.","settings.recording.historyGroupTitle":"History & context","settings.recording.historyRetentionLabel":"History retention (days)","settings.recording.historyRetentionDesc":"Entries older than this are pruned on new writes; 0 = no time-based pruning.","settings.recording.historyMaxEntriesLabel":"Max history entries","settings.recording.historyMaxEntriesDesc":"Max sessions retained locally. Blank = 200. Range 5–200.","settings.recording.polishContextWindowLabel":"Polish context window (minutes)","settings.recording.polishContextWindowDesc":"Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.","settings.recording.recordAudioForDebugLabel":"Keep raw recording (debug)","settings.recording.recordAudioForDebugDesc":"Save raw microphone audio as wav for diagnosing recognition issues.","settings.recording.audioRecordingMaxEntriesLabel":"Max raw recordings","settings.recording.audioRecordingMaxEntriesDesc":"Max wav files retained locally. Blank = 200.","settings.recording.startupGroupTitle":"Startup","settings.recording.startMinimizedLabel":"Start minimized (no main window)","settings.recording.startMinimizedDesc":"No main window on any launch path — menu bar / tray only.","settings.recording.autoUpdateCheckLabel":"Auto-check for updates","settings.recording.autoUpdateCheckDesc":"Check for updates on launch and every 60 minutes.","settings.recording.marketplaceGroupTitle":"Style Pack Marketplace","settings.recording.marketplaceBaseUrlLabel":"Backend URL","settings.recording.marketplaceBaseUrlDesc":"Marketplace backend URL. Blank uses the default.","settings.recording.marketplaceDevLoginLabel":"GitHub login (upload identity)","settings.recording.marketplaceDevLoginDesc":"Identifies the uploader. Blank disables upload and likes.","settings.recording.startupAtBoot":"Launch at login","settings.recording.startupAtBootDesc":"Start OpenLess automatically when you sign in.","settings.recording.startupAtBootError":"Failed to toggle launch at login: {{message}}","settings.channels.backToList":"Back to channels","settings.channels.done":"Done","settings.channels.llmTitle":"Text processing channels","settings.channels.asrTitle":"Speech recognition channels","settings.channels.current":"Currently used","settings.channels.enabled":"Enabled","settings.channels.disabled":"Disabled","settings.channels.enabledFor":"Enable {{name}}","settings.channels.modelNotSet":"No model set explicitly","settings.channels.localModelManaged":"Model managed by the system or Local models","settings.channels.lastCheck":"Last check","settings.channels.verifying":"Checking…","settings.channels.notVerified":"Not checked yet","settings.channels.passed":"Check passed","settings.channels.failed":"Check failed · {{reason}}","settings.channels.elapsed":"Took {{ms}} ms","settings.channels.staleResult":"Result is over 24 hours old","settings.channels.connectionTitle":"Service connection","settings.channels.modelTitle":"Model settings","settings.channels.modelHint":"Enter a model name directly, or fetch and select a model from your provider.","settings.channels.availableModels":"Available models","settings.channels.validationTitle":"Connection check","settings.channels.validationHint":"Manually send a real request to check this configuration. It may use service credits. Saving settings does not run a check.","settings.channels.autoSaveHint":"Changes save automatically. Once configured, you can check the connection manually.","settings.channels.nameHint":"This name distinguishes channels from the same provider. It does not affect the model or connection.","settings.channels.errModel":"Model","settings.channels.verify":"Verify","settings.channels.verifyHint":"Makes one real API call to check this channel works right now","settings.channels.errTimeout":"timeout","settings.channels.errNetwork":"network","settings.channels.errEndpoint":"endpoint","settings.channels.errGeneric":"failed","settings.channels.dragHint":"Drag to change priority","settings.channels.orderHint":"Requests use the first enabled channel. Drag to reorder; disabled channels move to the bottom.","settings.channels.empty":"No channels yet. Choose \"Add channel\" to connect your first service.","settings.channels.add":"Add channel","settings.channels.edit":"Edit","settings.channels.createTitle":"Add channel","settings.channels.editTitle":"Edit channel","settings.channels.providerLabel":"Provider","settings.channels.nameLabel":"Channel name (optional)","settings.channels.namePlaceholder":"e.g. SiliconFlow — main key","settings.channels.create":"Create","settings.channels.delete":"Delete channel","settings.channels.deleteConfirm":"Deleting also clears the keys stored for this channel.","settings.channels.confirmDelete":"Delete","settings.channels.justNow":"just now","settings.channels.minutesAgo":"{{count}}m ago","settings.channels.hoursAgo":"{{count}}h ago","settings.channels.daysAgo":"{{count}}d ago","settings.channels.localEngineModelHint":"Download and switch local models under AI services & models → Local models.","settings.providers.localEngineNoCredentials":"Local engines need no API key or endpoint.","settings.providers.localModelLabel":"Local model","settings.providers.localModelEmpty":"No local model downloaded yet","settings.providers.appleSpeechLocalNote":"Apple Speech uses the system built-in engine — no model selection needed.","settings.providers.localEngineNote":"Downloaded local models are selectable directly in the dropdown above; download and manage more under Local models.","settings.providers.localTag":"Local","settings.providers.llmTitle":"LLM (polishing)","settings.providers.llmDesc":"OpenAI-compatible protocol. Multiple vendors supported.","settings.providers.providerLabel":"Provider","settings.providers.llmProviderDesc":"Selecting a preset auto-fills the default Base URL.","settings.providers.credentialStorageNotice":"Credentials are stored in the OS credential vault.","settings.providers.codexOAuthNotice":"Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.","settings.providers.asrProviderDesc":"Switching providers automatically loads the matching credentials.","settings.providers.asrTitle":"ASR (transcription)","settings.providers.asrDesc":"Used to turn recorded speech into text.","settings.providers.omniTitle":"Multimodal model","settings.providers.omniDesc":"One model that turns audio + prompt into the final text directly (experimental pipeline).","settings.providers.pipelineModeLabel":"Pipeline mode","settings.providers.pipelineModeHint":"Traditional = ASR + LLM two-stage. Multimodal = a single audio-capable model in one pass.","settings.providers.pipelineModeTraditional":"Traditional","settings.providers.pipelineModeMultimodal":"Multimodal","settings.providers.pipelineIsolationNotice":"The two modes keep fully separate credentials. Switching modes keeps the other set stored but unused; switching back restores it.","settings.providers.presets.ark":"ARK (Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter (free models)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax (M3)","settings.providers.presets.stepfun":"StepFun","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"Tencent Cloud TokenHub","settings.providers.presets.customChatCompletions":"Custom · Chat Completions","settings.providers.presets.customResponses":"Custom · Responses","settings.providers.presets.customMessages":"Custom · Messages","settings.providers.presets.custom":"Custom","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrBailian":"Alibaba Bailian realtime ASR","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 Realtime ASR","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash (recorded file)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper (compatible)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"Custom OpenAI-compatible","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek Realtime ASR","settings.providers.presets.asrTencentCloud":"Tencent Cloud Hunyuan Realtime ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"Local sherpa-onnx (Experimental)","settings.providers.presets.asrFoundryLocalWhisper":"Local Whisper (Foundry Local)","settings.providers.presets.asrLocalWhisper":"Local Whisper (batch)","settings.providers.presets.asrLocalQwen3":"Local Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"Local Qwen3-ASR (MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"Local Qwen3-ASR (C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple Speech (macOS)","settings.providers.presets.omniOpenai":"OpenAI (audio-capable)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs uploads recorded audio to the configured endpoint for batch transcription.","settings.providers.zenmuxVocabularyNote":"ZenMux uses a JSON transcription protocol and does not receive dictionary hotwords (prompt/hotwords); the dictionary still feeds the polish step but does not bias speech recognition.","settings.providers.asrAdvancedNote":"Advanced options below only affect the Custom OpenAI-compatible and ZenMux presets; other named provider presets keep their built-in behavior.","settings.providers.asrAdvancedVerboseJsonLabel":"Segment metrics (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"Requests segment metrics for hallucination filtering when the server supports it; keep off for self-hosted servers that do not.","settings.providers.asrAdvancedChunkLabel":"Chunk duration (ms)","settings.providers.asrAdvancedChunkHint":"0 = no chunking, send the whole clip at once. Chunked requests suit long recordings or servers with per-request duration limits.","settings.providers.asrAdvancedEnableItnLabel":"Number normalization (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"Normalizes spoken numbers/units into Arabic numerals (e.g. “twenty twenty-six” → “2026”). Turn off to keep the raw text.","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API Key","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"Auth mode","settings.providers.volcengineAuthModeAppIdToken":"Legacy app (APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"API Key (new console)","settings.providers.volcengineMappingNote":"Secret Key is not required right now. Resource ID defaults to volc.seedasr.sauc.duration.","settings.providers.volcengineApiKeyNote":"Authenticate with an API Key created in the new speech console — no APP ID needed. Create it under API Keys management: console.volcengine.com/speech/new/setting/apikeys. Resource ID defaults to volc.seedasr.sauc.duration.","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API Key","settings.providers.xfyunNote":"Get AppID and API Key from the iFlytek Open Platform \"Realtime ASR\" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.","settings.providers.tencentCloudAppIdLabel":"Tencent Cloud AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Uses Tencent Cloud Speech Recognition API credentials. The default Hy-ASR-3.0-preview supports Chinese, English, and 20 dialects; Preview accepts only mono 16 kHz PCM up to 60 seconds and does not yet support context or hotword boosting.","settings.providers.tencentTokenHubNote":"Only online language models are listed. Some models always use reasoning; turning reasoning off keeps that model's fixed behavior.","settings.providers.localAsrActiveNotice":"Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.","settings.providers.localAsrTakeoverHint":"Once \"{{name}}\" is enabled, the ASR provider will be taken over.","settings.providers.asrProviderTakenOver":"A local engine is active. Pick another provider in the dropdown above to switch (the local engine stops automatically); manage local models under Services → Local models.","settings.providers.localAsrHint":"Runs on this machine, no API key needed. Download the model from HuggingFace.","settings.providers.foundryLocalAsrHint":"Runs on this device, no ASR API key needed. First use downloads runtime components and model.","settings.providers.localAsrPerformanceWarning":"Local inference is slower than cloud ASR with potentially lower Chinese accuracy. Best for offline or privacy-sensitive use.","settings.providers.localAsrReady":"{{model}} downloaded","settings.providers.localAsrNotReady":"{{model}} not downloaded","settings.providers.localAsrGoDownload":"Open Models page to download","settings.providers.localAsrManage":"Open Models page","settings.providers.localAsrDownloadedTitle":"Downloaded models","settings.providers.localAsrDelete":"Delete","settings.providers.fillDefault":"Fill default value","settings.providers.readFailed":"Read failed","settings.providers.apiKeyLabel":"API Key","settings.providers.baseUrlLabel":"Base URL","settings.providers.modelLabel":"Model","settings.providers.customModelLabel":"Custom model…","settings.providers.presetListLabel":"Back to presets","settings.providers.searchModels":"Search models…","settings.providers.noMatchingModels":"No matching models","settings.providers.orcarouterCatalogHint":"Loaded from OrcaRouter /models. Select a catalog model; manual model IDs are disabled for this provider.","settings.providers.orcarouterAsrCatalogHint":"Loaded from OrcaRouter /models and limited to Gemini models compatible with audio input. Manual model IDs are disabled.","settings.providers.temperatureLabel":"Temperature","settings.providers.temperaturePlaceholder":"Leave empty to omit; range 0–2 inclusive, e.g. 0.3","settings.providers.extraHeadersLabel":"Extra headers","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"Thinking","settings.providers.thinkingModeOn":"On","settings.providers.thinkingModeOff":"Off","settings.providers.requestFormatLabel":"Request format","settings.providers.messagesThinkingLabel":"Thinking mode","settings.providers.thinkingAdaptive":"Adaptive","settings.providers.thinkingBudget":"Fixed budget","settings.providers.maxTokensLabel":"Maximum output tokens","settings.providers.thinkingBudgetLabel":"Thinking budget tokens","settings.providers.responsesThinkingHint":"Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.","settings.providers.messagesThinkingHint":"Older models or compatible gateways may need a fixed budget below the output limit. Thinking requests omit temperature.","settings.providers.llmRequestFormatInvalid":"Invalid request format. Select a supported format.","settings.providers.llmThinkingModeInvalid":"Invalid thinking mode. Select a supported mode.","settings.providers.llmTokenLimitInvalid":"Token limits must be positive integers.","settings.providers.llmThinkingBudgetInvalid":"Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.","settings.providers.llmResponseIncomplete":"The response was incomplete or reached its output limit. Already emitted text is retained.","settings.providers.llmProtocolHeaderConflict":"Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.","settings.providers.llmStreamError":"The server returned a stream error. Check the model and request parameters.","settings.providers.saveProtocol":"Save protocol settings","settings.providers.thinkingModeHint":"Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.","settings.providers.bailianVocabularyIdLabel":"Hotword Vocabulary ID (optional)","settings.providers.bailianVocabularyIdNote":"If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.","settings.providers.bailianModelRealtimeHint":"Realtime model · transcribes as you speak.","settings.providers.bailianModelSyncFileHint":"Synchronous recording model · transcribes after you finish (single clip ≤ 5 min).","settings.providers.bailianModelAsyncFileHint":"Asynchronous file model · uploads the recording and waits for the transcription task.","settings.providers.appIdLabel":"App ID","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Resource ID","settings.providers.toolsLabel":"Connection check","settings.providers.toolsDesc":"Save the fields above, then validate the selected model or fetch models. Manual model input remains available if fetching fails.","settings.providers.validate":"Validate","settings.providers.validating":"Validating…","settings.providers.fetchModels":"Fetch models","settings.providers.loadingModels":"Fetching models…","settings.providers.modelMissing":"No model is configured. Please enter a model ID first.","settings.providers.modelsEmpty":"Credentials are valid, but no models were returned.","settings.providers.modelsLoaded":"Fetched {{count}} models.","settings.providers.selectModel":"Select a model to fill the field above","settings.providers.modelSaved":"Saved model {{model}}.","settings.providers.validateSuccess":"Connection check passed.","settings.providers.validateFailed":"Connection check failed.","settings.providers.providerHttpStatus":"Provider returned HTTP {{status}}. Check the API key permissions or endpoint.","settings.providers.endpointMustUseHttps":"HTTP endpoints are allowed, but API keys and audio content may leak in transit.","settings.providers.endpointHttpWarning":"HTTP endpoints are allowed, but API keys and request content may leak in transit.","settings.providers.endpointInvalid":"Endpoint format is invalid.","settings.providers.bailianEndpointSchemeInvalid":"Bailian realtime ASR uses the DashScope WebSocket gateway: the endpoint must start with wss:// (default: wss://dashscope.aliyuncs.com/api-ws/v1/inference/). An https:// compatible-mode URL will not work here.","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 realtime ASR uses the DashScope Realtime WebSocket gateway: the endpoint must start with wss:// (default: wss://dashscope.aliyuncs.com/api-ws/v1/realtime). An https:// URL will not work here.","settings.providers.responseTooLarge":"Provider response is too large to validate safely.","settings.providers.asrInvalidJson":"ASR response is not valid JSON.","settings.providers.asrMissingTextField":"ASR response is missing the text field.","settings.providers.apiKeyMissing":"API Key is empty.","settings.providers.endpointMissing":"Endpoint is empty.","settings.providers.volcengineAppIdMissing":"APP ID is empty.","settings.providers.volcengineAccessTokenMissing":"Access Token is empty.","settings.providers.requestTimeout":"Request timed out. Try again later.","settings.shortcuts.title":"Shortcut settings","settings.shortcuts.descAcc":"All shortcuts apply globally. Accessibility permission must be granted in Permissions.","settings.shortcuts.descNoAcc":"All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.","settings.shortcuts.startStop":"Start / Stop recording","settings.shortcuts.cancel":"Cancel current recording","settings.shortcuts.confirm":"Confirm capsule insertion","settings.shortcuts.switchStyle":"Switch to previous style","settings.shortcuts.openApp":"Open OpenLess","settings.shortcuts.stylePackTitle":"Style shortcuts","settings.shortcuts.stylePackDesc":"Bind a shortcut to each favorite style pack for one-press switching; disabled packs are re-enabled automatically.","settings.shortcuts.stylePackAdd":"Add style shortcut","settings.shortcuts.stylePackSelect":"Choose a style pack","settings.shortcuts.stylePackDisabledSuffix":" (disabled)","settings.shortcuts.stylePackRemove":"Remove","settings.shortcuts.agentPolish":"Polish selected text","settings.shortcuts.agentPolishDesc":"Select text → press → Claude polishes it → replaces the selection.","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"Hold a custom key → speak → Claude runs the task → result shown in a capsule.","settings.shortcuts.agentVoiceHint":"Set the hold-to-talk key under Advanced → Less Computer.","settings.shortcuts.agentVoiceTrigger":"Less Computer hold-to-talk key","settings.shortcuts.enable":"Enable","settings.shortcuts.disable":"Disable","settings.shortcuts.confirmHint":"Click ✓ on the capsule","settings.shortcuts.notSupported":"Not yet supported","settings.shortcuts.androidReadOnly":"Global shortcuts are not available on Android. Use the record button on the overview page.","settings.permissions.title":"Permissions","settings.permissions.descAcc":"OpenLess needs the following system permissions to work. After granting, fully quit and relaunch the app for changes to take effect.","settings.permissions.descNoAcc":"OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.","settings.permissions.micLabel":"Microphone","settings.permissions.micDesc":"Used to capture your voice input.","settings.permissions.accLabel":"Accessibility","settings.permissions.accDesc":"Used to listen to the global hotkey and write transcripts at the cursor.","settings.permissions.hotkeyLabel":"Global hotkey","settings.permissions.hotkeyDescWithAdapter":"Active adapter: {{adapter}}. Used to confirm the hotkey listener is installed.","settings.permissions.hotkeyDescPlain":"Used to confirm the hotkey listener is installed.","settings.permissions.networkLabel":"Network","settings.permissions.networkDesc":"Required for cloud ASR / LLM calls. Disable for local-only mode.","settings.permissions.networkOk":"Available","settings.permissions.networkOffline":"Unavailable","settings.permissions.checking":"Checking…","settings.permissions.granted":"Granted","settings.permissions.notApplicable":"Not required","settings.permissions.denied":"Not granted","settings.permissions.indeterminate":"Undetermined","settings.permissions.micNoDevice":"No microphone detected","settings.permissions.openSystem":"Open System Settings","settings.permissions.restart":"Reset and Restart","settings.permissions.grant":"Grant","settings.permissions.rerunAndroidSetup":"Run setup again","settings.permissions.hotkeyInstalled":"Installed","settings.permissions.hotkeyStarting":"Installing…","settings.permissions.hotkeyFailed":"Listener failed","settings.permissions.windowsImeLabel":"Windows input method backend","settings.permissions.windowsImeDesc":"Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.","settings.permissions.windowsImeInstalled":"Installed","settings.permissions.windowsImeUnavailable":"Unavailable","settings.permissions.androidImeLabel":"Input method (IME)","settings.permissions.androidImeSelected":"Selected","settings.permissions.androidImeEnabled":"Enabled","settings.permissions.androidImeDisabled":"Not enabled","settings.permissions.androidOverlayLabel":"Floating overlay","settings.permissions.androidAccessibilityLabel":"Accessibility service","settings.permissions.androidAccessibilityImpact":"Enable it to output results to the current input field without switching keyboards. If disabled, results are copied to the clipboard for manual paste.","settings.permissions.androidAccessibilityGrantedStale":"Authorized, not connected","settings.permissions.androidAccessibilityMessages.not_android":"Accessibility status is only available on Android.","settings.permissions.androidAccessibilityMessages.not_enabled":"Enable OpenLess in system accessibility settings.","settings.permissions.androidAccessibilityMessages.operational":"Accessibility service is running.","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"Accessibility is authorized but not connected. Re-enable OpenLess in system settings.","settings.permissions.androidAccessibilityMessages.status_read_failed":"Could not read accessibility status.","settings.permissions.androidShizukuLabel":"Shizuku enhancement","settings.permissions.androidShizukuHint":"Optional. Best-effort recovery when OEM settings block manual toggles; cannot fully eliminate cross-app race conditions. Shizuku may need to be restarted after a reboot.","settings.permissions.androidShizukuOpenApp":"Open Shizuku","settings.permissions.androidShizukuRequestPermission":"Request authorization","settings.permissions.androidShizukuRecover":"Recover accessibility","settings.permissions.androidShizukuRecoverConfirm":"Use Shizuku to try re-enabling the OpenLess accessibility service? OpenLess will merge with services already enabled when the write starts. If the global accessibility switch is off, enabling it may also start other registered services.","settings.permissions.androidShizukuYes":"yes","settings.permissions.androidShizukuNo":"no","settings.permissions.androidShizukuAccessibilityOperational":"Accessibility is registered and running.","settings.permissions.androidShizukuAccessibilityRegistered":"Registered: {{registered}} · Running: {{operational}}","settings.permissions.androidShizukuState.notInstalled":"Not installed","settings.permissions.androidShizukuState.notRunning":"Not running","settings.permissions.androidShizukuState.notAuthorized":"Not authorized","settings.permissions.androidShizukuState.authorized":"Authorized","settings.permissions.androidShizukuState.binderDead":"Disconnected","settings.permissions.androidShizukuState.notAndroid":"N/A","settings.permissions.androidShizukuMessages.not_android":"Shizuku is only available on Android.","settings.permissions.androidShizukuMessages.not_installed":"Shizuku or Sui backend is not installed.","settings.permissions.androidShizukuMessages.unsupported_backend":"This Shizuku backend is too old. Update Shizuku or Sui to v11 or newer.","settings.permissions.androidShizukuMessages.not_running":"Shizuku is not running. Start Shizuku or Sui first.","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku is not authorized. Grant OpenLess permission.","settings.permissions.androidShizukuMessages.binder_dead":"Shizuku connection lost. Restart Shizuku.","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku authorized. Accessibility is running.","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku authorized. Accessibility is registered but not running.","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku authorized. You can try recovering accessibility.","settings.permissions.androidShizukuMessages.operational":"Accessibility is registered and running.","settings.permissions.androidShizukuMessages.registered_stale":"Accessibility is registered, but the service is currently unavailable.","settings.permissions.androidShizukuMessages.not_registered":"Accessibility is not enabled in system settings.","settings.permissions.androidShizukuMessages.already_granted":"Shizuku permission was already granted.","settings.permissions.androidShizukuMessages.binder_unavailable":"Shizuku binder was unavailable during the permission request.","settings.permissions.androidShizukuMessages.request_cancelled":"Shizuku permission request was cancelled.","settings.permissions.androidShizukuMessages.granted":"Shizuku permission granted.","settings.permissions.androidShizukuMessages.denied":"Shizuku permission denied.","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Shizuku authorization was blocked. Open Shizuku and allow OpenLess manually.","settings.permissions.androidShizukuMessages.launched":"Opened Shizuku authorization.","settings.permissions.androidShizukuMessages.launch_failed":"Could not open Shizuku authorization.","settings.permissions.androidShizukuMessages.open_shizuku":"Opened Shizuku manager.","settings.permissions.androidShizukuMessages.jni_error":"Could not reach the Android Shizuku backend.","settings.permissions.androidShizukuMessages.status_parse_failed":"Could not parse Shizuku status.","settings.permissions.androidShizukuMessages.user_not_confirmed":"Recovery requires user confirmation.","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku is not authorized or unavailable.","settings.permissions.androidShizukuMessages.invalid_component":"Invalid accessibility service component ID.","settings.permissions.androidShizukuMessages.service_connect_failed":"Could not connect to the Shizuku privileged service.","settings.permissions.androidShizukuMessages.recovery_in_progress":"Another recovery is already in progress.","settings.permissions.androidShizukuMessages.parse_failed":"Could not parse the recovery result.","settings.permissions.androidShizukuMessages.service_not_bound":"Settings were written but accessibility is not running yet.","settings.permissions.androidShizukuMessages.success":"Accessibility service recovered.","settings.permissions.androidShizukuMessages.read_failed":"Could not read accessibility settings.","settings.permissions.androidShizukuMessages.read_enabled_failed":"Could not read accessibility enabled flag.","settings.permissions.androidShizukuMessages.merge_failed":"Could not merge accessibility services.","settings.permissions.androidShizukuMessages.write_services_failed":"Could not write enabled accessibility services.","settings.permissions.androidShizukuMessages.write_enabled_failed":"Could not enable accessibility.","settings.permissions.androidShizukuMessages.readback_failed":"Could not verify accessibility settings after write.","settings.permissions.androidShizukuMessages.oem_rollback":"The OEM rolled back the accessibility write.","settings.permissions.androidShizukuMessages.concurrent_change":"Accessibility settings changed during recovery.","settings.permissions.androidShizukuMessages.partial_rollback":"Recovery failed and settings could only be partially restored. Check system accessibility settings.","settings.permissions.androidShizukuMessages.manual_required":"Automatic recovery cannot safely enable accessibility while other registered services are present with the global switch off. Use system settings instead.","settings.permissions.androidShizukuMessages.max_retries":"Recovery failed after multiple attempts.","settings.permissions.androidShizukuMessages.internal_error":"Recovery failed due to an internal error.","settings.permissions.androidShizukuMessages.unknown":"Unknown Shizuku status.","settings.permissions.androidInsertStrategyLabel":"Text insertion strategy","settings.permissions.androidOverlayTriggerLabel":"Overlay visibility","settings.permissions.androidOverlayActivationModeLabel":"Overlay activation","settings.permissions.androidOverlayLeftSwipeActionLabel":"Left swipe action","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"Cancel swipe direction","settings.permissions.androidOverlaySizeLabel":"Overlay size","settings.permissions.androidOverlaySizeHint":"Adjusts the floating button diameter and keeps its current position.","settings.permissions.androidInsertStrategy.accessibility":"Auto output to input field","settings.permissions.androidInsertStrategy.clipboard":"Clipboard only","settings.permissions.androidInsertStrategyHint.accessibility":"Requires accessibility; falls back to clipboard when unavailable.","settings.permissions.androidInsertStrategyHint.clipboard":"No accessibility permission required; copies only for manual paste.","settings.permissions.androidOverlayTrigger.background":"When app is backgrounded","settings.permissions.androidOverlayTrigger.keyboard":"When keyboard appears","settings.permissions.androidOverlayTrigger.always":"Always visible","settings.permissions.androidOverlayTriggerHint.background":"Simple and battery-friendly; no overlay while typing in other apps.","settings.permissions.androidOverlayTriggerHint.keyboard":"This mode is shelved. Existing settings are moved back to background.","settings.permissions.androidOverlayTriggerHint.always":"Always available, but permanently on screen.","settings.permissions.androidOverlayTriggerDisabled.keyboard":"Keyboard-triggered display is shelved. Overlay gestures will replace keyboard detection.","settings.permissions.androidOverlayActivationMode.tap":"Tap to arm","settings.permissions.androidOverlayActivationMode.long_press":"Long press to arm","settings.permissions.androidOverlayActivationModeHint.tap":"First tap arms the overlay; second tap starts normal dictation.","settings.permissions.androidOverlayActivationModeHint.long_press":"Hold to arm the overlay; release stops the current recording or QA turn.","settings.permissions.androidOverlayLeftSwipeAction.translation":"Translation dictation","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"Switch style pack","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"Left swipe while armed starts translation dictation.","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"Left swipe while armed switches to the previous style pack.","settings.permissions.androidOverlayCancelSwipeDirection.up":"Swipe up","settings.permissions.androidOverlayCancelSwipeDirection.down":"Swipe down","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"Swipe up while recording to cancel without transcription or insertion.","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"Swipe down while recording to cancel without transcription or insertion.","settings.permissions.windowsIme.installed":"Installed. Voice input temporarily switches to the OpenLess IME.","settings.permissions.windowsIme.notInstalled":"Not installed. OpenLess is using the clipboard/WM_PASTE fallback.","settings.permissions.windowsIme.registrationBroken":"Registration is broken. Reinstall the OpenLess IME.","settings.permissions.windowsIme.notWindows":"Only available on Windows.","settings.advanced.multimodalPipelineTitle":"Multimodal recognition pipeline","settings.advanced.multimodalPipelineTitleHint":"One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.","settings.advanced.multimodalPipelineLabel":"Enable multimodal pipeline","settings.advanced.multimodalPipelineHint":"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.","settings.advanced.streamingInsertTitle":"Streaming insertion","settings.advanced.streamingInsertTitleLinux":"Streaming insertion (Experimental)","settings.advanced.streamingInsertDesc":"Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.","settings.advanced.streamingInsertLabel":"Streaming insertion","settings.advanced.streamingInsertHintMac":"Temporarily switches the input source to ABC so CJK IMEs cannot intercept keystrokes; restored on session end.","settings.advanced.streamingInsertHintWindows":"SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.","settings.advanced.streamingInsertHintLinux":"Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.","settings.advanced.streamingInsertSaveClipboardLabel":"Copy to clipboard","settings.advanced.streamingInsertSaveClipboardHint":"After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.","settings.advanced.localAsrTitle":"Local ASR models","settings.advanced.localAsrDesc":"Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.","settings.advanced.localAsrWarningShort":"Local inference is slower; under-spec hardware may drop words.","settings.advanced.qwen3Desc":"Once enabled, the ASR provider will be taken over.","settings.advanced.sherpaDesc":"Once enabled, the ASR provider will be taken over.","settings.advanced.foundryDesc":"Once enabled, the ASR provider will be taken over.","settings.advanced.notSupportedHere":"Not supported on this platform — no inference module bundled.","settings.advanced.enable":"Enable","settings.advanced.alreadyActive":"Active","settings.advanced.disableLocalLabel":"Disable local ASR","settings.advanced.disableLocalDesc":"Switch back to cloud ASR (defaults to Volcengine bigasr).","settings.advanced.disable":"Disable","settings.advanced.platformNotSupported":"Local ASR model integration is not supported on this platform.","settings.advanced.confirmEnableLocalTitle":"Enable local ASR?","settings.advanced.confirmEnableLocalBody":"Transcription will be slower than cloud and potentially less accurate.","settings.advanced.confirm":"Enable","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"Interface language","settings.language.desc":"Switch the UI language. Applies to the current session immediately and persists across launches.","settings.language.label":"Language","settings.language.labelDesc":"Choose \"Follow system\" to match the OS language at launch.","settings.language.followSystem":"Follow system","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"Some native menus (system tray, etc.) may require an app restart to fully switch.","settings.layout.title":"Layout","settings.theme.title":"Appearance","settings.theme.label":"Theme","settings.theme.activityHeatmapLabel":"Show annual activity heatmap on Overview","settings.theme.stackedRowLayoutLabel":"Readable layout (wrap rows)","settings.theme.stackedRowLayoutDesc":"On small screens or with large text, buttons and controls that no longer fit on one line move to the next line instead of overflowing or squashing text.","settings.theme.conservativeLayoutLabel":"Conservative layout","settings.theme.conservativeLayoutDesc":"Outside the home page, top bar, and bottom bar, settings and feature pages use a single full-width column to minimize horizontal overflow.","settings.theme.system":"Follow system","settings.theme.light":"Light","settings.theme.dark":"Dark","settings.remoteInput.title":"Remote Input","settings.remoteInput.enableLabel":"Enable remote input","settings.remoteInput.enableDesc":"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)","settings.remoteInput.portLabel":"Port","settings.remoteInput.defaultModeLabel":"Default recording mode","settings.remoteInput.modeToggle":"Tap to toggle","settings.remoteInput.modeHold":"Hold to talk","settings.remoteInput.urlLabel":"Access URL","settings.remoteInput.pinLabel":"Pairing code","settings.remoteInput.regeneratePin":"Regenerate","settings.remoteInput.portInUse":"Port {{port}} is in use, please change it","settings.remoteInput.startError":"Failed to start the remote input service: {{reason}}","settings.remoteInput.securityHint":"Reachable only on the same LAN and requires the pairing code; turn it off when not in use.","settings.remoteInput.certHint":"Verify the root certificate fingerprint before trusting it on first use. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.","settings.remoteInput.certFingerprintLabel":"This computer's root CA SHA-256","settings.remoteInput.certFingerprintCopy":"Copy full fingerprint","settings.remoteInput.certFingerprintCopied":"Fingerprint copied","settings.remoteInput.certFingerprintUnavailable":"The full fingerprint is unavailable. Do not install or trust a downloaded certificate.","settings.remoteInput.certVerifyHint":"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.","settings.remoteInput.certProfileHint":"Expect exactly one root certificate. Do not install a profile containing additional certificates, VPN or device management settings.","settings.remoteInput.certTrustWarning":"The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.","settings.remoteInput.certSetupLink":"Copy iPhone certificate link","settings.remoteInput.waitingStart":"The service is not running yet. Turn the switch off, then on again. Do not restart the app.","settings.remoteInput.starting":"Starting the remote input service…","settings.remoteInput.urlsStale":"These addresses come from the previous run and may be out of date.","settings.about.tagline":"Speak naturally, write perfectly","settings.about.checkUpdate":"Check for updates","settings.about.checkUpdateBtn":"Check","settings.about.checkStableUpdateBtn":"Check stable update","settings.about.checkBetaUpdateBtn":"Check Beta update","settings.about.checkingUpdate":"Checking…","settings.about.upToDate":"You are already on the latest version.","settings.about.updateError":"Update check or install failed. Please try again later.","settings.about.retryBtn":"Retry","settings.about.openReleases":"Open Releases","settings.about.source":"Source","settings.about.docs":"Docs","settings.about.feedback":"Feedback","settings.about.qq":"QQ community group","settings.about.qqDesc":"Search the group number in QQ to join, or scan the QR code.","settings.about.copyQq":"Copy group number","settings.about.privacy":"Privacy","settings.about.privacyDesc":"Recordings may be sent to the cloud provider you configure for transcription.","settings.about.localFirst":"Local-first","settings.about.linksTitle":"Documentation","settings.about.betaChannelLabel":"Join Beta channel","settings.about.betaChannelToggleLabel":"Enable Beta channel","settings.about.betaChannelDesc":"When on, background auto-update follows Beta; when off, it uses stable. Use the button below to manually check Beta anytime.","settings.about.autoUpdateSectionTitle":"Auto-update","settings.about.autoUpdateCheckLabelAndroid":"Auto-check and download updates","settings.about.autoUpdateCheckDescAndroid":"Checks on launch and every 60 minutes. When an update is found, downloads and opens the system installer. Channel follows the Beta toggle above.","settings.about.betaChannelFetching":"Fetching the latest Beta…","settings.about.betaChannelFetchBtn":"Look up latest Beta","settings.about.betaChannelLatestPrefix":"Latest Beta:","settings.about.betaChannelDownloadBtn":"Open download page","settings.about.betaChannelRefresh":"Refresh","settings.about.betaChannelNoBeta":"No Beta release has been published yet.","settings.about.betaChannelFetchError":"Failed to fetch Beta release info. Please try again later.","settings.about.betaChannelUpToDate":"Up to date","settings.about.betaChannelUpdateNow":"Update now","settings.about.betaChannelUpdateNowTitle":"Check and download the latest Beta, then show the update dialog","settings.about.betaChannelChecking":"Checking…","settings.about.updateDialog.available.title":"Update available","settings.about.updateDialog.available.desc":"OpenLess {{version}} is available. Update now?","settings.about.updateDialog.stableChannelSwitch.title":"Switch to Stable","settings.about.updateDialog.stableChannelSwitch.desc":"Current version: OpenLess {{currentVersion}}\nTarget version: OpenLess {{version}}\nThis switches from the Beta channel to Stable. Continue?","settings.about.updateDialog.downloading.title":"Downloading update","settings.about.updateDialog.downloading.desc":"Downloading OpenLess {{version}}. Keep the app open.","settings.about.updateDialog.downloaded.title":"Update ready","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} has been installed. Restart automatically now to apply it?","settings.about.updateDialog.installing.title":"Installing update","settings.about.updateDialog.installing.desc":"Installing OpenLess {{version}}. Keep the app open.","settings.about.updateDialog.install":"Update now","settings.about.updateDialog.androidInstall":"Download and open installer","settings.about.updateDialog.androidInstalled.title":"System installer opened","settings.about.updateDialog.androidInstalled.desc":"Follow the system prompts to finish installing. Reopen OpenLess to use {{version}}.","settings.about.updateDialog.downloadingLabel":"Downloading…","settings.about.updateDialog.installingLabel":"Installing…","settings.about.updateDialog.later":"Restart manually later","settings.about.updateDialog.restartNow":"Restart now","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"{{downloaded}} downloaded","settings.about.updateDialog.installError.title":"Update failed","settings.about.updateDialog.installError.desc":"The automatic update couldn't finish: {{error}}. You can download and install the latest version manually.","settings.about.updateDialog.manualDownload":"Download manually","startup.loading":"Starting OpenLess…","startup.loadingDesc":"Connecting to the local service and checking compatibility.","startup.failed":"OpenLess could not start","startup.recovery":"Check again. If the problem continues, fully quit and reopen the app. If this started after an upgrade, make sure the complete app is on the same version.","startup.retry":"Check again","startup.details":"Show error details","modal.serviceViews.label":"Service settings","modal.serviceViews.llm":"Language models","modal.serviceViews.asr":"Speech recognition","modal.serviceViews.omni":"Multimodal","modal.serviceViews.models":"Local models","modal.serviceViews.connections":"Connections","modal.serviceViews.statusConfigured":"Configured","modal.serviceViews.statusMissing":"Not configured","modal.searchPlaceholder":"Find a settings category…","modal.clearSearch":"Clear search","modal.categoriesLabel":"Settings categories","modal.searchResults":"Search results","modal.searchCount":"Categories found: {{count}}","modal.noResults":"No matching categories. Try “microphone”, “models” or “theme”.","modal.autoSaveHint":"Changes save automatically","modal.backToAdvanced":"Back to Experiments & extensions","modal.advancedPages.lessComputer":"Choose an agent and configure its model, permissions, and working directory.","modal.advancedPages.claudeConsole":"Detect Claude Code and view output from test tasks.","modal.advancedPages.multimodal":"Manage the experimental multimodal recognition switch.","modal.advancedPages.debug":"Keep debug recordings, inspect cursor context, and export logs.","modal.descriptions.general":"Choose a microphone, adjust recording and text input, or connect your phone.","modal.descriptions.shortcuts":"Set up shortcuts and choose what happens when you select text.","modal.descriptions.services":"Choose speech recognition and text processing services. Manage channels, local models and connections.","modal.descriptions.appearance":"Adjust the theme, page layout and interface language for comfortable reading.","modal.descriptions.privacy":"Check system permissions and connections. Manage history, recordings and local data.","modal.descriptions.advanced":"Configure Less Computer, multimodal processing and debugging as needed.","modal.descriptions.about":"View your version, update channel and automatic update settings.","modal.searchKeywords.general":"microphone recording input phone remote LAN PIN capsule mute startup autostart","modal.searchKeywords.shortcuts":"shortcut hotkey key combination selection polish voice editing","modal.searchKeywords.services":"ASR LLM API channel model cloud local offline network proxy marketplace","modal.searchKeywords.appearance":"theme dark light language font text size layout heatmap","modal.searchKeywords.privacy":"permission microphone accessibility history recording storage privacy export","modal.searchKeywords.advanced":"Less Computer Claude Agent multimodal Omni debug logs experiment","modal.searchKeywords.about":"version Beta stable update upgrade","modal.sections.appearance":"Appearance & language","modal.sections.shortcuts":"Shortcuts & selection","modal.sections.general":"Recording & input","modal.sections.services":"AI services & models","modal.sections.privacy":"Permissions & data","modal.sections.advanced":"Experiments & extensions","modal.sections.personalize":"Personalize","modal.sections.about":"About & updates","modal.sections.helpCenter":"Help center","modal.sections.releaseNotes":"Release notes","modal.personalize.font":"Font size","modal.personalize.fontDesc":"Scale the entire UI font size — applies instantly.","modal.personalize.fontSmall":"Small","modal.personalize.fontMedium":"Medium","modal.personalize.fontLarge":"Large","modal.personalize.blur":"Glass blur intensity","modal.personalize.blurDesc":"Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).","modal.about.tagline":"Speak naturally, write perfectly","modal.about.checkUpdate":"Check for updates","modal.about.checkUpdateBtn":"Check","modal.about.docs":"Docs","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"Feedback channel","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"Source","modal.about.qq":"Community QQ Group","modal.about.qqDesc":"Search the group number on QQ to join, or scan the QR code.","modal.about.copyQq":"Copy group number","modal.about.exportErrorLog":"Export error log","modal.about.exportErrorLogDesc":"Save the current session log to disk for debugging or sending us feedback.","modal.about.exportErrorLogBtn":"Export","modal.about.exporting":"Exporting…","modal.about.exportSuccess":"Saved","modal.about.exportFailed":"Export failed","modal.about.privacy":"Privacy","modal.about.privacyDesc":"Transcripts stay on this device; configured cloud providers may receive recorded audio for transcription.","modal.about.localFirst":"Local-first","windowChrome.restore":"Restore","windowChrome.minimize":"Minimize","windowChrome.maximize":"Maximize","windowChrome.close":"Close","hotkey.triggers.rightOption":"Right Option","hotkey.triggers.leftOption":"Left Option","hotkey.triggers.rightControl":"Right Control","hotkey.triggers.leftControl":"Left Control","hotkey.triggers.rightCommand":"Right Command","hotkey.triggers.leftCommand":"Left Command","hotkey.triggers.leftShift":"Left Shift","hotkey.triggers.rightShift":"Right Shift","hotkey.triggers.fn":"Fn (Globe key)","hotkey.triggers.rightAlt":"Right Alt","hotkey.triggers.mediaPlayPause":"⏯ Media Play/Pause","hotkey.triggers.custom":"Custom combination…","hotkey.fallback":"Global hotkey","hotkey.modeHoldSuffix":" (push-to-talk)","hotkey.modeToggleSuffix":" (start / stop)","hotkey.modeAutoSuffix":" (auto-detect)","hotkey.usageHold":"Hold {{trigger}} to talk, release to stop.","hotkey.usageToggle":"Press {{trigger}} to start, press again to stop.","hotkey.usageAuto":"Tap {{trigger}} to start / stop; hold it to talk and release to stop.","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows low-level keyboard hook","hotkey.adapter.fcitx5":"fcitx5 input method plugin","hotkey.adapter.unavailable":"Unavailable","localAsr.kicker":"LOCAL ASR","localAsr.title":"Models","localAsr.desc":"Manage on-device speech recognition models.","localAsr.storageTitle":"Model storage location","localAsr.storageBaseDir":"Selected parent folder","localAsr.storageModelsRoot":"Actual models folder","localAsr.storageDefault":"System default folder","localAsr.storageChoose":"Change folder","localAsr.storageReset":"Reset to default","localAsr.storageReveal":"Open models folder","localAsr.storageDesc":"Custom storage creates OpenLess/models under the selected folder and migrates existing models. OpenLess cancels downloads and releases loaded models before moving files.","localAsr.storageChooseTitle":"Choose local model storage parent folder","localAsr.storageChangeConfirm":"Existing local models will be moved to {{path}}/OpenLess/models. Downloads will be cancelled and loaded models released first. Continue?","localAsr.storageResetConfirm":"Existing local models will be moved back to the system default folder. Current folder: {{path}}. Continue?","localAsr.modelDir":"Model directory","localAsr.revealDir":"Open directory","localAsr.deleteConfirm":"Delete local model files for {{name}}? This cannot be undone.","localAsr.appleSpeechTitle":"Apple Speech recognition (macOS)","localAsr.appleSpeechDesc":"Transcribe speech locally using macOS's built-in speech recognition: no model download, no API key, no network. A zero-credential local fallback when your cloud ASR is unreliable. macOS will prompt for speech recognition permission on first use.","localAsr.appleSpeechUse":"Use Apple Speech","localAsr.qwenTitle":"Qwen3-ASR model manager","localAsr.qwenExperimentalBadge":"Experimental","localAsr.engineUnavailable":"The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.","localAsr.qwenUnavailableOnWindows":"Qwen3-ASR is not supported on Windows yet. Please use Foundry Local Whisper above instead.","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"On-device speech recognition, no ASR API key needed. First use requires downloading runtime and model.","localAsr.foundryAvailable":"Available on Windows","localAsr.foundryUnavailable":"Windows only","localAsr.foundryRuntimeReady":"Runtime components downloaded","localAsr.foundryRuntimeMissing":"Runtime components not downloaded","localAsr.foundryRuntimeSourceLabel":"Runtime component source","localAsr.foundryRuntimeSourceAuto":"Auto (NuGet first)","localAsr.foundryRuntimeSourceNuget":"NuGet official feed","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly feed","localAsr.foundryRuntimeSourceDesc":"Runtime components are downloaded before first use.","localAsr.foundrySelectedModel":"Selected model","localAsr.foundryActiveModel":"Current default alias","localAsr.foundryLoadedModel":"Loaded model","localAsr.foundryNotLoaded":"Not loaded","localAsr.foundryError":"Foundry status","localAsr.foundrySetDefault":"Set default / Enable Windows local ASR","localAsr.foundryEnabling":"Enabling…","localAsr.foundryPrepare":"Prepare / Download / Load","localAsr.foundryPreparing":"Preparing…","localAsr.foundryReleasing":"Releasing…","localAsr.foundryRetryPrepare":"Continue / Retry prepare","localAsr.foundryCancelPrepare":"Cancel prepare","localAsr.foundryCancelRequested":"Cancel requested","localAsr.foundryCancelling":"Cancelling…","localAsr.foundryCancelBestEffort":"Cancellation requested. Will stop after the current step completes. Retry later.","localAsr.foundryPrepareRuntime":"Prepare runtime components","localAsr.foundryPrepareModel":"Download model","localAsr.foundryPrepareLoad":"Load model","localAsr.foundryPrepareModelSkipped":"Model already downloaded; download skipped","localAsr.foundryPrepareDone":"Done","localAsr.foundryPrepareWaiting":"Waiting","localAsr.foundryApproxSizeMb":"about {{mb}} MB","localAsr.foundryLanguageLabel":"Recognition language","localAsr.foundryLanguageAuto":"Auto","localAsr.foundryLanguageZh":"Chinese zh","localAsr.foundryLanguageEn":"English en","localAsr.foundryLanguageDesc":"Choose Chinese for Chinese dictation, Auto for mixed use.","localAsr.foundryModelSmall":"Whisper Small (default / balanced)","localAsr.foundryModelSmallDesc":"Default balanced option for quality and resource use.","localAsr.foundryModelMedium":"Whisper Medium (higher quality)","localAsr.foundryModelMediumDesc":"Higher accuracy for stronger devices that can handle larger downloads and slower inference.","localAsr.foundryModelLarge":"Whisper Large V3 Turbo (best quality)","localAsr.foundryModelLargeDesc":"Large-model option for high-end devices and quality-first use.","localAsr.foundryModelBase":"Whisper Base (faster / lower resource)","localAsr.foundryModelBaseDesc":"Faster with lower resource use for lightweight daily dictation.","localAsr.foundryModelTiny":"Whisper Tiny (fastest / smoke test)","localAsr.foundryModelTinyDesc":"Fastest check option for confirming the Foundry path works.","localAsr.sherpaTitle":"Windows sherpa-onnx Local (Experimental)","localAsr.sherpaDesc":"Windows uses sherpa-onnx for offline batch recognition on this device with no ASR API key.","localAsr.sherpaRuntimeReady":"Model loaded","localAsr.sherpaRuntimeMissing":"Model not loaded","localAsr.sherpaSetDefault":"Set default / Enable sherpa-onnx","localAsr.sherpaPrepare":"Check local files / Load","localAsr.sherpaPreparing":"Loading…","localAsr.sherpaPrepareLocalFiles":"Check local model files","localAsr.sherpaModelDir":"Model directory","localAsr.sherpaRevealDir":"Open model directory","localAsr.sherpaError":"sherpa-onnx status","localAsr.sherpaLanguageJa":"Japanese ja","localAsr.sherpaLanguageKo":"Korean ko","localAsr.sherpaLanguageYue":"Cantonese yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small (default / Chinese-first)","localAsr.sherpaModelSenseVoiceDesc":"Default experimental model for Chinese and mixed Chinese-English dictation.","localAsr.sherpaModelParaformer":"Paraformer Chinese","localAsr.sherpaModelParaformerDesc":"Chinese-focused experimental model.","localAsr.sherpaModelWhisper":"Whisper Small multilingual","localAsr.sherpaModelWhisperDesc":"Multilingual experimental fallback aligned with Whisper-family behavior.","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3 (multilingual)","localAsr.sherpaModelWhisperLargeV3Desc":"The best open-source multilingual Whisper tier — high quality, large download.","localAsr.sherpaModelZipformer":"Zipformer Streaming (zh/en)","localAsr.sherpaModelZipformerDesc":"Streaming Chinese-English model with the lowest latency — text appears as you speak.","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.","localAsr.modelSelectTitle":"Models on this device","localAsr.modelSelectDesc":"Track downloads, manage files, or load a model to test it.","localAsr.modelSelectPlaceholder":"Select a downloaded model…","localAsr.modelSelectEmpty":"No downloaded models yet — grab one under “Download & manage”.","localAsr.groupDownload":"Download & manage","localAsr.groupOther":"Other","localAsr.mirrorLabel":"Download mirror","localAsr.mirrorDesc":"huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.","localAsr.mirrorHuggingface":"HuggingFace official (huggingface.co)","localAsr.mirrorHfMirror":"Mainland mirror (hf-mirror.com)","localAsr.activeBadge":"In use","localAsr.downloadedBadge":"Downloaded","localAsr.notDownloadedBadge":"Not downloaded","localAsr.download":"Download","localAsr.resume":"Resume","localAsr.cancel":"Cancel","localAsr.delete":"Delete","localAsr.setActive":"Set as default","localAsr.failed":"Failed","localAsr.cancelled":"Cancelled","localAsr.files":"files","localAsr.sizeLoading":"Fetching size…","localAsr.sizeUnknown":"Size unknown","localAsr.performanceWarning":"Local ASR is best for offline or privacy-sensitive use. First use requires model download.","localAsr.test":"Load & Test","localAsr.testRunning":"Testing…","localAsr.testHeading":"Built-in audio test","localAsr.testExpected":"Expected","localAsr.testActual":"Got","localAsr.testStats":"Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}","localAsr.testFailed":"Test failed","localAsr.engineStatusLabel":"Engine in memory","localAsr.engineLoaded":"Loaded: {{model}}","localAsr.engineUnloaded":"Not loaded (first transcription must load the model)","localAsr.loadNow":"Load now","localAsr.releaseNow":"Release now","localAsr.keepLoadedLabel":"Keep loaded for","localAsr.keepLoadedDesc":"How long Qwen3-ASR stays in memory after the last use, before being freed.","localAsr.keepImmediate":"Release immediately","localAsr.keep1min":"1 minute after last use","localAsr.keep5min":"5 minutes after last use (default)","localAsr.keep30min":"30 minutes after last use","localAsr.keepForever":"Never release (always loaded)","localAsr.sidebarTitle":"Downloaded & downloading","localAsr.activePill":"Active","localAsr.setDefault":"Set as default","localAsr.downloading":"Downloading","localAsr.startDownload":"Start download","localAsr.downloadNewModel":"Download new model","localAsr.activeModelLabel":"Model in use","localAsr.pickerNoModelDownloaded":"No downloaded models yet — download one on the Local models page first.","localAsr.partialDownloadsLabel":"Incomplete downloads","localAsr.partialDownloadsDesc":"Interrupted downloads left staging files behind; clean them up without affecting installed models.","localAsr.cleanupIncomplete":"Clean up incomplete download","localAsr.languagesLabel":"Languages","localAsr.partialBytesLabel":"Leftover files","localAsr.downloadDialogTitle":"Download Model","localAsr.downloadDialogAlreadyHave":"The model files are downloaded. Return to the model page to load and test, or choose its provider in ASR transcription.","localAsr.downloadDialogDesc":"Compare model sizes and descriptions, then download your choice. Select the matching local service under Speech recognition when it is ready.","localAsr.detailRepo":"Repository","localAsr.hfDownloads":"Downloads","localAsr.hfLikes":"Likes","localAsr.hfDescription":"About","localAsr.hfNoDescription":"No description yet","localAsr.hfCardFailed":"Failed to load model info","localAsr.detailFiles":"files","localAsr.detailDownloaded":"Downloaded","localAsr.detailEmpty":"Select a model to view its details","localAsr.foundryLanguage":"Language","localAsr.foundryRuntimeSource":"Runtime source","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"Keep loaded","localAsr.downloadSettingsTitle":"Download & storage","localAsr.downloadSettingsDesc":"Mirror source · model storage location · in-memory engine","localAsr.libraryEmptyTitle":"No local models yet","localAsr.libraryEmptyDesc":"Download a speech recognition model to process audio on this device. If an existing model is missing, reload the catalog.","localAsr.catalogTitle":"Model catalog","localAsr.catalogEmpty":"No models are available to display. Reload the catalog and try again.","localAsr.reloadCatalog":"Reload catalog","localAsr.engineLabel":"Recognition engine","localAsr.sizeLabel":"Model size","localAsr.allEngines":"All","localAsr.backToCatalog":"Back to catalog","localAsr.detailsTitle":"Model details","localAsr.testActivateHint":"Load and test makes this the active model, then runs the built-in audio test.","localAsr.downloadProgressHint":"After starting, track progress or cancel the download on the model page.","localAsr.errorDetails":"Error details"},"ja":{"cloudSync.title":"クラウド同期","cloudSync.description":"GitHub アカウントで辞書、スタイル、個人設定をデバイス間で同期します。","cloudSync.signIn":"GitHub でログイン","cloudSync.account":"同期アカウント","cloudSync.refresh":"状態を更新","cloudSync.loading":"クラウドの状態を確認中…","cloudSync.noBackup":"クラウドバックアップはありません","cloudSync.available":"クラウドバックアップがあります","cloudSync.summary":"単語 {{dictionary}} 件 · 修正规則 {{corrections}} 件 · スタイル {{stylePacks}} 件","cloudSync.updated":"更新日時:{{time}}","cloudSync.upload":"クラウドにバックアップ","cloudSync.restore":"クラウドから復元","cloudSync.delete":"クラウドバックアップを削除","cloudSync.working":"同期中…","cloudSync.uploadSuccess":"クラウドに保存しました","cloudSync.restoreSuccess":"クラウドの設定を復元しました","cloudSync.deleteSuccess":"クラウドバックアップを削除しました","cloudSync.failed":"同期に失敗しました:{{error}}","cloudSync.conflict":"クラウドの内容が更新されています。状態を更新してから、バックアップまたは復元を選んでください。","cloudSync.unavailable":"公式の同期サービスを利用できません。後でもう一度お試しください。","cloudSync.signInRequired":"先に GitHub でログインしてください。","cloudSync.restoreTitle":"クラウドバックアップを復元しますか?","cloudSync.restoreDescription":"クラウドの辞書、修正规則、スタイル、同期設定で、このデバイスの対応する内容を置き換えます。API キー、デバイスのパス、権限は保持されます。","cloudSync.deleteTitle":"クラウドバックアップを削除しますか?","cloudSync.deleteDescription":"この GitHub アカウントのクラウドバックアップだけを削除します。ローカルデータは保持されます。","cloudSync.confirmRestore":"復元して置き換える","cloudSync.confirmDelete":"バックアップを削除","cloudSync.scope":"辞書、修正规則、スタイルのアイコン、共通設定を同期します。API キー、ログイン情報、デバイス固有の設定は本機に保持されます。","macDictationKey.Changed":"The shortcut changed while saving. Please try again.","macDictationKey.label":"Mac Dictation key","macDictationKey.description":"Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.","macDictationKey.Permission":"Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.","macDictationKey.Busy":"Finish the current dictation before changing its shortcut.","macDictationKey.Unavailable":"Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.","app.name":"OpenLess","app.tagline":"自然に話し、きれいに書く","common.loading":"読み込み中…","common.retry":"再試行","common.settingsLoadFailed":"設定の読み込みに失敗しました","common.refresh":"更新","common.clear":"クリア","common.copy":"コピー","common.delete":"削除","common.later":"後で","common.cancel":"キャンセル","common.close":"閉じる","common.show":"表示","common.hide":"非表示","common.saved":"保存しました","common.saving":"保存中","common.experimental":"実験的","common.copied":"コピーしました","common.operationFailed":"操作に失敗しました","common.add":"追加","common.durationSeconds":"{{value}} 秒","common.durationMillis":"{{value}}ミリ秒","common.durationMinutes":"{{value}} 分","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"キャンセルしました","capsule.error":"エラーが発生しました","capsule.inserted":"{{count}} 文字を入力しました","capsule.translating":"翻訳中","capsule.selectionPolish.polishing":"推敲中...","capsule.selectionPolish.replaced":"置き換えました","capsule.selectionPolish.noSelection":"選択されていません","capsule.selectionPolish.failed":"推敲に失敗しました。もう一度お試しください","selectionPolishPreview.title":"選択範囲の推敲プレビュー","selectionPolishPreview.subtitle":"編集可能です。確認後はじめて元の選択範囲を置き換えます。","selectionPolishPreview.cancel":"キャンセル","selectionPolishPreview.resultLabel":"推敲結果","selectionPolishPreview.sourcePrefix":"原文:","selectionPolishPreview.applyError":"適用できません:","selectionPolishPreview.confirmReplace":"確認して置き換え","selectionVoiceIntent.title":"どうしますか?","selectionVoiceIntent.subtitle":"音声指示を認識しました。処理方法を選んでください。","selectionVoiceIntent.loading":"読み込み中…","selectionVoiceIntent.sourcePrefix":"選択範囲:","selectionVoiceIntent.errorPrefix":"続行できません:","selectionVoiceIntent.question":"質問する","selectionVoiceIntent.edit":"選択範囲を編集","selectionVoiceIntent.cancel":"キャンセル","qa.title":"質問","qa.headerHint":"いつでも質問","qa.thinking":"思考中…","qa.error":"エラーが発生しました。後でもう一度お試しください。","qa.errorRetry":"再試行","qa.errorRetryHint":"もう一度お試しください。","qa.pinTooltip":"ピン留め(自動で閉じない)","qa.unpinTooltip":"ピン留めを解除","qa.closeTooltip":"閉じる","qa.micLabel":"音声で質問","qa.micStop":"録音を終了","qa.selectionPreview":"選択テキスト:","qa.emptyTitle":"ご用件は?","qa.emptyDesc":"テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。","qa.recordingHint":"録音中… {{recordHotkey}} をもう一度押して終了し、質問します","qa.mobileRecordLabel":"録音ボタン","qa.mobileRecordStart":"録音を開始","qa.mobileRecordStop":"終了して送信","qa.composerPlaceholder":"質問を入力。Enter で送信","qa.composerSend":"送信","qa.statusIdle":"{{recordHotkey}} で質問","qa.statusRecording":"録音中","qa.statusThinking":"思考中","qa.statusError":"エラー","qa.jumpToLatest":"最新へ移動","qa.editApplyReplace":"プレビューして挿入を確認","qa.editApplyUnavailable":"適用できる編集結果がありません","qa.editRevertPrevious":"前のバージョンを保持","qa.editInstructionMode":"編集指示","lessComputer.title":"Less Computer","lessComputer.subtitle":"コンピュータに何をさせますか?","lessComputer.you":"あなた","lessComputer.working":"操作中…","lessComputer.tool":"{{name}} を使用","lessComputer.compaction":"コンテキストを圧縮しました","lessComputer.done":"完了","lessComputer.cost":"${{cost}}","lessComputer.error":"失敗しました。再試行してください。","lessComputer.closeTooltip":"閉じる","lessComputer.jumpToLatest":"最新へ移動","lessComputer.inputPlaceholder":"指示を入力、Enter で送信","lessComputer.send":"送信","lessComputer.approvalTitle":"ブロックされたコマンドを実行?","lessComputer.approvalRerunWarning":"注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。","lessComputer.approve":"許可","lessComputer.deny":"拒否","lessComputer.approved":"許可済み","lessComputer.denied":"拒否済み","nav.overview":"概要","nav.history":"履歴","nav.vocab":"辞書","nav.style":"スタイル","nav.marketplace":"マーケット","nav.translation":"翻訳","nav.selectionAsk":"選択追問","nav.corrections":"修正ルール","nav.polishMode":"推敲モード","nav.group.style":"スタイル","nav.group.tools":"ツール","nav.localAsr":"モデル設定","nav.more":"その他","marketplace.kicker":"マーケット","marketplace.title":"スタイルパック マーケット","marketplace.desc":"コミュニティのスタイルパックを閲覧・インストール・共有。","marketplace.searchPlaceholder":"名前 / 説明 / タグを検索…","marketplace.sortPopular":"人気順","marketplace.sortNew":"新着","marketplace.uploadBtn":"アップロード","marketplace.uploadDisabledHint":"先に 設定 → マーケット で GitHub ユーザー名を設定してください","marketplace.refreshBtn":"更新","marketplace.empty":"まだスタイルパックがありません","marketplace.emptyHint":"別のキーワードを試すか、自分のパックを共有してみましょう","marketplace.loadFailed":"読み込み失敗:{{err}}","marketplace.noDescription":"(説明なし)","marketplace.installBtn":"インストール","marketplace.installingBtn":"インストール中…","marketplace.downloadZipBtn":"ZIP をダウンロード","marketplace.downloadingZipBtn":"ダウンロード中…","marketplace.downloadAria":"「{{name}}」の ZIP をダウンロード","marketplace.likeBtn":"いいね","marketplace.installed":"「{{name}}」をローカルにインストールしました","marketplace.downloaded":"「{{name}}」の ZIP をダウンロードしました","marketplace.uploaded":"アップロード完了、審査中","marketplace.uploadTitle":"アップロードするパックを選択","marketplace.uploadHint":"{{login}} としてアップロードします。内容はクラウド審査キューに送信されます。","marketplace.uploadNoLocal":"アップロード可能なローカルパックがありません","marketplace.errors.detail":"詳細の読み込み失敗:{{err}}","marketplace.errors.install":"インストール失敗:{{err}}","marketplace.errors.download":"ZIP のダウンロード失敗:{{err}}","marketplace.errors.like":"いいね失敗:{{err}}","marketplace.errors.upload":"アップロード失敗:{{err}}","marketplace.errors.loadLocal":"ローカルパック読み込み失敗:{{err}}","marketplace.sortLiked":"いいね済み","marketplace.likedEmpty":"まだいいねしたパックがありません","marketplace.likedEmptyHint":"パックを開いて星をタップするとここに表示されます","marketplace.derivativeBadge":"@{{login}} から派生","marketplace.detail.withdrawBtn":"公開を取り下げる","marketplace.detail.withdrawConfirm":"「{{name}}」をマーケットから取り下げますか?ローカルコピーは保持されます。","marketplace.detail.withdrawSuccess":"マーケットから取り下げました","marketplace.detail.withdrawFailed":"取り下げ失敗:{{err}}","marketplace.myPacks.buttonLabel":"自分の公開","marketplace.myPacks.buttonTitle":"{{login}} の公開を見る","marketplace.myPacks.buttonTitleEmpty":"先に 設定 → マーケット で公開者名を設定してください","marketplace.myPacks.searchPlaceholder":"名前・タグを検索","marketplace.myPacks.notLoggedIn":"先に 設定 → マーケット で公開者名を設定してください","marketplace.myPacks.emptyTitle":"まだ公開したパックはありません","marketplace.myPacks.emptyHint":"「スタイル」ページで編集して「マーケットに公開」をクリックするか、右上からローカルパックをアップロードしてください。","marketplace.myPacks.noMatch":"一致するパックがありません","marketplace.myPacks.summary":"公開済み {{count}} 個","marketplace.myPacks.summaryPending":"公開済み {{count}} 個 · 審査中 {{pending}} 個","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"更新","marketplace.myPacks.actions.withdraw":"取り下げ","marketplace.myPacks.loadFailed":"自分の公開の読み込みに失敗:{{err}}","marketplace.myPacks.loadingTitle":"読み込み中…","marketplace.myPacks.loadingHint":"マーケットからあなたの最新公開を取得しています。","marketplace.myPacks.loadErrorTitle":"読み込み失敗","marketplace.myPacks.loadErrorRetry":"再試行","marketplace.upload.confirmBtn":"アップロード確定","marketplace.upload.updateTitle":"「{{name}}」を更新","marketplace.upload.updateHint":"アップロードするローカルの新版を選んで「アップロード確定」を押してください。同名パックは自動選択されます。","marketplace.upload.recommendedBadge":"推奨","marketplace.state.pending":"審査中","marketplace.state.approved":"公開済み","marketplace.state.rejected":"却下","marketplace.state.withdrawn":"取り下げ","marketplace.state.superseded":"新版に置換済み","marketplace.state.unknown":"不明","marketplace.oauth.title":"GitHub でサインイン","marketplace.oauth.generating":"デバイスコードを生成中…","marketplace.oauth.browserHint":"ブラウザで {{uri}} を開き、このコードを入力してください:","marketplace.oauth.copyBtn":"コピー","marketplace.oauth.copied":"デバイスコードをコピー","marketplace.oauth.copyFailed":"コピー失敗:{{err}}","marketplace.oauth.openBrowserBtn":"ブラウザを開く","marketplace.oauth.cancelBtn":"キャンセル","marketplace.oauth.waiting":"ブラウザでの認可を待っています…","marketplace.oauth.successAs":"@{{login}} としてサインイン","marketplace.oauth.retryBtn":"再試行","marketplace.oauth.closeBtn":"閉じる","marketplace.oauth.loginBtn":"サインイン","marketplace.oauth.loginTooltip":"GitHub でサインイン","marketplace.oauth.reloginTooltip":"再サインイン / アカウント切替(現在 @{{login}})","marketplace.modal.loggedIn":"現在のサインイン ID —— 設定 → 録音 → マーケット で変更","marketplace.modal.notLoggedIn":"未サインイン —— 設定 → 録音 → マーケット で公開者名を設定","marketplace.modal.notLoggedInLabel":"未サインイン","shell.shortcutLabel":"録音ショートカット","shell.shortcutHint":"開始 / 停止","shell.betaTag":"BETA","shell.betaNote":"ローカル保存、任意でクラウドバックアップ","shell.navHint.overview":"ステータス概要:使用状況・プロバイダー・権限の状態","shell.navHint.history":"入力履歴:過去の書き起こしを検索・再生・コピー","shell.navHint.vocab":"辞書:固有名詞の認識精度を上げるカスタムホットワード","shell.navHint.style":"スタイル:出力スタイルとカスタムプロンプトを管理","shell.navHint.translation":"翻訳:Shift を押しながら話すと目標言語で挿入","shell.navHint.selectionAsk":"選択質問:テキストを選択して音声で質問","shell.navHint.settings":"環境設定:ショートカット・プロバイダー・プライバシー・更新","shell.footer.account":"アカウント","shell.footer.feedback":"フィードバック","shell.footer.settings":"設定","shell.footer.help":"ヘルプ","shell.footer.version":"バージョン {{version}}","shell.footer.helpPopover.tagline":"ローカル駆動の音声入力レイヤー","shell.footer.helpPopover.releaseNotes":"リリースノートを見る ↗","shell.footer.helpPopover.docs":"ヘルプセンター ↗","shell.providerPrompt.title":"音声プロバイダーを設定","shell.providerPrompt.body":"ASR または LLM プロバイダーが未設定のため、音声入力と整文が一時的に利用できません。","shell.providerPrompt.later":"後で","shell.providerPrompt.openSettings":"設定を開く","shell.hotkeyModePrompt.title":"録音方式を確認","shell.hotkeyModePrompt.body":"デフォルトがトグルに変更されました。以前トリガーモードを変更した場合は、録音設定で確認してください。","shell.hotkeyModePrompt.later":"後で通知","shell.hotkeyModePrompt.openSettings":"録音設定を開く","onboarding.welcome":"OpenLess へようこそ","onboarding.intro":"ローカルで話し、ローカルで文字に。開始前にシステム権限が 2 つ必要です。","onboarding.accessibilityTitle":"アクセシビリティ","onboarding.hotkeyTitle":"グローバルショートカット","onboarding.accessibilityDesc":"グローバルショートカット(既定 {{trigger}})の検知と、認識結果のカーソル位置への入力に使用します。","onboarding.hotkeyDesc":"グローバルショートカット監視が利用可能か確認するために使用します。","onboarding.micTitle":"マイク","onboarding.micDesc":"音声入力の取得に使用します。","onboarding.actionNotApplicable":"権限不要","onboarding.actionGranted":"許可済み","onboarding.actionOpenSystem":"システム設定を開く","onboarding.actionRestart":"アクセシビリティをリセットして OpenLess を再起動","onboarding.actionGrant":"許可する","onboarding.actionRequestMic":"許可ダイアログを表示","onboarding.micNoDeviceHint":"マイクが検出されません。マイクを接続して有効にしてから、もう一度お試しください。","onboarding.accessibilityHint":"許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。","onboarding.footerHint":"すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。","onboarding.continueToSettings":"設定のみ開く(音声とグローバルショートカットは利用不可)","onboarding.androidContinue":"アプリに進む","onboarding.androidFooterHint":"音声入力にはマイク権限が必要です。上の「許可ダイアログを表示」をタップするか、先にアプリへ進み、概要ページで後から許可してください。","onboarding.androidTitle":"OpenLess を設定","onboarding.androidIntro":"モバイル権限とサービス設定を順番に完了します。","onboarding.androidStepCounter":"{{current}} / {{total}}","onboarding.androidBack":"戻る","onboarding.androidNext":"次へ","onboarding.androidFinish":"完了して開始","onboarding.androidSteps.microphoneTitle":"マイク権限","onboarding.androidSteps.microphoneDesc":"Android のシステム権限カードを表示し、OpenLess の録音を許可します。","onboarding.androidSteps.accessibilityTitle":"アクセシビリティサービス","onboarding.androidSteps.accessibilityDesc":"認識結果を現在の入力欄へ貼り付け、入力環境の検出を補助します。","onboarding.androidSteps.overlayPermissionTitle":"フローティングウィンドウ権限","onboarding.androidSteps.overlayPermissionDesc":"他のアプリ上に録音コントロールを表示できるようにします。","onboarding.androidSteps.overlayConfigTitle":"フローティングウィンドウ設定","onboarding.androidSteps.overlayConfigDesc":"表示タイミング、起動方法、スワイプ操作、ボタンサイズを設定します。","onboarding.androidSteps.asrTitle":"ASR クラウドサービス","onboarding.androidSteps.asrDesc":"音声認識サービスのプロバイダー、キー、エンドポイント、モデルを設定します。","onboarding.androidSteps.llmTitle":"LLM サービス","onboarding.androidSteps.llmDesc":"整文、翻訳、Q&A に使う言語モデルサービスを設定します。","overview.refresh":"状態を更新","overview.servicesTitle":"使用中の音声サービス","overview.statsTitle":"利用記録","overview.omniKind":"マルチモーダル音声","overview.omniName":"現在の Omni モデル","overview.statusLoading":"サービス設定を読み込み中…","overview.configureProvider":"設定する","overview.manageProvider":"サービスを管理","overview.recentEmptyHint":"まだ音声入力の記録がありません。上の案内に沿って試すと、ここに結果が表示されます。","overview.providerHelp.asr":"音声をテキストに変換します。","overview.providerHelp.llm":"あなたのスタイルに合わせて文章を整えます。","overview.providerHelp.omni":"1つのモデルで音声認識とテキスト処理を行います。","overview.actions.refresh":"再読み込み","overview.actions.services":"AI サービスとモデル","overview.actions.general":"録音と入力","overview.actions.shortcuts":"ショートカット","overview.actions.privacy":"権限とデータ","overview.guide.nextStep":"次のステップ","overview.guide.loadingTitle":"設定を読み込んでいます","overview.guide.loadingDesc":"使用中のサービスと次の操作をまもなく表示します。","overview.guide.unavailableTitle":"サービスの状態を読み込めません","overview.guide.unavailableDesc":"再読み込みするか、AI サービスで設定を確認してください。","overview.guide.servicesTitle":"まず音声サービスを設定しましょう","overview.guide.servicesDesc":"ここから始めるのがおすすめです。音声認識とテキスト処理のサービスを選びましょう。Omni モードでは、使用するマルチモーダルモデルだけを設定します。","overview.guide.permissionsTitle":"ショートカットの状態を確認しましょう","overview.guide.permissionsDesc":"ショートカット機能を利用できません。「権限とデータ」で状態と対処方法を確認してください。","overview.guide.shortcutsTitle":"録音ショートカットを設定しましょう","overview.guide.shortcutsDesc":"使いやすいキーを選ぶと、入力中に音声入力を始められます。","overview.guide.recordingTitle":"録音方法を確認しましょう","overview.guide.recordingDesc":"サービス設定は保存されています。録音設定でマイクと録音モードを選びましょう。","overview.guide.tryDictationTitle":"音声入力を試してみましょう","overview.guide.tryDictationDesc":"入力したい場所にカーソルを置いてください。{{shortcut}}","overview.guide.permissionsHint":"録音やショートカットが反応しない場合は、「権限とデータ」で権限、マイク、ショートカットの状態を確認してください。","overview.kicker":"概要","overview.title":"本日の概要","overview.desc":"本日のディクテーション統計とシステム状態。","overview.pressPrefix":"押す","overview.pressSuffix":"で録音開始","overview.asrKind":"音声認識","overview.llmKind":"テキスト処理","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI 互換","overview.llmConfigured":"アクティブ LLM を設定済み","overview.llmNotConfigured":"未設定","overview.statusConfigured":"設定済み","overview.statusNotConfigured":"未設定","overview.statusUnknown":"読み取れません","overview.credentialsLoadError":"認証情報の状態を読み取れません","overview.metricChars":"本日の文字数","overview.metricSegments":"{{count}} セグメント","overview.metricDuration":"本日の合計時間","overview.metricAvg":"平均セグメント","overview.metricAvgTrend":"本日の平均","overview.metricNoData":"データなし","overview.historyLoadError":"履歴の読み込みに失敗","overview.metricTotal":"累計記録","overview.metricTotalTrend":"ローカル保存(上限 200)","overview.activityTitle":"年間アクティビティ","overview.activityCount":"{{count}} 回の入力","overview.activityLoadError":"アクティビティの読み込みに失敗","overview.period.ariaLabel":"集計期間","overview.period.last7Days":"直近 7 日","overview.period.last30Days":"直近 30 日","overview.period.dailyAverage":"1 日平均 {{value}}","overview.period.minutes":"{{value}} 分","overview.period.hoursMinutes":"{{hours}} 時間 {{minutes}} 分","overview.metricName.ariaLabel":"指標","overview.metricName.count":"件数","overview.metricName.chars":"文字数","overview.metricName.duration":"時間","overview.recentTitle":"最近の認識","overview.recentAll":"すべて表示 →","overview.recentEmpty":"記録がありません。{{trigger}} を押して最初の録音を始めましょう。","overview.recentLoadFailed":"最近の認識を読み込めません。再試行してください。","overview.historyRetry":"再試行","overview.weekDays.0":"日","overview.weekDays.1":"月","overview.weekDays.2":"火","overview.weekDays.3":"水","overview.weekDays.4":"木","overview.weekDays.5":"金","overview.weekDays.6":"土","overview.inAppDictation.title":"アプリ内音声入力","overview.inAppDictation.start":"録音開始","overview.inAppDictation.stop":"録音停止","overview.inAppDictation.idle":"タップして録音開始","overview.inAppDictation.recording":"録音中…","overview.inAppDictation.processing":"処理中…","overview.androidMicBanner.title":"マイク権限が必要です","overview.androidMicBanner.desc":"マイクを許可すると、アプリ内音声入力が使えます。","overview.androidMicBanner.grant":"許可ダイアログを表示","overview.androidMicBanner.openSettings":"設定を開く","history.exportError":"録音のエクスポートに失敗しました。もう一度お試しください。","history.kicker":"履歴","history.title":"履歴","history.desc":"ローカルに保存された認識記録。","history.filterAll":"すべて","history.summary":"合計 {{total}} 件 · 表示 {{shown}}","history.searchPlaceholder":"文字起こしを検索…({{shortcut}})","history.searchNoMatch":"「{{query}}」に一致する項目はありません。","history.empty":"履歴がありません。{{trigger}} を押して録音してみましょう。","history.loadFailed":"履歴の読み込みに失敗:{{err}}","history.retry":"再試行","history.clearFailed":"履歴の消去に失敗:{{err}}","history.deleteFailed":"記録の削除に失敗:{{err}}","history.copyFailed":"コピーに失敗:{{err}}","history.playRecording":"録音を再生","history.audioLoading":"読み込み中…","history.audioDecodeFailed":"音声デコード失敗:{{err}}","history.exportRecording":"録音をエクスポート","history.exportFailed":"エクスポート失敗:{{err}}","history.retranscribe":"再認識","history.retranscribing":"認識中…","history.retranscribeFailed":"再認識に失敗:{{err}}","history.rawLabel":"原文","history.rawEmpty":"(空)","history.selectHint":"左側から 1 件選択して詳細を表示。","history.recorded":"録音 {{duration}}","history.stepAsr":"認識","history.multimodalPipeline":"マルチモーダル","history.stepAsrHint":"キーを離してから認識結果を待った時間。ストリーミング認識は録音中に変換するため、通常は録音時間よりずっと短くなります。","history.stepPolish":"推敲","history.stepInsert":"挿入","history.chars":"{{count}} 文字","history.vocabHits":"{{count}} ホットワード","history.inserted":"入力済み","history.pasteSent":"貼り付けを試行","history.copiedFallback":"コピー済み(要 {{shortcut}})","history.insertFailed":"入力失敗","history.confirmClear":"全 {{count}} 件の記録を削除しますか?この操作は取り消せません。","history.backToList":"一覧に戻る","history.repolish.title":"再整文","history.repolish.hint":"上の原文でもう一度整文を実行します。結果は今回の表示のみで、この記録には書き戻しません。元のスタイルパックが削除されているか、古い記録の場合は、再試行では現在のスタイルを使用します。","history.repolish.retry":"同じスタイルで再試行","history.repolish.retrying":"再試行中…","history.repolish.apply":"適用","history.repolish.applying":"整文中…","history.repolish.pickStyle":"スタイルパックを選択","history.repolish.noPacks":"利用できるスタイルパックがありません。","history.repolish.packsLoadFailed":"スタイルパックの読み込みに失敗:{{err}}","history.repolish.failed":"再整文に失敗:{{err}}","history.repolish.timeout":"現在の LLM プロバイダーが 30 秒以内に応答しませんでした。より速いプロバイダーに切り替えるか、後でもう一度お試しください(無料モデルプールは混雑しがちです)。","history.repolish.resultTitle":"{{name}} の結果","history.repolish.retryResultTitle":"再試行の結果","history.repolish.empty":"(モデルが空の結果を返しました)","history.repolish.clear":"結果を消去","vocabCard.title":"この語を覚えますか?","vocabCard.accept":"覚える","vocabCard.reject":"不要","insertFallbackCard.copy":"コピー","insertFallbackCard.copied":"コピーしました","insertFallbackCard.copyFailed":"コピーに失敗","insertFallbackCard.dismiss":"閉じる","vocab.selectAllVisible":"現在の結果を選択","vocab.selectedCount":"{{count}} 語を選択中","vocab.selectWord":"「{{phrase}}」を選択","vocab.deleteSelected":"選択項目を削除({{count}})","vocab.batchDeleteFailed":"{{count}} 語を削除できませんでした。選択状態を保持しています。再試行できます。","vocab.kicker":"辞書","vocab.title":"辞書","vocab.desc":"新語や専門用語を追加して認識精度を向上。","vocab.sectionTitle":"項目","vocab.placeholder":"単語を入力し、Enter または追加をクリック…","vocab.tip":"日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント","vocab.loadFailed":"読み込み失敗:{{err}}","vocab.empty":"語彙がありません。新語や専門用語を上に入力すると、ディクテーション時に優先的にマッチします。","vocab.tipDisabled":"クリックで無効化","vocab.tipEnabled":"クリックで有効化","vocab.removeAria":"削除","vocab.edit":"編集","vocab.editTitle":"単語を編集","vocab.editSave":"保存","vocab.editEmpty":"単語を入力してください。","vocab.filter.all":"すべて","vocab.filter.auto":"自動追加","vocab.filter.manual":"手動追加","vocab.searchPlaceholder":"検索","vocab.searchEmpty":"一致する単語がありません。","vocab.newWord":"新語","vocab.newWordTitle":"新語を追加","vocab.newWordDesc":"単語を直接入力、またはプリセットテンプレートから一括インポート。","vocab.newWordInputPlaceholder":"単語を入力して Enter で追加…","vocab.newWordTemplates":"プリセットテンプレート","vocab.newWordTemplateCount":"{{count}} 語","vocab.newWordAddSelected":"選択を追加","vocab.learnedSection":"自動収集({{count}})","vocab.removeAllLearned":"すべて削除","vocab.corrections.title":"補正ルール","vocab.corrections.tip":"ASR の誤認識を修正。{num} 数字ワイルドカード対応。","vocab.corrections.patternPlaceholder":"誤認識された表記(例:{num}粒)","vocab.corrections.replacementPlaceholder":"修正後の表記(例:{num}例)","vocab.corrections.empty":"補正ルールはまだありません。","vocab.corrections.invalid":"文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。","vocab.corrections.tipDisabled":"クリックしてこのルールを無効化","vocab.corrections.tipEnabled":"クリックしてこのルールを有効化","vocab.corrections.removeAria":"補正ルールを削除","vocab.corrections.learnedBadge":"自動","vocab.corrections.learnedTip":"あなたの手直しから自動で収集したものです。いつでも削除できます。","vocab.corrections.onlyLearned":"自動収集のみ表示({{count}})","vocab.corrections.removeAllLearned":"自動収集をすべて削除","vocab.corrections.suggestTitle":"この直しを覚えますか?","vocab.corrections.suggestAccept":"覚える","vocab.corrections.suggestDismiss":"不要","vocab.presets.title":"シーンプリセット","vocab.presets.tip":"複数選択で一括適用。編集・新規作成対応。","vocab.presets.create":"プリセット新規作成","vocab.presets.apply":"選択中を有効化","vocab.presets.save":"プリセットを保存","vocab.presets.edit":"{{name}} を編集","vocab.presets.newPreset":"新しいプリセット","vocab.presets.namePlaceholder":"プリセット名","vocab.presets.wordsPlaceholder":"語彙(カンマまたは改行区切り)","style.kicker":"スタイル","style.title":"出力スタイル","style.desc":"録音のデフォルト出力スタイルを選択。","style.masterToggle":"全体有効化","style.currentDefault":"現在のデフォルト","style.ariaSetDefault":"デフォルトに設定","style.saveFailed":"保存に失敗しました: {{error}}","style.customPromptTitle":"カスタムプロンプト","style.customPromptPlaceholder":"任意。このスタイルの組み込み system prompt の末尾に追加されます。","style.customPromptHint":"空のままなら現在の挙動を維持します。保存後、このスタイルの整文と repolish の両方に適用されます。Ctrl/Cmd+Enter でも保存できます。","style.customPromptSave":"プロンプトを保存","style.customPromptDirty":"未保存","style.systemPromptMovedHint":"フルの system prompt 編集は Settings -> Providers に移動しました。このページではスタイルの有効化とデフォルト設定だけを扱います。","style.modes.raw.name":"原文","style.modes.raw.desc":"句読点と必要な区切りのみ補い、書き換えや拡張はしません。","style.modes.raw.sample":"元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。","style.modes.light.name":"軽い整文","style.modes.light.desc":"口癖の除去、句読点の補完、自然な送信可能テキストへの整理。","style.modes.light.sample":"原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。","style.modes.structured.name":"明確な構造","style.modes.structured.desc":"開発の相談、技術的な問題解決、製品への意見を、用語を正確に保って整理します。","style.modes.structured.sample":"1. トピック 1\na. ポイント\nb. ポイント\n2. トピック 2\na. ポイント\nb. ポイント","style.modes.formal.name":"正式な表現","style.modes.formal.desc":"業務コミュニケーションやメール用途向け。よりプロフェッショナルで完成度の高い文体。","style.modes.formal.sample":"メール用途では挨拶 / 結びを自動認識します。空疎な定型句は持ち込みません。","style.pack.builtinTags.minimalEdits":"最小限の修正","style.pack.builtinTags.strongCorrection":"誤認識を補正","style.pack.builtinTags.communication":"コミュニケーション","style.pack.builtinTags.natural":"自然な文章","style.pack.builtinTags.organized":"整理","style.pack.builtinTags.workplaceCommunication":"仕事のやり取り","style.pack.builtinTags.aiCoding":"AIコーディング","style.pack.builtinTags.technicalStructure":"技術内容の構造化","style.pack.newName":"名称未設定のスタイル","style.pack.newDescription":"このスタイルを使う場面を簡潔に説明してください。","style.pack.uploadIcon":"{{name}} の SVG アイコンをアップロード","style.pack.resetIcon":"既定のアイコンに戻す","style.pack.iconSaved":"アイコンを保存しました","style.pack.iconInvalid":"外部リソースを含まない有効な SVG を選択してください(最大 256 KB)。","style.pack.iconSaveFailed":"アイコンを保存できませんでした。もう一度お試しください。","style.pack.selectionListTitle":"選択範囲の推敲スタイル","style.pack.selectionListDesc":"ASRを使わない選択済みテキスト向け:文法・明瞭さ・書式の推敲。スタイルとプロンプトを個別に選べます。","style.pack.dictationTab":"録音 / ASRスタイル","style.pack.selectionTab":"選択範囲の推敲","style.pack.current":"現在","style.pack.useForSelection":"選択範囲に使用","style.pack.writtenPolish":"書面の推敲","style.pack.selectionPromptTitle":"選択範囲の推敲プロンプト(ASRなし)","style.pack.selectionPromptHint":"ユーザーが選択した書面テキスト用。ASRは経由せず、書き起こしとして扱わず、その中の質問にも答えません。","style.pack.selectionPromptEditorDesc":"選択範囲の推敲プロンプトを編集中。入力はユーザーが選択した書面テキストで、ASRは経由しません。","style.pack.dictationPromptEditorDesc":"録音 / ASRスタイルのプロンプトを編集中。入力は音声認識後の書き起こしテキストです。","style.pack.dictationPromptTitle":"録音 / ASRプロンプト","style.pack.dictationPromptHint":"録音の書き起こし後のASRテキスト用。口語整理、ASR誤字修正、固有名詞の復元ルールをここに書けます。","style.pack.selectionPromptFallback":"書面推敲プロンプトが未設定です。安全なデフォルトを使用します。","style.pack.selectionActivated":"「{{name}}」を選択範囲の推敲に設定しました","style.pack.selectionActivateFailed":"選択範囲の推敲スタイル切替に失敗:{{err}}","style.pack.selectionChars":"{{count}} 文字","style.pack.kicker":"スタイルパック","style.pack.title":"スタイルパック","style.pack.desc":"ローカルスタイルパックを管理。","style.pack.marketplaceBtn":"マーケット","style.pack.loadFailed":"スタイルパックの読み込みに失敗:{{err}}","style.pack.importZip":"ZIP をインポート","style.pack.exportZip":"ZIP をエクスポート","style.pack.exportShort":"エクスポート","style.pack.publishMarketplace":"マーケットに公開","style.pack.updateMarketplace":"マーケットの新版に更新","style.pack.publishDisabledHint":"先に 設定 → マーケット で GitHub ユーザー名を設定してください","style.pack.publishSuccess":"公開完了、マーケット審査待ち","style.pack.publishFailed":"公開失敗:{{err}}","style.pack.publishBuiltinRejected":"ビルトインパックは直接公開できません。先に編集してインポート版を作成してください。","style.pack.builtin":"ビルトイン","style.pack.imported":"インポート","style.pack.active":"使用中","style.pack.activate":"有効化","style.pack.edit":"編集","style.pack.closeEditor":"閉じる","style.pack.unsaved":"未保存","style.pack.listTitle":"ローカルパック","style.pack.listDesc":"パックを閲覧・切替。","style.pack.listCount":"{{count}} 個","style.pack.addPackTileTitle":"新規パック","style.pack.addPackTileHint":"空のテンプレートから開始。","style.pack.createSuccess":"新規パックを作成しました","style.pack.createFailed":"パック作成失敗:{{err}}","style.pack.save":"保存","style.pack.revert":"元に戻す","style.pack.saveSuccess":"スタイルパックを保存しました","style.pack.saveFailed":"スタイルパック保存失敗:{{err}}","style.pack.activateSuccess":"\"{{name}}\" を使用中に設定しました","style.pack.activateFailed":"使用中の設定に失敗:{{err}}","style.pack.importSuccess":"\"{{name}}\" をインポートしました","style.pack.importFailed":"ZIP インポート失敗:{{err}}","style.pack.exportSuccess":"{{path}} にエクスポートしました","style.pack.exportFailed":"ZIP エクスポート失敗:{{err}}","style.pack.exportDirtyFirst":"ZIP をエクスポートする前に現在のパックを保存してください。","style.pack.resetBuiltin":"リセット","style.pack.resetSuccess":"\"{{name}}\" をリセットしました","style.pack.resetFailed":"パックのリセット失敗:{{err}}","style.pack.deleteImported":"削除","style.pack.deleteConfirm":"\"{{name}}\" を削除しますか?この操作は取り消せません。","style.pack.deleteSuccess":"\"{{name}}\" を削除しました","style.pack.deleteFailed":"パック削除失敗:{{err}}","style.pack.summaryCurrentEmpty":"まだパックが選択されていません","style.pack.editorTitle":"パック編集","style.pack.editorDesc":"このパックを編集します。","style.pack.metaTitle":"インストール情報","style.pack.metaSource":"ソース","style.pack.metaBaseMode":"ベースモード","style.pack.metaUpdatedAt":"更新日時","style.pack.fieldName":"名前","style.pack.fieldAuthor":"作者","style.pack.fieldAuthorPlaceholder":"任意。ソース表示用","style.pack.fieldVersion":"バージョン","style.pack.fieldTags":"タグ","style.pack.fieldTagsPlaceholder":"カンマ区切り、例: community, voiceover, formal","style.pack.fieldDescription":"説明","style.pack.fieldModel":"推奨モデル(メタデータのみ)","style.pack.fieldModelPlaceholder":"任意。例: gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"メタデータのみ。実際のモデルは切り替わりません。","style.pack.fieldCompatibility":"互換アプリバージョン","style.pack.fieldCompatibilityPlaceholder":"任意。例: >=1.3.0","style.pack.fullPromptTitle":"System Prompt","style.pack.fullPromptHint":"このパック固有の Prompt です。","style.pack.promptChars":"{{count}} 文字","style.pack.runtimeTitle":"OpenLess 実行時付加指令","style.pack.runtimeDesc":"読み取り専用の実行時ヘルパー。","style.pack.runtimeContextTitle":"コンテキスト前提","style.pack.runtimeContextDesc":"言語とアプリのコンテキストから","style.pack.runtimeContextEmpty":"現在のプレビューでは付加されません。","style.pack.runtimeHotwordTitle":"ホットワードブロック","style.pack.runtimeHotwordDesc":"有効なホットワードから","style.pack.runtimeHotwordEmpty":"現在のプレビューでは付加されません。","style.pack.runtimeHistoryTitle":"マルチターン履歴ガード","style.pack.runtimeHistoryDesc":"ライブのマルチターン polish のみで使用","style.pack.runtimeHistoryEmpty":"前のターンが存在する場合のみ付加。","style.pack.runtimeActive":"有効","style.pack.runtimeInactive":"無効","style.pack.runtimePreviewFailed":"実行時プレビュー生成失敗:{{err}}","style.pack.runtimePreviewOmittedFrontApp":"プレビューはフロントアプリのラベルを省略しています。","style.pack.examplesTitle":"効果例","style.pack.examplesDesc":"パックと一緒にエクスポートされます。","style.pack.addExample":"例を追加","style.pack.examplesEmpty":"まだ例がありません。","style.pack.exampleTitlePlaceholder":"例 {{index}} のタイトル","style.pack.exampleInput":"入力","style.pack.exampleOutput":"出力","style.pack.examplesCount":"{{count}} 個の例","style.pack.discardCloseConfirm":"未保存の変更を破棄してエディタを閉じますか?","style.pack.discardSwitchConfirm":"未保存の変更を破棄して \"{{name}}\" に切り替えますか?","style.pack.derivativeBadge":"@{{login}} から派生","translation.searchLanguages":"言語を検索…","translation.noMatchingLanguages":"一致する言語がありません","translation.selectedLanguages":"{{count}} 言語を選択中","translation.languageSupportHint":"音声認識で使える言語はサービスによって異なります。翻訳先はアプリの表示言語とは独立しています。","translation.kicker":"翻訳","translation.title":"翻訳","translation.desc":"録音後に自動翻訳してから入力。","translation.statusEnabled":"有効","translation.statusDisabled":"無効","translation.working.title":"作業言語","translation.working.desc":"日常使用する言語を選択し、整文と翻訳に反映。","translation.target.title":"翻訳ターゲット言語","translation.target.desc":"録音中に Shift で翻訳を起動。「無効」で Shift 無効化。","translation.target.disabled":"無効(Shift で翻訳を発動しない)","translation.target.sameAsWorking":"ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。","translation.style.title":"翻訳スタイル","translation.style.desc":"「スタイル」ページで現在有効なスタイルパックを自動的に引き継ぎます。","translation.style.unavailable":"取得できません","translation.save.workingFailed":"作業言語の保存に失敗しました。もう一度お試しください。","translation.save.targetFailed":"翻訳ターゲット言語の保存に失敗しました。もう一度お試しください。","translation.save.hotkeyRegisterFailed":"翻訳ショートカットの登録に失敗しました。設定は保存されていません。","translation.save.hotkeySaveFailed":"翻訳ショートカットの保存に失敗しました。もう一度お試しください。","translation.howto.title":"使い方","translation.howto.step1":"任意の入力欄にカーソルを置く。","translation.howto.step2":"{{trigger}} を押して録音開始。","translation.howto.step3":"録音中に {{shortcut}} を一度押して翻訳を起動。","translation.howto.step4":"再度 {{trigger}} を押して停止。","translation.howto.step5":"翻訳結果がカーソル位置に挿入されます。","translation.howto.indicatorTitle":"翻訳モードの確認方法","translation.howto.indicatorDesc":"Shift を押すと画面下部に青い「翻訳中」表示が出ます。","translation.howto.fallbackTitle":"セーフティフォールバック","translation.howto.fallbackDesc":"翻訳失敗時は原文がそのまま挿入されます。","selectionAsk.title":"選択追問","selectionAsk.desc":"テキストを選択して音声で質問。複数ターンの追問対応。","selectionAsk.shortcutSettings":"ショートカット設定","selectionAsk.guide.openTitle":"パネルを開く","selectionAsk.guide.openDesc":"{{hotkey}} で会話を始めます。","selectionAsk.guide.unsetDesc":"まずショートカット設定で選択追問のキーを割り当ててください。","selectionAsk.guide.selectTitle":"知りたい内容を選択","selectionAsk.guide.askTitle":"声で質問する","selectionAsk.guide.askDesc":"{{recordHotkey}} で録音し、もう一度押して送信します。","selectionAsk.guide.followup":"録音キーでもう一度、続けて質問できます。","selectionAsk.guide.dismiss":"パネルを閉じて、この会話を終了","selectionAsk.hotkey.title":"フロートウィンドウのショートカット","selectionAsk.save.historySaveFailed":"Q&A 履歴設定の保存に失敗しました。もう一度お試しください。","selectionAsk.history.title":"履歴を保存","selectionAsk.history.desc":"有効時、Q&A 記録をローカルに保存。デフォルト OFF。","selectionAsk.howto.title":"使い方","selectionAsk.howto.step2":"任意のアプリでテキストを選択。","settings.selectionWorkspace.title":"選択範囲アシスタント","settings.selectionWorkspace.hint":"テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。","settings.selectionWorkspace.polishHotkey":"選択範囲アシスタントのショートカット","settings.selectionWorkspace.polishHotkeyDesc":"音声編集オフ時は推敲、オン時は押しながら話す(録音方式はグローバル設定に従う)。","settings.selectionWorkspace.polishDelivery":"結果の処理","settings.selectionWorkspace.voiceDeliveryDesc":"音声編集後:選択範囲を直接置換するか、Ask パネルで確認してから置換します。","settings.selectionWorkspace.voiceEnable":"音声編集","settings.selectionWorkspace.voiceEnableDesc":"上と同じショートカットを使用。録音方式はグローバル設定に従います(現在:{{recordingLabel}})。","settings.selectionWorkspace.autoIntent":"意図を自動判定","settings.selectionWorkspace.autoIntentDesc":"オン時は設定モデルが質問/編集を判定。モデル失敗時のみ?/疑問語ヒューリスティックにフォールバック。","settings.selectionWorkspace.editKeywords":"追加の疑問手がかり","settings.selectionWorkspace.editKeywordsDesc":"自動判定オフ時のみ。1行1語で質問扱い。なければ?/疑問語ヒューリスティック。","settings.selectionPolish.title":"選択範囲の推敲","settings.selectionPolish.hotkey":"起動ショートカット","settings.selectionPolish.hotkeyDesc":"記録後すぐに有効になります。録音・質問などのグローバルショートカットと重複すると拒否されます。","settings.selectionPolish.delivery":"結果の処理方法","settings.selectionPolish.hint":"任意のテキストを選択してから起動します。マイクやASRは不要で、現在のスタイルパックと専用の選択用プロンプトを使用します。","settings.selectionPolish.directReplace":"直接置き換え","settings.selectionPolish.directReplaceHint":"モデル完了後に元の選択範囲を安全に置き換えます。","settings.selectionPolish.previewConfirm":"プレビューして確認","settings.selectionPolish.previewConfirmHint":"編集可能なウィンドウで結果を確認してから、元の選択範囲を置き換えます。","settings.kicker":"設定","settings.title":"設定","settings.desc":"録音、プロバイダー、ショートカット、権限の設定。","settings.network.title":"ネットワーク","settings.network.useSystemProxyLabel":"システムプロキシを使用","settings.network.useSystemProxyDesc":"オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。","settings.dataStorage.title":"データ保存","settings.dataStorage.desc":"この端末に保存される会話履歴とコンテキスト。","settings.dataStorage.cursorContextLabel":"カーソル文脈(実験的)","settings.dataStorage.cursorContextDesc":"推敲時に、いま書いている文書のカーソル周辺の原文を読み取り、同音語・固有名詞・代名詞の書き分けをモデルが判断できるようにします。オンにすると、そのテキストがリクエストとともに設定中の LLM プロバイダへ送信されます。オフのときは一文字も読み取りません。パスワード入力欄、Secure Input、パスワード管理アプリ、ターミナルは常に読み取りません。macOS のみ。","settings.codingConsole.title":"Claude コンソール","settings.codingConsole.desc":"ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。","settings.codingConsole.guardNote":"復元可能な操作はデフォルトで許可。rm -rf / sudo / 強制プッシュなどの高リスクコマンドはブロック。作業ディレクトリが git リポジトリなら実行前にスナップショットを作成し巻き戻し可能。","settings.codingConsole.status":"状態","settings.codingConsole.detect":"検出","settings.codingConsole.detecting":"検出中…","settings.codingConsole.installed":"Claude を検出","settings.codingConsole.notInstalled":"claude が見つかりません","settings.codingConsole.notInstalledHint":"まず Claude Code をインストールしてください(docs.anthropic.com/claude-code 参照)。または下に実行ファイルのフルパスを入力してください。","settings.codingConsole.mcpServers":"MCP サーバー {{count}} 件","settings.codingConsole.computerUsePresent":"デスクトップ操作(computer use)MCP を検出","settings.codingConsole.computerUseAbsent":"デスクトップ操作 MCP なし(コピー / 貼り付けなどの軽い操作は Bash で可能、不要)","settings.codingConsole.exePath":"実行ファイル","settings.codingConsole.workdir":"作業ディレクトリ","settings.codingConsole.workdirDesc":"任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。","settings.codingConsole.workdirPlaceholder":"空欄なら一時ディレクトリで実行","settings.codingConsole.permissionMode":"権限モード","settings.codingConsole.mode.acceptEdits":"許可(復元可能)","settings.codingConsole.mode.plan":"読み取り専用 / 計画","settings.codingConsole.mode.default":"デフォルト(都度確認)","settings.codingConsole.mode.bypassPermissions":"完全許可(高リスク)","settings.codingConsole.promptPlaceholder":"Claude に指示、例:カレントディレクトリのファイル名を一覧表示","settings.codingConsole.run":"実行","settings.codingConsole.running":"実行中…","settings.codingConsole.cancel":"キャンセル","settings.codingConsole.clear":"クリア","settings.codingConsole.riskWarn":"高リスクの意図を検出:{{reason}}。ガードレールが実行時に高リスクコマンドをブロックします。","settings.codingConsole.toolUse":"ツール {{name}}","settings.codingConsole.done":"完了","settings.codingConsole.doneCost":"完了 · コスト ${{cost}}","settings.codingConsole.cancelled":"キャンセル済み","settings.codingConsole.outputPlaceholder":"出力はここにストリーミング表示されます…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。","settings.codingAgent.enable":"Less Computer を有効化","settings.codingAgent.comingSoonNote":"設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。","settings.codingAgent.hotkeyHint":"有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。","settings.codingAgent.voiceHotkey":"押しながら話すキー","settings.codingAgent.voiceHotkeyDesc":"押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。","settings.codingAgent.provider":"Agent バックエンド","settings.codingAgent.opencodeReady":"OpenCode v{{version}} を検出しました。","settings.codingAgent.opencodeMissing":"opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。","settings.codingAgent.cliReady":"{{name}} v{{version}} を検出しました。","settings.codingAgent.cliMissing":"{{name}} コマンドが見つかりません。先にインストールとログインを行うか、下の「実行ファイル」に絶対パスを入力してください。","settings.codingAgent.sandboxGuardHint":"このバックエンドは粗い粒度のサンドボックス段階しか持たず、コマンド単位の高リスク一覧はありません。制限に触れた場合はそのままエラーとして報告し、「このコマンドを承認」カードは表示されません。","settings.codingAgent.codexModelHint":"Codex のモデル名(gpt-5 など)を入力します。空欄の場合は ~/.codex/config.toml の設定を使います。","settings.codingAgent.codexBudgetHint":"Codex には実行ごとの米ドル予算上限がありません。料金は設定したプロバイダーに依存します。","settings.codingAgent.codexMode.plan":"読み取り専用 / 計画","settings.codingAgent.codexMode.workspaceWrite":"ワークスペースへの書き込みを許可","settings.codingAgent.codexModelPlaceholder":"空欄 = Codex の既定値","settings.codingAgent.dshModelHint":"dsh の headless プロファイルにモデル切り替えはありません。モデルは dsh 自身のプロファイルで決まり、ここでは変更できません。","settings.codingAgent.panelHotkey":"パネルキー(音声 Agent)","settings.codingAgent.panelHotkeyDesc":"録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。","settings.codingAgent.quickHotkey":"クイック取得キー","settings.codingAgent.quickHotkeyDesc":"選択テキストを取得 → Claude → 結果をカーソル位置へ。パネルなし、より高速。","settings.codingAgent.model":"モデル","settings.codingAgent.modelPlaceholder":"デフォルト: sonnet","settings.codingAgent.modelDefault":"デフォルト(自動 sonnet)","settings.codingAgent.modelHint":"Haiku = 最速 · Sonnet = バランス · Opus = 最強","settings.codingAgent.opencodeModelDefault":"OpenCode のデフォルトモデルを使用","settings.codingAgent.opencodeModelHint":"現在の OpenCode アカウントで利用できる provider/model を自動取得し、選択内容をすぐ保存します。","settings.codingAgent.opencodeModelsRefresh":"モデルを再取得","settings.codingAgent.opencodeModelsRefreshing":"OpenCode モデルを取得中…","settings.codingAgent.opencodeModelsLoaded":"{{count}} 個のモデルを取得しました。","settings.codingAgent.opencodeModelsEmpty":"利用可能なモデルが返されませんでした。OpenCode にログインするか、モデルプロバイダーを設定してください。","settings.codingAgent.opencodeModelsError":"モデルの取得に失敗しました:{{message}}","settings.codingAgent.exe":"実行ファイルのパス","settings.codingAgent.openPanel":"テキストテスト","settings.codingAgent.openPanelHint":"Less Computer パネルを直接開き、現在の Agent とモデル設定をテキストで確認します。","settings.codingAgent.openPanelAction":"Less Computer を開く","settings.debug.cursorLabel":"カーソル","settings.debug.title":"デバッグツール","settings.debug.desc":"認識の問題を調査するときに使用。通常はオフのままで構いません。","settings.debug.cursorProbeLabel":"カーソル文脈プローブ","settings.debug.cursorProbeDesc":"クリックしたあと、カウントダウン中に対象アプリへ切り替えて入力欄をクリックしてください。そこのカーソル周辺の原文を読み取り、どのアプリが読めてどれが安全ゲートに阻まれるかを確認できます。読み取りは一度きりで、どのプロバイダにも送信しません。","settings.debug.cursorProbeBtn":"プローブ(5 秒後)","settings.debug.cursorProbeCountdown":"{{n}} 秒後に読み取り…","settings.marketplace.title":"拡張マーケット","settings.marketplace.desc":"スタイルマーケットの投稿者 ID。スタイルの閲覧とインストールは「スタイル」ページで行います。","settings.marketplace.github.signIn":"GitHub でログイン","settings.marketplace.github.signedIn":"GitHub でログイン済み","settings.marketplace.github.signedOut":"ログインするとスタイルの投稿・いいねができます。","settings.marketplace.github.signOut":"ログアウト","settings.marketplace.github.starting":"ログインを開始しています…","settings.marketplace.github.codeHint":"開いた GitHub ページでこのコードを入力してください:","settings.marketplace.github.openGithub":"GitHub を開く","settings.marketplace.github.waiting":"GitHub を開きました。承認するとログインします…","settings.marketplace.github.failed":"ログインに失敗しました。再試行してください","settings.recording.title":"録音と入力","settings.recording.desc":"グローバル録音のショートカットとトリガー方式を定義します。","settings.recording.hotkeyLabel":"録音ショートカット","settings.recording.hotkeyDescAcc":"押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。","settings.recording.hotkeyDescNoAcc":"押すと音声キャプチャを開始(グローバル)。追加の権限は不要。","settings.recording.modeLabel":"録音方式","settings.recording.modeDesc":"トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。","settings.recording.modeToggle":"トグル式","settings.recording.modeHold":"押し続けて話す","settings.recording.modeAuto":"自動","settings.recording.silenceAutoStopLabel":"無音で自動停止","settings.recording.silenceAutoStopDesc":"トグルモードのみ有効。音声を検出した後、無音が選択した時間続いたら録音を自動停止して送信します。一度も話さない場合は10秒後にキャンセル。既定ではオフで、2回目のキー押下による停止と Esc によるキャンセルは引き続き有効です。","settings.recording.silenceAutoStopSecondsLabel":"無音の長さ","settings.recording.silenceAutoStopSecondsValue":"{{value}} 秒","settings.recording.migrationNoticeTitle":"デフォルトがトグル式に変更されました","settings.recording.migrationNoticeDesc":"以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。","settings.recording.microphoneLabel":"優先マイク","settings.recording.microphoneDesc":"優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。","settings.recording.microphoneDefault":"システムのデフォルトマイク","settings.recording.microphoneDefaultDesc":"システムのデフォルト入力デバイスを使用","settings.recording.microphoneSystemDefault":"システムデフォルト","settings.recording.microphoneUnavailable":"利用不可","settings.recording.microphoneLoadError":"マイクの読み込みに失敗:{{message}}","settings.recording.microphoneDialogTitle":"マイク","settings.recording.microphoneDialogDesc":"声を拾えるマイクを選択してください。メーターが動かない場合は別のマイクを試してください。","settings.recording.microphoneMonitorError":"入力レベルの監視に失敗:{{message}}","settings.recording.capsuleLabel":"録音カプセル","settings.recording.capsuleDesc":"録音 / 転写中、画面下部に半透明のカプセルを表示。","settings.recording.capsuleStyleTypeless":"Typeless コンパクトスタイル","settings.recording.capsuleStyleLabel":"カプセルスタイル","settings.recording.capsuleStyleSiri":"光条 Siri スタイル","settings.recording.capsuleStyleClassic":"Openless デフォルトスタイル","settings.recording.muteDuringRecordingLabel":"録音中はミュート","settings.recording.muteDuringRecordingDesc":"録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。","settings.recording.audioCueLabel":"録音開始音","settings.recording.audioCueDesc":"ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。","settings.recording.audioCuePreview":"試聴","settings.recording.insertGroupTitle":"挿入とクリップボード","settings.recording.restoreClipboardLabel":"入力後にクリップボードを復元","settings.recording.restoreClipboardDesc":"ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。","settings.recording.pasteShortcutLabel":"貼り付けショートカット","settings.recording.pasteShortcutDesc":"挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。","settings.recording.pasteShortcutCtrlV":"Ctrl+V(既定 / ほとんどのアプリ)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert(xterm / urxvt)","settings.recording.comboRecordLabel":"ショートカットを記録","settings.recording.comboRecordDesc":"クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。","settings.recording.comboRecordBtn":"ショートカットを記録","settings.recording.comboResetBtn":"リセット","settings.recording.comboMenuToggle":"その他の操作","settings.recording.comboDisableHint":"コアショートカットは無効化できません(録音にはショートカットが必須です)","settings.recording.comboRecordHint":"ショートカットの組み合わせを押してください…","settings.recording.comboNeedKey":"組み合わせキー(例: ⌘⇧J)を設定してください。修飾キー単体は使えません","settings.recording.comboRecorded":"記録済み","settings.recording.comboClear":"クリア","settings.recording.comboConflict":"このショートカットの組み合わせは使用できません","settings.recording.allowNonTsfFallbackLabel":"非 TSF フォールバックを許可","settings.recording.allowNonTsfFallbackDesc":"Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。","settings.recording.windowsInsertionModeLabel":"Windows 挿入方式","settings.recording.windowsInsertionModeDesc":"聴写結果をカーソル位置へ挿入する方法。クリップボード貼り付けは上の「貼り付けショートカット」を使い、改行を保持します。","settings.recording.windowsInsertionModeTsf":"TSF IME(既定)","settings.recording.windowsInsertionModeSendInput":"SendInput キー入力シミュレーション","settings.recording.windowsInsertionModePaste":"クリップボード貼り付け(Ctrl+V など)","settings.recording.macosNewlineModeLabel":"改行の送り方","settings.recording.macosNewlineModeDesc":"自動では既知のターミナルアプリに Line Feed(U+000A / Ctrl+J)、それ以外に Shift+Return を送ります。通常の Return は送信になります。","settings.recording.macosNewlineModeAuto":"自動(ターミナルでは Line Feed)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return(チャットで改行)","settings.recording.macosNewlineModeLineFeed":"Line Feed(ターミナル CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return(複数メッセージに分割)","settings.recording.windowsSendInputNewlineModeLabel":"SendInput 改行シミュレーション","settings.recording.windowsSendInputNewlineModeDesc":"SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。","settings.recording.windowsSendInputNewlineModeEnter":"Enter(多くのエディタ)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter(チャット入力)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"キーボード一覧に OpenLess を表示","settings.recording.windowsShowOpenlessInKeyboardListDesc":"オフにすると Win+Space で OpenLess に切り替わりません。SendInput とクリップボード貼り付け挿入には影響しません。オンに戻すと一覧に再表示されます。","settings.recording.windowsShowOpenlessInKeyboardListError":"キーボード一覧を更新できません:システムが OpenLess 言語プロファイルの変更を拒否しました。","settings.recording.historyGroupTitle":"履歴とコンテキスト","settings.recording.historyRetentionLabel":"履歴保持期間(日)","settings.recording.historyRetentionDesc":"保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。","settings.recording.historyMaxEntriesLabel":"履歴件数の上限","settings.recording.historyMaxEntriesDesc":"ローカル保持セッション上限。空欄 = 200。範囲 5–200。","settings.recording.polishContextWindowLabel":"会話コンテキスト窓(分)","settings.recording.polishContextWindowDesc":"直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。","settings.recording.recordAudioForDebugLabel":"元の録音を保持(デバッグ)","settings.recording.recordAudioForDebugDesc":"生のマイク音声を wav で保存し、認識問題の診断に利用。","settings.recording.audioRecordingMaxEntriesLabel":"元音声の保持件数","settings.recording.audioRecordingMaxEntriesDesc":"ローカル保持 wav ファイル上限。空欄 = 200。","settings.recording.startupGroupTitle":"起動","settings.recording.startMinimizedLabel":"起動時にメインウィンドウを表示しない","settings.recording.startMinimizedDesc":"どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。","settings.recording.autoUpdateCheckLabel":"アップデートを自動チェック","settings.recording.autoUpdateCheckDesc":"起動時および 60 分ごとに自動チェック。","settings.recording.marketplaceGroupTitle":"スタイルパックマーケット","settings.recording.marketplaceBaseUrlLabel":"バックエンド URL","settings.recording.marketplaceBaseUrlDesc":"マーケットプレイスの URL。空欄でデフォルト値。","settings.recording.marketplaceDevLoginLabel":"GitHub ログイン名(アップロード ID)","settings.recording.marketplaceDevLoginDesc":"アップロード者を識別。空欄でアップロード・いいね無効。","settings.recording.startupAtBoot":"起動時に自動起動","settings.recording.startupAtBootDesc":"ログイン時に OpenLess を自動起動。","settings.recording.startupAtBootError":"自動起動の切り替えに失敗:{{message}}","settings.channels.backToList":"チャンネル一覧に戻る","settings.channels.done":"完了","settings.channels.llmTitle":"テキスト処理チャンネル","settings.channels.asrTitle":"音声認識チャンネル","settings.channels.current":"使用中","settings.channels.enabled":"有効","settings.channels.disabled":"無効","settings.channels.enabledFor":"{{name}} を有効にする","settings.channels.modelNotSet":"モデルの個別設定なし","settings.channels.localModelManaged":"モデルはシステムまたは「ローカルモデル」で管理","settings.channels.lastCheck":"前回の接続確認","settings.channels.verifying":"確認中…","settings.channels.notVerified":"未確認","settings.channels.passed":"確認に成功","settings.channels.failed":"確認に失敗 · {{reason}}","settings.channels.elapsed":"所要時間 {{ms}} ms","settings.channels.staleResult":"24 時間以上前の結果","settings.channels.connectionTitle":"サービス接続","settings.channels.modelTitle":"モデル設定","settings.channels.modelHint":"モデル名を直接入力するか、プロバイダーから一覧を取得して選択します。","settings.channels.availableModels":"利用可能なモデル","settings.channels.validationTitle":"接続の確認","settings.channels.validationHint":"実際にリクエストを送信して設定を確認します。サービスの利用枠を消費する場合があります。設定の保存だけでは確認を実行しません。","settings.channels.autoSaveHint":"変更は自動保存されます。設定が終わったら、手動で接続を確認できます。","settings.channels.nameHint":"同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。","settings.channels.errModel":"モデル","settings.channels.verify":"検証","settings.channels.verifyHint":"実際に API を1回呼んで、このチャネルが今使えるか確認します","settings.channels.errTimeout":"タイムアウト","settings.channels.errNetwork":"ネットワーク","settings.channels.errEndpoint":"エンドポイント","settings.channels.errGeneric":"失敗","settings.channels.dragHint":"ドラッグで優先順位を変更","settings.channels.orderHint":"有効なチャネルのうち、先頭のものを使用します。ドラッグで順序を変更できます。無効なチャネルは末尾に移動します。","settings.channels.empty":"チャネルがまだありません。「チャネルを追加」で最初のサービスを接続しましょう。","settings.channels.add":"チャネルを追加","settings.channels.edit":"編集","settings.channels.createTitle":"チャネルを追加","settings.channels.editTitle":"チャネルを編集","settings.channels.providerLabel":"プロバイダー","settings.channels.nameLabel":"チャネル名(任意)","settings.channels.namePlaceholder":"例:SiliconFlow — メインキー","settings.channels.create":"作成","settings.channels.delete":"チャネルを削除","settings.channels.deleteConfirm":"削除するとこのチャネルに保存された鍵も消去されます。","settings.channels.confirmDelete":"削除する","settings.channels.justNow":"たった今","settings.channels.minutesAgo":"{{count}}分前","settings.channels.hoursAgo":"{{count}}時間前","settings.channels.daysAgo":"{{count}}日前","settings.channels.localEngineModelHint":"「AI サービスとモデル → ローカルモデル」でモデルをダウンロード・切り替えできます。","settings.providers.localEngineNoCredentials":"ローカルエンジンに API キーやエンドポイントは不要です。","settings.providers.localModelLabel":"ローカルモデル","settings.providers.localModelEmpty":"ローカルモデル未ダウンロード","settings.providers.appleSpeechLocalNote":"Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。","settings.providers.localEngineNote":"ダウンロード済みのローカルモデルは上のドロップダウンで直接選択できます。他のモデルは「ローカルモデル」でダウンロード・管理します。","settings.providers.localTag":"ローカル","settings.providers.llmTitle":"LLM モデル(整文)","settings.providers.llmDesc":"OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。","settings.providers.providerLabel":"サプライヤー","settings.providers.llmProviderDesc":"選択するとデフォルトの Base URL が自動入力されます。","settings.providers.credentialStorageNotice":"資格情報は OS の資格情報ストアに保存されます。","settings.providers.codexOAuthNotice":"Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。","settings.providers.asrProviderDesc":"切り替えると対応する認証情報が自動選択されます。","settings.providers.asrTitle":"ASR 音声(転写)","settings.providers.asrDesc":"録音した音声をテキストに文字起こしします。","settings.providers.omniTitle":"マルチモーダルモデル","settings.providers.omniDesc":"1つのモデルが「プロンプト + 音声」から最終テキストを直接出力します(実験的パイプライン)。","settings.providers.pipelineModeLabel":"認識パイプライン","settings.providers.pipelineModeHint":"従来 = ASR 文字起こし + LLM 整形の2段式。マルチモーダル = 音声対応モデルが1回で完了。","settings.providers.pipelineModeTraditional":"従来モード","settings.providers.pipelineModeMultimodal":"マルチモーダルモード","settings.providers.pipelineIsolationNotice":"2つのモードは完全に独立した認証情報を使用します。切り替えてももう一方の設定は削除されず、切り戻せば復元されます。","settings.providers.presets.ark":"ARK(Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter(無料モデル)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax(M3)","settings.providers.presets.stepfun":"StepFun(階躍星辰)","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"Tencent Cloud TokenHub","settings.providers.presets.customChatCompletions":"カスタム · Chat Completions","settings.providers.presets.customResponses":"カスタム · Responses","settings.providers.presets.customMessages":"カスタム · Messages","settings.providers.presets.custom":"カスタム","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrBailian":"Alibaba Bailian リアルタイム ASR","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 リアルタイム ASR","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash(録音ファイル)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper(互換)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"カスタム OpenAI 互換","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek リアルタイム音声認識","settings.providers.presets.asrTencentCloud":"Tencent Cloud Hunyuan リアルタイム ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"ローカル sherpa-onnx(実験的)","settings.providers.presets.asrFoundryLocalWhisper":"ローカル Whisper(Foundry Local)","settings.providers.presets.asrLocalWhisper":"ローカル Whisper(バッチ)","settings.providers.presets.asrLocalQwen3":"ローカル Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"ローカル Qwen3-ASR(MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"ローカル Qwen3-ASR(C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple 音声認識 (macOS)","settings.providers.presets.omniOpenai":"OpenAI(音声対応)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs は録音音声を設定済みのエンドポイントへアップロードしてバッチ文字起こしします。","settings.providers.zenmuxVocabularyNote":"ZenMux は JSON 文字起こしプロトコルを使用し、辞書ホットワード(prompt/hotwords)は送信されません。辞書は依然として潤色段階には渡りますが、音声認識のバイアスには使用されません。","settings.providers.asrAdvancedNote":"以下の詳細オプションは「カスタム OpenAI 互換」と「ZenMux」のプリセットのみに影響します。その他の名前付きプロバイダーのプリセットは内蔵動作のままです。","settings.providers.asrAdvancedVerboseJsonLabel":"セグメント指標 (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"サーバーが対応する場合に幻聴フィルタ用の segments 指標を要求します。非対応の自前サーバーではオフのままにしてください。","settings.providers.asrAdvancedChunkLabel":"分割時間 (ms)","settings.providers.asrAdvancedChunkHint":"0 = 分割なし(全体を一括送信)。長い録音や1リクエストの時間制限があるサーバー向けに分割送信できます。","settings.providers.asrAdvancedEnableItnLabel":"数字正規化 (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"口頭の数字・単位を算用数字に正規化します(例:「にせんにじゅうろく」→「2026」)。オフにすると元の表記を保持します。","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API Key","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"認証モード","settings.providers.volcengineAuthModeAppIdToken":"レガシーアプリ(APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"新版コンソール API Key","settings.providers.volcengineMappingNote":"Secret Key は現在不要。Resource ID のデフォルトは volc.seedasr.sauc.duration。","settings.providers.volcengineApiKeyNote":"新版スピーチコンソールで作成した API Key で認証します(APP ID 不要)。API Key はスピーチコンソールの「API Key 管理」で作成:console.volcengine.com/speech/new/setting/apikeys。Resource ID のデフォルトは volc.seedasr.sauc.duration。","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API Key","settings.providers.xfyunNote":"iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。","settings.providers.tencentCloudAppIdLabel":"Tencent Cloud AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Tencent Cloud 音声認識 API の認証情報を使用します。既定の Hy-ASR-3.0-preview は中国語・英語・20 方言に対応します。Preview は 60 秒以内の 16kHz モノラル PCM のみ対応し、コンテキストとホットワード強化は未対応です。","settings.providers.tencentTokenHubNote":"現在オンラインの言語モデルのみを表示します。一部のモデルは常に思考を使用し、思考をオフにしてもモデル固有の動作を維持します。","settings.providers.localAsrActiveNotice":"現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。","settings.providers.localAsrTakeoverHint":"「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。","settings.providers.asrProviderTakenOver":"ローカルエンジンを使用中です。上のドロップダウンで別のプロバイダーを選ぶと切り替えられます(ローカルエンジンは自動的に停止します)。ローカルモデルは「サービス → ローカルモデル」で管理します。","settings.providers.localAsrHint":"デバイス上で動作、API キー不要。HuggingFace からモデルをダウンロード。","settings.providers.foundryLocalAsrHint":"デバイス上で動作、ASR API キー不要。初回はランタイムとモデルをダウンロード。","settings.providers.localAsrPerformanceWarning":"ローカル推論はクラウドより遅く、中国語の精度が低くなる場合があります。オフラインまたはプライバシー重視の場合に。","settings.providers.localAsrReady":"{{model}} ダウンロード済み","settings.providers.localAsrNotReady":"{{model}} 未ダウンロード","settings.providers.localAsrGoDownload":"モデル設定でダウンロード","settings.providers.localAsrManage":"モデル設定を開く","settings.providers.localAsrDownloadedTitle":"ダウンロード済みモデル","settings.providers.localAsrDelete":"削除","settings.providers.fillDefault":"デフォルト値を入力","settings.providers.readFailed":"読み込み失敗","settings.providers.apiKeyLabel":"API キー","settings.providers.baseUrlLabel":"エンドポイント","settings.providers.modelLabel":"モデル","settings.providers.customModelLabel":"カスタムモデル…","settings.providers.presetListLabel":"プリセットに戻る","settings.providers.searchModels":"モデルを検索…","settings.providers.noMatchingModels":"一致するモデルがありません","settings.providers.orcarouterCatalogHint":"OrcaRouter /models から取得します。このプロバイダーではカタログから選択し、モデル ID の手入力はできません。","settings.providers.orcarouterAsrCatalogHint":"OrcaRouter /models から取得し、音声入力に対応する Gemini のみ表示します。モデル ID の手入力はできません。","settings.providers.temperatureLabel":"Temperature","settings.providers.temperaturePlaceholder":"空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3","settings.providers.extraHeadersLabel":"追加 Headers","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"思考","settings.providers.thinkingModeOn":"オン","settings.providers.thinkingModeOff":"オフ","settings.providers.requestFormatLabel":"リクエスト形式","settings.providers.messagesThinkingLabel":"思考方式","settings.providers.thinkingAdaptive":"適応型","settings.providers.thinkingBudget":"固定予算","settings.providers.maxTokensLabel":"最大出力トークン数","settings.providers.thinkingBudgetLabel":"思考トークン予算","settings.providers.responsesThinkingHint":"一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。","settings.providers.messagesThinkingHint":"旧モデルや互換ゲートウェイでは固定予算が必要な場合があります。予算は最大出力未満にしてください。思考時は温度を送信しません。","settings.providers.llmRequestFormatInvalid":"リクエスト形式が無効です。選択し直してください。","settings.providers.llmThinkingModeInvalid":"思考方式が無効です。選択し直してください。","settings.providers.llmTokenLimitInvalid":"トークン上限は正の整数にしてください。","settings.providers.llmThinkingBudgetInvalid":"思考予算は1024以上、固定予算では最大出力未満にしてください。","settings.providers.llmResponseIncomplete":"応答が未完了か出力上限に達しました。出力済みテキストは保持されます。","settings.providers.llmProtocolHeaderConflict":"Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。","settings.providers.llmStreamError":"サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。","settings.providers.saveProtocol":"プロトコル設定を保存","settings.providers.thinkingModeHint":"選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。","settings.providers.bailianVocabularyIdLabel":"ホットワード Vocabulary ID(任意)","settings.providers.bailianVocabularyIdNote":"DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。","settings.providers.bailianModelRealtimeHint":"リアルタイムモデル · 話しながら文字起こし。","settings.providers.bailianModelSyncFileHint":"同期録音モデル · 話し終えてから一括で文字起こし(1 本 ≤ 5 分)。","settings.providers.bailianModelAsyncFileHint":"非同期ファイルモデル · 録音をアップロードし、文字起こしタスクの完了を待ちます。","settings.providers.appIdLabel":"App ID(アプリケーション ID)","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Resource ID","settings.providers.toolsLabel":"接続チェック","settings.providers.toolsDesc":"上記の設定を保存してから、現在のモデル接続性を検証またはモデル一覧を取得します。失敗してもモデル ID を手動入力できます。","settings.providers.validate":"検証","settings.providers.validating":"検証中…","settings.providers.fetchModels":"モデル一覧","settings.providers.loadingModels":"モデル取得中…","settings.providers.modelMissing":"モデルが未設定です。先にモデル ID を入力してください。","settings.providers.modelsEmpty":"認証成功ですが、利用可能なモデルが返されませんでした。","settings.providers.modelsLoaded":"{{count}} 個のモデルを取得しました。","settings.providers.selectModel":"モデルを選んで上記欄に入力","settings.providers.modelSaved":"モデル {{model}} を保存しました。","settings.providers.validateSuccess":"接続チェックに合格しました。","settings.providers.validateFailed":"接続チェックに失敗しました。","settings.providers.providerHttpStatus":"サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。","settings.providers.endpointMustUseHttps":"HTTP Endpoint も使用できますが、API Key と音声内容が通信中に漏えいする可能性があります。","settings.providers.endpointHttpWarning":"HTTP Endpoint も使用できますが、API Key とリクエスト内容が通信中に漏えいする可能性があります。","settings.providers.endpointInvalid":"Endpoint の形式が無効です。","settings.providers.bailianEndpointSchemeInvalid":"Bailian リアルタイム ASR は DashScope の WebSocket ゲートウェイを使用します。エンドポイントは wss:// で始まる必要があります(既定: wss://dashscope.aliyuncs.com/api-ws/v1/inference/)。https:// の互換モード URL はここでは使用できません。","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 リアルタイム ASR は DashScope Realtime WebSocket ゲートウェイを使用します。エンドポイントは wss:// で始まる必要があります(既定: wss://dashscope.aliyuncs.com/api-ws/v1/realtime)。https:// の URL はここでは使用できません。","settings.providers.responseTooLarge":"サプライヤーの応答が大きすぎるため、安全のため検証を停止しました。","settings.providers.asrInvalidJson":"ASR の応答が有効な JSON ではありません。","settings.providers.asrMissingTextField":"ASR の応答に text フィールドがありません。","settings.providers.apiKeyMissing":"API Key が空です。","settings.providers.endpointMissing":"Endpoint が空です。","settings.providers.volcengineAppIdMissing":"APP ID が空です。","settings.providers.volcengineAccessTokenMissing":"Access Token が空です。","settings.providers.requestTimeout":"リクエストがタイムアウトしました。後で再試行してください。","settings.shortcuts.title":"ショートカット設定","settings.shortcuts.descAcc":"すべてのショートカットはグローバルで有効。権限設定でアクセシビリティを許可する必要があります。","settings.shortcuts.descNoAcc":"すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。","settings.shortcuts.startStop":"録音開始 / 停止","settings.shortcuts.cancel":"本回の録音をキャンセル","settings.shortcuts.confirm":"カプセル入力を確定","settings.shortcuts.switchStyle":"前のスタイルに切り替え","settings.shortcuts.openApp":"OpenLess を開く","settings.shortcuts.stylePackTitle":"スタイル直行ショートカット","settings.shortcuts.stylePackDesc":"よく使うスタイルパックにショートカットを割り当てて一発切替;無効中のパックは自動で有効化されます。","settings.shortcuts.stylePackAdd":"スタイルショートカットを追加","settings.shortcuts.stylePackSelect":"スタイルパックを選択","settings.shortcuts.stylePackDisabledSuffix":"(無効)","settings.shortcuts.stylePackRemove":"削除","settings.shortcuts.agentPolish":"選択テキストを推敲","settings.shortcuts.agentPolishDesc":"テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"カスタムキーを押しながら話す → Claude がタスクを実行 → 結果をカプセル表示。","settings.shortcuts.agentVoiceHint":"「詳細 → Less Computer」で押しながら話すキーを設定してください。","settings.shortcuts.agentVoiceTrigger":"Less Computer 押しながら話すキー","settings.shortcuts.enable":"有効化","settings.shortcuts.disable":"無効化","settings.shortcuts.confirmHint":"右側の ✓ をクリック","settings.shortcuts.notSupported":"未対応","settings.shortcuts.androidReadOnly":"Android ではグローバルショートカットは使えません。概要ページの録音ボタンを使ってください。","settings.permissions.title":"権限","settings.permissions.descAcc":"OpenLess は正常動作のため以下のシステム権限が必要です。許可後は通常、App を完全に終了して再起動する必要があります。","settings.permissions.descNoAcc":"OpenLess はマイクへのアクセスと、グローバルショートカット監視状態を通じてネイティブフックの正常動作を判定する必要があります。","settings.permissions.micLabel":"マイク","settings.permissions.micDesc":"音声入力の取得に使用します。","settings.permissions.accLabel":"アクセシビリティ","settings.permissions.accDesc":"グローバルショートカットの監視と認識結果のカーソル位置への入力に使用。","settings.permissions.hotkeyLabel":"グローバルショートカット","settings.permissions.hotkeyDescWithAdapter":"現在のアダプタ:{{adapter}}。ショートカット監視がインストール済みかを判定します。","settings.permissions.hotkeyDescPlain":"ショートカット監視がインストール済みかを判定します。","settings.permissions.networkLabel":"ネットワーク","settings.permissions.networkDesc":"クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。","settings.permissions.networkOk":"利用可能","settings.permissions.networkOffline":"利用不可","settings.permissions.checking":"確認中…","settings.permissions.granted":"許可済み","settings.permissions.notApplicable":"権限不要","settings.permissions.denied":"未許可","settings.permissions.indeterminate":"未確定","settings.permissions.micNoDevice":"マイクが検出されません","settings.permissions.openSystem":"システム設定を開く","settings.permissions.restart":"リセットして再起動","settings.permissions.grant":"許可する","settings.permissions.rerunAndroidSetup":"セットアップを再実行","settings.permissions.hotkeyInstalled":"インストール済み","settings.permissions.hotkeyStarting":"インストール中…","settings.permissions.hotkeyFailed":"監視失敗","settings.permissions.windowsImeLabel":"Windows 入力メソッドバックエンド","settings.permissions.windowsImeDesc":"音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。","settings.permissions.windowsImeInstalled":"インストール済み","settings.permissions.windowsImeUnavailable":"利用不可","settings.permissions.androidImeLabel":"入力メソッド (IME)","settings.permissions.androidImeSelected":"選択中","settings.permissions.androidImeEnabled":"有効","settings.permissions.androidImeDisabled":"無効","settings.permissions.androidOverlayLabel":"フローティングオーバーレイ","settings.permissions.androidAccessibilityLabel":"アクセシビリティ","settings.permissions.androidAccessibilityImpact":"有効にすると、キーボードを切り替えずに現在の入力欄へ結果を出力します。無効の場合はクリップボードへコピーし、手動で貼り付けます。","settings.permissions.androidAccessibilityGrantedStale":"許可済み・未接続","settings.permissions.androidAccessibilityMessages.not_android":"アクセシビリティ状態は Android でのみ利用できます。","settings.permissions.androidAccessibilityMessages.not_enabled":"システムのアクセシビリティ設定で OpenLess を有効にしてください。","settings.permissions.androidAccessibilityMessages.operational":"アクセシビリティ サービスは稼働中です。","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"アクセシビリティは許可済みですが未接続です。システム設定で OpenLess を再度有効にしてください。","settings.permissions.androidAccessibilityMessages.status_read_failed":"アクセシビリティ状態を読み取れませんでした。","settings.permissions.androidShizukuLabel":"Shizuku 拡張モード","settings.permissions.androidShizukuHint":"任意機能。OEM 設定で手動切り替えが難しい場合のベストエフォート復旧。跨アプリ競合を完全には排除できません。再起動後は Shizuku の再起動が必要な場合があります。","settings.permissions.androidShizukuOpenApp":"Shizuku を開く","settings.permissions.androidShizukuRequestPermission":"権限をリクエスト","settings.permissions.androidShizukuRecover":"アクセシビリティを復旧","settings.permissions.androidShizukuRecoverConfirm":"Shizuku で OpenLess のアクセシビリティサービスを再有効化しますか?書き込み時点で有効なサービスはマージされます。グローバルスイッチがオフの場合、有効化すると登録済みの他サービスも起動する可能性があります。","settings.permissions.androidShizukuYes":"はい","settings.permissions.androidShizukuNo":"いいえ","settings.permissions.androidShizukuAccessibilityOperational":"アクセシビリティは登録済みで稼働中です。","settings.permissions.androidShizukuAccessibilityRegistered":"登録: {{registered}} · 稼働: {{operational}}","settings.permissions.androidShizukuState.notInstalled":"未インストール","settings.permissions.androidShizukuState.notRunning":"未起動","settings.permissions.androidShizukuState.notAuthorized":"未承認","settings.permissions.androidShizukuState.authorized":"承認済み","settings.permissions.androidShizukuState.binderDead":"切断","settings.permissions.androidShizukuState.notAndroid":"N/A","settings.permissions.androidShizukuMessages.not_android":"Shizuku は Android でのみ利用できます。","settings.permissions.androidShizukuMessages.not_installed":"Shizuku または Sui バックエンドがインストールされていません。","settings.permissions.androidShizukuMessages.unsupported_backend":"この Shizuku バックエンドは古すぎます。Shizuku または Sui を v11 以降に更新してください。","settings.permissions.androidShizukuMessages.not_running":"Shizuku が起動していません。先に Shizuku または Sui を起動してください。","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku が未承認です。OpenLess に権限を付与してください。","settings.permissions.androidShizukuMessages.binder_dead":"Shizuku 接続が切断されました。Shizuku を再起動してください。","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku 承認済み。アクセシビリティは稼働中です。","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku 承認済み。アクセシビリティは登録済みですが稼働していません。","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku 承認済み。アクセシビリティの復旧を試せます。","settings.permissions.androidShizukuMessages.operational":"アクセシビリティは登録済みで稼働中です。","settings.permissions.androidShizukuMessages.registered_stale":"アクセシビリティは登録済みですが、サービスは現在利用できません。","settings.permissions.androidShizukuMessages.not_registered":"システム設定でアクセシビリティが有効になっていません。","settings.permissions.androidShizukuMessages.already_granted":"Shizuku 権限は既に付与されています。","settings.permissions.androidShizukuMessages.binder_unavailable":"権限リクエスト中に Shizuku バインダーが利用できませんでした。","settings.permissions.androidShizukuMessages.request_cancelled":"Shizuku 権限リクエストがキャンセルされました。","settings.permissions.androidShizukuMessages.granted":"Shizuku 権限が付与されました。","settings.permissions.androidShizukuMessages.denied":"Shizuku 権限が拒否されました。","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Shizuku 権限がブロックされました。Shizuku を開いて OpenLess を手動で許可してください。","settings.permissions.androidShizukuMessages.launched":"Shizuku 承認画面を開きました。","settings.permissions.androidShizukuMessages.launch_failed":"Shizuku 承認画面を開けませんでした。","settings.permissions.androidShizukuMessages.open_shizuku":"Shizuku マネージャーを開きました。","settings.permissions.androidShizukuMessages.jni_error":"Android Shizuku バックエンドに接続できませんでした。","settings.permissions.androidShizukuMessages.status_parse_failed":"Shizuku 状態を解析できませんでした。","settings.permissions.androidShizukuMessages.user_not_confirmed":"復旧にはユーザーの確認が必要です。","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku が未承認または利用できません。","settings.permissions.androidShizukuMessages.invalid_component":"無効なアクセシビリティサービスコンポーネント ID です。","settings.permissions.androidShizukuMessages.service_connect_failed":"Shizuku 特権サービスに接続できませんでした。","settings.permissions.androidShizukuMessages.recovery_in_progress":"別の復旧処理が進行中です。しばらくしてから再試行してください。","settings.permissions.androidShizukuMessages.parse_failed":"復旧結果を解析できませんでした。","settings.permissions.androidShizukuMessages.service_not_bound":"設定は書き込まれましたが、アクセシビリティはまだ稼働していません。","settings.permissions.androidShizukuMessages.success":"アクセシビリティサービスを復旧しました。","settings.permissions.androidShizukuMessages.read_failed":"アクセシビリティ設定を読み取れませんでした。","settings.permissions.androidShizukuMessages.read_enabled_failed":"アクセシビリティ有効フラグを読み取れませんでした。","settings.permissions.androidShizukuMessages.merge_failed":"アクセシビリティサービス一覧をマージできませんでした。","settings.permissions.androidShizukuMessages.write_services_failed":"有効なアクセシビリティサービス一覧を書き込めませんでした。","settings.permissions.androidShizukuMessages.write_enabled_failed":"アクセシビリティを有効化できませんでした。","settings.permissions.androidShizukuMessages.readback_failed":"書き込み後にアクセシビリティ設定を検証できませんでした。","settings.permissions.androidShizukuMessages.oem_rollback":"OEM がアクセシビリティ書き込みをロールバックしました。","settings.permissions.androidShizukuMessages.concurrent_change":"復旧中にアクセシビリティ設定が変更されました。","settings.permissions.androidShizukuMessages.partial_rollback":"復旧に失敗し、設定は一部のみ元に戻せました。システムのアクセシビリティ設定を確認してください。","settings.permissions.androidShizukuMessages.manual_required":"グローバルスイッチがオフで他の登録済みサービスがある場合、安全に自動復旧できません。システム設定から手動で操作してください。","settings.permissions.androidShizukuMessages.max_retries":"複数回試行後も復旧に失敗しました。","settings.permissions.androidShizukuMessages.internal_error":"内部エラーにより復旧に失敗しました。","settings.permissions.androidShizukuMessages.unknown":"不明な Shizuku 状態です。","settings.permissions.androidInsertStrategyLabel":"挿入方式","settings.permissions.androidOverlayTriggerLabel":"表示タイミング","settings.permissions.androidOverlayActivationModeLabel":"起動方法","settings.permissions.androidOverlayLeftSwipeActionLabel":"左スワイプ動作","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"キャンセル方向","settings.permissions.androidOverlaySizeLabel":"オーバーレイサイズ","settings.permissions.androidOverlaySizeHint":"フローティングボタンの直径を調整し、現在位置を保持します。","settings.permissions.androidInsertStrategy.accessibility":"入力欄へ自動出力","settings.permissions.androidInsertStrategy.clipboard":"クリップボードのみ","settings.permissions.androidInsertStrategyHint.accessibility":"アクセシビリティが必要です。使えない場合はクリップボードにコピーします。","settings.permissions.androidInsertStrategyHint.clipboard":"アクセシビリティ権限は不要です。コピー後に手動で貼り付けます。","settings.permissions.androidOverlayTrigger.background":"バックグラウンド","settings.permissions.androidOverlayTrigger.keyboard":"キーボード表示時","settings.permissions.androidOverlayTrigger.always":"常時","settings.permissions.androidOverlayTriggerHint.background":"シンプル","settings.permissions.androidOverlayTriggerHint.keyboard":"このモードは保留中です。既存設定はバックグラウンドに戻します。","settings.permissions.androidOverlayTriggerHint.always":"常に表示","settings.permissions.androidOverlayTriggerDisabled.keyboard":"キーボード表示時の表示は保留中です。今後はフローティングウィンドウのジェスチャーで置き換えます。","settings.permissions.androidOverlayActivationMode.tap":"タップで起動","settings.permissions.androidOverlayActivationMode.long_press":"長押しで起動","settings.permissions.androidOverlayActivationModeHint.tap":"1回目のタップで待機状態に入り、2回目のタップで通常の音声入力を開始します。","settings.permissions.androidOverlayActivationModeHint.long_press":"押している間だけ待機状態に入り、離すと現在の録音またはQAターンを終了します。","settings.permissions.androidOverlayLeftSwipeAction.translation":"翻訳入力","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"スタイルパック切替","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"待機状態で左スワイプすると翻訳入力を開始します。","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"待機状態で左スワイプすると前のスタイルパックへ切り替えます。","settings.permissions.androidOverlayCancelSwipeDirection.up":"上へスワイプ","settings.permissions.androidOverlayCancelSwipeDirection.down":"下へスワイプ","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"録音中に上へスワイプすると、文字起こしや挿入をせずにキャンセルします。","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"録音中に下へスワイプすると、文字起こしや挿入をせずにキャンセルします。","settings.permissions.windowsIme.installed":"インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。","settings.permissions.windowsIme.notInstalled":"未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。","settings.permissions.windowsIme.registrationBroken":"登録が破損しています。OpenLess IME を再インストールしてください。","settings.permissions.windowsIme.notWindows":"Windows のみ利用可能。","settings.advanced.multimodalPipelineTitle":"マルチモーダル認識パイプライン","settings.advanced.multimodalPipelineTitleHint":"1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。","settings.advanced.multimodalPipelineLabel":"マルチモーダルパイプラインを有効化","settings.advanced.multimodalPipelineHint":"有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。","settings.advanced.streamingInsertTitle":"ストリーミング入力","settings.advanced.streamingInsertTitleLinux":"ストリーミング入力(実験的)","settings.advanced.streamingInsertDesc":"逐字リアルタイム挿入で体感遅延を低減。条件不一致時はワンショット貼り付けにフォールバック。","settings.advanced.streamingInsertLabel":"ストリーミング入力","settings.advanced.streamingInsertHintMac":"ストリーミング中は一時的に ABC 入力ソースへ切替(CJK IME による傍受を回避)。セッション終了時に自動で元へ戻ります。","settings.advanced.streamingInsertHintWindows":"SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。","settings.advanced.streamingInsertHintLinux":"fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。","settings.advanced.streamingInsertSaveClipboardLabel":"クリップボードに保存","settings.advanced.streamingInsertSaveClipboardHint":"挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。","settings.advanced.localAsrTitle":"ローカル ASR モデル","settings.advanced.localAsrDesc":"転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。","settings.advanced.localAsrWarningShort":"ローカル推論は遅く、スペック不足では欠字の可能性があります。","settings.advanced.qwen3Desc":"有効化すると ASR プロバイダーが引き継がれます。","settings.advanced.sherpaDesc":"有効化すると ASR プロバイダーが引き継がれます。","settings.advanced.foundryDesc":"有効化すると ASR プロバイダーが引き継がれます。","settings.advanced.notSupportedHere":"このプラットフォームでは未対応(推論モジュール未組込)。","settings.advanced.enable":"有効化","settings.advanced.alreadyActive":"有効","settings.advanced.disableLocalLabel":"ローカル ASR を無効化","settings.advanced.disableLocalDesc":"クラウド ASR(既定は Volcengine bigasr)に戻します。","settings.advanced.disable":"無効化","settings.advanced.platformNotSupported":"このプラットフォームではローカル ASR モデル統合に対応していません。","settings.advanced.confirmEnableLocalTitle":"ローカル ASR を有効化しますか?","settings.advanced.confirmEnableLocalBody":"有効にすると転写はクラウドより遅くなり、精度が低くなる場合があります。","settings.advanced.confirm":"有効化する","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"表示言語","settings.language.desc":"UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。","settings.language.label":"言語","settings.language.labelDesc":"「システムに従う」を選ぶと OS の言語に合わせます。","settings.language.followSystem":"システムに従う","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"一部のネイティブメニュー(トレイ等)は再起動後に反映されます。","settings.layout.title":"レイアウト","settings.theme.title":"外観","settings.theme.label":"テーマ","settings.theme.activityHeatmapLabel":"概要ページに年間アクティビティを表示","settings.theme.stackedRowLayoutLabel":"読みやすいレイアウト(はみ出し防止)","settings.theme.stackedRowLayoutDesc":"小さい画面や大きな文字サイズでは、1行に収まらないボタンや設定が次の行に折り返され、横方向のはみ出しや文字の潰れを防ぎます。","settings.theme.conservativeLayoutLabel":"保守レイアウト","settings.theme.conservativeLayoutDesc":"ホーム、上部バー、下部バー以外の設定・機能ページを単列・全幅にし、横方向のはみ出しを最大限防ぎます。","settings.theme.system":"システムに従う","settings.theme.light":"ライト","settings.theme.dark":"ダーク","settings.remoteInput.title":"リモート入力","settings.remoteInput.enableLabel":"リモート入力を有効化","settings.remoteInput.enableDesc":"スマホ/タブレットのブラウザから PC に接続して録音し、音声を PC のカーソル位置にリアルタイムで入力します(HTTPS が必要。初回アクセス時は証明書を信頼してください)","settings.remoteInput.portLabel":"待ち受けポート","settings.remoteInput.defaultModeLabel":"既定の録音方式","settings.remoteInput.modeToggle":"タップで切替","settings.remoteInput.modeHold":"押し続けて話す","settings.remoteInput.urlLabel":"アクセス URL","settings.remoteInput.pinLabel":"ペアリングコード","settings.remoteInput.regeneratePin":"再生成","settings.remoteInput.portInUse":"ポート {{port}} は使用中です。変更してください","settings.remoteInput.startError":"リモート入力サービスの起動に失敗しました:{{reason}}","settings.remoteInput.securityHint":"同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。","settings.remoteInput.certHint":"初回接続ではルート証明書の指紋を確認してから信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。","settings.remoteInput.certFingerprintLabel":"このコンピューターのルート CA SHA-256","settings.remoteInput.certFingerprintCopy":"指紋全体をコピー","settings.remoteInput.certFingerprintCopied":"指紋をコピーしました","settings.remoteInput.certFingerprintUnavailable":"完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。","settings.remoteInput.certVerifyHint":"スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。","settings.remoteInput.certProfileHint":"プロファイルにはルート証明書が 1 枚だけ含まれるはずです。追加の証明書、VPN、デバイス管理の設定がある場合はインストールしないでください。","settings.remoteInput.certTrustWarning":"初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。","settings.remoteInput.certSetupLink":"iPhone 証明書リンクをコピー","settings.remoteInput.waitingStart":"サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。","settings.remoteInput.starting":"リモート入力サービスを起動しています…","settings.remoteInput.urlsStale":"これらのアドレスは前回の起動時のもので、古くなっている可能性があります。","settings.about.tagline":"自然に話し、きれいに書く","settings.about.checkUpdate":"アップデート確認","settings.about.checkUpdateBtn":"確認","settings.about.checkStableUpdateBtn":"正式版を確認","settings.about.checkBetaUpdateBtn":"Beta を確認","settings.about.checkingUpdate":"確認中…","settings.about.upToDate":"現在最新バージョンです。","settings.about.updateError":"確認またはアップデートに失敗しました。後で再試行してください。","settings.about.retryBtn":"再試行","settings.about.openReleases":"Releases を開く","settings.about.source":"ソース","settings.about.docs":"ドキュメント","settings.about.feedback":"フィードバック","settings.about.qq":"コミュニティ QQ グループ","settings.about.qqDesc":"QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。","settings.about.copyQq":"グループ番号をコピー","settings.about.privacy":"プライバシー","settings.about.privacyDesc":"録音は、設定したクラウドプロバイダーへ文字起こしのため送信される場合があります。","settings.about.localFirst":"ローカル優先","settings.about.linksTitle":"ドキュメント","settings.about.betaChannelLabel":"Beta チャンネルに参加","settings.about.betaChannelToggleLabel":"Beta チャンネルを有効化","settings.about.betaChannelDesc":"オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。","settings.about.autoUpdateSectionTitle":"自動更新","settings.about.autoUpdateCheckLabelAndroid":"自動確認してダウンロード","settings.about.autoUpdateCheckDescAndroid":"起動時と 60 分ごとに確認。更新があれば自動ダウンロードしシステムインストーラを開きます。チャンネルは上の Beta スイッチに従います。","settings.about.betaChannelFetching":"最新 Beta 版を取得中…","settings.about.betaChannelFetchBtn":"最新 Beta を確認","settings.about.betaChannelLatestPrefix":"最新 Beta:","settings.about.betaChannelDownloadBtn":"ダウンロード ページを開く","settings.about.betaChannelRefresh":"再取得","settings.about.betaChannelNoBeta":"まだ Beta リリースは公開されていません。","settings.about.betaChannelFetchError":"Beta バージョン情報の取得に失敗しました。後で再試行してください。","settings.about.betaChannelUpToDate":"最新です","settings.about.betaChannelUpdateNow":"今すぐ更新","settings.about.betaChannelUpdateNowTitle":"最新 Beta を確認・ダウンロードし、更新ダイアログを表示します","settings.about.betaChannelChecking":"確認中…","settings.about.updateDialog.available.title":"新しいバージョンがあります","settings.about.updateDialog.available.desc":"OpenLess {{version}} が見つかりました。今すぐ更新しますか?","settings.about.updateDialog.stableChannelSwitch.title":"正式版に切り替える","settings.about.updateDialog.stableChannelSwitch.desc":"現在のバージョン:OpenLess {{currentVersion}}\n対象バージョン:OpenLess {{version}}\nBeta チャンネルから正式版に切り替えます。続行しますか?","settings.about.updateDialog.downloading.title":"アップデートをダウンロード中","settings.about.updateDialog.downloading.desc":"OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。","settings.about.updateDialog.downloaded.title":"アップデートの準備完了","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} のインストールが完了しました。今すぐ自動再起動して適用しますか?","settings.about.updateDialog.installing.title":"アップデートをインストール中","settings.about.updateDialog.installing.desc":"OpenLess {{version}} をインストール中です。アプリを開いたままにしてください。","settings.about.updateDialog.install":"今すぐ更新","settings.about.updateDialog.androidInstall":"ダウンロードしてインストーラを開く","settings.about.updateDialog.androidInstalled.title":"システムインストーラを開きました","settings.about.updateDialog.androidInstalled.desc":"画面の指示に従ってインストールしてください。完了後 OpenLess を再度開くと {{version}} が使えます。","settings.about.updateDialog.downloadingLabel":"ダウンロード中…","settings.about.updateDialog.installingLabel":"インストール中…","settings.about.updateDialog.later":"後で手動再起動","settings.about.updateDialog.restartNow":"今すぐ再起動","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"ダウンロード済み {{downloaded}}","settings.about.updateDialog.installError.title":"更新に失敗しました","settings.about.updateDialog.installError.desc":"自動更新を完了できませんでした:{{error}}。ダウンロードページから手動で最新版を入手できます。","settings.about.updateDialog.manualDownload":"手動でダウンロード","startup.loading":"OpenLess を起動中…","startup.loadingDesc":"ローカルサービスに接続し、互換性を確認しています。","startup.failed":"OpenLess を起動できません","startup.recovery":"再確認してください。解決しない場合はアプリを完全に終了して開き直してください。更新後に発生した場合は、アプリ全体が同じバージョンであることを確認してください。","startup.retry":"再確認","startup.details":"エラーの詳細","modal.serviceViews.label":"サービス設定","modal.serviceViews.llm":"言語モデル","modal.serviceViews.asr":"音声認識","modal.serviceViews.omni":"マルチモーダル","modal.serviceViews.models":"ローカルモデル","modal.serviceViews.connections":"接続と拡張","modal.serviceViews.statusConfigured":"設定済み","modal.serviceViews.statusMissing":"未設定","modal.searchPlaceholder":"設定カテゴリを検索…","modal.clearSearch":"検索をクリア","modal.categoriesLabel":"設定カテゴリ","modal.searchResults":"検索結果","modal.searchCount":"関連するカテゴリ:{{count}} 件","modal.noResults":"カテゴリが見つかりません。「マイク」「モデル」「テーマ」などをお試しください。","modal.autoSaveHint":"変更は自動保存されます","modal.backToAdvanced":"実験と拡張に戻る","modal.advancedPages.lessComputer":"Agent を選び、モデル・権限・作業ディレクトリを設定します。","modal.advancedPages.claudeConsole":"Claude Code を検出し、テストタスクの実行出力を確認します。","modal.advancedPages.multimodal":"実験的なマルチモーダル認識の有効・無効を設定します。","modal.advancedPages.debug":"デバッグ録音の保持、カーソル周辺の確認、ログの書き出しを行います。","modal.descriptions.general":"マイク、録音方法、文字入力を設定し、スマートフォンからの入力を接続します。","modal.descriptions.shortcuts":"各機能のショートカットと、テキスト選択後の操作を設定します。","modal.descriptions.services":"音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。","modal.descriptions.appearance":"テーマ、レイアウト、表示言語を読みやすく調整します。","modal.descriptions.privacy":"システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。","modal.descriptions.advanced":"必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。","modal.descriptions.about":"現在のバージョン、更新チャンネル、自動更新を確認します。","modal.searchKeywords.general":"マイク 録音 入力 スマホ リモート LAN PIN カプセル ミュート 起動","modal.searchKeywords.shortcuts":"ショートカット ホットキー キー 選択 推敲 音声編集","modal.searchKeywords.services":"ASR LLM API チャンネル モデル クラウド ローカル ネットワーク プロキシ マーケット","modal.searchKeywords.appearance":"テーマ ダーク ライト 言語 フォント 文字 サイズ レイアウト ヒートマップ","modal.searchKeywords.privacy":"権限 マイク アクセシビリティ 履歴 録音 保存 プライバシー エクスポート","modal.searchKeywords.advanced":"Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験","modal.searchKeywords.about":"バージョン Beta 安定 更新 アップデート","modal.sections.appearance":"外観と言語","modal.sections.shortcuts":"ショートカットと選択","modal.sections.general":"録音と入力","modal.sections.services":"AI サービスとモデル","modal.sections.privacy":"権限とデータ","modal.sections.advanced":"実験機能と拡張","modal.sections.personalize":"パーソナライズ","modal.sections.about":"バージョンと更新","modal.sections.helpCenter":"ヘルプセンター","modal.sections.releaseNotes":"リリースノート","modal.personalize.font":"フォントサイズ","modal.personalize.fontDesc":"UI のフォントサイズを全体的にスケール。即時反映。","modal.personalize.fontSmall":"小","modal.personalize.fontMedium":"中","modal.personalize.fontLarge":"大","modal.personalize.blur":"すりガラス強度","modal.personalize.blurDesc":"ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。","modal.about.tagline":"自然に話し、きれいに書く","modal.about.checkUpdate":"アップデート確認","modal.about.checkUpdateBtn":"確認","modal.about.docs":"ドキュメント","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"フィードバックチャネル","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"ソース","modal.about.qq":"コミュニティ QQ グループ","modal.about.qqDesc":"QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。","modal.about.copyQq":"グループ番号をコピー","modal.about.exportErrorLog":"エラーログをエクスポート","modal.about.exportErrorLogDesc":"現在のセッションの実行ログをローカルに保存。問題の調査やフィードバック送付にお使いください。","modal.about.exportErrorLogBtn":"エクスポート","modal.about.exporting":"エクスポート中…","modal.about.exportSuccess":"保存しました","modal.about.exportFailed":"エクスポート失敗","modal.about.privacy":"プライバシー","modal.about.privacyDesc":"認識結果はローカルに保存されます。設定したクラウドプロバイダーは文字起こしのため録音を受信する場合があります。","modal.about.localFirst":"ローカル優先","windowChrome.restore":"元のサイズに戻す","windowChrome.minimize":"最小化","windowChrome.maximize":"最大化","windowChrome.close":"閉じる","hotkey.triggers.rightOption":"右 Option","hotkey.triggers.leftOption":"左 Option","hotkey.triggers.rightControl":"右 Control","hotkey.triggers.leftControl":"左 Control","hotkey.triggers.rightCommand":"右 Command","hotkey.triggers.leftCommand":"左 Command","hotkey.triggers.leftShift":"左 Shift","hotkey.triggers.rightShift":"右 Shift","hotkey.triggers.fn":"Fn (地球キー)","hotkey.triggers.rightAlt":"右 Alt","hotkey.triggers.mediaPlayPause":"⏯ メディア再生/一時停止","hotkey.triggers.custom":"カスタム組み合わせ…","hotkey.fallback":"グローバルショートカット","hotkey.modeHoldSuffix":"(押し続けて話す)","hotkey.modeToggleSuffix":"(開始 / 停止)","hotkey.modeAutoSuffix":"(自動判別)","hotkey.usageHold":"{{trigger}} を押し続けて話し、離して終了。","hotkey.usageToggle":"{{trigger}} で録音開始、もう 1 回押して終了。","hotkey.usageAuto":"{{trigger}} を短く押すと開始 / 停止、押し続けると話し終えて離すと停止。","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows 低レベルキーボードフック","hotkey.adapter.fcitx5":"fcitx5 インプットメソッドプラグイン","hotkey.adapter.unavailable":"利用不可","localAsr.kicker":"ローカル ASR","localAsr.title":"モデル設定","localAsr.desc":"デバイス上の音声認識モデルを管理。","localAsr.storageTitle":"モデル保存場所","localAsr.storageBaseDir":"選択した親フォルダ","localAsr.storageModelsRoot":"実際のモデルフォルダ","localAsr.storageDefault":"システム既定フォルダ","localAsr.storageChoose":"フォルダを変更","localAsr.storageReset":"既定に戻す","localAsr.storageReveal":"モデルフォルダを開く","localAsr.storageDesc":"カスタム保存先では選択フォルダ配下に OpenLess/models を作成し、既存モデルを移行します。移行前にダウンロードをキャンセルし、読み込み済みモデルを解放します。","localAsr.storageChooseTitle":"ローカルモデル保存先の親フォルダを選択","localAsr.storageChangeConfirm":"既存のローカルモデルを {{path}}/OpenLess/models に移動します。先にダウンロードをキャンセルし、読み込み済みモデルを解放します。続行しますか?","localAsr.storageResetConfirm":"既存のローカルモデルをシステム既定フォルダに戻します。現在のフォルダ: {{path}}。続行しますか?","localAsr.modelDir":"モデルフォルダ","localAsr.revealDir":"フォルダを開く","localAsr.deleteConfirm":"{{name}} のローカルモデルファイルを削除しますか?この操作は取り消せません。","localAsr.appleSpeechTitle":"Apple 音声認識(macOS)","localAsr.appleSpeechDesc":"macOS 標準の音声認識を使ってローカルで文字起こしします。モデルのダウンロード・API キー・ネットワークは不要。クラウド ASR が不安定なときの認証情報不要なローカルフォールバックです。初回利用時に音声認識の許可ダイアログが表示されます。","localAsr.appleSpeechUse":"Apple 音声認識を使う","localAsr.qwenTitle":"Qwen3-ASR モデル管理","localAsr.qwenExperimentalBadge":"実験的","localAsr.engineUnavailable":"現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。","localAsr.qwenUnavailableOnWindows":"Windows では Qwen3-ASR にまだ対応していません。上記の Foundry Local Whisper をご利用ください。","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"デバイス上で音声認識。ASR API キー不要。初回はランタイムとモデルのダウンロードが必要。","localAsr.foundryAvailable":"Windows で利用可能","localAsr.foundryUnavailable":"Windows のみ対応","localAsr.foundryRuntimeReady":"ランタイムコンポーネントはダウンロード済み","localAsr.foundryRuntimeMissing":"ランタイムコンポーネント未ダウンロード","localAsr.foundryRuntimeSourceLabel":"ランタイムコンポーネントの取得元","localAsr.foundryRuntimeSourceAuto":"自動(NuGet 優先)","localAsr.foundryRuntimeSourceNuget":"NuGet 公式フィード","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly フィード","localAsr.foundryRuntimeSourceDesc":"初回使用前にランタイムコンポーネントをダウンロード。","localAsr.foundrySelectedModel":"選択中のモデル","localAsr.foundryActiveModel":"現在の既定 alias","localAsr.foundryLoadedModel":"読み込み済みモデル","localAsr.foundryNotLoaded":"未読み込み","localAsr.foundryError":"Foundry 状態","localAsr.foundrySetDefault":"既定に設定 / Windows ローカル ASR を有効化","localAsr.foundryEnabling":"有効化中…","localAsr.foundryPrepare":"準備 / ダウンロード / 読み込み","localAsr.foundryPreparing":"準備中…","localAsr.foundryReleasing":"解放中…","localAsr.foundryRetryPrepare":"準備を続行 / 再試行","localAsr.foundryCancelPrepare":"準備をキャンセル","localAsr.foundryCancelRequested":"キャンセル要求済み","localAsr.foundryCancelling":"キャンセル中…","localAsr.foundryCancelBestEffort":"キャンセルをリクエスト済み。現在のステップ完了後に停止します。後で再試行できます。","localAsr.foundryPrepareRuntime":"ランタイムコンポーネントを準備","localAsr.foundryPrepareModel":"モデルをダウンロード","localAsr.foundryPrepareLoad":"モデルを読み込み","localAsr.foundryPrepareModelSkipped":"モデルはダウンロード済みのため、ダウンロードをスキップ","localAsr.foundryPrepareDone":"完了","localAsr.foundryPrepareWaiting":"待機中","localAsr.foundryApproxSizeMb":"約 {{mb}} MB","localAsr.foundryLanguageLabel":"認識言語","localAsr.foundryLanguageAuto":"自動","localAsr.foundryLanguageZh":"中国語 zh","localAsr.foundryLanguageEn":"英語 en","localAsr.foundryLanguageDesc":"中国語聞き取りは「中文」を、混用は「自動」を選択。","localAsr.foundryModelSmall":"Whisper Small(既定 / バランス)","localAsr.foundryModelSmallDesc":"品質とリソース使用量のバランスを取った既定オプション。","localAsr.foundryModelMedium":"Whisper Medium(高品質)","localAsr.foundryModelMediumDesc":"より高い精度。大きなダウンロードと遅めの推論を許容できる高性能デバイス向け。","localAsr.foundryModelLarge":"Whisper Large V3 Turbo(最高品質)","localAsr.foundryModelLargeDesc":"高性能デバイスと品質優先の用途に向く大きなモデル。","localAsr.foundryModelBase":"Whisper Base(高速 / 低リソース)","localAsr.foundryModelBaseDesc":"より高速でリソース消費が少なく、日常の軽量ディクテーションに適しています。","localAsr.foundryModelTiny":"Whisper Tiny(最速 / スモークテスト)","localAsr.foundryModelTinyDesc":"Foundry 経路が動作するか確認するための最速オプション。","localAsr.sherpaTitle":"Windows sherpa-onnx Local(実験的)","localAsr.sherpaDesc":"Windows では sherpa-onnx によるデバイス上のオフライン一括認識を使用します。ASR API キーは不要です。","localAsr.sherpaRuntimeReady":"モデル読み込み済み","localAsr.sherpaRuntimeMissing":"モデル未読み込み","localAsr.sherpaSetDefault":"既定に設定 / sherpa-onnx を有効化","localAsr.sherpaPrepare":"ローカルファイルを確認 / 読み込み","localAsr.sherpaPreparing":"読み込み中…","localAsr.sherpaPrepareLocalFiles":"ローカルモデルファイルを確認","localAsr.sherpaModelDir":"モデルディレクトリ","localAsr.sherpaRevealDir":"モデルディレクトリを開く","localAsr.sherpaError":"sherpa-onnx 状態","localAsr.sherpaLanguageJa":"日本語 ja","localAsr.sherpaLanguageKo":"韓国語 ko","localAsr.sherpaLanguageYue":"広東語 yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small(既定 / 中国語優先)","localAsr.sherpaModelSenseVoiceDesc":"中国語および中英混在ディクテーション向けの既定実験モデル。","localAsr.sherpaModelParaformer":"Paraformer 中国語","localAsr.sherpaModelParaformerDesc":"中国語向けの実験モデル。","localAsr.sherpaModelWhisper":"Whisper Small 多言語","localAsr.sherpaModelWhisperDesc":"Whisper 系列の挙動に合わせた多言語実験フォールバックモデル。","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3(多言語)","localAsr.sherpaModelWhisperLargeV3Desc":"オープンソース多言語モデルの中で最高品質の Whisper 系。高品質だが大容量。","localAsr.sherpaModelZipformer":"Zipformer ストリーミング(中英)","localAsr.sherpaModelZipformerDesc":"話しながら文字が出るストリーミング型の中英モデル。遅延が最も小さく、リアルタイム文字起こしに適します。","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。","localAsr.modelSelectTitle":"この端末のモデル","localAsr.modelSelectDesc":"ダウンロード状況の確認、ファイルの管理、モデルの読み込みとテストができます。","localAsr.modelSelectPlaceholder":"ダウンロード済みモデルを選択…","localAsr.modelSelectEmpty":"ダウンロード済みモデルがありません。「ダウンロードと管理」から入手してください。","localAsr.groupDownload":"ダウンロードと管理","localAsr.groupOther":"その他","localAsr.mirrorLabel":"ダウンロードミラー","localAsr.mirrorDesc":"公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。","localAsr.mirrorHuggingface":"HuggingFace 公式 (huggingface.co)","localAsr.mirrorHfMirror":"中国ミラー (hf-mirror.com)","localAsr.activeBadge":"使用中","localAsr.downloadedBadge":"ダウンロード済み","localAsr.notDownloadedBadge":"未ダウンロード","localAsr.download":"ダウンロード","localAsr.resume":"続行","localAsr.cancel":"キャンセル","localAsr.delete":"削除","localAsr.setActive":"デフォルトに設定","localAsr.failed":"失敗","localAsr.cancelled":"キャンセル済み","localAsr.files":"ファイル","localAsr.sizeLoading":"サイズ問い合わせ中…","localAsr.sizeUnknown":"サイズ不明","localAsr.performanceWarning":"ローカル ASR はオフラインやプライバシー重視のシーンに最適。初回使用時にモデルのダウンロードが必要。","localAsr.test":"ロードしてテスト","localAsr.testRunning":"テスト中…","localAsr.testHeading":"内蔵オーディオテスト","localAsr.testExpected":"原文","localAsr.testActual":"認識","localAsr.testStats":"音声長 {{audio}}s · ロード {{load}}s · 推論 {{transcribe}}s · バックエンド {{backend}}","localAsr.testFailed":"テスト失敗","localAsr.engineStatusLabel":"メモリ上のエンジン","localAsr.engineLoaded":"ロード済み:{{model}}(約 1.2-3.4 GB のメモリを使用)","localAsr.engineUnloaded":"未ロード(初回ディクテーション時に約 3-5 秒のロードが必要)","localAsr.loadNow":"今すぐロード","localAsr.releaseNow":"今すぐ解放","localAsr.keepLoadedLabel":"ロード保持時間","localAsr.keepLoadedDesc":"ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。","localAsr.keepImmediate":"使用直後に解放","localAsr.keep1min":"最終使用から 1 分","localAsr.keep5min":"最終使用から 5 分(既定)","localAsr.keep30min":"最終使用から 30 分","localAsr.keepForever":"解放しない(常にロード)","localAsr.sidebarTitle":"ダウンロード済み・進行中","localAsr.activePill":"使用中","localAsr.setDefault":"デフォルトに設定","localAsr.downloading":"ダウンロード中","localAsr.startDownload":"ダウンロード開始","localAsr.downloadNewModel":"新しいモデルをダウンロード","localAsr.activeModelLabel":"使用中のモデル","localAsr.pickerNoModelDownloaded":"ダウンロード済みのモデルがありません。先にローカルモデルページで取得してください。","localAsr.partialDownloadsLabel":"未完了のダウンロード","localAsr.partialDownloadsDesc":"中断されたダウンロードの一時ファイルが残っています。インストール済みモデルに影響せず一括削除できます。","localAsr.cleanupIncomplete":"未完了ダウンロードを削除","localAsr.languagesLabel":"言語","localAsr.partialBytesLabel":"残存ファイル","localAsr.downloadDialogTitle":"モデルをダウンロード","localAsr.downloadDialogAlreadyHave":"モデルファイルはダウンロード済みです。モデルページで読み込みとテストを行うか、「ASR 音声文字起こし」で対応するプロバイダーを選択してください。","localAsr.downloadDialogDesc":"サイズと説明を確認してモデルをダウンロードします。完了後、「音声認識」で対応するローカルサービスを選択してください。","localAsr.detailRepo":"リポジトリ","localAsr.hfDownloads":"ダウンロード数","localAsr.hfLikes":"いいね","localAsr.hfDescription":"モデル紹介","localAsr.hfNoDescription":"紹介文はありません","localAsr.hfCardFailed":"モデル情報の取得に失敗しました","localAsr.detailFiles":"ファイル","localAsr.detailDownloaded":"ダウンロード済み","localAsr.detailEmpty":"モデルを選択して詳細を表示","localAsr.foundryLanguage":"言語","localAsr.foundryRuntimeSource":"ランタイムソース","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"保持","localAsr.downloadSettingsTitle":"ダウンロードとストレージ設定","localAsr.downloadSettingsDesc":"ミラーソース · モデル保存場所 · メモリ内エンジン","localAsr.libraryEmptyTitle":"ローカルモデルがありません","localAsr.libraryEmptyDesc":"音声認識モデルをダウンロードすると、この端末で音声を処理できます。既存のモデルが表示されない場合は、一覧を再読み込みしてください。","localAsr.catalogTitle":"モデルカタログ","localAsr.catalogEmpty":"表示できるモデルがありません。カタログを再読み込みしてください。","localAsr.reloadCatalog":"一覧を再読み込み","localAsr.engineLabel":"認識エンジン","localAsr.sizeLabel":"モデルサイズ","localAsr.allEngines":"すべて","localAsr.backToCatalog":"カタログに戻る","localAsr.detailsTitle":"モデルの詳細","localAsr.testActivateHint":"「読み込みとテスト」はこのモデルを使用中に設定してから、内蔵音声テストを実行します。","localAsr.downloadProgressHint":"開始後はモデルページで進行状況の確認やダウンロードのキャンセルができます。","localAsr.errorDetails":"エラーの詳細"},"ko":{"cloudSync.title":"클라우드 동기화","cloudSync.description":"GitHub 계정으로 사전, 스타일, 개인 설정을 기기 간에 동기화합니다.","cloudSync.signIn":"GitHub로 로그인","cloudSync.account":"동기화 계정","cloudSync.refresh":"상태 새로고침","cloudSync.loading":"클라우드 상태 확인 중…","cloudSync.noBackup":"클라우드 백업 없음","cloudSync.available":"클라우드 백업 있음","cloudSync.summary":"단어 {{dictionary}}개 · 교정 규칙 {{corrections}}개 · 스타일 {{stylePacks}}개","cloudSync.updated":"업데이트: {{time}}","cloudSync.upload":"클라우드에 백업","cloudSync.restore":"클라우드에서 복원","cloudSync.delete":"클라우드 백업 삭제","cloudSync.working":"동기화 중…","cloudSync.uploadSuccess":"클라우드에 백업했습니다","cloudSync.restoreSuccess":"클라우드 설정을 복원했습니다","cloudSync.deleteSuccess":"클라우드 백업을 삭제했습니다","cloudSync.failed":"동기화 실패: {{error}}","cloudSync.conflict":"클라우드 내용이 변경되었습니다. 상태를 새로고친 후 백업 또는 복원을 선택하세요.","cloudSync.unavailable":"공식 동기화 서비스를 이용할 수 없습니다. 나중에 다시 시도하세요.","cloudSync.signInRequired":"먼저 GitHub로 로그인하세요.","cloudSync.restoreTitle":"클라우드 백업을 복원할까요?","cloudSync.restoreDescription":"클라우드의 사전, 교정 규칙, 스타일, 동기화 설정으로 해당 로컬 내용을 덮어씁니다. API 키, 기기 경로, 권한은 유지됩니다.","cloudSync.deleteTitle":"클라우드 백업을 삭제할까요?","cloudSync.deleteDescription":"이 GitHub 계정의 클라우드 백업만 삭제합니다. 로컬 데이터는 유지됩니다.","cloudSync.confirmRestore":"복원 및 덮어쓰기","cloudSync.confirmDelete":"백업 삭제","cloudSync.scope":"사전, 교정 규칙, 스타일 아이콘, 공통 설정을 동기화합니다. API 키, 로그인 정보, 기기별 설정은 이 기기에 유지됩니다.","macDictationKey.Changed":"The shortcut changed while saving. Please try again.","macDictationKey.label":"Mac Dictation key","macDictationKey.description":"Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.","macDictationKey.Permission":"Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.","macDictationKey.Busy":"Finish the current dictation before changing its shortcut.","macDictationKey.Unavailable":"Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.","app.name":"OpenLess","app.tagline":"자연스럽게 말하고, 정확하게 작성하세요","common.loading":"로딩 중…","common.retry":"다시 시도","common.settingsLoadFailed":"설정 로드 실패","common.refresh":"새로고침","common.clear":"지우기","common.copy":"복사","common.delete":"삭제","common.later":"나중에","common.cancel":"취소","common.close":"닫기","common.show":"표시","common.hide":"숨기기","common.saved":"저장됨","common.saving":"저장 중","common.experimental":"실험적","common.copied":"복사됨","common.operationFailed":"작업 실패","common.add":"추가","common.durationSeconds":"{{value}}초","common.durationMillis":"{{value}}ms","common.durationMinutes":"{{value}}분","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"취소됨","capsule.error":"오류 발생","capsule.inserted":"{{count}}자 입력됨","capsule.translating":"번역 중","capsule.selectionPolish.polishing":"다듬는 중...","capsule.selectionPolish.replaced":"교체됨","capsule.selectionPolish.noSelection":"선택된 내용 없음","capsule.selectionPolish.failed":"다듬기 실패, 다시 시도하세요","selectionPolishPreview.title":"선택 영역 다듬기 미리보기","selectionPolishPreview.subtitle":"편집 가능합니다. 확인을 클릭한 뒤에만 원래 선택 영역을 교체합니다.","selectionPolishPreview.cancel":"취소","selectionPolishPreview.resultLabel":"다듬기 결과","selectionPolishPreview.sourcePrefix":"원문: ","selectionPolishPreview.applyError":"적용하지 못했습니다: ","selectionPolishPreview.confirmReplace":"확인 후 교체","selectionVoiceIntent.title":"어떻게 하시겠어요?","selectionVoiceIntent.subtitle":"음성 지시를 인식했습니다. 처리 방법을 선택하세요.","selectionVoiceIntent.loading":"로딩 중…","selectionVoiceIntent.sourcePrefix":"선택 영역: ","selectionVoiceIntent.errorPrefix":"계속할 수 없습니다: ","selectionVoiceIntent.question":"질문하기","selectionVoiceIntent.edit":"선택 영역 편집","selectionVoiceIntent.cancel":"취소","qa.title":"질문","qa.headerHint":"언제든 질문하세요","qa.thinking":"생각 중…","qa.error":"오류가 발생했습니다. 잠시 후 다시 시도해 주세요.","qa.errorRetry":"재시도","qa.errorRetryHint":"다시 시도해 주세요.","qa.pinTooltip":"고정(자동으로 닫히지 않음)","qa.unpinTooltip":"고정 해제","qa.closeTooltip":"닫기","qa.micLabel":"음성으로 질문","qa.micStop":"녹음 종료","qa.selectionPreview":"선택된 텍스트 기반:","qa.emptyTitle":"무엇을 도와드릴까요?","qa.emptyDesc":"텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.","qa.recordingHint":"녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문","qa.mobileRecordLabel":"녹음 버튼","qa.mobileRecordStart":"녹음 시작","qa.mobileRecordStop":"종료하고 제출","qa.composerPlaceholder":"질문을 입력하세요. Enter로 보내기","qa.composerSend":"보내기","qa.statusIdle":"{{recordHotkey}} 로 질문","qa.statusRecording":"녹음 중","qa.statusThinking":"생각 중","qa.statusError":"오류","qa.jumpToLatest":"최신으로 이동","qa.editApplyReplace":"미리보기 후 삽입 확인","qa.editApplyUnavailable":"적용할 편집 결과가 없습니다","qa.editRevertPrevious":"이전 버전 유지","qa.editInstructionMode":"편집 지시","lessComputer.title":"Less Computer","lessComputer.subtitle":"컴퓨터로 무엇을 할까요?","lessComputer.you":"나","lessComputer.working":"조작 중…","lessComputer.tool":"{{name}} 사용","lessComputer.compaction":"컨텍스트가 압축되었습니다","lessComputer.done":"완료","lessComputer.cost":"${{cost}}","lessComputer.error":"실패했습니다. 다시 시도하세요.","lessComputer.closeTooltip":"닫기","lessComputer.jumpToLatest":"최신으로 이동","lessComputer.inputPlaceholder":"명령을 입력하고 Enter로 전송","lessComputer.send":"전송","lessComputer.approvalTitle":"차단된 명령을 실행할까요?","lessComputer.approvalRerunWarning":"주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.","lessComputer.approve":"허용","lessComputer.deny":"거부","lessComputer.approved":"허용됨","lessComputer.denied":"거부됨","nav.overview":"개요","nav.history":"기록","nav.vocab":"사전","nav.style":"스타일","nav.marketplace":"마켓","nav.translation":"번역","nav.selectionAsk":"선택 질문","nav.corrections":"교정 규칙","nav.polishMode":"다듬기 모드","nav.group.style":"스타일","nav.group.tools":"도구","nav.localAsr":"모델 설정","nav.more":"더보기","marketplace.kicker":"마켓","marketplace.title":"스타일 팩 마켓","marketplace.desc":"커뮤니티 스타일 팩 둘러보기, 설치, 공유.","marketplace.searchPlaceholder":"이름 / 설명 / 태그 검색…","marketplace.sortPopular":"인기순","marketplace.sortNew":"최신","marketplace.uploadBtn":"업로드","marketplace.uploadDisabledHint":"먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요","marketplace.refreshBtn":"새로고침","marketplace.empty":"아직 스타일 팩이 없습니다","marketplace.emptyHint":"다른 키워드로 검색하거나 직접 업로드해 보세요","marketplace.loadFailed":"불러오기 실패: {{err}}","marketplace.noDescription":"(설명 없음)","marketplace.installBtn":"설치","marketplace.installingBtn":"설치 중…","marketplace.downloadZipBtn":"ZIP 다운로드","marketplace.downloadingZipBtn":"다운로드 중…","marketplace.downloadAria":"\"{{name}}\" ZIP 다운로드","marketplace.likeBtn":"좋아요","marketplace.installed":"\"{{name}}\"을(를) 로컬에 설치했습니다","marketplace.downloaded":"\"{{name}}\" ZIP을 다운로드했습니다","marketplace.uploaded":"업로드 완료, 심사 대기 중","marketplace.uploadTitle":"업로드할 팩 선택","marketplace.uploadHint":"{{login}}(으)로 업로드합니다. 콘텐츠는 클라우드 심사 큐로 전송됩니다.","marketplace.uploadNoLocal":"업로드 가능한 로컬 팩이 없습니다","marketplace.errors.detail":"상세 불러오기 실패: {{err}}","marketplace.errors.install":"설치 실패: {{err}}","marketplace.errors.download":"ZIP 다운로드 실패: {{err}}","marketplace.errors.like":"좋아요 실패: {{err}}","marketplace.errors.upload":"업로드 실패: {{err}}","marketplace.errors.loadLocal":"로컬 팩 불러오기 실패: {{err}}","marketplace.sortLiked":"좋아요한 팩","marketplace.likedEmpty":"아직 좋아요한 팩이 없습니다","marketplace.likedEmptyHint":"팩을 열고 별을 누르면 여기에 표시됩니다","marketplace.derivativeBadge":"@{{login}}에서 파생","marketplace.detail.withdrawBtn":"게시 취소","marketplace.detail.withdrawConfirm":"\"{{name}}\"을(를) 마켓에서 내릴까요? 로컬 사본은 유지됩니다.","marketplace.detail.withdrawSuccess":"마켓에서 내렸습니다","marketplace.detail.withdrawFailed":"취소 실패: {{err}}","marketplace.myPacks.buttonLabel":"내 게시물","marketplace.myPacks.buttonTitle":"{{login}}의 게시물 보기","marketplace.myPacks.buttonTitleEmpty":"먼저 설정 → 마켓에서 게시자 이름을 입력하세요","marketplace.myPacks.searchPlaceholder":"이름·태그 검색","marketplace.myPacks.notLoggedIn":"먼저 설정 → 마켓에서 게시자 이름을 입력하세요","marketplace.myPacks.emptyTitle":"아직 게시한 팩이 없습니다","marketplace.myPacks.emptyHint":"\"스타일\" 페이지에서 편집 후 \"마켓에 게시\"를 누르거나, 오른쪽 위에서 로컬 팩을 업로드하세요.","marketplace.myPacks.noMatch":"일치하는 팩이 없습니다","marketplace.myPacks.summary":"게시 {{count}}개","marketplace.myPacks.summaryPending":"게시 {{count}}개 · 심사 중 {{pending}}개","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"업데이트","marketplace.myPacks.actions.withdraw":"내리기","marketplace.myPacks.loadFailed":"내 게시물 불러오기 실패: {{err}}","marketplace.myPacks.loadingTitle":"불러오는 중…","marketplace.myPacks.loadingHint":"마켓에서 최신 게시물을 가져오는 중입니다.","marketplace.myPacks.loadErrorTitle":"불러오기 실패","marketplace.myPacks.loadErrorRetry":"다시 시도","marketplace.upload.confirmBtn":"업로드 확정","marketplace.upload.updateTitle":"\"{{name}}\" 업데이트","marketplace.upload.updateHint":"업로드할 로컬 최신본을 선택하고 \"업로드 확정\"을 누르세요. 동명 팩이 기본 선택됩니다.","marketplace.upload.recommendedBadge":"권장","marketplace.state.pending":"심사 중","marketplace.state.approved":"게시됨","marketplace.state.rejected":"거부","marketplace.state.withdrawn":"내려짐","marketplace.state.superseded":"신버전으로 대체","marketplace.state.unknown":"알 수 없음","marketplace.oauth.title":"GitHub로 로그인","marketplace.oauth.generating":"디바이스 코드 생성 중…","marketplace.oauth.browserHint":"브라우저에서 {{uri}}을(를) 열고 아래 코드를 입력하세요:","marketplace.oauth.copyBtn":"복사","marketplace.oauth.copied":"디바이스 코드 복사됨","marketplace.oauth.copyFailed":"복사 실패: {{err}}","marketplace.oauth.openBrowserBtn":"브라우저 열기","marketplace.oauth.cancelBtn":"취소","marketplace.oauth.waiting":"브라우저에서 인증을 기다리는 중…","marketplace.oauth.successAs":"@{{login}}(으)로 로그인","marketplace.oauth.retryBtn":"다시 시도","marketplace.oauth.closeBtn":"닫기","marketplace.oauth.loginBtn":"로그인","marketplace.oauth.loginTooltip":"GitHub로 로그인","marketplace.oauth.reloginTooltip":"다시 로그인 / 계정 전환(현재 @{{login}})","marketplace.modal.loggedIn":"현재 로그인 ID — 설정 → 녹음 → 마켓에서 변경","marketplace.modal.notLoggedIn":"로그인되지 않음 — 설정 → 녹음 → 마켓에서 게시자 이름을 설정","marketplace.modal.notLoggedInLabel":"로그인 안 됨","shell.shortcutLabel":"녹음 단축키","shell.shortcutHint":"시작 / 정지","shell.betaTag":"BETA","shell.betaNote":"로컬 저장, 선택적 클라우드 백업","shell.navHint.overview":"상태 개요: 사용량 통계, 제공자 및 권한 상태","shell.navHint.history":"받아쓰기 기록: 과거 전사 검색·재생·복사","shell.navHint.vocab":"사전: 고유명사 인식률을 높이는 사용자 지정 핫워드","shell.navHint.style":"스타일: 출력 스타일과 사용자 지정 프롬프트 관리","shell.navHint.translation":"번역: Shift를 누른 채 말하면 대상 언어로 삽입","shell.navHint.selectionAsk":"선택 질문: 텍스트를 선택한 뒤 음성으로 질문","shell.navHint.settings":"환경설정: 단축키, 제공자, 개인정보 및 업데이트","shell.footer.account":"계정","shell.footer.feedback":"피드백","shell.footer.settings":"설정","shell.footer.help":"도움말","shell.footer.version":"버전 {{version}}","shell.footer.helpPopover.tagline":"로컬 기반 음성 입력 레이어","shell.footer.helpPopover.releaseNotes":"릴리스 노트 보기 ↗","shell.footer.helpPopover.docs":"도움말 센터 ↗","shell.providerPrompt.title":"음성 공급자 설정","shell.providerPrompt.body":"ASR 또는 LLM 공급자가 설정되지 않아 음성 입력과 정리가 일시적으로 작동하지 않습니다.","shell.providerPrompt.later":"나중에","shell.providerPrompt.openSettings":"설정 열기","shell.hotkeyModePrompt.title":"녹음 방식 확인","shell.hotkeyModePrompt.body":"기본값이 토글로 변경되었습니다. 이전에 트리거 방식을 변경한 경우 녹음 설정에서 확인하세요.","shell.hotkeyModePrompt.later":"나중에 알림","shell.hotkeyModePrompt.openSettings":"녹음 설정 열기","onboarding.welcome":"OpenLess 에 오신 것을 환영합니다","onboarding.intro":"로컬에서 말하고 로컬에서 입력합니다. 시작 전에 두 가지 시스템 권한이 필요합니다.","onboarding.accessibilityTitle":"접근성","onboarding.hotkeyTitle":"전역 단축키","onboarding.accessibilityDesc":"전역 단축키(기본 {{trigger}}) 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.","onboarding.hotkeyDesc":"전역 단축키 감지가 사용 가능한지 확인하기 위해 사용합니다.","onboarding.micTitle":"마이크","onboarding.micDesc":"음성 입력을 캡처하기 위해 사용합니다.","onboarding.actionNotApplicable":"권한 불필요","onboarding.actionGranted":"허용됨","onboarding.actionOpenSystem":"시스템 설정 열기","onboarding.actionRestart":"접근성 권한 재설정 후 OpenLess 재시작","onboarding.actionGrant":"허용","onboarding.actionRequestMic":"권한 대화상자 표시","onboarding.micNoDeviceHint":"마이크가 감지되지 않습니다. 마이크를 연결하고 활성화한 후 다시 시도하세요.","onboarding.accessibilityHint":"허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).","onboarding.footerHint":"모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.","onboarding.continueToSettings":"설정만 열기(음성 및 전역 단축키는 사용 불가)","onboarding.androidContinue":"앱으로 계속","onboarding.androidFooterHint":"받아쓰기에는 마이크 권한이 필요합니다. 위의 권한 요청을 탭하거나, 앱으로 먼저 들어가 개요 페이지에서 나중에 허용할 수 있습니다.","onboarding.androidTitle":"OpenLess 설정","onboarding.androidIntro":"모바일 권한과 서비스 설정을 단계별로 완료합니다.","onboarding.androidStepCounter":"{{current}} / {{total}} 단계","onboarding.androidBack":"이전","onboarding.androidNext":"다음","onboarding.androidFinish":"완료하고 시작","onboarding.androidSteps.microphoneTitle":"마이크 권한","onboarding.androidSteps.microphoneDesc":"Android 시스템 권한 카드를 표시하고 OpenLess 녹음을 허용합니다.","onboarding.androidSteps.accessibilityTitle":"접근성 서비스","onboarding.androidSteps.accessibilityDesc":"인식 결과를 현재 입력란에 붙여넣고 입력 환경 감지를 보조합니다.","onboarding.androidSteps.overlayPermissionTitle":"플로팅 창 권한","onboarding.androidSteps.overlayPermissionDesc":"다른 앱 위에 녹음 제어 버튼을 표시할 수 있게 합니다.","onboarding.androidSteps.overlayConfigTitle":"플로팅 창 설정","onboarding.androidSteps.overlayConfigDesc":"표시 시점, 활성화 방식, 스와이프 동작, 버튼 크기를 설정합니다.","onboarding.androidSteps.asrTitle":"ASR 클라우드 서비스","onboarding.androidSteps.asrDesc":"음성 인식 서비스의 공급자, 키, 엔드포인트, 모델을 설정합니다.","onboarding.androidSteps.llmTitle":"LLM 서비스","onboarding.androidSteps.llmDesc":"문장 다듬기, 번역, Q&A에 사용할 언어 모델 서비스를 설정합니다.","overview.refresh":"상태 새로고침","overview.servicesTitle":"현재 음성 서비스","overview.statsTitle":"사용 기록","overview.omniKind":"멀티모달 음성","overview.omniName":"현재 Omni 모델","overview.statusLoading":"서비스 설정을 불러오는 중…","overview.configureProvider":"설정하기","overview.manageProvider":"서비스 관리","overview.recentEmptyHint":"아직 받아쓰기 기록이 없습니다. 위 안내에 따라 사용해 보면 결과가 여기에 표시됩니다.","overview.providerHelp.asr":"음성을 텍스트로 변환합니다.","overview.providerHelp.llm":"내 스타일에 맞게 글을 정리하고 다듬습니다.","overview.providerHelp.omni":"하나의 모델로 음성 인식과 텍스트 처리를 수행합니다.","overview.actions.refresh":"다시 불러오기","overview.actions.services":"AI 서비스 및 모델","overview.actions.general":"녹음 및 입력","overview.actions.shortcuts":"단축키","overview.actions.privacy":"권한 및 데이터","overview.guide.nextStep":"다음 단계","overview.guide.loadingTitle":"설정을 불러오고 있어요","overview.guide.loadingDesc":"잠시 후 현재 서비스와 다음 할 일을 보여 드릴게요.","overview.guide.unavailableTitle":"서비스 상태를 불러올 수 없어요","overview.guide.unavailableDesc":"다시 불러오거나 AI 서비스에서 설정을 확인해 주세요.","overview.guide.servicesTitle":"먼저 음성 서비스를 설정하세요","overview.guide.servicesDesc":"여기서 시작하는 것을 추천해요. 음성 인식과 텍스트 처리 서비스를 선택하세요. Omni 모드에서는 사용할 멀티모달 모델만 설정하면 됩니다.","overview.guide.permissionsTitle":"단축키 상태를 확인하세요","overview.guide.permissionsDesc":"현재 단축키 기능을 사용할 수 없습니다. 권한 및 데이터에서 상태와 해결 방법을 확인해 주세요.","overview.guide.shortcutsTitle":"녹음 단축키를 설정하세요","overview.guide.shortcutsDesc":"편한 단축키를 선택하면 입력 중에 받아쓰기를 시작할 수 있어요.","overview.guide.recordingTitle":"녹음 방식을 확인하세요","overview.guide.recordingDesc":"서비스 설정이 저장되어 있습니다. 녹음 설정에서 마이크와 원하는 녹음 방식을 선택하세요.","overview.guide.tryDictationTitle":"받아쓰기를 해 보세요","overview.guide.tryDictationDesc":"입력할 곳에 커서를 놓으세요. {{shortcut}}","overview.guide.permissionsHint":"녹음이나 단축키가 반응하지 않나요? 권한 및 데이터에서 권한, 마이크, 단축키 상태를 확인하세요.","overview.kicker":"개요","overview.title":"오늘 개요","overview.desc":"오늘의 받아쓰기 통계와 시스템 상태.","overview.pressPrefix":"누르기","overview.pressSuffix":"녹음 시작","overview.asrKind":"음성 인식","overview.llmKind":"텍스트 처리","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI 호환","overview.llmConfigured":"활성 LLM 구성됨","overview.llmNotConfigured":"구성되지 않음","overview.statusConfigured":"구성됨","overview.statusNotConfigured":"구성되지 않음","overview.statusUnknown":"읽을 수 없음","overview.credentialsLoadError":"자격 증명 상태를 읽을 수 없습니다","overview.metricChars":"오늘 글자 수","overview.metricSegments":"{{count}} 세그먼트","overview.metricDuration":"오늘 총 시간","overview.metricAvg":"평균 세그먼트","overview.metricAvgTrend":"오늘 평균","overview.metricNoData":"데이터 없음","overview.historyLoadError":"기록 로드 실패","overview.metricTotal":"누적 기록","overview.metricTotalTrend":"로컬 보관(상한 200)","overview.activityTitle":"연간 활동","overview.activityCount":"{{count}}회 받아쓰기","overview.activityLoadError":"활동 데이터 로드 실패","overview.period.ariaLabel":"집계 기간","overview.period.last7Days":"최근 7일","overview.period.last30Days":"최근 30일","overview.period.dailyAverage":"일평균 {{value}}","overview.period.minutes":"{{value}}분","overview.period.hoursMinutes":"{{hours}}시간 {{minutes}}분","overview.metricName.ariaLabel":"지표","overview.metricName.count":"건수","overview.metricName.chars":"글자 수","overview.metricName.duration":"시간","overview.recentTitle":"최근 인식","overview.recentAll":"전체 보기 →","overview.recentEmpty":"아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.","overview.recentLoadFailed":"최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.","overview.historyRetry":"다시 시도","overview.weekDays.0":"일","overview.weekDays.1":"월","overview.weekDays.2":"화","overview.weekDays.3":"수","overview.weekDays.4":"목","overview.weekDays.5":"금","overview.weekDays.6":"토","overview.inAppDictation.title":"앱 내 받아쓰기","overview.inAppDictation.start":"녹음 시작","overview.inAppDictation.stop":"녹음 중지","overview.inAppDictation.idle":"탭하여 녹음 시작","overview.inAppDictation.recording":"녹음 중…","overview.inAppDictation.processing":"처리 중…","overview.androidMicBanner.title":"마이크 권한이 필요합니다","overview.androidMicBanner.desc":"마이크를 허용하면 앱 내 받아쓰기와 음성 입력을 사용할 수 있습니다.","overview.androidMicBanner.grant":"권한 요청","overview.androidMicBanner.openSettings":"설정 열기","history.exportError":"녹음을 내보내지 못했습니다. 다시 시도해 주세요.","history.kicker":"기록","history.title":"기록","history.desc":"로컬에 저장된 인식 기록.","history.filterAll":"전체","history.summary":"총 {{total}}건 · 표시 {{shown}}","history.searchPlaceholder":"기록 검색…({{shortcut}})","history.searchNoMatch":"“{{query}}”과(와) 일치하는 항목이 없습니다.","history.empty":"기록이 없습니다. {{trigger}} 를 눌러 한 번 녹음해 보세요.","history.loadFailed":"기록 로드 실패: {{err}}","history.retry":"다시 시도","history.clearFailed":"기록 비우기 실패: {{err}}","history.deleteFailed":"항목 삭제 실패: {{err}}","history.copyFailed":"복사 실패: {{err}}","history.playRecording":"녹음 재생","history.audioLoading":"로딩 중…","history.audioDecodeFailed":"오디오 디코딩 실패: {{err}}","history.exportRecording":"녹음 내보내기","history.exportFailed":"내보내기 실패: {{err}}","history.retranscribe":"다시 인식","history.retranscribing":"인식 중…","history.retranscribeFailed":"다시 인식 실패: {{err}}","history.rawLabel":"원문","history.rawEmpty":"(비어 있음)","history.selectHint":"왼쪽에서 하나를 선택하여 자세히 보기.","history.recorded":"녹음 {{duration}}","history.stepAsr":"인식","history.multimodalPipeline":"멀티모달","history.stepAsrHint":"키를 뗀 후 인식 결과를 기다린 시간. 스트리밍 인식은 녹음 중에 변환하므로 보통 녹음 시간보다 훨씬 짧습니다.","history.stepPolish":"다듬기","history.stepInsert":"삽입","history.chars":"{{count}}자","history.vocabHits":"핫워드 {{count}}개","history.inserted":"입력됨","history.pasteSent":"붙여넣기 시도됨","history.copiedFallback":"복사됨({{shortcut}} 필요)","history.insertFailed":"입력 실패","history.confirmClear":"전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.","history.backToList":"목록으로","history.repolish.title":"다시 다듬기","history.repolish.hint":"위 원문으로 다듬기를 다시 실행합니다. 결과는 이번 조회에만 표시되며 기록에 반영되지 않습니다. 원래 스타일 팩이 삭제되었거나 오래된 기록인 경우, 다시 시도 시 현재 스타일을 사용합니다.","history.repolish.retry":"같은 스타일로 재시도","history.repolish.retrying":"재시도 중…","history.repolish.apply":"적용","history.repolish.applying":"다듬는 중…","history.repolish.pickStyle":"스타일 팩 선택","history.repolish.noPacks":"사용할 수 있는 스타일 팩이 없습니다.","history.repolish.packsLoadFailed":"스타일 팩 로드 실패: {{err}}","history.repolish.failed":"다시 다듬기 실패: {{err}}","history.repolish.timeout":"현재 LLM 제공자가 30초 안에 응답하지 않았습니다. 더 빠른 제공자로 바꾸거나 잠시 후 다시 시도하세요 — 무료 모델 풀은 대기가 잦습니다.","history.repolish.resultTitle":"{{name}} 결과","history.repolish.retryResultTitle":"재시도 결과","history.repolish.empty":"(모델이 빈 결과를 반환했습니다)","history.repolish.clear":"결과 지우기","vocabCard.title":"이 단어를 기억할까요?","vocabCard.accept":"기억하기","vocabCard.reject":"건너뛰기","insertFallbackCard.copy":"복사","insertFallbackCard.copied":"복사됨","insertFallbackCard.copyFailed":"복사 실패","insertFallbackCard.dismiss":"닫기","vocab.selectAllVisible":"현재 결과 선택","vocab.selectedCount":"단어 {{count}}개 선택됨","vocab.selectWord":"“{{phrase}}” 선택","vocab.deleteSelected":"선택 항목 삭제({{count}})","vocab.batchDeleteFailed":"단어 {{count}}개를 삭제하지 못했습니다. 다시 시도할 수 있도록 선택을 유지합니다.","vocab.kicker":"사전","vocab.title":"사전","vocab.desc":"새 단어나 전문 용어를 추가하여 인식 정확도 향상.","vocab.sectionTitle":"항목","vocab.placeholder":"단어를 입력하고 Enter 또는 추가 클릭…","vocab.tip":"한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트","vocab.loadFailed":"로드 실패: {{err}}","vocab.empty":"어휘가 없습니다. 위에 새 단어나 전문 용어를 입력하면 받아쓰기 시 우선 매칭됩니다.","vocab.tipDisabled":"클릭하여 비활성화","vocab.tipEnabled":"클릭하여 활성화","vocab.removeAria":"삭제","vocab.edit":"편집","vocab.editTitle":"단어 편집","vocab.editSave":"저장","vocab.editEmpty":"단어를 입력하세요.","vocab.filter.all":"전체","vocab.filter.auto":"자동 추가","vocab.filter.manual":"수동 추가","vocab.searchPlaceholder":"검색","vocab.searchEmpty":"일치하는 단어가 없습니다.","vocab.newWord":"새 단어","vocab.newWordTitle":"새 단어 추가","vocab.newWordDesc":"단어를 직접 입력하거나 프리셋 템플릿에서 일괄 가져오세요.","vocab.newWordInputPlaceholder":"단어 입력 후 Enter로 추가…","vocab.newWordTemplates":"프리셋 템플릿","vocab.newWordTemplateCount":"{{count}}개 단어","vocab.newWordAddSelected":"선택 추가","vocab.learnedSection":"자동 수집 ({{count}})","vocab.removeAllLearned":"모두 삭제","vocab.corrections.title":"교정 규칙","vocab.corrections.tip":"ASR 오인식 수정. {num} 숫자 와일드카드 지원.","vocab.corrections.patternPlaceholder":"오인식 표현, 예: {num}粒","vocab.corrections.replacementPlaceholder":"대상 표현, 예: {num}例","vocab.corrections.empty":"아직 교정 규칙이 없습니다.","vocab.corrections.invalid":"문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.","vocab.corrections.tipDisabled":"이 규칙 비활성화","vocab.corrections.tipEnabled":"이 규칙 활성화","vocab.corrections.removeAria":"교정 규칙 삭제","vocab.corrections.learnedBadge":"자동","vocab.corrections.learnedTip":"직접 고친 내용에서 자동으로 수집했습니다. 언제든 삭제할 수 있습니다.","vocab.corrections.onlyLearned":"자동 수집만 보기 ({{count}})","vocab.corrections.removeAllLearned":"자동 수집 전체 삭제","vocab.corrections.suggestTitle":"이 수정을 기억할까요?","vocab.corrections.suggestAccept":"기억하기","vocab.corrections.suggestDismiss":"괜찮아요","vocab.presets.title":"시나리오 프리셋","vocab.presets.tip":"다중 선택 일괄 적용 가능. 편집 및 생성 지원.","vocab.presets.create":"프리셋 새로 만들기","vocab.presets.apply":"선택 활성화","vocab.presets.save":"프리셋 저장","vocab.presets.edit":"{{name}} 편집","vocab.presets.newPreset":"새 프리셋","vocab.presets.namePlaceholder":"프리셋 이름","vocab.presets.wordsPlaceholder":"어휘(쉼표 또는 줄바꿈으로 구분)","style.kicker":"스타일","style.title":"출력 스타일","style.desc":"녹음의 기본 출력 스타일 선택.","style.masterToggle":"전체 활성화","style.currentDefault":"현재 기본","style.ariaSetDefault":"기본으로 설정","style.saveFailed":"저장 실패: {{error}}","style.customPromptTitle":"사용자 프롬프트","style.customPromptPlaceholder":"선택 사항입니다. 이 스타일의 기본 system prompt 끝에 추가됩니다.","style.customPromptHint":"비워 두면 현재 동작이 그대로 유지됩니다. 저장 후 이 스타일의 실시간 다듬기와 repolish 모두에 적용됩니다. Ctrl/Cmd+Enter로도 저장할 수 있습니다.","style.customPromptSave":"프롬프트 저장","style.customPromptDirty":"미저장","style.systemPromptMovedHint":"전체 system prompt 편집은 Settings -> Providers 로 이동했습니다. 이 페이지는 이제 스타일 활성화와 기본값만 다룹니다.","style.modes.raw.name":"원문","style.modes.raw.desc":"구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.","style.modes.raw.sample":"원래 구어체 유지. \"음\", \"그게\" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.","style.modes.light.name":"가벼운 정리","style.modes.light.desc":"입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.","style.modes.light.sample":"원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.","style.modes.structured.name":"명확한 구조","style.modes.structured.desc":"개발 협업, 기술 문제 해결, 제품 피드백을 정확한 용어와 명확한 구조로 정리합니다.","style.modes.structured.sample":"1. 주제 1\na. 포인트\nb. 포인트\n2. 주제 2\na. 포인트\nb. 포인트","style.modes.formal.name":"정식 표현","style.modes.formal.desc":"업무 커뮤니케이션과 메일에 적합. 더 전문적이고 완성도 높은 문체.","style.modes.formal.sample":"메일 시나리오에서 인사말과 맺음말을 자동 인식. 공허한 상투어는 추가하지 않습니다.","style.pack.builtinTags.minimalEdits":"최소 수정","style.pack.builtinTags.strongCorrection":"정확한 교정","style.pack.builtinTags.communication":"의사소통","style.pack.builtinTags.natural":"자연스러움","style.pack.builtinTags.organized":"체계적 정리","style.pack.builtinTags.workplaceCommunication":"업무 소통","style.pack.builtinTags.aiCoding":"AI 코딩","style.pack.builtinTags.technicalStructure":"기술 내용 구조화","style.pack.newName":"이름 없는 스타일","style.pack.newDescription":"이 스타일을 언제 사용하는지 간단히 설명하세요.","style.pack.uploadIcon":"{{name}}의 SVG 아이콘 업로드","style.pack.resetIcon":"기본 아이콘 복원","style.pack.iconSaved":"아이콘이 저장되었습니다","style.pack.iconInvalid":"외부 리소스가 없는 유효한 SVG 아이콘을 선택하세요(최대 256 KB).","style.pack.iconSaveFailed":"아이콘을 저장하지 못했습니다. 다시 시도하세요.","style.pack.selectionListTitle":"선택 영역 다듬기 스타일","style.pack.selectionListDesc":"ASR 없이 선택한 글에 사용: 문법, 명확성, 형식 다듬기. 스타일과 프롬프트를 따로 고를 수 있습니다.","style.pack.dictationTab":"녹음 / ASR 스타일","style.pack.selectionTab":"선택 영역 다듬기","style.pack.current":"현재","style.pack.useForSelection":"선택 영역에 사용","style.pack.writtenPolish":"서면 다듬기","style.pack.selectionPromptTitle":"선택 영역 다듬기 프롬프트(ASR 없음)","style.pack.selectionPromptHint":"사용자가 선택한 서면 텍스트용. ASR을 거치지 않으며, 받아쓰기로 취급하지 않고 그 안의 질문에도 답하지 않습니다.","style.pack.selectionPromptEditorDesc":"선택 영역 다듬기 프롬프트를 편집 중입니다. 입력은 사용자가 선택한 서면 텍스트이며 ASR을 거치지 않습니다.","style.pack.dictationPromptEditorDesc":"녹음 / ASR 스타일 프롬프트를 편집 중입니다. 입력은 음성 인식 후 받아쓰기 텍스트입니다.","style.pack.dictationPromptTitle":"녹음 / ASR 프롬프트","style.pack.dictationPromptHint":"녹음 후 받아쓰기한 ASR 텍스트용. 구어 정리, ASR 오타 수정, 고유명사 복원 규칙을 여기에 작성하세요.","style.pack.selectionPromptFallback":"서면 다듬기 프롬프트가 아직 설정되지 않았습니다. 안전한 기본값을 사용합니다.","style.pack.selectionActivated":"선택 영역 다듬기에 \"{{name}}\"을(를) 설정했습니다","style.pack.selectionActivateFailed":"선택 영역 다듬기 스타일 전환 실패: {{err}}","style.pack.selectionChars":"{{count}}자","style.pack.kicker":"스타일 팩","style.pack.title":"스타일 팩","style.pack.desc":"로컬 스타일 팩 관리.","style.pack.marketplaceBtn":"마켓","style.pack.loadFailed":"스타일 팩 불러오기 실패: {{err}}","style.pack.importZip":"ZIP 가져오기","style.pack.exportZip":"ZIP 내보내기","style.pack.exportShort":"내보내기","style.pack.publishMarketplace":"마켓에 게시","style.pack.updateMarketplace":"마켓 새 버전으로 업데이트","style.pack.publishDisabledHint":"먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요","style.pack.publishSuccess":"게시 완료, 마켓 심사 대기 중","style.pack.publishFailed":"게시 실패: {{err}}","style.pack.publishBuiltinRejected":"기본 팩은 직접 게시할 수 없습니다. 먼저 편집해서 가져오기 버전을 만드세요.","style.pack.builtin":"기본","style.pack.imported":"가져옴","style.pack.active":"사용 중","style.pack.activate":"활성화","style.pack.edit":"편집","style.pack.closeEditor":"닫기","style.pack.unsaved":"저장 안 됨","style.pack.listTitle":"로컬 팩","style.pack.listDesc":"팩 둘러보기·전환.","style.pack.listCount":"{{count}}개","style.pack.addPackTileTitle":"새 팩","style.pack.addPackTileHint":"빈 템플릿으로 시작.","style.pack.createSuccess":"새 팩이 생성되었습니다","style.pack.createFailed":"팩 생성 실패: {{err}}","style.pack.save":"저장","style.pack.revert":"되돌리기","style.pack.saveSuccess":"스타일 팩이 저장되었습니다","style.pack.saveFailed":"스타일 팩 저장 실패: {{err}}","style.pack.activateSuccess":"\"{{name}}\"을(를) 사용 중으로 설정했습니다","style.pack.activateFailed":"사용 중 설정 실패: {{err}}","style.pack.importSuccess":"\"{{name}}\"을(를) 가져왔습니다","style.pack.importFailed":"ZIP 가져오기 실패: {{err}}","style.pack.exportSuccess":"{{path}}에 내보냈습니다","style.pack.exportFailed":"ZIP 내보내기 실패: {{err}}","style.pack.exportDirtyFirst":"ZIP을 내보내기 전에 현재 팩을 저장하세요.","style.pack.resetBuiltin":"재설정","style.pack.resetSuccess":"\"{{name}}\"을(를) 재설정했습니다","style.pack.resetFailed":"팩 재설정 실패: {{err}}","style.pack.deleteImported":"삭제","style.pack.deleteConfirm":"\"{{name}}\"을(를) 삭제할까요? 되돌릴 수 없습니다.","style.pack.deleteSuccess":"\"{{name}}\"을(를) 삭제했습니다","style.pack.deleteFailed":"팩 삭제 실패: {{err}}","style.pack.summaryCurrentEmpty":"아직 팩이 선택되지 않았습니다","style.pack.editorTitle":"팩 편집","style.pack.editorDesc":"이 팩을 편집합니다.","style.pack.metaTitle":"설치 정보","style.pack.metaSource":"소스","style.pack.metaBaseMode":"베이스 모드","style.pack.metaUpdatedAt":"업데이트","style.pack.fieldName":"이름","style.pack.fieldAuthor":"작성자","style.pack.fieldAuthorPlaceholder":"선택. 출처 표시용","style.pack.fieldVersion":"버전","style.pack.fieldTags":"태그","style.pack.fieldTagsPlaceholder":"쉼표로 구분, 예: community, voiceover, formal","style.pack.fieldDescription":"설명","style.pack.fieldModel":"권장 모델(메타데이터)","style.pack.fieldModelPlaceholder":"선택. 예: gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"메타데이터일 뿐 실제 모델을 전환하지 않습니다.","style.pack.fieldCompatibility":"호환 앱 버전","style.pack.fieldCompatibilityPlaceholder":"선택. 예: >=1.3.0","style.pack.fullPromptTitle":"System Prompt","style.pack.fullPromptHint":"이 팩만의 Prompt입니다.","style.pack.promptChars":"{{count}}자","style.pack.runtimeTitle":"OpenLess 런타임 추가 지시","style.pack.runtimeDesc":"읽기 전용 런타임 보조.","style.pack.runtimeContextTitle":"컨텍스트 전제","style.pack.runtimeContextDesc":"언어·앱 컨텍스트에서","style.pack.runtimeContextEmpty":"현재 미리보기에는 추가되지 않습니다.","style.pack.runtimeHotwordTitle":"핫워드 블록","style.pack.runtimeHotwordDesc":"활성화된 핫워드에서","style.pack.runtimeHotwordEmpty":"현재 미리보기에는 추가되지 않습니다.","style.pack.runtimeHistoryTitle":"멀티턴 히스토리 가드","style.pack.runtimeHistoryDesc":"실시간 멀티턴 polish 전용","style.pack.runtimeHistoryEmpty":"이전 턴이 있을 때만 추가됩니다.","style.pack.runtimeActive":"활성","style.pack.runtimeInactive":"비활성","style.pack.runtimePreviewFailed":"런타임 미리보기 생성 실패: {{err}}","style.pack.runtimePreviewOmittedFrontApp":"미리보기에서 프런트앱 라벨이 생략되었습니다.","style.pack.examplesTitle":"효과 예시","style.pack.examplesDesc":"팩과 함께 내보내집니다.","style.pack.addExample":"예시 추가","style.pack.examplesEmpty":"아직 예시가 없습니다.","style.pack.exampleTitlePlaceholder":"예시 {{index}} 제목","style.pack.exampleInput":"입력","style.pack.exampleOutput":"출력","style.pack.examplesCount":"{{count}}개 예시","style.pack.discardCloseConfirm":"저장하지 않은 변경 사항을 버리고 에디터를 닫을까요?","style.pack.discardSwitchConfirm":"저장하지 않은 변경 사항을 버리고 \"{{name}}\"(으)로 전환할까요?","style.pack.derivativeBadge":"@{{login}}에서 파생","translation.searchLanguages":"언어 검색…","translation.noMatchingLanguages":"일치하는 언어가 없습니다","translation.selectedLanguages":"언어 {{count}}개 선택됨","translation.languageSupportHint":"음성 서비스에 따라 지원 언어가 다릅니다. 번역 언어는 앱 표시 언어와 별개입니다.","translation.kicker":"번역","translation.title":"번역","translation.desc":"녹음 후 대상 언어로 자동 번역하여 삽입.","translation.statusEnabled":"활성화됨","translation.statusDisabled":"비활성화됨","translation.working.title":"작업 언어","translation.working.desc":"일상적으로 사용하는 언어를 선택하여 정리와 번역에 반영.","translation.target.title":"번역 대상 언어","translation.target.desc":"녹음 중 Shift 로 번역 실행. \"비활성화\" 시 Shift 무효.","translation.target.disabled":"비활성화 (Shift 로 번역 발동 안 함)","translation.target.sameAsWorking":"대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.","translation.style.title":"번역 스타일","translation.style.desc":"「스타일」 페이지에서 현재 활성화된 스타일 팩을 자동으로 사용합니다.","translation.style.unavailable":"사용할 수 없음","translation.save.workingFailed":"작업 언어 저장에 실패했습니다. 다시 시도하세요.","translation.save.targetFailed":"번역 대상 언어 저장에 실패했습니다. 다시 시도하세요.","translation.save.hotkeyRegisterFailed":"번역 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.","translation.save.hotkeySaveFailed":"번역 단축키 저장에 실패했습니다. 다시 시도하세요.","translation.howto.title":"사용 방법","translation.howto.step1":"아무 입력 필드에 커서를 놓으세요.","translation.howto.step2":"{{trigger}} 를 눌러 녹음 시작.","translation.howto.step3":"녹음 중 {{shortcut}} 를 한 번 눌러 번역 활성화.","translation.howto.step4":"다시 {{trigger}} 를 눌러 정지.","translation.howto.step5":"번역 결과가 커서 위치에 삽입됩니다.","translation.howto.indicatorTitle":"번역 모드 활성화 확인 방법","translation.howto.indicatorDesc":"Shift 를 누르면 화면 하단에 파란색 \"번역 중\" 표시가 나타납니다.","translation.howto.fallbackTitle":"안전 폴백","translation.howto.fallbackDesc":"번역 실패 시 원본 전사가 삽입됩니다.","selectionAsk.title":"선택 질문","selectionAsk.desc":"텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.","selectionAsk.shortcutSettings":"단축키 설정","selectionAsk.guide.openTitle":"질문 패널 열기","selectionAsk.guide.openDesc":"{{hotkey}}로 대화를 시작하세요.","selectionAsk.guide.unsetDesc":"먼저 단축키 설정에서 선택 질문 단축키를 지정하세요.","selectionAsk.guide.selectTitle":"궁금한 내용 선택","selectionAsk.guide.askTitle":"말로 질문하기","selectionAsk.guide.askDesc":"{{recordHotkey}}로 녹음하고, 다시 눌러 전송하세요.","selectionAsk.guide.followup":"녹음 단축키를 다시 눌러 후속 질문을 할 수 있어요.","selectionAsk.guide.dismiss":"패널을 닫고 이번 대화 종료","selectionAsk.hotkey.title":"플로팅 창 단축키","selectionAsk.save.historySaveFailed":"Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.","selectionAsk.history.title":"기록 저장","selectionAsk.history.desc":"활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.","selectionAsk.howto.title":"사용 방법","selectionAsk.howto.step2":"아무 앱에서 텍스트 선택.","settings.selectionWorkspace.title":"선택 영역 도우미","settings.selectionWorkspace.hint":"텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.","settings.selectionWorkspace.polishHotkey":"선택 영역 도우미 단축키","settings.selectionWorkspace.polishHotkeyDesc":"음성 편집 끄면 바로 다듬기, 켜면 누른 채 말하기(녹음 방식은 전역 설정 따름).","settings.selectionWorkspace.polishDelivery":"결과 처리","settings.selectionWorkspace.voiceDeliveryDesc":"음성 편집 후: 선택 영역을 바로 교체하거나 Ask 패널에서 확인 후 교체합니다.","settings.selectionWorkspace.voiceEnable":"음성 편집","settings.selectionWorkspace.voiceEnableDesc":"위와 같은 단축키 사용. 녹음 방식은 전역 설정을 따릅니다(현재: {{recordingLabel}}).","settings.selectionWorkspace.autoIntent":"의도 자동 판별","settings.selectionWorkspace.autoIntentDesc":"켜면 설정된 모델이 질문/편집을 판별합니다. 모델 실패 시에만 의문사 휴리스틱으로 폴백합니다.","settings.selectionWorkspace.editKeywords":"추가 의문 단서","settings.selectionWorkspace.editKeywordsDesc":"자동 판별 끔일 때만. 한 줄에 하나면 질문. 없으면 ?/의문사 휴리스틱.","settings.selectionPolish.title":"선택 영역 다듬기","settings.selectionPolish.hotkey":"실행 단축키","settings.selectionPolish.hotkeyDesc":"녹화 후 즉시 적용됩니다. 녹음, 질문 등 다른 전역 단축키와 충돌하면 거부됩니다.","settings.selectionPolish.delivery":"결과 처리 방식","settings.selectionPolish.hint":"텍스트를 선택한 뒤 실행합니다. 마이크나 ASR이 필요 없으며 현재 스타일 팩과 전용 선택 프롬프트를 사용합니다.","settings.selectionPolish.directReplace":"직접 교체","settings.selectionPolish.directReplaceHint":"모델 완료 후 원래 선택 영역을 안전하게 교체합니다.","settings.selectionPolish.previewConfirm":"미리보기 후 확인","settings.selectionPolish.previewConfirmHint":"편집 가능한 창에서 결과를 확인한 뒤 원래 선택 영역을 교체합니다.","settings.kicker":"설정","settings.title":"설정","settings.desc":"녹음, 공급자, 단축키, 권한 설정.","settings.network.title":"네트워크","settings.network.useSystemProxyLabel":"시스템 프록시 사용","settings.network.useSystemProxyDesc":"켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.","settings.dataStorage.title":"데이터 저장","settings.dataStorage.desc":"이 기기에 보관되는 대화 기록과 컨텍스트.","settings.dataStorage.cursorContextLabel":"커서 문맥 (실험적)","settings.dataStorage.cursorContextDesc":"다듬을 때 작성 중인 문서에서 커서 주변 원문을 읽어, 동음이의어·고유명사·대명사를 모델이 구분할 수 있게 합니다. 켜면 해당 텍스트가 요청과 함께 설정된 LLM 제공자로 전송됩니다. 끄면 한 글자도 읽지 않습니다. 비밀번호 입력란, Secure Input, 비밀번호 관리자, 터미널은 항상 읽지 않습니다. macOS 전용.","settings.codingConsole.title":"Claude 콘솔","settings.codingConsole.desc":"로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.","settings.codingConsole.guardNote":"복구 가능한 작업은 기본 허용; rm -rf / sudo / 강제 푸시 등 고위험 명령은 차단; 작업 디렉터리가 git 저장소이면 실행 전 스냅샷을 만들어 되돌릴 수 있습니다.","settings.codingConsole.status":"상태","settings.codingConsole.detect":"감지","settings.codingConsole.detecting":"감지 중…","settings.codingConsole.installed":"Claude 감지됨","settings.codingConsole.notInstalled":"claude 를 찾을 수 없음","settings.codingConsole.notInstalledHint":"먼저 Claude Code 를 설치하세요(docs.anthropic.com/claude-code 참고). 또는 아래에 실행 파일 전체 경로를 입력하세요.","settings.codingConsole.mcpServers":"MCP 서버 {{count}}개 구성됨","settings.codingConsole.computerUsePresent":"데스크톱 제어(computer use) MCP 구성됨","settings.codingConsole.computerUseAbsent":"데스크톱 제어 MCP 없음(복사/붙여넣기 같은 가벼운 작업은 Bash 로 가능, 불필요)","settings.codingConsole.exePath":"실행 파일","settings.codingConsole.workdir":"작업 디렉터리","settings.codingConsole.workdirDesc":"선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.","settings.codingConsole.workdirPlaceholder":"비우면 임시 디렉터리에서 실행","settings.codingConsole.permissionMode":"권한 모드","settings.codingConsole.mode.acceptEdits":"허용(복구 가능)","settings.codingConsole.mode.plan":"읽기 전용 / 계획","settings.codingConsole.mode.default":"기본(매번 확인)","settings.codingConsole.mode.bypassPermissions":"완전 허용(위험)","settings.codingConsole.promptPlaceholder":"Claude 에게 작업 지시, 예: 현재 디렉터리 파일 목록","settings.codingConsole.run":"실행","settings.codingConsole.running":"실행 중…","settings.codingConsole.cancel":"취소","settings.codingConsole.clear":"지우기","settings.codingConsole.riskWarn":"고위험 의도 감지: {{reason}}. 가드레일이 실행 시 고위험 명령을 차단합니다.","settings.codingConsole.toolUse":"도구 {{name}}","settings.codingConsole.done":"완료","settings.codingConsole.doneCost":"완료 · 비용 ${{cost}}","settings.codingConsole.cancelled":"취소됨","settings.codingConsole.outputPlaceholder":"출력이 여기에 스트리밍됩니다…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.","settings.codingAgent.enable":"Less Computer 켜기","settings.codingAgent.comingSoonNote":"설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.","settings.codingAgent.hotkeyHint":"켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.","settings.codingAgent.voiceHotkey":"누르고 말하기 키","settings.codingAgent.voiceHotkeyDesc":"누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.","settings.codingAgent.provider":"Agent 백엔드","settings.codingAgent.opencodeReady":"OpenCode v{{version}} 감지됨.","settings.codingAgent.opencodeMissing":"opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.","settings.codingAgent.cliReady":"{{name}} v{{version}}을(를) 감지했습니다.","settings.codingAgent.cliMissing":"{{name}} 명령을 찾을 수 없습니다. 먼저 설치하고 로그인하거나, 아래 \"실행 파일\"에 절대 경로를 입력하세요.","settings.codingAgent.sandboxGuardHint":"이 백엔드는 명령 단위 고위험 목록 없이 큰 단위의 샌드박스 등급만 제공합니다. 제한에 걸리면 \"이 명령 승인\" 카드를 띄우지 않고 실패를 그대로 알립니다.","settings.codingAgent.codexModelHint":"Codex 모델 이름(예: gpt-5)을 입력하세요. 비워 두면 ~/.codex/config.toml 설정을 사용합니다.","settings.codingAgent.codexBudgetHint":"Codex에는 실행별 달러 예산 상한이 없습니다. 비용은 구성한 제공자에 따라 달라집니다.","settings.codingAgent.codexMode.plan":"읽기 전용 / 계획","settings.codingAgent.codexMode.workspaceWrite":"워크스페이스 쓰기 허용","settings.codingAgent.codexModelPlaceholder":"비워 두면 Codex 기본값","settings.codingAgent.dshModelHint":"dsh의 headless 프로필에는 모델 전환이 없습니다. 모델은 dsh 자체 프로필에서 결정되며 여기서는 바꿀 수 없습니다.","settings.codingAgent.panelHotkey":"패널 키(음성 Agent)","settings.codingAgent.panelHotkeyDesc":"녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.","settings.codingAgent.quickHotkey":"빠른 가져오기 키","settings.codingAgent.quickHotkeyDesc":"선택한 텍스트 → Claude → 결과를 커서 위치로. 패널 없이 더 빠름.","settings.codingAgent.model":"모델","settings.codingAgent.modelPlaceholder":"기본: sonnet","settings.codingAgent.modelDefault":"기본(자동 sonnet)","settings.codingAgent.modelHint":"Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강","settings.codingAgent.opencodeModelDefault":"OpenCode 기본 모델 사용","settings.codingAgent.opencodeModelHint":"현재 OpenCode 계정에서 사용 가능한 provider/model을 자동으로 가져오고 선택 즉시 저장합니다.","settings.codingAgent.opencodeModelsRefresh":"모델 다시 가져오기","settings.codingAgent.opencodeModelsRefreshing":"OpenCode 모델을 가져오는 중…","settings.codingAgent.opencodeModelsLoaded":"모델 {{count}}개를 가져왔습니다.","settings.codingAgent.opencodeModelsEmpty":"사용 가능한 모델이 반환되지 않았습니다. OpenCode에 로그인하거나 모델 제공자를 설정하세요.","settings.codingAgent.opencodeModelsError":"모델 가져오기 실패: {{message}}","settings.codingAgent.exe":"실행 파일 경로","settings.codingAgent.openPanel":"텍스트 테스트","settings.codingAgent.openPanelHint":"Less Computer 패널을 열어 현재 Agent와 모델 설정을 텍스트로 확인합니다.","settings.codingAgent.openPanelAction":"Less Computer 열기","settings.debug.cursorLabel":"커서","settings.debug.title":"디버그 도구","settings.debug.desc":"인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.","settings.debug.cursorProbeLabel":"커서 문맥 프로브","settings.debug.cursorProbeDesc":"누른 뒤 카운트다운 안에 대상 앱으로 전환해 입력란을 클릭하세요. 그곳의 커서 주변 원문을 읽어, 어떤 앱이 읽히고 어떤 앱이 안전 게이트에 막히는지 확인할 수 있습니다. 한 번만 읽으며 어떤 제공자에게도 보내지 않습니다.","settings.debug.cursorProbeBtn":"프로브 (5초 후)","settings.debug.cursorProbeCountdown":"{{n}}초 후 읽기…","settings.marketplace.title":"확장 마켓","settings.marketplace.desc":"스타일 마켓 업로드 신원. 스타일 둘러보기와 설치는 「스타일」 페이지에서 합니다.","settings.marketplace.github.signIn":"GitHub로 로그인","settings.marketplace.github.signedIn":"GitHub로 로그인됨","settings.marketplace.github.signedOut":"로그인하면 스타일 업로드와 좋아요를 할 수 있습니다.","settings.marketplace.github.signOut":"로그아웃","settings.marketplace.github.starting":"로그인을 시작하는 중…","settings.marketplace.github.codeHint":"열린 GitHub 페이지에서 이 코드를 입력하세요:","settings.marketplace.github.openGithub":"GitHub 열기","settings.marketplace.github.waiting":"GitHub를 열었습니다. 승인하면 로그인됩니다…","settings.marketplace.github.failed":"로그인 실패, 다시 시도하세요","settings.recording.title":"녹음 및 입력","settings.recording.desc":"전역 녹음의 단축키와 트리거 방식을 정의합니다.","settings.recording.hotkeyLabel":"녹음 단축키","settings.recording.hotkeyDescAcc":"누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.","settings.recording.hotkeyDescNoAcc":"누르면 음성 캡처 시작(전역). 추가 권한 불필요.","settings.recording.modeLabel":"녹음 방식","settings.recording.modeDesc":"토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.","settings.recording.modeToggle":"토글 방식","settings.recording.modeHold":"눌러서 말하기","settings.recording.modeAuto":"자동","settings.recording.silenceAutoStopLabel":"침묵 시 자동 중지","settings.recording.silenceAutoStopDesc":"토글 모드에서만 동작합니다. 음성이 감지된 후 선택한 시간 동안 침묵이 이어지면 녹음을 자동으로 종료하고 제출합니다. 말을 전혀 하지 않으면 10초 후 취소됩니다. 기본적으로 꺼져 있으며, 두 번째 키 누름으로 중지하고 Esc로 취소하는 동작은 그대로 유지됩니다.","settings.recording.silenceAutoStopSecondsLabel":"침묵 시간","settings.recording.silenceAutoStopSecondsValue":"{{value}}초","settings.recording.migrationNoticeTitle":"기본값이 토글 방식으로 변경됨","settings.recording.migrationNoticeDesc":"이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. \"눌러서 말하기\"가 더 익숙하다면 다시 전환할 수 있습니다.","settings.recording.microphoneLabel":"기본 선택 마이크","settings.recording.microphoneDesc":"우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.","settings.recording.microphoneDefault":"시스템 기본 마이크","settings.recording.microphoneDefaultDesc":"시스템 기본 입력 장치 사용","settings.recording.microphoneSystemDefault":"시스템 기본값","settings.recording.microphoneUnavailable":"사용할 수 없음","settings.recording.microphoneLoadError":"마이크 로드 실패: {{message}}","settings.recording.microphoneDialogTitle":"마이크","settings.recording.microphoneDialogDesc":"목소리를 받을 수 있는 마이크를 선택하세요. 미터가 움직이지 않으면 다른 마이크를 시도하세요.","settings.recording.microphoneMonitorError":"입력 레벨 모니터링 실패: {{message}}","settings.recording.capsuleLabel":"녹음 캡슐","settings.recording.capsuleDesc":"녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.","settings.recording.capsuleStyleTypeless":"Typeless 컴팩트 스타일","settings.recording.capsuleStyleLabel":"캡슐 스타일","settings.recording.capsuleStyleSiri":"시리 광선 스타일","settings.recording.capsuleStyleClassic":"Openless 기본 스타일","settings.recording.muteDuringRecordingLabel":"녹음 중 음소거","settings.recording.muteDuringRecordingDesc":"녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.","settings.recording.audioCueLabel":"녹음 시작음","settings.recording.audioCueDesc":"단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.","settings.recording.audioCuePreview":"미리듣기","settings.recording.insertGroupTitle":"삽입 및 클립보드","settings.recording.restoreClipboardLabel":"입력 후 클립보드 복원","settings.recording.restoreClipboardDesc":"붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).","settings.recording.pasteShortcutLabel":"붙여넣기 단축키","settings.recording.pasteShortcutDesc":"삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).","settings.recording.pasteShortcutCtrlV":"Ctrl+V (기본 / 대부분 앱)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert (xterm / urxvt)","settings.recording.comboRecordLabel":"단축키 녹화","settings.recording.comboRecordDesc":"클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.","settings.recording.comboRecordBtn":"단축키 녹화","settings.recording.comboResetBtn":"초기화","settings.recording.comboMenuToggle":"더보기","settings.recording.comboDisableHint":"핵심 단축키는 비활성화할 수 없습니다 (녹음에는 단축키가 필수입니다)","settings.recording.comboRecordHint":"단축키 조합을 눌러 주세요…","settings.recording.comboNeedKey":"조합 키(예: ⌘⇧J)를 설정하세요. 단일 보조 키는 사용할 수 없습니다","settings.recording.comboRecorded":"녹화됨","settings.recording.comboClear":"지우기","settings.recording.comboConflict":"이 단축키 조합은 사용할 수 없습니다","settings.recording.allowNonTsfFallbackLabel":"비 TSF 폴백 허용","settings.recording.allowNonTsfFallbackDesc":"Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.","settings.recording.windowsInsertionModeLabel":"Windows 삽입 방식","settings.recording.windowsInsertionModeDesc":"받아쓰기 결과를 커서 위치에 삽입하는 방법. 클립보드 붙여넣기는 위의 「붙여넣기 단축키」를 사용하며 줄바꿈을 유지합니다.","settings.recording.windowsInsertionModeTsf":"TSF 입력기(기본)","settings.recording.windowsInsertionModeSendInput":"SendInput 키 입력 시뮬레이션","settings.recording.windowsInsertionModePaste":"클립보드 붙여넣기(Ctrl+V 등)","settings.recording.macosNewlineModeLabel":"줄바꿈 처리","settings.recording.macosNewlineModeDesc":"자동은 알려진 터미널 앱에서 Line Feed(U+000A / Ctrl+J)를, 그 밖의 앱에서는 Shift+Return을 사용합니다. 일반 Return은 메시지를 전송합니다.","settings.recording.macosNewlineModeAuto":"자동(터미널에서는 Line Feed)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return(채팅에서 줄바꿈)","settings.recording.macosNewlineModeLineFeed":"Line Feed(터미널 CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return(여러 메시지로 분할)","settings.recording.windowsSendInputNewlineModeLabel":"SendInput 줄바꿈 시뮬레이션","settings.recording.windowsSendInputNewlineModeDesc":"SendInput 모드에서 줄바꿈을 어떤 키로 보낼지. 채팅 입력창은 Shift+Enter, 메모장 / VS Code 등은 Enter.","settings.recording.windowsSendInputNewlineModeEnter":"Enter(대부분 편집기)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter(채팅 입력창)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"키보드 목록에 OpenLess 표시","settings.recording.windowsShowOpenlessInKeyboardListDesc":"끄면 Win+Space로 OpenLess에 전환되지 않습니다. SendInput 및 클립보드 붙여넣기 삽입에는 영향 없습니다. 다시 켜면 목록에 복원됩니다.","settings.recording.windowsShowOpenlessInKeyboardListError":"키보드 목록을 업데이트할 수 없습니다: 시스템이 OpenLess 언어 프로필 변경을 거부했습니다.","settings.recording.historyGroupTitle":"기록 및 컨텍스트","settings.recording.historyRetentionLabel":"기록 보관 기간(일)","settings.recording.historyRetentionDesc":"보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.","settings.recording.historyMaxEntriesLabel":"기록 개수 상한","settings.recording.historyMaxEntriesDesc":"로컬 보관 세션 상한. 빈칸 = 200. 범위 5–200.","settings.recording.polishContextWindowLabel":"대화 컨텍스트 윈도(분)","settings.recording.polishContextWindowDesc":"최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.","settings.recording.recordAudioForDebugLabel":"원본 녹음 보관(디버그)","settings.recording.recordAudioForDebugDesc":"원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.","settings.recording.audioRecordingMaxEntriesLabel":"원본 녹음 보관 개수","settings.recording.audioRecordingMaxEntriesDesc":"로컬 보관 wav 파일 상한. 빈칸 = 200.","settings.recording.startupGroupTitle":"시작","settings.recording.startMinimizedLabel":"시작 시 메인 창 숨기기","settings.recording.startMinimizedDesc":"모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.","settings.recording.autoUpdateCheckLabel":"자동 업데이트 확인","settings.recording.autoUpdateCheckDesc":"시작 시 및 60 분마다 자동 확인.","settings.recording.marketplaceGroupTitle":"스타일 팩 마켓플레이스","settings.recording.marketplaceBaseUrlLabel":"백엔드 URL","settings.recording.marketplaceBaseUrlDesc":"마켓플레이스 백엔드 URL. 빈칸은 기본값 사용.","settings.recording.marketplaceDevLoginLabel":"GitHub 로그인 이름 (업로드 ID)","settings.recording.marketplaceDevLoginDesc":"업로더를 식별합니다. 빈칸 시 업로드 및 좋아요 비활성화.","settings.recording.startupAtBoot":"부팅 시 자동 시작","settings.recording.startupAtBootDesc":"로그인 시 OpenLess 자동 시작.","settings.recording.startupAtBootError":"자동 시작 전환 실패: {{message}}","settings.channels.backToList":"채널 목록으로 돌아가기","settings.channels.done":"완료","settings.channels.llmTitle":"텍스트 처리 채널","settings.channels.asrTitle":"음성 인식 채널","settings.channels.current":"현재 사용 중","settings.channels.enabled":"사용","settings.channels.disabled":"사용 안 함","settings.channels.enabledFor":"{{name}} 사용","settings.channels.modelNotSet":"모델을 별도로 설정하지 않음","settings.channels.localModelManaged":"시스템 또는 로컬 모델 페이지에서 모델 관리","settings.channels.lastCheck":"마지막 확인","settings.channels.verifying":"확인 중…","settings.channels.notVerified":"아직 확인하지 않음","settings.channels.passed":"확인 성공","settings.channels.failed":"확인 실패 · {{reason}}","settings.channels.elapsed":"소요 시간 {{ms}} ms","settings.channels.staleResult":"24시간이 지난 결과","settings.channels.connectionTitle":"서비스 연결","settings.channels.modelTitle":"모델 설정","settings.channels.modelHint":"모델 이름을 직접 입력하거나 공급자의 모델 목록을 가져와 선택하세요.","settings.channels.availableModels":"사용 가능한 모델","settings.channels.validationTitle":"연결 확인","settings.channels.validationHint":"실제 요청을 보내 현재 설정을 확인합니다. 서비스 사용량이 차감될 수 있습니다. 설정을 저장해도 자동으로 확인하지 않습니다.","settings.channels.autoSaveHint":"변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.","settings.channels.nameHint":"같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.","settings.channels.errModel":"모델","settings.channels.verify":"검증","settings.channels.verifyHint":"실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다","settings.channels.errTimeout":"시간 초과","settings.channels.errNetwork":"네트워크","settings.channels.errEndpoint":"주소","settings.channels.errGeneric":"실패","settings.channels.dragHint":"드래그해서 우선순위 변경","settings.channels.orderHint":"사용 중인 채널 중 맨 위의 채널로 요청합니다. 드래그로 순서를 바꾸면 사용하지 않는 채널은 맨 아래로 이동합니다.","settings.channels.empty":"아직 채널이 없습니다. \"채널 추가\"로 첫 서비스를 연결하세요.","settings.channels.add":"채널 추가","settings.channels.edit":"편집","settings.channels.createTitle":"채널 추가","settings.channels.editTitle":"채널 편집","settings.channels.providerLabel":"공급자","settings.channels.nameLabel":"채널 이름 (선택)","settings.channels.namePlaceholder":"예: SiliconFlow — 메인 키","settings.channels.create":"만들기","settings.channels.delete":"채널 삭제","settings.channels.deleteConfirm":"삭제하면 이 채널에 저장된 키도 함께 지워집니다.","settings.channels.confirmDelete":"삭제","settings.channels.justNow":"방금","settings.channels.minutesAgo":"{{count}}분 전","settings.channels.hoursAgo":"{{count}}시간 전","settings.channels.daysAgo":"{{count}}일 전","settings.channels.localEngineModelHint":"AI 서비스 및 모델 → 로컬 모델에서 모델을 내려받고 전환할 수 있습니다.","settings.providers.localEngineNoCredentials":"로컬 엔진은 API 키나 엔드포인트가 필요 없습니다.","settings.providers.localModelLabel":"로컬 모델","settings.providers.localModelEmpty":"아직 다운로드된 로컬 모델이 없습니다","settings.providers.appleSpeechLocalNote":"Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.","settings.providers.localEngineNote":"다운로드된 로컬 모델은 위의 드롭다운에서 바로 선택할 수 있습니다. 더 많은 모델은 「로컬 모델」에서 다운로드하고 관리합니다.","settings.providers.localTag":"로컬","settings.providers.llmTitle":"LLM 모델(정리)","settings.providers.llmDesc":"OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.","settings.providers.providerLabel":"공급자","settings.providers.llmProviderDesc":"선택 시 Base URL 기본값이 자동 입력됩니다.","settings.providers.credentialStorageNotice":"자격 증명은 OS 자격 증명 저장소에 보관됩니다.","settings.providers.codexOAuthNotice":"Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.","settings.providers.asrProviderDesc":"전환 시 해당하는 자격 증명이 자동 선택됩니다.","settings.providers.asrTitle":"ASR 음성(전사)","settings.providers.asrDesc":"녹음된 음성을 텍스트로 전사합니다.","settings.providers.omniTitle":"멀티모달 모델","settings.providers.omniDesc":"하나의 모델이 프롬프트 + 오디오를 받아 최종 텍스트를 한 번에 출력합니다(실험적 파이프라인).","settings.providers.pipelineModeLabel":"인식 파이프라인","settings.providers.pipelineModeHint":"전통 = ASR 전사 + LLM 다듬기 2단계. 멀티모달 = 오디오 지원 모델이 한 번에 처리.","settings.providers.pipelineModeTraditional":"전통 모드","settings.providers.pipelineModeMultimodal":"멀티모달 모드","settings.providers.pipelineIsolationNotice":"두 모드는 완전히 분리된 자격 증명을 사용합니다. 전환해도 다른 쪽 설정은 삭제되지 않으며, 다시 전환하면 복원됩니다.","settings.providers.presets.ark":"ARK (Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter(무료 모델)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax (M3)","settings.providers.presets.stepfun":"StepFun","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"Tencent Cloud TokenHub","settings.providers.presets.customChatCompletions":"사용자 지정 · Chat Completions","settings.providers.presets.customResponses":"사용자 지정 · Responses","settings.providers.presets.customMessages":"사용자 지정 · Messages","settings.providers.presets.custom":"사용자 정의","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrBailian":"Alibaba Bailian 실시간 ASR","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 실시간 ASR","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash (녹음 파일)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper(호환)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"커스텀 OpenAI 호환","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek 실시간 음성 인식","settings.providers.presets.asrTencentCloud":"Tencent Cloud Hunyuan 실시간 ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"로컬 sherpa-onnx(실험적)","settings.providers.presets.asrFoundryLocalWhisper":"로컬 Whisper(Foundry Local)","settings.providers.presets.asrLocalWhisper":"로컬 Whisper(배치)","settings.providers.presets.asrLocalQwen3":"로컬 Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"로컬 Qwen3-ASR(MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"로컬 Qwen3-ASR(C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple 음성 (macOS)","settings.providers.presets.omniOpenai":"OpenAI (오디오 지원)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs는 녹음 오디오를 설정된 엔드포인트에 업로드해 일괄 전사합니다.","settings.providers.zenmuxVocabularyNote":"ZenMux는 JSON 전사 프로토콜을 사용하며 사전 핫워드(prompt/hotwords)를 보내지 않습니다. 사전은 여전히 다듬기 단계에 전달되지만 음성 인식 편향에는 사용되지 않습니다.","settings.providers.asrAdvancedNote":"아래 고급 옵션은「커스텀 OpenAI 호환」및「ZenMux」프리셋에만 적용됩니다. 다른 명명된 공급자 프리셋은 내장 동작을 유지합니다.","settings.providers.asrAdvancedVerboseJsonLabel":"세그먼트 지표 (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"서버가 지원할 때 환각 필터링용 segments 지표를 요청합니다. 지원하지 않는 자체 호스팅 서버에서는 꺼두세요.","settings.providers.asrAdvancedChunkLabel":"분할 시간 (ms)","settings.providers.asrAdvancedChunkHint":"0 = 분할 없음(전체를 한 번에 전송). 긴 녹음이나 요청당 시간 제한이 있는 서버에 적합합니다.","settings.providers.asrAdvancedEnableItnLabel":"숫자 정규화 (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"구어 숫자/단위를 아라비아 숫자로 정규화합니다(예: \"이천이십육\" → \"2026\"). 끄면 원문을 유지합니다.","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API Key","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"인증 모드","settings.providers.volcengineAuthModeAppIdToken":"레거시 앱 (APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"새 콘솔 API Key","settings.providers.volcengineMappingNote":"Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.seedasr.sauc.duration.","settings.providers.volcengineApiKeyNote":"새 음성 콘솔에서 만든 API Key로 인증하며 APP ID는 필요 없습니다. API Key는 음성 콘솔의 \"API Key 관리\"에서 생성합니다: console.volcengine.com/speech/new/setting/apikeys. Resource ID 기본값은 volc.seedasr.sauc.duration입니다.","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API Key","settings.providers.xfyunNote":"iFlytek 오픈 플랫폼 \"실시간 음성 인식\" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.","settings.providers.tencentCloudAppIdLabel":"Tencent Cloud AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Tencent Cloud 음성 인식 API 자격 증명을 사용합니다. 기본 Hy-ASR-3.0-preview는 중국어·영어·20개 방언을 지원합니다. Preview는 60초 이내의 16kHz 모노 PCM만 지원하며, 컨텍스트와 핫워드 강화는 아직 지원하지 않습니다.","settings.providers.tencentTokenHubNote":"현재 온라인인 언어 모델만 표시합니다. 일부 모델은 항상 추론을 사용하며, 추론을 꺼도 해당 모델의 고정 동작을 유지합니다.","settings.providers.localAsrActiveNotice":"현재 \"{{name}}\" 사용 중. \"고급\" 탭에서 전환 또는 비활성화할 수 있습니다.","settings.providers.localAsrTakeoverHint":"\"{{name}}\" 활성화 시 ASR 프로바이더가 인수됩니다.","settings.providers.asrProviderTakenOver":"현재 로컬 엔진을 사용 중입니다. 위의 드롭다운에서 다른 공급자를 선택하면 전환됩니다(로컬 엔진은 자동으로 중지됨). 로컬 모델은 「서비스 → 로컬 모델」에서 관리합니다.","settings.providers.localAsrHint":"기기에서 실행, API 키 불필요. HuggingFace 에서 모델 다운로드.","settings.providers.foundryLocalAsrHint":"기기에서 실행, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드.","settings.providers.localAsrPerformanceWarning":"로컬 추론은 클라우드보다 느리며 중국어 정확도가 낮을 수 있습니다. 오프라인 또는 개인정보 보호 시나리오에 적합.","settings.providers.localAsrReady":"{{model}} 다운로드됨","settings.providers.localAsrNotReady":"{{model}} 다운로드되지 않음","settings.providers.localAsrGoDownload":"모델 설정에서 다운로드","settings.providers.localAsrManage":"모델 설정으로 이동","settings.providers.localAsrDownloadedTitle":"다운로드된 모델","settings.providers.localAsrDelete":"삭제","settings.providers.fillDefault":"기본값 입력","settings.providers.readFailed":"읽기 실패","settings.providers.apiKeyLabel":"API 키","settings.providers.baseUrlLabel":"엔드포인트","settings.providers.modelLabel":"모델","settings.providers.customModelLabel":"사용자 정의 모델…","settings.providers.presetListLabel":"프리셋으로 돌아가기","settings.providers.searchModels":"모델 검색…","settings.providers.noMatchingModels":"일치하는 모델이 없습니다","settings.providers.orcarouterCatalogHint":"OrcaRouter /models에서 불러옵니다. 이 공급자는 카탈로그 모델만 선택할 수 있으며 모델 ID 직접 입력은 지원하지 않습니다.","settings.providers.orcarouterAsrCatalogHint":"OrcaRouter /models에서 불러오며 오디오 입력을 지원하는 Gemini 모델만 표시합니다. 모델 ID 직접 입력은 지원하지 않습니다.","settings.providers.temperatureLabel":"Temperature","settings.providers.temperaturePlaceholder":"비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3","settings.providers.extraHeadersLabel":"추가 Headers","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"사고","settings.providers.thinkingModeOn":"켜짐","settings.providers.thinkingModeOff":"꺼짐","settings.providers.requestFormatLabel":"요청 형식","settings.providers.messagesThinkingLabel":"사고 방식","settings.providers.thinkingAdaptive":"적응형","settings.providers.thinkingBudget":"고정 예산","settings.providers.maxTokensLabel":"최대 출력 토큰","settings.providers.thinkingBudgetLabel":"사고 토큰 예산","settings.providers.responsesThinkingHint":"일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.","settings.providers.messagesThinkingHint":"이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.","settings.providers.llmRequestFormatInvalid":"잘못된 요청 형식입니다. 다시 선택하세요.","settings.providers.llmThinkingModeInvalid":"잘못된 사고 방식입니다. 다시 선택하세요.","settings.providers.llmTokenLimitInvalid":"토큰 한도는 양의 정수여야 합니다.","settings.providers.llmThinkingBudgetInvalid":"사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.","settings.providers.llmResponseIncomplete":"응답이 완료되지 않았거나 출력 한도에 도달했습니다. 이미 출력된 텍스트는 유지됩니다.","settings.providers.llmProtocolHeaderConflict":"Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.","settings.providers.llmStreamError":"서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.","settings.providers.saveProtocol":"프로토콜 설정 저장","settings.providers.thinkingModeHint":"선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.","settings.providers.bailianVocabularyIdLabel":"핫워드 Vocabulary ID(선택)","settings.providers.bailianVocabularyIdNote":"DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.","settings.providers.bailianModelRealtimeHint":"실시간 모델 · 말하는 동안 바로 전사.","settings.providers.bailianModelSyncFileHint":"동기 녹음 모델 · 말을 마친 뒤 전체 전사(한 클립 ≤ 5분).","settings.providers.bailianModelAsyncFileHint":"비동기 파일 모델 · 녹음을 업로드한 뒤 전사 작업 완료를 기다립니다.","settings.providers.appIdLabel":"App ID(애플리케이션 ID)","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Resource ID","settings.providers.toolsLabel":"연결 확인","settings.providers.toolsDesc":"위 설정을 먼저 저장한 후 현재 모델 연결성을 검증하거나 모델을 가져오세요. 실패해도 모델 ID 를 수동 입력할 수 있습니다.","settings.providers.validate":"검증","settings.providers.validating":"검증 중…","settings.providers.fetchModels":"모델 가져오기","settings.providers.loadingModels":"모델 가져오는 중…","settings.providers.modelMissing":"모델이 설정되지 않았습니다. 먼저 모델 ID 를 입력해 주세요.","settings.providers.modelsEmpty":"인증 성공이지만 사용 가능한 모델이 반환되지 않았습니다.","settings.providers.modelsLoaded":"{{count}}개의 모델을 가져왔습니다.","settings.providers.selectModel":"모델을 선택해 위 필드에 입력","settings.providers.modelSaved":"모델 {{model}} 을(를) 저장했습니다.","settings.providers.validateSuccess":"연결 확인을 통과했습니다.","settings.providers.validateFailed":"연결 확인에 실패했습니다.","settings.providers.providerHttpStatus":"공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.","settings.providers.endpointMustUseHttps":"HTTP Endpoint 를 사용할 수 있지만, API Key 와 음성 내용이 전송 중 유출될 수 있습니다.","settings.providers.endpointHttpWarning":"HTTP Endpoint 를 사용할 수 있지만, API Key 와 요청 내용이 전송 중 유출될 수 있습니다.","settings.providers.endpointInvalid":"Endpoint 형식이 올바르지 않습니다.","settings.providers.bailianEndpointSchemeInvalid":"Bailian 실시간 ASR은 DashScope WebSocket 게이트웨이를 사용합니다. 엔드포인트는 wss://로 시작해야 합니다(기본값: wss://dashscope.aliyuncs.com/api-ws/v1/inference/). https:// 호환 모드 주소는 여기서 사용할 수 없습니다.","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 실시간 ASR은 DashScope Realtime WebSocket 게이트웨이를 사용합니다. 엔드포인트는 wss://로 시작해야 합니다(기본값: wss://dashscope.aliyuncs.com/api-ws/v1/realtime). https:// 주소는 여기서 사용할 수 없습니다.","settings.providers.responseTooLarge":"공급자 응답이 너무 커서 안전을 위해 검증을 중단했습니다.","settings.providers.asrInvalidJson":"ASR 응답이 유효한 JSON 이 아닙니다.","settings.providers.asrMissingTextField":"ASR 응답에 text 필드가 없습니다.","settings.providers.apiKeyMissing":"API Key 가 비어 있습니다.","settings.providers.endpointMissing":"Endpoint 가 비어 있습니다.","settings.providers.volcengineAppIdMissing":"APP ID 가 비어 있습니다.","settings.providers.volcengineAccessTokenMissing":"Access Token 이 비어 있습니다.","settings.providers.requestTimeout":"요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.","settings.shortcuts.title":"단축키 설정","settings.shortcuts.descAcc":"모든 단축키는 전역에서 작동. 권한 설정에서 접근성을 활성화해야 합니다.","settings.shortcuts.descNoAcc":"모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.","settings.shortcuts.startStop":"녹음 시작 / 정지","settings.shortcuts.cancel":"이번 녹음 취소","settings.shortcuts.confirm":"캡슐 입력 확정","settings.shortcuts.switchStyle":"이전 스타일로 전환","settings.shortcuts.openApp":"OpenLess 열기","settings.shortcuts.stylePackTitle":"스타일 바로가기 단축키","settings.shortcuts.stylePackDesc":"자주 쓰는 스타일 팩에 단축키를 지정해 한 번에 전환합니다. 비활성화된 팩은 자동으로 다시 활성화됩니다.","settings.shortcuts.stylePackAdd":"스타일 단축키 추가","settings.shortcuts.stylePackSelect":"스타일 팩 선택","settings.shortcuts.stylePackDisabledSuffix":" (비활성화됨)","settings.shortcuts.stylePackRemove":"제거","settings.shortcuts.agentPolish":"선택 텍스트 다듬기","settings.shortcuts.agentPolishDesc":"텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"사용자 지정 키를 누른 채 말하기 → Claude 작업 실행 → 결과 캡슐 표시.","settings.shortcuts.agentVoiceHint":"「고급 → Less Computer」에서 누르고 말하기 키를 설정하세요.","settings.shortcuts.agentVoiceTrigger":"Less Computer 누르고 말하기 키","settings.shortcuts.enable":"활성화","settings.shortcuts.disable":"비활성화","settings.shortcuts.confirmHint":"오른쪽 ✓ 클릭","settings.shortcuts.notSupported":"지원되지 않음","settings.shortcuts.androidReadOnly":"Android에서는 전역 단축키를 사용할 수 없습니다. 개요 페이지의 녹음 버튼을 사용하세요.","settings.permissions.title":"권한","settings.permissions.descAcc":"OpenLess 가 정상 작동하려면 다음 시스템 권한이 필요합니다. 허용 후에는 일반적으로 앱을 완전히 종료한 후 재시작해야 적용됩니다.","settings.permissions.descNoAcc":"OpenLess 는 마이크 사용과 전역 단축키 감지 상태를 통해 네이티브 후크의 정상 동작을 판정해야 합니다.","settings.permissions.micLabel":"마이크","settings.permissions.micDesc":"음성 입력을 캡처하기 위해 사용합니다.","settings.permissions.accLabel":"접근성","settings.permissions.accDesc":"전역 단축키 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.","settings.permissions.hotkeyLabel":"전역 단축키","settings.permissions.hotkeyDescWithAdapter":"현재 어댑터: {{adapter}}. 단축키 감지가 설치되었는지 판정하기 위해 사용.","settings.permissions.hotkeyDescPlain":"단축키 감지가 설치되었는지 판정하기 위해 사용.","settings.permissions.networkLabel":"네트워크","settings.permissions.networkDesc":"클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.","settings.permissions.networkOk":"사용 가능","settings.permissions.networkOffline":"사용 불가","settings.permissions.checking":"확인 중…","settings.permissions.granted":"허용됨","settings.permissions.notApplicable":"권한 불필요","settings.permissions.denied":"허용되지 않음","settings.permissions.indeterminate":"미결정","settings.permissions.micNoDevice":"마이크가 감지되지 않음","settings.permissions.openSystem":"시스템 설정 열기","settings.permissions.restart":"재설정 후 재시작","settings.permissions.grant":"허용","settings.permissions.rerunAndroidSetup":"설정 마법사 다시 실행","settings.permissions.hotkeyInstalled":"설치됨","settings.permissions.hotkeyStarting":"설치 중…","settings.permissions.hotkeyFailed":"감지 실패","settings.permissions.windowsImeLabel":"Windows 입력기 백엔드","settings.permissions.windowsImeDesc":"음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.","settings.permissions.windowsImeInstalled":"설치됨","settings.permissions.windowsImeUnavailable":"사용 불가","settings.permissions.androidImeLabel":"입력기 (IME)","settings.permissions.androidImeSelected":"선택됨","settings.permissions.androidImeEnabled":"활성화됨","settings.permissions.androidImeDisabled":"비활성","settings.permissions.androidOverlayLabel":"플로팅 오버레이","settings.permissions.androidAccessibilityLabel":"접근성 서비스","settings.permissions.androidAccessibilityImpact":"켜면 키보드를 전환하지 않고 현재 입력칸에 결과를 출력합니다. 끄면 클립보드에 복사되며 직접 붙여넣어야 합니다.","settings.permissions.androidAccessibilityGrantedStale":"승인됨, 연결 안 됨","settings.permissions.androidAccessibilityMessages.not_android":"접근성 상태는 Android에서만 사용할 수 있습니다.","settings.permissions.androidAccessibilityMessages.not_enabled":"시스템 접근성 설정에서 OpenLess를 활성화하세요.","settings.permissions.androidAccessibilityMessages.operational":"접근성 서비스가 실행 중입니다.","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"접근성은 승인되었지만 연결되지 않았습니다. 시스템 설정에서 OpenLess를 다시 활성화하세요.","settings.permissions.androidAccessibilityMessages.status_read_failed":"접근성 상태를 읽을 수 없습니다.","settings.permissions.androidShizukuLabel":"Shizuku 확장 모드","settings.permissions.androidShizukuHint":"선택 기능. OEM 설정에서 수동 전환이 어려울 때 최선 노력 복구를 시도합니다. 앱 간 경합을 완전히 제거할 수는 없습니다. 재부팅 후 Shizuku를 다시 시작해야 할 수 있습니다.","settings.permissions.androidShizukuOpenApp":"Shizuku 열기","settings.permissions.androidShizukuRequestPermission":"권한 요청","settings.permissions.androidShizukuRecover":"접근성 복구","settings.permissions.androidShizukuRecoverConfirm":"Shizuku로 OpenLess 접근성 서비스를 다시 활성화할까요? 쓰기 시점에 활성화된 서비스는 병합됩니다. 전역 스위치가 꺼져 있으면 활성화 시 등록된 다른 서비스도 함께 시작될 수 있습니다.","settings.permissions.androidShizukuYes":"예","settings.permissions.androidShizukuNo":"아니오","settings.permissions.androidShizukuAccessibilityOperational":"접근성이 등록되어 실행 중입니다.","settings.permissions.androidShizukuAccessibilityRegistered":"등록: {{registered}} · 실행: {{operational}}","settings.permissions.androidShizukuState.notInstalled":"미설치","settings.permissions.androidShizukuState.notRunning":"미실행","settings.permissions.androidShizukuState.notAuthorized":"미승인","settings.permissions.androidShizukuState.authorized":"승인됨","settings.permissions.androidShizukuState.binderDead":"연결 끊김","settings.permissions.androidShizukuState.notAndroid":"해당 없음","settings.permissions.androidShizukuMessages.not_android":"Shizuku는 Android에서만 사용할 수 있습니다.","settings.permissions.androidShizukuMessages.not_installed":"Shizuku 또는 Sui 백엔드가 설치되어 있지 않습니다.","settings.permissions.androidShizukuMessages.unsupported_backend":"이 Shizuku 백엔드는 너무 오래되었습니다. Shizuku 또는 Sui를 v11 이상으로 업데이트하세요.","settings.permissions.androidShizukuMessages.not_running":"Shizuku가 실행 중이 아닙니다. 먼저 Shizuku 또는 Sui를 시작하세요.","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku가 승인되지 않았습니다. OpenLess 권한을 부여하세요.","settings.permissions.androidShizukuMessages.binder_dead":"Shizuku 연결이 끊어졌습니다. Shizuku를 다시 시작하세요.","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku 승인됨. 접근성이 실행 중입니다.","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku 승인됨. 접근성은 등록되었지만 실행되지 않습니다.","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku 승인됨. 접근성 복구를 시도할 수 있습니다.","settings.permissions.androidShizukuMessages.operational":"접근성이 등록되어 실행 중입니다.","settings.permissions.androidShizukuMessages.registered_stale":"접근성은 등록되었지만 서비스는 현재 사용할 수 없습니다.","settings.permissions.androidShizukuMessages.not_registered":"시스템 설정에서 접근성이 활성화되어 있지 않습니다.","settings.permissions.androidShizukuMessages.already_granted":"Shizuku 권한이 이미 부여되었습니다.","settings.permissions.androidShizukuMessages.binder_unavailable":"권한 요청 중 Shizuku 바인더를 사용할 수 없었습니다.","settings.permissions.androidShizukuMessages.request_cancelled":"Shizuku 권한 요청이 취소되었습니다.","settings.permissions.androidShizukuMessages.granted":"Shizuku 권한이 부여되었습니다.","settings.permissions.androidShizukuMessages.denied":"Shizuku 권한이 거부되었습니다.","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Shizuku 권한이 차단되었습니다. Shizuku를 열어 OpenLess를 수동으로 허용하세요.","settings.permissions.androidShizukuMessages.launched":"Shizuku 승인 화면을 열었습니다.","settings.permissions.androidShizukuMessages.launch_failed":"Shizuku 승인 화면을 열 수 없습니다.","settings.permissions.androidShizukuMessages.open_shizuku":"Shizuku 관리자를 열었습니다.","settings.permissions.androidShizukuMessages.jni_error":"Android Shizuku 백엔드에 연결할 수 없습니다.","settings.permissions.androidShizukuMessages.status_parse_failed":"Shizuku 상태를 해석할 수 없습니다.","settings.permissions.androidShizukuMessages.user_not_confirmed":"복구하려면 사용자 확인이 필요합니다.","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku가 승인되지 않았거나 사용할 수 없습니다.","settings.permissions.androidShizukuMessages.invalid_component":"잘못된 접근성 서비스 구성 요소 ID입니다.","settings.permissions.androidShizukuMessages.service_connect_failed":"Shizuku 특권 서비스에 연결할 수 없습니다.","settings.permissions.androidShizukuMessages.recovery_in_progress":"다른 복구 작업이 진행 중입니다. 잠시 후 다시 시도하세요.","settings.permissions.androidShizukuMessages.parse_failed":"복구 결과를 해석할 수 없습니다.","settings.permissions.androidShizukuMessages.service_not_bound":"설정은 기록되었지만 접근성이 아직 실행되지 않습니다.","settings.permissions.androidShizukuMessages.success":"접근성 서비스를 복구했습니다.","settings.permissions.androidShizukuMessages.read_failed":"접근성 설정을 읽을 수 없습니다.","settings.permissions.androidShizukuMessages.read_enabled_failed":"접근성 사용 플래그를 읽을 수 없습니다.","settings.permissions.androidShizukuMessages.merge_failed":"접근성 서비스 목록을 병합할 수 없습니다.","settings.permissions.androidShizukuMessages.write_services_failed":"활성화된 접근성 서비스 목록을 기록할 수 없습니다.","settings.permissions.androidShizukuMessages.write_enabled_failed":"접근성을 활성화할 수 없습니다.","settings.permissions.androidShizukuMessages.readback_failed":"기록 후 접근성 설정을 검증할 수 없습니다.","settings.permissions.androidShizukuMessages.oem_rollback":"OEM이 접근성 기록을 되돌렸습니다.","settings.permissions.androidShizukuMessages.concurrent_change":"복구 중 접근성 설정이 변경되었습니다.","settings.permissions.androidShizukuMessages.partial_rollback":"복구에 실패했으며 설정은 일부만 되돌릴 수 있었습니다. 시스템 접근성 설정을 확인하세요.","settings.permissions.androidShizukuMessages.manual_required":"전역 스위치가 꺼져 있고 다른 등록된 서비스가 있으면 자동 복구를 안전하게 수행할 수 없습니다. 시스템 설정에서 수동으로 진행하세요.","settings.permissions.androidShizukuMessages.max_retries":"여러 번 시도한 뒤에도 복구에 실패했습니다.","settings.permissions.androidShizukuMessages.internal_error":"내부 오류로 복구에 실패했습니다.","settings.permissions.androidShizukuMessages.unknown":"알 수 없는 Shizuku 상태입니다.","settings.permissions.androidInsertStrategyLabel":"텍스트 삽입 방식","settings.permissions.androidOverlayTriggerLabel":"오버레이 표시","settings.permissions.androidOverlayActivationModeLabel":"오버레이 활성화","settings.permissions.androidOverlayLeftSwipeActionLabel":"왼쪽 스와이프 동작","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"취소 스와이프 방향","settings.permissions.androidOverlaySizeLabel":"오버레이 크기","settings.permissions.androidOverlaySizeHint":"플로팅 버튼 지름을 조정하고 현재 위치를 유지합니다.","settings.permissions.androidInsertStrategy.accessibility":"입력칸에 자동 출력","settings.permissions.androidInsertStrategy.clipboard":"클립보드만","settings.permissions.androidInsertStrategyHint.accessibility":"접근성 서비스가 필요합니다. 사용할 수 없으면 클립보드에 복사합니다.","settings.permissions.androidInsertStrategyHint.clipboard":"접근성 권한이 필요 없으며 직접 붙여넣습니다.","settings.permissions.androidOverlayTrigger.background":"백그라운드","settings.permissions.androidOverlayTrigger.keyboard":"키보드 표시 시","settings.permissions.androidOverlayTrigger.always":"항상","settings.permissions.androidOverlayTriggerHint.background":"단순","settings.permissions.androidOverlayTriggerHint.keyboard":"이 모드는 보류되었습니다. 기존 설정은 백그라운드로 되돌립니다.","settings.permissions.androidOverlayTriggerHint.always":"항상 표시","settings.permissions.androidOverlayTriggerDisabled.keyboard":"키보드 표시 감지는 보류되었습니다. 이후 오버레이 제스처로 대체합니다.","settings.permissions.androidOverlayActivationMode.tap":"탭으로 활성화","settings.permissions.androidOverlayActivationMode.long_press":"길게 눌러 활성화","settings.permissions.androidOverlayActivationModeHint.tap":"첫 탭은 대기 상태로 전환하고, 두 번째 탭은 일반 받아쓰기를 시작합니다.","settings.permissions.androidOverlayActivationModeHint.long_press":"누르고 있는 동안 대기 상태가 되며, 손을 떼면 현재 녹음 또는 QA 턴을 종료합니다.","settings.permissions.androidOverlayLeftSwipeAction.translation":"번역 받아쓰기","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"스타일 팩 전환","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"대기 상태에서 왼쪽으로 밀면 번역 받아쓰기를 시작합니다.","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"대기 상태에서 왼쪽으로 밀면 이전 스타일 팩으로 전환합니다.","settings.permissions.androidOverlayCancelSwipeDirection.up":"위로 스와이프","settings.permissions.androidOverlayCancelSwipeDirection.down":"아래로 스와이프","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"녹음 중 위로 밀면 전사와 삽입 없이 취소합니다.","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"녹음 중 아래로 밀면 전사와 삽입 없이 취소합니다.","settings.permissions.windowsIme.installed":"설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.","settings.permissions.windowsIme.notInstalled":"설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.","settings.permissions.windowsIme.registrationBroken":"등록이 손상되었습니다. OpenLess 입력기를 재설치하세요.","settings.permissions.windowsIme.notWindows":"Windows 만 사용 가능.","settings.advanced.multimodalPipelineTitle":"멀티모달 인식 파이프라인 ","settings.advanced.multimodalPipelineTitleHint":"단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.","settings.advanced.multimodalPipelineLabel":"멀티모달 파이프라인 활성화","settings.advanced.multimodalPipelineHint":"활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.","settings.advanced.streamingInsertTitle":"스트리밍 입력","settings.advanced.streamingInsertTitleLinux":"스트리밍 입력 (실험적)","settings.advanced.streamingInsertDesc":"실시간 글자별 삽입으로 체감 지연 감소. 조건 불충족 시 일괄 붙여넣기로 전환.","settings.advanced.streamingInsertLabel":"스트리밍 입력","settings.advanced.streamingInsertHintMac":"스트리밍 중 입력 소스를 ABC 로 임시 전환 (CJK IME 가로채기 방지). 세션 종료 시 자동 복원.","settings.advanced.streamingInsertHintWindows":"SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.","settings.advanced.streamingInsertHintLinux":"fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.","settings.advanced.streamingInsertSaveClipboardLabel":"클립보드에 저장","settings.advanced.streamingInsertSaveClipboardHint":"삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.","settings.advanced.localAsrTitle":"로컬 ASR 모델 ","settings.advanced.localAsrDesc":"전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.","settings.advanced.localAsrWarningShort":"로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.","settings.advanced.qwen3Desc":"활성화하면 ASR 프로바이더가 인수됩니다.","settings.advanced.sherpaDesc":"활성화하면 ASR 프로바이더가 인수됩니다.","settings.advanced.foundryDesc":"활성화하면 ASR 프로바이더가 인수됩니다.","settings.advanced.notSupportedHere":"이 플랫폼에서는 미지원 (추론 모듈 미내장).","settings.advanced.enable":"활성화","settings.advanced.alreadyActive":"활성","settings.advanced.disableLocalLabel":"로컬 ASR 비활성화","settings.advanced.disableLocalDesc":"클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.","settings.advanced.disable":"비활성화","settings.advanced.platformNotSupported":"이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.","settings.advanced.confirmEnableLocalTitle":"로컬 ASR 을 활성화할까요?","settings.advanced.confirmEnableLocalBody":"활성화 후 전사는 클라우드보다 느리고 정확도가 낮을 수 있습니다.","settings.advanced.confirm":"활성화","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"인터페이스 언어","settings.language.desc":"UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.","settings.language.label":"언어","settings.language.labelDesc":"\"시스템 따라가기\"를 선택하면 OS 언어를 따릅니다.","settings.language.followSystem":"시스템 따라가기","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.","settings.layout.title":"레이아웃","settings.theme.title":"모양","settings.theme.label":"테마","settings.theme.activityHeatmapLabel":"개요 페이지에 연간 활동 표시","settings.theme.stackedRowLayoutLabel":"읽기 쉬운 레이아웃(넘침 방지 줄바꿈)","settings.theme.stackedRowLayoutDesc":"작은 화면이나 큰 글꼴에서 한 줄에 맞지 않는 버튼과 옵션은 다음 줄로 넘어가 가로 넘침과 글자 눌림을 방지합니다.","settings.theme.conservativeLayoutLabel":"보수적 레이아웃","settings.theme.conservativeLayoutDesc":"홈, 상단 바, 하단 바 외 설정·기능 페이지를 단일 열·전체 너비로 표시하여 가로 넘침을 최대한 방지합니다.","settings.theme.system":"시스템 따르기","settings.theme.light":"라이트","settings.theme.dark":"다크","settings.remoteInput.title":"원격 입력","settings.remoteInput.enableLabel":"원격 입력 활성화","settings.remoteInput.enableDesc":"휴대폰/태블릿 브라우저를 PC에 연결해 녹음하고, 음성을 PC 커서 위치에 실시간으로 입력합니다(HTTPS 필요, 첫 접속 시 인증서를 신뢰해야 함)","settings.remoteInput.portLabel":"수신 포트","settings.remoteInput.defaultModeLabel":"기본 녹음 방식","settings.remoteInput.modeToggle":"탭하여 전환","settings.remoteInput.modeHold":"눌러서 말하기","settings.remoteInput.urlLabel":"접속 URL","settings.remoteInput.pinLabel":"페어링 코드","settings.remoteInput.regeneratePin":"재생성","settings.remoteInput.portInUse":"포트 {{port}}이(가) 사용 중입니다. 변경하세요","settings.remoteInput.startError":"원격 입력 서비스 시작에 실패했습니다: {{reason}}","settings.remoteInput.securityHint":"같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.","settings.remoteInput.certHint":"처음 연결할 때 루트 인증서 지문을 확인한 후 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.","settings.remoteInput.certFingerprintLabel":"이 컴퓨터의 루트 CA SHA-256","settings.remoteInput.certFingerprintCopy":"전체 지문 복사","settings.remoteInput.certFingerprintCopied":"지문 복사됨","settings.remoteInput.certFingerprintUnavailable":"전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.","settings.remoteInput.certVerifyHint":"휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.","settings.remoteInput.certProfileHint":"프로파일에는 루트 인증서 한 개만 있어야 합니다. 추가 인증서, VPN 또는 기기 관리 설정이 있으면 설치하지 마세요.","settings.remoteInput.certTrustWarning":"최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.","settings.remoteInput.certSetupLink":"iPhone 인증서 링크 복사","settings.remoteInput.waitingStart":"서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.","settings.remoteInput.starting":"원격 입력 서비스를 시작하는 중입니다…","settings.remoteInput.urlsStale":"이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.","settings.about.tagline":"자연스럽게 말하고, 정확하게 작성하세요","settings.about.checkUpdate":"업데이트 확인","settings.about.checkUpdateBtn":"확인","settings.about.checkStableUpdateBtn":"정식판 확인","settings.about.checkBetaUpdateBtn":"Beta 확인","settings.about.checkingUpdate":"확인 중…","settings.about.upToDate":"현재 최신 버전입니다.","settings.about.updateError":"확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.","settings.about.retryBtn":"다시 시도","settings.about.openReleases":"Releases 열기","settings.about.source":"소스","settings.about.docs":"문서","settings.about.feedback":"피드백","settings.about.qq":"커뮤니티 QQ 그룹","settings.about.qqDesc":"QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.","settings.about.copyQq":"그룹 번호 복사","settings.about.privacy":"프라이버시","settings.about.privacyDesc":"녹음은 전사를 위해 설정한 클라우드 공급자에게 전송될 수 있습니다.","settings.about.localFirst":"로컬 우선","settings.about.linksTitle":"문서 링크","settings.about.betaChannelLabel":"Beta 채널 참여","settings.about.betaChannelToggleLabel":"Beta 채널 사용","settings.about.betaChannelDesc":"켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.","settings.about.autoUpdateSectionTitle":"자동 업데이트","settings.about.autoUpdateCheckLabelAndroid":"자동 확인 및 다운로드","settings.about.autoUpdateCheckDescAndroid":"시작 시 및 60분마다 확인합니다. 업데이트가 있으면 자동 다운로드 후 시스템 설치 프로그램을 엽니다. 채널은 위 Beta 스위치를 따릅니다.","settings.about.betaChannelFetching":"최신 Beta 버전을 가져오는 중…","settings.about.betaChannelFetchBtn":"최신 Beta 확인","settings.about.betaChannelLatestPrefix":"최신 Beta:","settings.about.betaChannelDownloadBtn":"다운로드 페이지 열기","settings.about.betaChannelRefresh":"새로 고침","settings.about.betaChannelNoBeta":"아직 게시된 Beta 릴리스가 없습니다.","settings.about.betaChannelFetchError":"Beta 릴리스 정보를 가져오지 못했습니다. 잠시 후 다시 시도하세요.","settings.about.betaChannelUpToDate":"최신","settings.about.betaChannelUpdateNow":"지금 업데이트","settings.about.betaChannelUpdateNowTitle":"최신 Beta를 확인·다운로드하고 업데이트 대화상자를 표시합니다","settings.about.betaChannelChecking":"확인 중…","settings.about.updateDialog.available.title":"새 버전 발견","settings.about.updateDialog.available.desc":"OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?","settings.about.updateDialog.stableChannelSwitch.title":"정식 버전으로 전환","settings.about.updateDialog.stableChannelSwitch.desc":"현재 버전: OpenLess {{currentVersion}}\n대상 버전: OpenLess {{version}}\nBeta 채널에서 정식 버전으로 전환합니다. 계속하시겠습니까?","settings.about.updateDialog.downloading.title":"업데이트 다운로드 중","settings.about.updateDialog.downloading.desc":"OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.","settings.about.updateDialog.downloaded.title":"업데이트 준비 완료","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} 설치가 완료되었습니다. 지금 자동 재시작하여 적용하시겠습니까?","settings.about.updateDialog.installing.title":"업데이트 설치 중","settings.about.updateDialog.installing.desc":"OpenLess {{version}} 을(를) 설치 중입니다. 앱을 열어 두세요.","settings.about.updateDialog.install":"지금 업데이트","settings.about.updateDialog.androidInstall":"다운로드 후 설치 프로그램 열기","settings.about.updateDialog.androidInstalled.title":"시스템 설치 프로그램이 열렸습니다","settings.about.updateDialog.androidInstalled.desc":"안내에 따라 설치를 완료하세요. 설치 후 OpenLess를 다시 열면 {{version}}을 사용할 수 있습니다.","settings.about.updateDialog.downloadingLabel":"다운로드 중…","settings.about.updateDialog.installingLabel":"설치 중…","settings.about.updateDialog.later":"나중에 수동 재시작","settings.about.updateDialog.restartNow":"지금 재시작","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"다운로드됨 {{downloaded}}","settings.about.updateDialog.installError.title":"업데이트 실패","settings.about.updateDialog.installError.desc":"자동 업데이트를 완료하지 못했습니다: {{error}}. 다운로드 페이지에서 최신 버전을 직접 받아 설치할 수 있습니다.","settings.about.updateDialog.manualDownload":"수동 다운로드","startup.loading":"OpenLess 시작 중…","startup.loadingDesc":"로컬 서비스에 연결하고 호환성을 확인하고 있습니다.","startup.failed":"OpenLess를 시작할 수 없습니다","startup.recovery":"다시 확인하세요. 문제가 계속되면 앱을 완전히 종료한 후 다시 여세요. 업그레이드 후 발생한 경우 앱 전체가 동일한 버전인지 확인하세요.","startup.retry":"다시 확인","startup.details":"오류 세부 정보","modal.serviceViews.label":"서비스 설정","modal.serviceViews.llm":"언어 모델","modal.serviceViews.asr":"음성 인식","modal.serviceViews.omni":"멀티모달","modal.serviceViews.models":"로컬 모델","modal.serviceViews.connections":"연결 및 확장","modal.serviceViews.statusConfigured":"설정됨","modal.serviceViews.statusMissing":"미설정","modal.searchPlaceholder":"설정 카테고리 찾기…","modal.clearSearch":"검색 지우기","modal.categoriesLabel":"설정 카테고리","modal.searchResults":"검색 결과","modal.searchCount":"관련 카테고리 {{count}}개","modal.noResults":"일치하는 카테고리가 없습니다. “마이크”, “모델” 또는 “테마”를 검색해 보세요.","modal.autoSaveHint":"변경 사항이 자동 저장됩니다","modal.backToAdvanced":"실험 및 확장으로 돌아가기","modal.advancedPages.lessComputer":"Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.","modal.advancedPages.claudeConsole":"Claude Code를 감지하고 테스트 작업의 실행 출력을 확인합니다.","modal.advancedPages.multimodal":"실험적 멀티모달 인식 기능의 사용 여부를 설정합니다.","modal.advancedPages.debug":"디버그 녹음을 보관하고 커서 문맥을 확인하며 로그를 내보냅니다.","modal.descriptions.general":"마이크, 녹음 방식, 텍스트 입력을 설정하고 휴대폰 입력을 연결합니다.","modal.descriptions.shortcuts":"기능별 단축키와 텍스트 선택 후 동작을 설정합니다.","modal.descriptions.services":"음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.","modal.descriptions.appearance":"테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.","modal.descriptions.privacy":"시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.","modal.descriptions.advanced":"필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.","modal.descriptions.about":"현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.","modal.searchKeywords.general":"마이크 녹음 입력 휴대폰 원격 LAN PIN 캡슐 음소거 시작 자동시작","modal.searchKeywords.shortcuts":"단축키 핫키 키 조합 선택 다듬기 음성 편집","modal.searchKeywords.services":"ASR LLM API 채널 모델 클라우드 로컬 네트워크 프록시 마켓","modal.searchKeywords.appearance":"테마 다크 라이트 언어 글꼴 글자 크기 배치 레이아웃 히트맵","modal.searchKeywords.privacy":"권한 마이크 접근성 기록 녹음 저장 개인정보 내보내기","modal.searchKeywords.advanced":"Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험","modal.searchKeywords.about":"버전 Beta 안정 업데이트 업그레이드","modal.sections.appearance":"모양 및 언어","modal.sections.shortcuts":"단축키 및 선택","modal.sections.general":"녹음 및 입력","modal.sections.services":"AI 서비스 및 모델","modal.sections.privacy":"권한 및 데이터","modal.sections.advanced":"실험 기능 및 확장","modal.sections.personalize":"개인 설정","modal.sections.about":"정보 및 업데이트","modal.sections.helpCenter":"도움말 센터","modal.sections.releaseNotes":"릴리스 노트","modal.personalize.font":"글꼴 크기","modal.personalize.fontDesc":"UI 글꼴 크기를 전체 스케일. 즉시 반영.","modal.personalize.fontSmall":"소","modal.personalize.fontMedium":"중","modal.personalize.fontLarge":"대","modal.personalize.blur":"서리유리 강도","modal.personalize.blurDesc":"창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).","modal.about.tagline":"자연스럽게 말하고, 정확하게 작성하세요","modal.about.checkUpdate":"업데이트 확인","modal.about.checkUpdateBtn":"확인","modal.about.docs":"문서","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"피드백 채널","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"소스","modal.about.qq":"커뮤니티 QQ 그룹","modal.about.qqDesc":"QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.","modal.about.copyQq":"그룹 번호 복사","modal.about.exportErrorLog":"오류 로그 내보내기","modal.about.exportErrorLogDesc":"현재 세션의 실행 로그를 로컬에 저장합니다. 문제 조사나 피드백 전송에 사용하세요.","modal.about.exportErrorLogBtn":"내보내기","modal.about.exporting":"내보내는 중…","modal.about.exportSuccess":"저장됨","modal.about.exportFailed":"내보내기 실패","modal.about.privacy":"프라이버시","modal.about.privacyDesc":"인식 결과는 로컬에 저장되며 설정한 클라우드 공급자가 전사를 위해 녹음을 수신할 수 있습니다.","modal.about.localFirst":"로컬 우선","windowChrome.restore":"이전 크기로 복원","windowChrome.minimize":"최소화","windowChrome.maximize":"최대화","windowChrome.close":"닫기","hotkey.triggers.rightOption":"오른쪽 Option","hotkey.triggers.leftOption":"왼쪽 Option","hotkey.triggers.rightControl":"오른쪽 Control","hotkey.triggers.leftControl":"왼쪽 Control","hotkey.triggers.rightCommand":"오른쪽 Command","hotkey.triggers.leftCommand":"왼쪽 Command","hotkey.triggers.leftShift":"왼쪽 Shift","hotkey.triggers.rightShift":"오른쪽 Shift","hotkey.triggers.fn":"Fn (지구본 키)","hotkey.triggers.rightAlt":"오른쪽 Alt","hotkey.triggers.mediaPlayPause":"⏯ 미디어 재생/일시정지","hotkey.triggers.custom":"사용자 지정 조합…","hotkey.fallback":"전역 단축키","hotkey.modeHoldSuffix":"(눌러서 말하기)","hotkey.modeToggleSuffix":"(시작 / 정지)","hotkey.modeAutoSuffix":"(자동 인식)","hotkey.usageHold":"{{trigger}} 를 누르고 말한 후 떼면 종료.","hotkey.usageToggle":"{{trigger}} 로 녹음 시작, 다시 누르면 종료.","hotkey.usageAuto":"{{trigger}} 를 짧게 누르면 시작 / 정지, 길게 누르면 말한 뒤 떼면 종료.","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows 저수준 키보드 후크","hotkey.adapter.fcitx5":"fcitx5 입력기 플러그인","hotkey.adapter.unavailable":"사용 불가","localAsr.kicker":"로컬 ASR","localAsr.title":"모델 설정","localAsr.desc":"기기 내 음성 인식 모델 관리.","localAsr.storageTitle":"모델 저장 위치","localAsr.storageBaseDir":"선택한 상위 폴더","localAsr.storageModelsRoot":"실제 모델 폴더","localAsr.storageDefault":"시스템 기본 폴더","localAsr.storageChoose":"폴더 변경","localAsr.storageReset":"기본값으로 복원","localAsr.storageReveal":"모델 폴더 열기","localAsr.storageDesc":"사용자 지정 저장소는 선택한 폴더 아래에 OpenLess/models 를 만들고 기존 모델을 이동합니다. 이동 전에 다운로드를 취소하고 로드된 모델을 해제합니다.","localAsr.storageChooseTitle":"로컬 모델 저장 상위 폴더 선택","localAsr.storageChangeConfirm":"기존 로컬 모델을 {{path}}/OpenLess/models 로 이동합니다. 먼저 다운로드를 취소하고 로드된 모델을 해제합니다. 계속할까요?","localAsr.storageResetConfirm":"기존 로컬 모델을 시스템 기본 폴더로 되돌립니다. 현재 폴더: {{path}}. 계속할까요?","localAsr.modelDir":"모델 폴더","localAsr.revealDir":"폴더 열기","localAsr.deleteConfirm":"{{name}} 로컬 모델 파일을 삭제할까요? 되돌릴 수 없습니다.","localAsr.appleSpeechTitle":"Apple 음성 인식(macOS)","localAsr.appleSpeechDesc":"macOS 기본 음성 인식을 사용해 로컬에서 음성을 텍스트로 변환합니다. 모델 다운로드, API 키, 네트워크가 모두 필요 없습니다. 클라우드 ASR이 불안정할 때 자격 증명이 필요 없는 로컬 폴백입니다. 처음 사용할 때 음성 인식 권한 요청이 표시됩니다.","localAsr.appleSpeechUse":"Apple 음성 사용","localAsr.qwenTitle":"Qwen3-ASR 모델 관리","localAsr.qwenExperimentalBadge":"실험적","localAsr.engineUnavailable":"현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.","localAsr.qwenUnavailableOnWindows":"Windows 에서는 아직 Qwen3-ASR 을 지원하지 않습니다. 위의 Foundry Local Whisper 를 사용해 주세요.","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"기기 내 음성 인식, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드 필요.","localAsr.foundryAvailable":"Windows 에서 사용 가능","localAsr.foundryUnavailable":"Windows 전용","localAsr.foundryRuntimeReady":"런타임 구성 요소 다운로드됨","localAsr.foundryRuntimeMissing":"런타임 구성 요소 미다운로드","localAsr.foundryRuntimeSourceLabel":"런타임 구성 요소 다운로드 소스","localAsr.foundryRuntimeSourceAuto":"자동(NuGet 우선)","localAsr.foundryRuntimeSourceNuget":"NuGet 공식 피드","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly 피드","localAsr.foundryRuntimeSourceDesc":"첫 사용 전 런타임 구성 요소 다운로드 필요.","localAsr.foundrySelectedModel":"선택한 모델","localAsr.foundryActiveModel":"현재 기본 alias","localAsr.foundryLoadedModel":"로드된 모델","localAsr.foundryNotLoaded":"로드되지 않음","localAsr.foundryError":"Foundry 상태","localAsr.foundrySetDefault":"기본값으로 설정 / Windows 로컬 ASR 활성화","localAsr.foundryEnabling":"활성화 중…","localAsr.foundryPrepare":"준비 / 다운로드 / 로드","localAsr.foundryPreparing":"준비 중…","localAsr.foundryReleasing":"해제 중…","localAsr.foundryRetryPrepare":"준비 계속 / 다시 시도","localAsr.foundryCancelPrepare":"준비 취소","localAsr.foundryCancelRequested":"취소 요청됨","localAsr.foundryCancelling":"취소 중…","localAsr.foundryCancelBestEffort":"취소 요청됨. 현재 단계 완료 후 중지. 나중에 재시도 가능.","localAsr.foundryPrepareRuntime":"런타임 구성 요소 준비","localAsr.foundryPrepareModel":"모델 다운로드","localAsr.foundryPrepareLoad":"모델 로드","localAsr.foundryPrepareModelSkipped":"모델이 이미 다운로드되어 다운로드 단계를 건너뜀","localAsr.foundryPrepareDone":"완료","localAsr.foundryPrepareWaiting":"대기 중","localAsr.foundryApproxSizeMb":"약 {{mb}} MB","localAsr.foundryLanguageLabel":"인식 언어","localAsr.foundryLanguageAuto":"자동","localAsr.foundryLanguageZh":"중국어 zh","localAsr.foundryLanguageEn":"영어 en","localAsr.foundryLanguageDesc":"중국어는 \"중문\", 혼합 사용은 \"자동\" 선택.","localAsr.foundryModelSmall":"Whisper Small(기본 / 균형)","localAsr.foundryModelSmallDesc":"품질과 리소스 사용량을 균형 있게 맞춘 기본 옵션.","localAsr.foundryModelMedium":"Whisper Medium(더 높은 품질)","localAsr.foundryModelMediumDesc":"더 높은 정확도. 더 큰 다운로드와 느린 추론을 감당할 수 있는 고성능 기기에 적합합니다.","localAsr.foundryModelLarge":"Whisper Large V3 Turbo(최고 품질)","localAsr.foundryModelLargeDesc":"고성능 기기와 품질 우선 사용에 맞는 대형 모델 옵션.","localAsr.foundryModelBase":"Whisper Base(더 빠름 / 낮은 리소스)","localAsr.foundryModelBaseDesc":"더 빠르고 리소스를 적게 사용해 가벼운 일상 받아쓰기에 적합합니다.","localAsr.foundryModelTiny":"Whisper Tiny(가장 빠름 / 스모크 테스트)","localAsr.foundryModelTinyDesc":"Foundry 경로가 작동하는지 확인하기 위한 가장 빠른 옵션.","localAsr.sherpaTitle":"Windows sherpa-onnx Local(실험적)","localAsr.sherpaDesc":"Windows는 sherpa-onnx로 기기 내 오프라인 일괄 인식을 수행하며 ASR API 키가 필요 없습니다.","localAsr.sherpaRuntimeReady":"모델 로드됨","localAsr.sherpaRuntimeMissing":"모델 로드되지 않음","localAsr.sherpaSetDefault":"기본값으로 설정 / sherpa-onnx 활성화","localAsr.sherpaPrepare":"로컬 파일 확인 / 로드","localAsr.sherpaPreparing":"로드 중…","localAsr.sherpaPrepareLocalFiles":"로컬 모델 파일 확인","localAsr.sherpaModelDir":"모델 디렉터리","localAsr.sherpaRevealDir":"모델 디렉터리 열기","localAsr.sherpaError":"sherpa-onnx 상태","localAsr.sherpaLanguageJa":"일본어 ja","localAsr.sherpaLanguageKo":"한국어 ko","localAsr.sherpaLanguageYue":"광둥어 yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small(기본 / 중국어 우선)","localAsr.sherpaModelSenseVoiceDesc":"중국어 및 중영 혼합 받아쓰기에 적합한 기본 실험 모델.","localAsr.sherpaModelParaformer":"Paraformer 중국어","localAsr.sherpaModelParaformerDesc":"중국어 중심 실험 모델.","localAsr.sherpaModelWhisper":"Whisper Small 다국어","localAsr.sherpaModelWhisperDesc":"Whisper 계열 동작에 맞춘 다국어 실험 폴백 모델.","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3 (다국어)","localAsr.sherpaModelWhisperLargeV3Desc":"오픈소스 다국어 모델 중 품질이 가장 좋은 Whisper 계열. 고품질이지만 용량이 큽니다.","localAsr.sherpaModelZipformer":"Zipformer 스트리밍(중·영)","localAsr.sherpaModelZipformerDesc":"말하는 동안 텍스트가 나오는 스트리밍 중·영어 모델로, 지연이 가장 낮아 실시간 받아쓰기에 적합합니다.","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.","localAsr.modelSelectTitle":"이 기기의 모델","localAsr.modelSelectDesc":"다운로드 상태를 확인하고 파일을 관리하거나 모델을 불러와 테스트하세요.","localAsr.modelSelectPlaceholder":"다운로드된 모델 선택…","localAsr.modelSelectEmpty":"다운로드된 모델이 없습니다. 「다운로드 및 관리」에서 받으세요.","localAsr.groupDownload":"다운로드 및 관리","localAsr.groupOther":"기타","localAsr.mirrorLabel":"다운로드 미러","localAsr.mirrorDesc":"공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.","localAsr.mirrorHuggingface":"HuggingFace 공식 (huggingface.co)","localAsr.mirrorHfMirror":"중국 미러 (hf-mirror.com)","localAsr.activeBadge":"사용 중","localAsr.downloadedBadge":"다운로드됨","localAsr.notDownloadedBadge":"다운로드되지 않음","localAsr.download":"다운로드","localAsr.resume":"계속 다운로드","localAsr.cancel":"취소","localAsr.delete":"삭제","localAsr.setActive":"기본으로 설정","localAsr.failed":"실패","localAsr.cancelled":"취소됨","localAsr.files":"파일","localAsr.sizeLoading":"크기 조회 중…","localAsr.sizeUnknown":"크기 알 수 없음","localAsr.performanceWarning":"로컬 ASR 은 오프라인 또는 개인정보 보호 시나리오에 적합. 첫 사용 시 모델 다운로드 필요.","localAsr.test":"로드하여 테스트","localAsr.testRunning":"테스트 중…","localAsr.testHeading":"내장 오디오 테스트","localAsr.testExpected":"원문","localAsr.testActual":"인식","localAsr.testStats":"오디오 길이 {{audio}}s · 로드 {{load}}s · 추론 {{transcribe}}s · 백엔드 {{backend}}","localAsr.testFailed":"테스트 실패","localAsr.engineStatusLabel":"메모리에 있는 엔진","localAsr.engineLoaded":"로드됨: {{model}}(약 1.2-3.4 GB 메모리 사용)","localAsr.engineUnloaded":"로드되지 않음(첫 받아쓰기 시 약 3-5 초 로드 필요)","localAsr.loadNow":"지금 로드","localAsr.releaseNow":"지금 해제","localAsr.keepLoadedLabel":"로드 유지 시간","localAsr.keepLoadedDesc":"로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.","localAsr.keepImmediate":"말하기 직후 해제","localAsr.keep1min":"마지막 사용 후 1분","localAsr.keep5min":"마지막 사용 후 5분(기본)","localAsr.keep30min":"마지막 사용 후 30분","localAsr.keepForever":"해제하지 않음(항상 유지)","localAsr.sidebarTitle":"다운로드 완료 및 진행 중","localAsr.activePill":"현재 사용 중","localAsr.setDefault":"기본값으로 설정","localAsr.downloading":"다운로드 중","localAsr.startDownload":"다운로드 시작","localAsr.downloadNewModel":"새 모델 다운로드","localAsr.activeModelLabel":"사용 중인 모델","localAsr.pickerNoModelDownloaded":"다운로드된 모델이 아직 없습니다. 로컬 모델 페이지에서 먼저 내려받으세요.","localAsr.partialDownloadsLabel":"완료되지 않은 다운로드","localAsr.partialDownloadsDesc":"중단된 다운로드의 임시 파일이 남아 있습니다. 설치된 모델에 영향 없이 정리할 수 있습니다.","localAsr.cleanupIncomplete":"미완료 다운로드 정리","localAsr.languagesLabel":"언어","localAsr.partialBytesLabel":"남은 파일","localAsr.downloadDialogTitle":"모델 다운로드","localAsr.downloadDialogAlreadyHave":"모델 파일이 다운로드되었습니다. 모델 페이지에서 불러와 테스트하거나 ASR 음성 전사에서 해당 제공업체를 선택하세요.","localAsr.downloadDialogDesc":"모델 크기와 설명을 확인하고 다운로드하세요. 완료되면 음성 인식에서 해당 로컬 서비스를 선택하세요.","localAsr.detailRepo":"저장소","localAsr.hfDownloads":"다운로드 수","localAsr.hfLikes":"좋아요","localAsr.hfDescription":"모델 소개","localAsr.hfNoDescription":"소개가 없습니다","localAsr.hfCardFailed":"모델 정보를 불러오지 못했습니다","localAsr.detailFiles":"개 파일","localAsr.detailDownloaded":"다운로드됨","localAsr.detailEmpty":"모델을 선택하여 세부 정보 보기","localAsr.foundryLanguage":"언어","localAsr.foundryRuntimeSource":"런타임 소스","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"유지","localAsr.downloadSettingsTitle":"다운로드 및 저장 설정","localAsr.downloadSettingsDesc":"미러 소스 · 모델 저장 위치 · 메모리 내 엔진","localAsr.libraryEmptyTitle":"아직 로컬 모델이 없습니다","localAsr.libraryEmptyDesc":"음성 인식 모델을 다운로드하면 이 기기에서 오디오를 처리할 수 있습니다. 기존 모델이 보이지 않으면 목록을 새로 불러오세요.","localAsr.catalogTitle":"모델 카탈로그","localAsr.catalogEmpty":"표시할 모델이 없습니다. 카탈로그를 새로 불러온 후 다시 시도하세요.","localAsr.reloadCatalog":"목록 새로고침","localAsr.engineLabel":"인식 엔진","localAsr.sizeLabel":"모델 크기","localAsr.allEngines":"전체","localAsr.backToCatalog":"카탈로그로 돌아가기","localAsr.detailsTitle":"모델 세부 정보","localAsr.testActivateHint":"불러오기 및 테스트를 실행하면 이 모델을 현재 사용 모델로 설정한 후 내장 오디오 테스트를 진행합니다.","localAsr.downloadProgressHint":"시작 후 모델 페이지에서 진행 상황을 확인하거나 다운로드를 취소할 수 있습니다.","localAsr.errorDetails":"오류 세부 정보"},"es":{"cloudSync.title":"Sincronización en la nube","cloudSync.description":"Usa tu cuenta de GitHub para sincronizar el diccionario, los estilos y las preferencias entre dispositivos.","cloudSync.signIn":"Iniciar sesión con GitHub","cloudSync.account":"Cuenta de sincronización","cloudSync.refresh":"Actualizar estado","cloudSync.loading":"Consultando el estado de la nube…","cloudSync.noBackup":"Todavía no hay una copia en la nube","cloudSync.available":"Hay una copia disponible en la nube","cloudSync.summary":"{{dictionary}} palabras · {{corrections}} correcciones · {{stylePacks}} estilos","cloudSync.updated":"Actualizado {{time}}","cloudSync.upload":"Crear copia en la nube","cloudSync.restore":"Restaurar desde la nube","cloudSync.delete":"Eliminar copia en la nube","cloudSync.working":"Sincronizando…","cloudSync.uploadSuccess":"Copia guardada en la nube","cloudSync.restoreSuccess":"Ajustes restaurados desde la nube","cloudSync.deleteSuccess":"Copia en la nube eliminada","cloudSync.failed":"La sincronización falló: {{error}}","cloudSync.conflict":"La copia en la nube ha cambiado. Actualiza su estado antes de elegir entre crear una copia o restaurarla.","cloudSync.unavailable":"El servicio oficial de sincronización no está disponible ahora. Inténtalo más tarde.","cloudSync.signInRequired":"Primero inicia sesión con GitHub.","cloudSync.restoreTitle":"¿Restaurar la copia de la nube?","cloudSync.restoreDescription":"Las entradas del diccionario, las correcciones, los estilos y las preferencias sincronizadas de la nube reemplazarán sus equivalentes locales. Las claves API, las rutas y los permisos permanecerán en este dispositivo.","cloudSync.deleteTitle":"¿Eliminar la copia de la nube?","cloudSync.deleteDescription":"Solo se eliminará la copia en la nube de esta cuenta de GitHub. Se conservarán los datos locales.","cloudSync.confirmRestore":"Restaurar y reemplazar","cloudSync.confirmDelete":"Eliminar copia","cloudSync.scope":"Sincroniza el diccionario, las correcciones, los iconos de estilos y las preferencias comunes. Las claves API, las credenciales y los ajustes del dispositivo permanecen aquí.","macDictationKey.Changed":"El atajo cambió mientras se guardaba. Inténtalo de nuevo.","macDictationKey.label":"Tecla de dictado de Mac","macDictationKey.description":"Sustituye el atajo de dictado actual por la tecla del micrófono. Al salir de OpenLess, la tecla se devuelve a macOS.","macDictationKey.Permission":"Permite OpenLess en «Privacidad y seguridad → Accesibilidad» de macOS y vuelve a intentarlo.","macDictationKey.Busy":"Termina el dictado en curso antes de cambiar el atajo.","macDictationKey.Unavailable":"No se pudo activar el atajo; la asignación guardada no cambió. Reinténtalo o elige otra tecla.","app.name":"OpenLess","app.tagline":"Habla con naturalidad, escribe con precisión","common.loading":"Cargando…","common.retry":"Reintentar","common.settingsLoadFailed":"No se pudieron cargar los ajustes","common.refresh":"Actualizar","common.clear":"Borrar","common.copy":"Copiar","common.delete":"Eliminar","common.later":"Más tarde","common.cancel":"Cancelar","common.close":"Cerrar","common.show":"Mostrar","common.hide":"Ocultar","common.saved":"Guardado","common.saving":"Guardando…","common.experimental":"Experimental","common.copied":"Copiado","common.operationFailed":"La operación falló","common.add":"Añadir","common.durationSeconds":"{{value}}s","common.durationMillis":"{{value}}ms","common.durationMinutes":"{{value}}min","capsule.thinking":"pensando","capsule.using":"actuando","capsule.cancelled":"Cancelado","capsule.error":"Se ha producido un error","capsule.inserted":"{{count}} insertados","capsule.translating":"Traduciendo","capsule.selectionPolish.polishing":"Mejorando el texto…","capsule.selectionPolish.replaced":"Reemplazado","capsule.selectionPolish.noSelection":"No hay texto seleccionado","capsule.selectionPolish.failed":"No se pudo mejorar el texto. Inténtalo de nuevo","selectionPolishPreview.title":"Vista previa del texto mejorado","selectionPolishPreview.subtitle":"Puedes editar el resultado. El texto seleccionado solo se reemplazará cuando confirmes.","selectionPolishPreview.cancel":"Cancelar","selectionPolishPreview.resultLabel":"Texto mejorado","selectionPolishPreview.sourcePrefix":"Original: ","selectionPolishPreview.applyError":"No se pudo aplicar: ","selectionPolishPreview.confirmReplace":"Confirmar y reemplazar","selectionVoiceIntent.title":"¿Qué quieres hacer?","selectionVoiceIntent.subtitle":"Hemos reconocido tu instrucción de voz. Elige cómo continuar.","selectionVoiceIntent.loading":"Cargando…","selectionVoiceIntent.sourcePrefix":"Selección: ","selectionVoiceIntent.errorPrefix":"No se pudo continuar: ","selectionVoiceIntent.question":"Hacer una pregunta","selectionVoiceIntent.edit":"Editar la selección","selectionVoiceIntent.cancel":"Cancelar","qa.title":"Preguntar","qa.headerHint":"Pregunta cuando quieras","qa.thinking":"Pensando…","qa.error":"Se ha producido un error. Inténtalo de nuevo.","qa.errorRetry":"Reintentar","qa.errorRetryHint":"Inténtalo de nuevo.","qa.pinTooltip":"Fijar (mantener abierto)","qa.unpinTooltip":"Desfijar","qa.closeTooltip":"Cerrar","qa.micLabel":"Preguntar por voz","qa.micStop":"Detener la grabación","qa.selectionPreview":"A partir del texto seleccionado:","qa.emptyTitle":"¿En qué puedo ayudarte?","qa.emptyDesc":"Selecciona un texto para preguntar sobre él o escribe tu pregunta abajo. Las respuestas aparecerán aquí y podrás seguir preguntando.","qa.recordingHint":"Grabando… pulsa {{recordHotkey}} de nuevo para enviar","qa.mobileRecordLabel":"botón de grabación","qa.mobileRecordStart":"Iniciar grabación","qa.mobileRecordStop":"Detener y enviar","qa.composerPlaceholder":"Escribe una pregunta. Pulsa Intro para enviar","qa.composerSend":"Enviar","qa.statusIdle":"Pulsa {{recordHotkey}} para preguntar","qa.statusRecording":"Grabando","qa.statusThinking":"Pensando","qa.statusError":"Error","qa.jumpToLatest":"Ir al último mensaje","qa.editApplyReplace":"Ver vista previa y confirmar inserción","qa.editApplyUnavailable":"No hay ningún resultado que aplicar","qa.editRevertPrevious":"Conservar la versión anterior","qa.editInstructionMode":"Instrucción de edición","lessComputer.title":"Less Computer","lessComputer.subtitle":"¿Qué quieres que haga tu ordenador?","lessComputer.you":"Tú","lessComputer.working":"Actuando…","lessComputer.tool":"Se ha usado {{name}}","lessComputer.compaction":"Contexto resumido","lessComputer.done":"Hecho","lessComputer.cost":"${{cost}}","lessComputer.error":"Ha fallado. Inténtalo de nuevo.","lessComputer.closeTooltip":"Cerrar","lessComputer.jumpToLatest":"Ir al último mensaje","lessComputer.inputPlaceholder":"Escribe una instrucción. Pulsa Intro para enviar","lessComputer.send":"Enviar","lessComputer.approvalTitle":"¿Ejecutar el comando bloqueado?","lessComputer.approvalRerunWarning":"Al aprobar, se vuelve a ejecutar sobre un espacio de trabajo ya modificado. Repetir operaciones que no sean idempotentes puede producir efectos adicionales.","lessComputer.approve":"Aprobar","lessComputer.deny":"Rechazar","lessComputer.approved":"Aprobado","lessComputer.denied":"Rechazado","nav.overview":"Resumen","nav.history":"Historial","nav.vocab":"Diccionario","nav.style":"Estilo","nav.marketplace":"Catálogo","nav.translation":"Traducción","nav.selectionAsk":"Preguntar","nav.corrections":"Correcciones","nav.polishMode":"Modo de redacción","nav.group.style":"Estilo","nav.group.tools":"Herramientas","nav.localAsr":"Modelos","nav.more":"Más","marketplace.kicker":"CATÁLOGO","marketplace.title":"Catálogo de paquetes de estilos","marketplace.desc":"Explora, instala y comparte paquetes de estilos de la comunidad.","marketplace.searchPlaceholder":"Buscar por nombre, descripción o etiquetas…","marketplace.sortPopular":"Populares","marketplace.sortNew":"Recientes","marketplace.uploadBtn":"Subir","marketplace.uploadDisabledHint":"Primero inicia sesión con GitHub en Ajustes → Catálogo","marketplace.refreshBtn":"Actualizar","marketplace.empty":"Todavía no hay paquetes de estilos","marketplace.emptyHint":"Prueba otra palabra clave o sube tu propio paquete","marketplace.loadFailed":"No se pudo cargar: {{err}}","marketplace.noDescription":"(sin descripción)","marketplace.installBtn":"Instalar","marketplace.installingBtn":"Instalando…","marketplace.downloadZipBtn":"Descargar ZIP","marketplace.downloadingZipBtn":"Descargando…","marketplace.downloadAria":"Descargar el ZIP de «{{name}}»","marketplace.likeBtn":"Me gusta","marketplace.installed":"«{{name}}» se ha instalado en este dispositivo","marketplace.downloaded":"Se ha descargado el ZIP de «{{name}}»","marketplace.uploaded":"Subido; pendiente de revisión","marketplace.uploadTitle":"Elige un paquete de estilos para subir","marketplace.uploadHint":"Se subirá como {{login}}. El contenido pasará a la cola de revisión en la nube.","marketplace.uploadNoLocal":"No hay paquetes locales que se puedan subir","marketplace.errors.detail":"No se pudieron cargar los detalles: {{err}}","marketplace.errors.install":"No se pudo instalar: {{err}}","marketplace.errors.download":"No se pudo descargar el ZIP: {{err}}","marketplace.errors.like":"No se pudo marcar «Me gusta»: {{err}}","marketplace.errors.upload":"No se pudo subir: {{err}}","marketplace.errors.loadLocal":"No se pudieron cargar los paquetes locales: {{err}}","marketplace.sortLiked":"Me gusta","marketplace.likedEmpty":"Todavía no has marcado ningún paquete con «Me gusta»","marketplace.likedEmptyHint":"Abre un paquete y pulsa la estrella. Los paquetes que te gusten aparecerán aquí","marketplace.derivativeBadge":"Basado en @{{login}}","marketplace.detail.withdrawBtn":"Retirar","marketplace.detail.withdrawConfirm":"¿Retirar «{{name}}» del catálogo? Se conservará tu copia local.","marketplace.detail.withdrawSuccess":"Retirado del catálogo","marketplace.detail.withdrawFailed":"No se pudo retirar: {{err}}","marketplace.myPacks.buttonLabel":"Mis paquetes","marketplace.myPacks.buttonTitle":"Ver las publicaciones de {{login}}","marketplace.myPacks.buttonTitleEmpty":"Primero configura tu identidad de autor en Ajustes → Catálogo","marketplace.myPacks.searchPlaceholder":"Buscar por nombre o etiquetas","marketplace.myPacks.notLoggedIn":"Primero configura tu identidad de autor en Ajustes → Catálogo","marketplace.myPacks.emptyTitle":"Todavía no has publicado paquetes de estilos","marketplace.myPacks.emptyHint":"Edita un paquete en la página Estilo y pulsa «Publicar en el catálogo», o sube un paquete local desde la esquina superior derecha.","marketplace.myPacks.noMatch":"No se encontraron paquetes de estilos","marketplace.myPacks.summary":"{{count}} publicados","marketplace.myPacks.summaryPending":"{{count}} publicados · {{pending}} pendientes de revisión","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"Actualizar","marketplace.myPacks.actions.withdraw":"Retirar","marketplace.myPacks.loadFailed":"No se pudieron cargar tus paquetes: {{err}}","marketplace.myPacks.loadingTitle":"Cargando…","marketplace.myPacks.loadingHint":"Obteniendo tus últimas publicaciones del catálogo.","marketplace.myPacks.loadErrorTitle":"No se pudo cargar","marketplace.myPacks.loadErrorRetry":"Reintentar","marketplace.upload.confirmBtn":"Confirmar subida","marketplace.upload.updateTitle":"Actualizar «{{name}}»","marketplace.upload.updateHint":"Elige la versión local más reciente y pulsa «Confirmar subida». Se selecciona de antemano el paquete del mismo nombre.","marketplace.upload.recommendedBadge":"Recomendado","marketplace.state.pending":"Pendiente","marketplace.state.approved":"Publicado","marketplace.state.rejected":"Rechazado","marketplace.state.withdrawn":"Retirado","marketplace.state.superseded":"Sustituido","marketplace.state.unknown":"Desconocido","marketplace.oauth.title":"Iniciar sesión con GitHub","marketplace.oauth.generating":"Generando código de dispositivo…","marketplace.oauth.browserHint":"Abre {{uri}} en el navegador e introduce este código:","marketplace.oauth.copyBtn":"Copiar","marketplace.oauth.copied":"Código de dispositivo copiado","marketplace.oauth.copyFailed":"No se pudo copiar: {{err}}","marketplace.oauth.openBrowserBtn":"Abrir navegador","marketplace.oauth.cancelBtn":"Cancelar","marketplace.oauth.waiting":"Esperando autorización en el navegador…","marketplace.oauth.successAs":"Sesión iniciada como @{{login}}","marketplace.oauth.retryBtn":"Reintentar","marketplace.oauth.closeBtn":"Cerrar","marketplace.oauth.loginBtn":"Iniciar sesión","marketplace.oauth.loginTooltip":"Iniciar sesión con GitHub","marketplace.oauth.reloginTooltip":"Pulsa para volver a iniciar sesión o cambiar de cuenta (actual: @{{login}})","marketplace.modal.loggedIn":"Identidad de la sesión actual; cámbiala en Ajustes → Grabación → Catálogo","marketplace.modal.notLoggedIn":"Sin sesión iniciada; configura tu nombre de autor en Ajustes → Grabación → Catálogo","marketplace.modal.notLoggedInLabel":"Sin sesión iniciada","shell.shortcutLabel":"Atajo de grabación","shell.shortcutHint":"Iniciar / Detener","shell.betaTag":"BETA","shell.betaNote":"Almacenamiento local y copia en la nube opcional","shell.navHint.overview":"Resumen: estadísticas de uso y estado de los servicios y permisos","shell.navHint.history":"Historial de dictado: busca, reproduce y copia transcripciones anteriores","shell.navHint.vocab":"Diccionario: palabras personalizadas para reconocer mejor los nombres propios","shell.navHint.style":"Estilos de redacción: administra estilos de salida e instrucciones personalizadas","shell.navHint.translation":"Traducción: mantén pulsada Mayús mientras hablas para insertar el texto en otro idioma","shell.navHint.selectionAsk":"Preguntar sobre una selección: selecciona texto y pregunta por voz","shell.navHint.settings":"Preferencias: atajos, proveedores, privacidad y actualizaciones","shell.footer.account":"Cuenta","shell.footer.feedback":"Comentarios","shell.footer.settings":"Ajustes","shell.footer.help":"Ayuda","shell.footer.version":"Versión {{version}}","shell.footer.helpPopover.tagline":"Entrada de voz centrada en tu dispositivo","shell.footer.helpPopover.releaseNotes":"Notas de la versión ↗","shell.footer.helpPopover.docs":"Centro de ayuda ↗","shell.providerPrompt.title":"Configurar servicios de voz","shell.providerPrompt.body":"Todavía no hay ningún servicio ASR ni LLM configurado. Añade las credenciales para usar la entrada de voz y la mejora del texto.","shell.providerPrompt.later":"Más tarde","shell.providerPrompt.openSettings":"Abrir ajustes","shell.hotkeyModePrompt.title":"Revisar el modo de grabación","shell.hotkeyModePrompt.body":"El modo predeterminado ahora es Alternar. Si antes cambiaste el modo de activación, compruébalo en los ajustes de Grabación.","shell.hotkeyModePrompt.later":"Recordármelo más tarde","shell.hotkeyModePrompt.openSettings":"Abrir Grabación","onboarding.welcome":"Te damos la bienvenida a OpenLess","onboarding.intro":"Habla y escribe desde tu dispositivo. Antes de empezar necesitamos dos permisos del sistema.","onboarding.accessibilityTitle":"Accesibilidad","onboarding.hotkeyTitle":"Atajo global","onboarding.accessibilityDesc":"Permite detectar el atajo global (predeterminado: {{trigger}}) e insertar transcripciones donde está el cursor.","onboarding.hotkeyDesc":"Permite comprobar que el detector de atajos globales esté disponible.","onboarding.micTitle":"Micrófono","onboarding.micDesc":"Permite capturar tu voz.","onboarding.actionNotApplicable":"No es necesario","onboarding.actionGranted":"Concedido","onboarding.actionOpenSystem":"Abrir Ajustes del Sistema","onboarding.actionRestart":"Restablecer Accesibilidad y reiniciar OpenLess","onboarding.actionGrant":"Conceder","onboarding.actionRequestMic":"Solicitar acceso","onboarding.micNoDeviceHint":"No se ha detectado ningún micrófono. Conecta y activa uno, y vuelve a intentarlo.","onboarding.accessibilityHint":"Después de conceder el permiso, debes **cerrar OpenLess por completo** y volver a abrirlo (requisito de TCC en macOS).","onboarding.footerHint":"Esta configuración inicial se cerrará cuando se concedan ambos permisos. Si no se cierra, sal de OpenLess desde la barra de menús y vuelve a abrirlo.","onboarding.continueToSettings":"Abrir solo los ajustes (sin voz ni atajos globales)","onboarding.androidContinue":"Continuar a la aplicación","onboarding.androidFooterHint":"El dictado requiere acceso al micrófono. Pulsa «Solicitar acceso» arriba o continúa y concédelo después desde Resumen.","onboarding.androidTitle":"Configurar OpenLess","onboarding.androidIntro":"Configura paso a paso los permisos y servicios del móvil.","onboarding.androidStepCounter":"Paso {{current}} de {{total}}","onboarding.androidBack":"Atrás","onboarding.androidNext":"Siguiente","onboarding.androidFinish":"Finalizar y entrar","onboarding.androidSteps.microphoneTitle":"Permiso de micrófono","onboarding.androidSteps.microphoneDesc":"Abre el diálogo de permisos de Android y permite que OpenLess grabe tu voz.","onboarding.androidSteps.accessibilityTitle":"Servicio de accesibilidad","onboarding.androidSteps.accessibilityDesc":"Inserta el resultado del reconocimiento en el campo activo y ayuda a detectar el contexto de entrada.","onboarding.androidSteps.overlayPermissionTitle":"Permiso de ventana flotante","onboarding.androidSteps.overlayPermissionDesc":"Permite que OpenLess muestre el control de grabación sobre otras aplicaciones.","onboarding.androidSteps.overlayConfigTitle":"Ajustes de la ventana flotante","onboarding.androidSteps.overlayConfigDesc":"Configura la visibilidad, la activación, los gestos de deslizamiento y el tamaño del botón.","onboarding.androidSteps.asrTitle":"Servicio ASR en la nube","onboarding.androidSteps.asrDesc":"Configura el proveedor de reconocimiento de voz, la clave, la dirección y el modelo.","onboarding.androidSteps.llmTitle":"Servicio LLM","onboarding.androidSteps.llmDesc":"Configura el modelo de lenguaje para mejorar texto, traducir y responder preguntas.","overview.refresh":"Actualizar estado","overview.servicesTitle":"Servicios de voz actuales","overview.statsTitle":"Tu actividad","overview.omniKind":"Voz multimodal","overview.omniName":"Modelo Omni actual","overview.statusLoading":"Leyendo la configuración de servicios…","overview.configureProvider":"Configurar","overview.manageProvider":"Administrar servicio","overview.recentEmptyHint":"Todavía no hay dictados. Prueba uno siguiendo la guía de arriba y el resultado aparecerá aquí.","overview.providerHelp.asr":"Convierte tu voz en texto.","overview.providerHelp.llm":"Organiza y mejora el texto con tu estilo.","overview.providerHelp.omni":"Un mismo modelo reconoce la voz y procesa el texto.","overview.actions.refresh":"Reintentar","overview.actions.services":"Servicios y modelos de IA","overview.actions.general":"Grabación y entrada","overview.actions.shortcuts":"Atajos","overview.actions.privacy":"Permisos y datos","overview.guide.nextStep":"Siguiente paso","overview.guide.loadingTitle":"Leyendo tu configuración","overview.guide.loadingDesc":"En breve aparecerán tus servicios actuales y el siguiente paso.","overview.guide.unavailableTitle":"El estado de los servicios no está disponible","overview.guide.unavailableDesc":"Vuelve a consultarlo o abre Servicios de IA para revisar la configuración.","overview.guide.servicesTitle":"Configura tus servicios de voz","overview.guide.servicesDesc":"Empieza eligiendo servicios de reconocimiento de voz y procesamiento de texto. En modo Omni, basta con configurar el modelo multimodal activo.","overview.guide.permissionsTitle":"Comprueba el estado de tus atajos","overview.guide.permissionsDesc":"El adaptador de atajos no está disponible. Abre Permisos y datos para consultar su estado y las opciones disponibles.","overview.guide.shortcutsTitle":"Elige un atajo de grabación","overview.guide.shortcutsDesc":"Elige un atajo cómodo para empezar a dictar mientras escribes.","overview.guide.recordingTitle":"Elige cómo grabar","overview.guide.recordingDesc":"La configuración del servicio está guardada. Abre los ajustes de grabación para elegir el micrófono y el modo de grabación.","overview.guide.tryDictationTitle":"Prueba el dictado","overview.guide.tryDictationDesc":"Coloca el cursor donde quieras escribir. {{shortcut}}","overview.guide.permissionsHint":"¿No responden la grabación o los atajos? Revisa los permisos, el acceso al micrófono y el estado de los atajos en Permisos y datos.","overview.kicker":"PANEL","overview.title":"Resumen de hoy","overview.desc":"Estadísticas de dictado de hoy y estado del sistema.","overview.pressPrefix":"Pulsa","overview.pressSuffix":"para empezar","overview.asrKind":"Reconocimiento de voz","overview.llmKind":"Procesamiento de texto","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"Compatible con OpenAI","overview.llmConfigured":"LLM activo configurado","overview.llmNotConfigured":"Sin configurar","overview.statusConfigured":"Configurado","overview.statusNotConfigured":"Sin configurar","overview.statusUnknown":"No disponible","overview.credentialsLoadError":"No se pudo consultar el estado de las credenciales","overview.metricChars":"Caracteres de hoy","overview.metricSegments":"{{count}} segmentos","overview.metricDuration":"Duración total de hoy","overview.metricAvg":"Media por segmento","overview.metricAvgTrend":"Media de hoy","overview.metricNoData":"Sin datos","overview.historyLoadError":"No se pudo cargar el historial","overview.metricTotal":"Registros totales","overview.metricTotalTrend":"Archivo local (máx. 200)","overview.activityTitle":"Actividad anual","overview.activityCount":"{{count}} dictados","overview.activityLoadError":"No se pudieron cargar los datos de actividad","overview.period.ariaLabel":"Periodo del informe","overview.period.last7Days":"Últimos 7 días","overview.period.last30Days":"Últimos 30 días","overview.period.dailyAverage":"{{value}} / día","overview.period.minutes":"{{value}} min","overview.period.hoursMinutes":"{{hours}} h {{minutes}} min","overview.metricName.ariaLabel":"Métrica","overview.metricName.count":"Cantidad","overview.metricName.chars":"Caracteres","overview.metricName.duration":"Duración","overview.recentTitle":"Transcripciones recientes","overview.recentAll":"Ver todo →","overview.recentEmpty":"Todavía no hay registros. Pulsa {{trigger}} para empezar tu primera grabación.","overview.recentLoadFailed":"No se pudieron cargar las transcripciones recientes. Inténtalo de nuevo.","overview.historyRetry":"Reintentar","overview.weekDays.0":"Dom","overview.weekDays.1":"Lun","overview.weekDays.2":"Mar","overview.weekDays.3":"Mié","overview.weekDays.4":"Jue","overview.weekDays.5":"Vie","overview.weekDays.6":"Sáb","overview.inAppDictation.title":"Dictado en la aplicación","overview.inAppDictation.start":"Iniciar grabación","overview.inAppDictation.stop":"Detener grabación","overview.inAppDictation.idle":"Pulsa para empezar a grabar","overview.inAppDictation.recording":"Grabando…","overview.inAppDictation.processing":"Procesando…","overview.androidMicBanner.title":"Se necesita permiso de micrófono","overview.androidMicBanner.desc":"Concede acceso al micrófono para usar el dictado y la entrada de voz en la aplicación.","overview.androidMicBanner.grant":"Solicitar acceso","overview.androidMicBanner.openSettings":"Abrir ajustes","history.exportError":"No se pudo exportar la grabación. Inténtalo de nuevo.","history.kicker":"HISTORIAL","history.title":"Historial","history.desc":"Transcripciones guardadas en este dispositivo.","history.filterAll":"Todo","history.summary":"{{total}} en total · {{shown}} visibles","history.searchPlaceholder":"Buscar transcripciones… ({{shortcut}})","history.searchNoMatch":"No hay registros que coincidan con «{{query}}».","history.empty":"Todavía no hay historial. Pulsa {{trigger}} para grabar.","history.loadFailed":"No se pudo cargar el historial: {{err}}","history.retry":"Reintentar","history.clearFailed":"No se pudo borrar el historial: {{err}}","history.deleteFailed":"No se pudo eliminar el registro: {{err}}","history.copyFailed":"No se pudo copiar: {{err}}","history.playRecording":"Reproducir grabación","history.audioLoading":"Cargando…","history.audioDecodeFailed":"No se pudo decodificar el audio: {{err}}","history.exportRecording":"Exportar grabación","history.exportFailed":"No se pudo exportar: {{err}}","history.retranscribe":"Volver a transcribir","history.retranscribing":"Transcribiendo…","history.retranscribeFailed":"No se pudo volver a transcribir: {{err}}","history.rawLabel":"Original","history.rawEmpty":"(vacío)","history.selectHint":"Selecciona un registro de la izquierda para ver sus detalles.","history.recorded":"Grabación: {{duration}}","history.stepAsr":"Transcripción","history.multimodalPipeline":"Multimodal","history.stepAsrHint":"Tiempo de espera de la transcripción tras soltar la tecla. El reconocimiento en tiempo real transcribe mientras hablas, por lo que suele ser mucho más corto que la grabación.","history.stepPolish":"Mejora del texto","history.stepInsert":"Inserción","history.chars":"{{count}} caracteres","history.vocabHits":"{{count}} coincidencias del diccionario","history.inserted":"Insertado","history.pasteSent":"Pegado enviado","history.copiedFallback":"Copiado (usa {{shortcut}})","history.insertFailed":"No se pudo insertar","history.confirmClear":"¿Eliminar los {{count}} registros del historial? Esta acción no se puede deshacer.","history.backToList":"Volver a la lista","history.repolish.title":"Volver a mejorar el texto","history.repolish.hint":"Vuelve a mejorar la transcripción de arriba. Los resultados solo se muestran durante esta visita y no modifican el registro. Si se eliminó el paquete original o el registro es anterior a los paquetes de estilos, se usará el estilo actual.","history.repolish.retry":"Reintentar con el mismo estilo","history.repolish.retrying":"Reintentando…","history.repolish.apply":"Aplicar","history.repolish.applying":"Mejorando el texto…","history.repolish.pickStyle":"Elegir un paquete de estilos","history.repolish.noPacks":"No hay paquetes de estilos disponibles.","history.repolish.packsLoadFailed":"No se pudieron cargar los paquetes de estilos: {{err}}","history.repolish.failed":"No se pudo volver a mejorar el texto: {{err}}","history.repolish.timeout":"El proveedor LLM actual no respondió en 30 segundos. Prueba un proveedor más rápido o inténtalo más tarde; los modelos gratuitos suelen tener cola de espera.","history.repolish.resultTitle":"Resultado de {{name}}","history.repolish.retryResultTitle":"Resultado del nuevo intento","history.repolish.empty":"(el modelo devolvió un resultado vacío)","history.repolish.clear":"Borrar resultados","vocabCard.title":"¿Recordar esta palabra?","vocabCard.accept":"Recordar","vocabCard.reject":"Omitir","insertFallbackCard.copy":"Copiar","insertFallbackCard.copied":"Copiado","insertFallbackCard.copyFailed":"No se pudo copiar","insertFallbackCard.dismiss":"Descartar","vocab.selectAllVisible":"Seleccionar resultados actuales","vocab.selectedCount":"{{count}} palabras seleccionadas","vocab.selectWord":"Seleccionar «{{phrase}}»","vocab.deleteSelected":"Eliminar seleccionadas ({{count}})","vocab.batchDeleteFailed":"No se pudieron eliminar {{count}} palabras. Siguen seleccionadas para que puedas reintentarlo.","vocab.kicker":"DICCIONARIO","vocab.title":"Diccionario","vocab.desc":"Añade términos o jerga para mejorar la precisión del reconocimiento.","vocab.sectionTitle":"Entradas","vocab.placeholder":"Escribe una palabra y pulsa Intro o Añadir…","vocab.tip":"Admite chino e inglés combinados · los prefijos numéricos se comparan literalmente · las coincidencias se cuentan automáticamente","vocab.loadFailed":"No se pudo cargar: {{err}}","vocab.empty":"Todavía no hay entradas. Añade arriba un término o expresión especializada para que el modelo les dé prioridad.","vocab.tipDisabled":"Pulsa para desactivar esta entrada","vocab.tipEnabled":"Pulsa para activar esta entrada","vocab.removeAria":"Eliminar","vocab.edit":"Editar","vocab.editTitle":"Editar palabra","vocab.editSave":"Guardar","vocab.editEmpty":"La palabra no puede estar vacía.","vocab.filter.all":"Todas","vocab.filter.auto":"Añadidas automáticamente","vocab.filter.manual":"Añadidas manualmente","vocab.searchPlaceholder":"Buscar","vocab.searchEmpty":"No se encontraron palabras.","vocab.newWord":"Nueva palabra","vocab.newWordTitle":"Añadir palabras","vocab.newWordDesc":"Escribe una palabra o importa varias usando plantillas predefinidas.","vocab.newWordInputPlaceholder":"Escribe una palabra y pulsa Intro para añadirla…","vocab.newWordTemplates":"Plantillas predefinidas","vocab.newWordTemplateCount":"{{count}} palabras","vocab.newWordAddSelected":"Añadir seleccionadas","vocab.learnedSection":"Recogidas automáticamente ({{count}})","vocab.removeAllLearned":"Eliminar todas","vocab.corrections.title":"Reglas de corrección","vocab.corrections.tip":"Corrige errores frecuentes del reconocimiento de voz. Admite el comodín numérico {num}.","vocab.corrections.patternPlaceholder":"Texto incorrecto, p. ej., {num} casas","vocab.corrections.replacementPlaceholder":"Texto deseado, p. ej., {num} casos","vocab.corrections.empty":"Todavía no hay reglas de corrección.","vocab.corrections.invalid":"Solo se admiten reemplazos literales o un comodín numérico {num}; por ejemplo, {num} casas → {num} casos.","vocab.corrections.tipDisabled":"Pulsa para desactivar esta regla","vocab.corrections.tipEnabled":"Pulsa para activar esta regla","vocab.corrections.removeAria":"Eliminar regla de corrección","vocab.corrections.learnedBadge":"automática","vocab.corrections.learnedTip":"Recogida automáticamente de tus propias correcciones. Puedes eliminarla cuando quieras.","vocab.corrections.onlyLearned":"Solo automáticas ({{count}})","vocab.corrections.removeAllLearned":"Eliminar todas las automáticas","vocab.corrections.suggestTitle":"¿Recordar esta corrección?","vocab.corrections.suggestAccept":"Recordar","vocab.corrections.suggestDismiss":"No, gracias","vocab.presets.title":"Preajustes por contexto","vocab.presets.tip":"Selecciona varios para aplicarlos a la vez. Puedes editarlos y crear otros nuevos.","vocab.presets.create":"Nuevo preajuste","vocab.presets.apply":"Aplicar seleccionados","vocab.presets.save":"Guardar preajuste","vocab.presets.edit":"Editar {{name}}","vocab.presets.newPreset":"Nuevo preajuste","vocab.presets.namePlaceholder":"Nombre del preajuste","vocab.presets.wordsPlaceholder":"Términos separados por comas o saltos de línea","style.kicker":"ESTILO","style.title":"Estilo de salida","style.desc":"Elige el estilo de salida predeterminado para las grabaciones.","style.masterToggle":"Interruptor general","style.currentDefault":"Predeterminado actual","style.ariaSetDefault":"Usar como predeterminado","style.saveFailed":"No se pudo guardar: {{error}}","style.customPromptTitle":"Instrucciones personalizadas","style.customPromptPlaceholder":"Opcional. Se añaden a las instrucciones del sistema incluidas en este estilo.","style.customPromptHint":"Déjalo vacío para mantener el comportamiento actual. Al guardar, se aplicará tanto al dictado como a la mejora posterior del texto. También puedes guardar con Ctrl/Cmd+Enter.","style.customPromptSave":"Guardar instrucciones","style.customPromptDirty":"Sin guardar","style.systemPromptMovedHint":"La edición de las instrucciones completas del sistema está ahora en Ajustes → Proveedores. Esta página solo controla los estilos activos y el predeterminado.","style.modes.raw.name":"Original","style.modes.raw.desc":"Solo añade puntuación y pausas naturales, sin reescribir ni ampliar.","style.modes.raw.sample":"Conserva el ritmo del habla y las frases originales; elimina muletillas como «eh» o «ya sabes».","style.modes.light.name":"Mejora ligera","style.modes.light.desc":"Elimina muletillas, añade puntuación y produce un texto natural listo para enviar.","style.modes.light.sample":"Da fluidez a la transcripción sin que suene artificial; conserva tu tono y tus expresiones.","style.modes.structured.name":"Estructurado","style.modes.structured.desc":"Organiza conversaciones de programación, diagnósticos y comentarios sobre productos con terminología precisa.","style.modes.structured.sample":"1. Primer tema\na. Punto\nb. Punto\n2. Segundo tema\na. Punto\nb. Punto","style.modes.formal.name":"Formal","style.modes.formal.desc":"Tono para correos y trabajo: más completo y profesional.","style.modes.formal.sample":"Detecta saludos y despedidas en correos y evita las fórmulas de cortesía vacías.","style.pack.builtinTags.minimalEdits":"Cambios mínimos","style.pack.builtinTags.strongCorrection":"Corrección precisa","style.pack.builtinTags.communication":"Comunicación","style.pack.builtinTags.natural":"Natural","style.pack.builtinTags.organized":"Organizado","style.pack.builtinTags.workplaceCommunication":"Comunicación laboral","style.pack.builtinTags.aiCoding":"Programación con IA","style.pack.builtinTags.technicalStructure":"Estructura técnica","style.pack.newName":"Estilo sin título","style.pack.newDescription":"Describe brevemente cuándo usar este estilo.","style.pack.uploadIcon":"Subir un icono SVG para {{name}}","style.pack.resetIcon":"Restaurar icono predeterminado","style.pack.iconSaved":"Icono guardado","style.pack.iconInvalid":"Elige un icono SVG válido sin recursos externos (hasta 256 KB).","style.pack.iconSaveFailed":"No se pudo guardar el icono. Inténtalo de nuevo.","style.pack.selectionListTitle":"Estilos para el texto seleccionado","style.pack.selectionListDesc":"Mejora la gramática, la claridad y el formato del texto escrito seleccionado, sin reconocimiento de voz. Elige por separado su estilo e instrucciones.","style.pack.dictationTab":"Estilos de grabación / ASR","style.pack.selectionTab":"Mejorar selección","style.pack.current":"Actual","style.pack.useForSelection":"Usar para la selección","style.pack.writtenPolish":"Mejora de texto escrito","style.pack.selectionPromptTitle":"Instrucciones para la selección (sin ASR)","style.pack.selectionPromptHint":"Para texto escrito seleccionado por el usuario, no para transcripciones. No lo trates como dictado ni respondas a sus preguntas.","style.pack.selectionPromptEditorDesc":"Edita las instrucciones para mejorar texto escrito seleccionado expresamente por el usuario, sin ASR.","style.pack.dictationPromptEditorDesc":"Edita las instrucciones del estilo de grabación / ASR; la entrada es el texto transcrito tras el dictado.","style.pack.dictationPromptTitle":"Instrucciones para grabación / ASR","style.pack.dictationPromptHint":"Para texto reconocido tras el dictado. Define aquí reglas para limpiar el lenguaje oral, corregir errores del ASR y restaurar términos.","style.pack.selectionPromptFallback":"Todavía no hay instrucciones para texto escrito; se usará una configuración predeterminada segura.","style.pack.selectionActivated":"«{{name}}» se usará para mejorar la selección.","style.pack.selectionActivateFailed":"No se pudo cambiar el estilo de la selección: {{err}}","style.pack.selectionChars":"{{count}} caracteres","style.pack.kicker":"PAQUETES DE ESTILOS","style.pack.title":"Paquetes de estilos","style.pack.desc":"Administra tus paquetes de estilos locales.","style.pack.marketplaceBtn":"Catálogo","style.pack.loadFailed":"No se pudieron cargar los paquetes de estilos: {{err}}","style.pack.importZip":"Importar ZIP","style.pack.exportZip":"Exportar ZIP","style.pack.exportShort":"Exportar","style.pack.publishMarketplace":"Publicar en el catálogo","style.pack.updateMarketplace":"Actualizar versión del catálogo","style.pack.publishDisabledHint":"Primero configura tu inicio de sesión de GitHub en Ajustes → Catálogo","style.pack.publishSuccess":"Publicado; pendiente de revisión en el catálogo","style.pack.publishFailed":"No se pudo publicar: {{err}}","style.pack.publishBuiltinRejected":"Los paquetes incluidos no se pueden publicar. Primero crea una copia desde el editor.","style.pack.builtin":"Incluido","style.pack.imported":"Importado","style.pack.active":"Activo","style.pack.activate":"Activar","style.pack.edit":"Editar","style.pack.closeEditor":"Cerrar","style.pack.unsaved":"Sin guardar","style.pack.listTitle":"Paquetes locales","style.pack.listDesc":"Explora y cambia de paquete.","style.pack.listCount":"{{count}} paquetes","style.pack.addPackTileTitle":"Nuevo paquete","style.pack.addPackTileHint":"Empieza con una plantilla en blanco.","style.pack.createSuccess":"Se ha creado el paquete.","style.pack.createFailed":"No se pudo crear el paquete: {{err}}","style.pack.save":"Guardar","style.pack.revert":"Revertir","style.pack.saveSuccess":"Paquete de estilos guardado.","style.pack.saveFailed":"No se pudo guardar el paquete: {{err}}","style.pack.activateSuccess":"«{{name}}» es ahora el paquete actual.","style.pack.activateFailed":"No se pudo cambiar el paquete actual: {{err}}","style.pack.importSuccess":"Se ha importado «{{name}}».","style.pack.importFailed":"No se pudo importar el ZIP: {{err}}","style.pack.exportSuccess":"Exportado a {{path}}","style.pack.exportFailed":"No se pudo exportar el ZIP: {{err}}","style.pack.exportDirtyFirst":"Guarda este paquete antes de exportarlo como ZIP.","style.pack.resetBuiltin":"Restablecer","style.pack.resetSuccess":"Se ha restablecido «{{name}}».","style.pack.resetFailed":"No se pudo restablecer el paquete: {{err}}","style.pack.deleteImported":"Eliminar","style.pack.deleteConfirm":"¿Eliminar «{{name}}»? Esta acción no se puede deshacer.","style.pack.deleteSuccess":"Se ha eliminado «{{name}}».","style.pack.deleteFailed":"No se pudo eliminar el paquete: {{err}}","style.pack.summaryCurrentEmpty":"Todavía no hay ningún paquete seleccionado","style.pack.editorTitle":"Editar paquete","style.pack.editorDesc":"Edita este paquete.","style.pack.metaTitle":"Información de instalación","style.pack.metaSource":"Origen","style.pack.metaBaseMode":"Modo base","style.pack.metaUpdatedAt":"Actualizado","style.pack.fieldName":"Nombre","style.pack.fieldAuthor":"Autor","style.pack.fieldAuthorPlaceholder":"Etiqueta de origen opcional","style.pack.fieldVersion":"Versión","style.pack.fieldTags":"Etiquetas","style.pack.fieldTagsPlaceholder":"Etiquetas separadas por comas, p. ej., comunidad, locución, formal","style.pack.fieldDescription":"Descripción","style.pack.fieldModel":"Modelo recomendado (metadatos)","style.pack.fieldModelPlaceholder":"Opcional, p. ej., gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"Solo metadatos. No cambia el modelo.","style.pack.fieldCompatibility":"Versión compatible de la aplicación","style.pack.fieldCompatibilityPlaceholder":"Opcional, p. ej., >=1.3.0","style.pack.fullPromptTitle":"Instrucciones del sistema","style.pack.fullPromptHint":"Las instrucciones propias de este paquete.","style.pack.promptChars":"{{count}} caracteres","style.pack.runtimeTitle":"Directivas de ejecución de OpenLess","style.pack.runtimeDesc":"Complementos de ejecución de solo lectura.","style.pack.runtimeContextTitle":"Información de contexto","style.pack.runtimeContextDesc":"Del idioma y el contexto de la aplicación","style.pack.runtimeContextEmpty":"No se añade en esta vista previa.","style.pack.runtimeHotwordTitle":"Bloque de palabras clave","style.pack.runtimeHotwordDesc":"De las palabras clave activadas","style.pack.runtimeHotwordEmpty":"No se añade en esta vista previa.","style.pack.runtimeHistoryTitle":"Reglas para el historial de conversación","style.pack.runtimeHistoryDesc":"Solo para la mejora del texto en varias intervenciones","style.pack.runtimeHistoryEmpty":"Solo se añade si hay intervenciones anteriores.","style.pack.runtimeActive":"Activo","style.pack.runtimeInactive":"Inactivo","style.pack.runtimePreviewFailed":"No se pudo generar la vista previa de ejecución: {{err}}","style.pack.runtimePreviewOmittedFrontApp":"La vista previa omite la etiqueta de la aplicación activa.","style.pack.examplesTitle":"Ejemplos de resultados","style.pack.examplesDesc":"Se exportan con el paquete.","style.pack.addExample":"Añadir ejemplo","style.pack.examplesEmpty":"Todavía no hay ejemplos.","style.pack.exampleTitlePlaceholder":"Título del ejemplo {{index}}","style.pack.exampleInput":"Entrada","style.pack.exampleOutput":"Salida","style.pack.examplesCount":"{{count}} ejemplos","style.pack.discardCloseConfirm":"¿Descartar los cambios sin guardar y cerrar el editor?","style.pack.discardSwitchConfirm":"¿Descartar los cambios sin guardar y cambiar a «{{name}}»?","style.pack.derivativeBadge":"Basado en @{{login}}","translation.searchLanguages":"Buscar idiomas…","translation.noMatchingLanguages":"No se encontraron idiomas","translation.selectedLanguages":"{{count}} idiomas seleccionados","translation.languageSupportHint":"Los idiomas de reconocimiento disponibles dependen del proveedor. Los idiomas de traducción son independientes del idioma de la aplicación.","translation.kicker":"TRADUCCIÓN","translation.title":"Traducción","translation.desc":"Traduce automáticamente las grabaciones a otro idioma antes de insertar el texto.","translation.statusEnabled":"Activada","translation.statusDisabled":"Desactivada","translation.working.title":"Idiomas habituales","translation.working.desc":"Selecciona los idiomas que usas a menudo para mejorar la redacción y la traducción.","translation.target.title":"Idioma de destino","translation.target.desc":"Pulsa Mayús mientras grabas para traducir. Con «Desactivada», Mayús no hace nada.","translation.target.disabled":"Desactivada (Mayús no hace nada)","translation.target.sameAsWorking":"El destino coincide con tu único idioma habitual, por lo que la traducción no tendrá efecto: Mayús solo mejorará el texto. Elige otro destino o añade otro idioma habitual arriba.","translation.style.title":"Estilo de traducción","translation.style.desc":"Hereda automáticamente el paquete activo de la página Estilo.","translation.style.unavailable":"No disponible","translation.save.workingFailed":"No se pudieron guardar los idiomas habituales. Inténtalo de nuevo.","translation.save.targetFailed":"No se pudo guardar el idioma de destino. Inténtalo de nuevo.","translation.save.hotkeyRegisterFailed":"No se pudo registrar el atajo de traducción. La preferencia no se ha guardado.","translation.save.hotkeySaveFailed":"No se pudo guardar el atajo de traducción. Inténtalo de nuevo.","translation.howto.title":"Cómo usarlo","translation.howto.step1":"Coloca el cursor en cualquier campo de texto.","translation.howto.step2":"Pulsa {{trigger}} para empezar a grabar.","translation.howto.step3":"Pulsa {{shortcut}} una vez durante la grabación para activar la traducción.","translation.howto.step4":"Vuelve a pulsar {{trigger}} para detenerla.","translation.howto.step5":"La traducción se insertará donde esté el cursor.","translation.howto.indicatorTitle":"Cómo saber si la traducción está activada","translation.howto.indicatorDesc":"Después de pulsar Mayús, aparece un indicador azul de «Traduciendo» en la parte inferior de la pantalla.","translation.howto.fallbackTitle":"Alternativa en caso de error","translation.howto.fallbackDesc":"Si la traducción falla, se inserta la transcripción original.","selectionAsk.title":"Preguntar sobre una selección","selectionAsk.desc":"Selecciona texto y pregunta por voz, con preguntas de seguimiento.","selectionAsk.shortcutSettings":"Ajustes de atajos","selectionAsk.guide.openTitle":"Abre el panel","selectionAsk.guide.openDesc":"Pulsa {{hotkey}} para empezar una conversación.","selectionAsk.guide.unsetDesc":"Primero asigna un atajo para preguntar sobre una selección en Ajustes de atajos.","selectionAsk.guide.selectTitle":"Selecciona un texto que quieras explorar","selectionAsk.guide.askTitle":"Di tu pregunta","selectionAsk.guide.askDesc":"Pulsa {{recordHotkey}} para grabar y vuelve a pulsarlo para enviar.","selectionAsk.guide.followup":"Usa de nuevo el atajo de grabación para hacer otra pregunta.","selectionAsk.guide.dismiss":"Cierra el panel y termina esta conversación","selectionAsk.hotkey.title":"Atajo para abrir el panel","selectionAsk.save.historySaveFailed":"No se pudo guardar la preferencia de historial de preguntas. Inténtalo de nuevo.","selectionAsk.history.title":"Guardar historial","selectionAsk.history.desc":"Guarda las conversaciones en este dispositivo. Desactivado de forma predeterminada.","selectionAsk.howto.title":"Cómo usarlo","selectionAsk.howto.step2":"Selecciona texto en cualquier aplicación.","settings.selectionWorkspace.title":"Asistente de selección","settings.selectionWorkspace.hint":"Selecciona texto y usa un solo atajo: mejora el texto si la edición por voz está desactivada; si está activada, mantén pulsado y habla, y luego elige Preguntar o Editar.","settings.selectionWorkspace.polishHotkey":"Atajo del asistente de selección","settings.selectionWorkspace.polishHotkeyDesc":"Mejora el texto directamente sin edición por voz; con ella activada, mantén pulsado para hablar. La grabación sigue los ajustes generales.","settings.selectionWorkspace.polishDelivery":"Tratamiento del resultado","settings.selectionWorkspace.voiceDeliveryDesc":"Tras editar por voz, reemplaza la selección directamente o revisa el resultado en el panel de preguntas antes de confirmar.","settings.selectionWorkspace.voiceEnable":"Edición por voz","settings.selectionWorkspace.voiceEnableDesc":"Usa el mismo atajo de arriba. La grabación sigue los ajustes generales (actual: {{recordingLabel}}).","settings.selectionWorkspace.autoIntent":"Detectar intención automáticamente","settings.selectionWorkspace.autoIntentDesc":"El modelo configurado distingue entre preguntas y ediciones. Si falla, se recurre a las palabras interrogativas.","settings.selectionWorkspace.editKeywords":"Indicadores adicionales de pregunta","settings.selectionWorkspace.editKeywordsDesc":"Solo si la detección automática está desactivada. Escribe un indicador por línea para forzar Preguntar; en otros casos se usan «?» y palabras interrogativas.","settings.selectionPolish.title":"Mejorar selección","settings.selectionPolish.hotkey":"Atajo de activación","settings.selectionPolish.hotkeyDesc":"El atajo entra en vigor de inmediato. Se rechazan los conflictos con la grabación, las preguntas y otros atajos globales.","settings.selectionPolish.delivery":"Tratamiento del resultado","settings.selectionPolish.hint":"Actívalo después de seleccionar texto. No requiere micrófono ni ASR y utiliza el paquete de estilos actual con sus instrucciones para selecciones.","settings.selectionPolish.directReplace":"Reemplazar directamente","settings.selectionPolish.directReplaceHint":"Reemplaza de forma segura la selección original cuando el modelo termina.","settings.selectionPolish.previewConfirm":"Revisar y confirmar","settings.selectionPolish.previewConfirmHint":"Revisa el resultado en una ventana editable y confirma para reemplazar la selección original.","settings.kicker":"AJUSTES","settings.title":"Ajustes","settings.desc":"Grabación, proveedores, atajos y permisos.","settings.network.title":"Red","settings.network.useSystemProxyLabel":"Usar proxy del sistema","settings.network.useSystemProxyDesc":"Al activarlo, las solicitudes usan el proxy del sistema. Al desactivarlo, se conectan directamente, lo que suele reducir la latencia con servicios locales, pero puede impedir el acceso a GitHub y las actualizaciones en algunas regiones. No afecta a los flujos de voz en tiempo real ni a Less Computer.","settings.dataStorage.title":"Almacenamiento de datos","settings.dataStorage.desc":"Historial de conversaciones y contexto guardados en este dispositivo.","settings.dataStorage.cursorContextLabel":"Contexto del cursor (experimental)","settings.dataStorage.cursorContextDesc":"Al mejorar el texto, lee lo que rodea al cursor en el documento para distinguir homófonos, nombres propios y pronombres. Si se activa, ese texto se envía al proveedor LLM configurado junto con la solicitud. Si se desactiva, no se lee nada. Nunca se leen campos de contraseña, Entrada Segura, gestores de contraseñas ni terminales. Solo macOS.","settings.codingConsole.title":"Consola de Claude","settings.codingConsole.desc":"Detecta Claude Code y MCP para controlar el ordenador. Ejecuta Claude sin interfaz, con límites de seguridad, y consulta la salida progresiva y el coste.","settings.codingConsole.guardNote":"Las acciones reversibles se permiten de forma predeterminada. Se bloquean comandos de alto riesgo como rm -rf, sudo y force push. Si el directorio es un repositorio Git, se crea una instantánea antes de cada ejecución para poder revertirla.","settings.codingConsole.status":"Estado","settings.codingConsole.detect":"Detectar","settings.codingConsole.detecting":"Detectando…","settings.codingConsole.installed":"Claude detectado","settings.codingConsole.notInstalled":"No se encontró claude","settings.codingConsole.notInstalledHint":"Instala primero Claude Code (consulta docs.anthropic.com/claude-code) o introduce abajo la ruta completa de su ejecutable.","settings.codingConsole.mcpServers":"{{count}} servidores MCP configurados","settings.codingConsole.computerUsePresent":"MCP de control del escritorio configurado","settings.codingConsole.computerUseAbsent":"Sin MCP de control del escritorio; no es necesario para acciones sencillas como copiar y pegar mediante Bash","settings.codingConsole.exePath":"Ejecutable","settings.codingConsole.workdir":"Directorio de trabajo","settings.codingConsole.workdirDesc":"Opcional. Claude se ejecutará en este directorio. Si es un repositorio Git, se creará una instantánea antes de ejecutarse para poder revertir los cambios.","settings.codingConsole.workdirPlaceholder":"Vacío = ejecutar en un directorio temporal","settings.codingConsole.permissionMode":"Modo de permisos","settings.codingConsole.mode.acceptEdits":"Permitir acciones reversibles","settings.codingConsole.mode.plan":"Solo lectura / plan","settings.codingConsole.mode.default":"Predeterminado (preguntar siempre)","settings.codingConsole.mode.bypassPermissions":"Omitir todos los permisos (riesgoso)","settings.codingConsole.promptPlaceholder":"Pide algo a Claude, por ejemplo, listar los archivos del directorio actual","settings.codingConsole.run":"Ejecutar","settings.codingConsole.running":"Ejecutando…","settings.codingConsole.cancel":"Cancelar","settings.codingConsole.clear":"Borrar","settings.codingConsole.riskWarn":"Intención de alto riesgo detectada: {{reason}}. La protección bloquea los comandos de alto riesgo al ejecutarlos.","settings.codingConsole.toolUse":"herramienta {{name}}","settings.codingConsole.done":"Hecho","settings.codingConsole.doneCost":"Hecho · coste ${{cost}}","settings.codingConsole.cancelled":"Cancelado","settings.codingConsole.outputPlaceholder":"La salida aparecerá aquí progresivamente…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"Mantén pulsada una tecla y habla para que el agente elegido actúe en tu ordenador. Solo macOS.","settings.codingAgent.enable":"Activar Less Computer","settings.codingAgent.comingSoonNote":"La configuración se guarda ahora; la activación por atajo y el flujo de ejecución llegarán en una versión posterior.","settings.codingAgent.hotkeyHint":"Mantén pulsado el atajo para hablar. Al soltarlo, el agente elegido mostrará el resultado en la cápsula.","settings.codingAgent.voiceHotkey":"Tecla para mantener pulsada y hablar","settings.codingAgent.voiceHotkeyDesc":"Mantén pulsado para hablar y suelta para ejecutar. Admite Ctrl, Option o Fn por separado. Consulta sus funciones en los ajustes avanzados.","settings.codingAgent.provider":"Motor del agente","settings.codingAgent.opencodeReady":"OpenCode v{{version}} detectado.","settings.codingAgent.opencodeMissing":"No se encontró el comando opencode. Instálalo con npm i -g opencode-ai e inicia sesión con opencode auth login antes de usarlo.","settings.codingAgent.cliReady":"Se ha detectado {{name}} v{{version}}.","settings.codingAgent.cliMissing":"No se encontró el comando {{name}}. Instálalo e inicia sesión primero, o introduce su ruta absoluta en Ejecutable.","settings.codingAgent.sandboxGuardHint":"Este motor solo ofrece niveles generales de aislamiento, sin una lista de comandos de alto riesgo. Al alcanzar un límite, muestra el error en lugar de una tarjeta para aprobar el comando.","settings.codingAgent.codexModelHint":"Introduce un modelo de Codex (p. ej., gpt-5). Déjalo vacío para usar ~/.codex/config.toml.","settings.codingAgent.codexBudgetHint":"Codex no permite fijar un presupuesto en USD por ejecución; el coste depende de tu proveedor configurado.","settings.codingAgent.codexMode.plan":"Solo lectura / plan","settings.codingAgent.codexMode.workspaceWrite":"Permitir escritura en el espacio de trabajo","settings.codingAgent.codexModelPlaceholder":"Vacío = predeterminado de Codex","settings.codingAgent.dshModelHint":"El perfil sin interfaz de dsh no permite cambiar el modelo. Se usa el definido en el propio perfil de dsh.","settings.codingAgent.panelHotkey":"Atajo del panel (agente por voz)","settings.codingAgent.panelHotkeyDesc":"Graba voz → ASR → Claude → salida progresiva en un panel. Predeterminado: Cmd/Ctrl+Shift+Enter.","settings.codingAgent.quickHotkey":"Atajo de acción rápida","settings.codingAgent.quickHotkeyDesc":"Envía el texto seleccionado a Claude y devuelve el resultado al cursor. Sin panel y más rápido.","settings.codingAgent.model":"Modelo","settings.codingAgent.modelPlaceholder":"Predeterminado: sonnet","settings.codingAgent.modelDefault":"Predeterminado (sonnet automático)","settings.codingAgent.modelHint":"Haiku = más rápido · Sonnet = equilibrado · Opus = más potente","settings.codingAgent.opencodeModelDefault":"Usar el modelo predeterminado de OpenCode","settings.codingAgent.opencodeModelHint":"Obtiene automáticamente los proveedores y modelos disponibles para tu cuenta de OpenCode y guarda la selección de inmediato.","settings.codingAgent.opencodeModelsRefresh":"Actualizar modelos","settings.codingAgent.opencodeModelsRefreshing":"Obteniendo modelos de OpenCode…","settings.codingAgent.opencodeModelsLoaded":"Se han obtenido {{count}} modelos.","settings.codingAgent.opencodeModelsEmpty":"No se recibieron modelos. Inicia sesión en OpenCode o configura primero un proveedor de modelos.","settings.codingAgent.opencodeModelsError":"No se pudieron obtener los modelos: {{message}}","settings.codingAgent.exe":"Ruta del ejecutable","settings.codingAgent.openPanel":"Prueba con texto","settings.codingAgent.openPanelHint":"Abre el panel de Less Computer para comprobar el agente y el modelo actuales usando texto.","settings.codingAgent.openPanelAction":"Abrir Less Computer","settings.debug.cursorLabel":"Cursor","settings.debug.title":"Herramientas de depuración","settings.debug.desc":"Para investigar problemas de reconocimiento. Desactivadas de forma predeterminada.","settings.debug.cursorProbeLabel":"Comprobar contexto del cursor","settings.debug.cursorProbeDesc":"Pulsa y, antes de que termine la cuenta atrás, cambia a la aplicación de destino y selecciona un campo de texto. Se leerá el texto que rodea al cursor para comprobar qué aplicaciones permiten la lectura y cuáles bloquean las protecciones. Una sola lectura, sin enviarla a ningún proveedor.","settings.debug.cursorProbeBtn":"Comprobar en 5 s","settings.debug.cursorProbeCountdown":"Se leerá en {{n}}s…","settings.marketplace.title":"Catálogo","settings.marketplace.desc":"Identidad de autor para subir paquetes al catálogo. Explora e instala estilos en la página Estilos.","settings.marketplace.github.signIn":"Iniciar sesión con GitHub","settings.marketplace.github.signedIn":"Sesión iniciada con GitHub","settings.marketplace.github.signedOut":"Inicia sesión para subir estilos y marcar paquetes con «Me gusta».","settings.marketplace.github.signOut":"Cerrar sesión","settings.marketplace.github.starting":"Iniciando sesión…","settings.marketplace.github.codeHint":"Introduce este código en la página de GitHub que se acaba de abrir:","settings.marketplace.github.openGithub":"Abrir GitHub","settings.marketplace.github.waiting":"GitHub abierto; la sesión se iniciará cuando autorices el acceso…","settings.marketplace.github.failed":"No se pudo iniciar sesión. Inténtalo de nuevo","settings.recording.title":"Grabación y entrada","settings.recording.desc":"Atajo global de grabación y modo de activación.","settings.recording.hotkeyLabel":"Atajo de grabación","settings.recording.hotkeyDescAcc":"Pulsa para grabar voz desde cualquier aplicación (requiere permiso de Accesibilidad).","settings.recording.hotkeyDescNoAcc":"Pulsa para grabar voz desde cualquier aplicación.","settings.recording.modeLabel":"Modo de activación","settings.recording.modeDesc":"Alternar: pulsa una vez para iniciar y otra para detener. Mantener para hablar: graba mientras mantienes pulsado.","settings.recording.modeToggle":"Alternar","settings.recording.modeHold":"Mantener para hablar","settings.recording.modeAuto":"Automático","settings.recording.silenceAutoStopLabel":"Detener tras un silencio","settings.recording.silenceAutoStopDesc":"Solo en modo Alternar. Después de detectar voz, detiene y envía la grabación cuando el silencio dura el tiempo elegido. Desactivado de forma predeterminada; puedes seguir pulsando el atajo o Esc.","settings.recording.silenceAutoStopSecondsLabel":"Duración del silencio","settings.recording.silenceAutoStopSecondsValue":"{{value}}s","settings.recording.migrationNoticeTitle":"El modo de grabación predeterminado ahora es Alternar","settings.recording.migrationNoticeDesc":"Esta actualización cambia el modo predeterminado. Si prefieres mantener pulsado para hablar, cámbialo aquí.","settings.recording.microphoneLabel":"Micrófono preferido","settings.recording.microphoneDesc":"Elige el dispositivo de entrada preferido. Si no está disponible, se usará el predeterminado del sistema.","settings.recording.microphoneDefault":"Micrófono predeterminado del sistema","settings.recording.microphoneDefaultDesc":"Usar el dispositivo de entrada predeterminado del sistema","settings.recording.microphoneSystemDefault":"predeterminado del sistema","settings.recording.microphoneUnavailable":"no disponible","settings.recording.microphoneLoadError":"No se pudieron cargar los micrófonos: {{message}}","settings.recording.microphoneDialogTitle":"Micrófono","settings.recording.microphoneDialogDesc":"Elige un micrófono que pueda captar tu voz.","settings.recording.microphoneMonitorError":"No se pudo supervisar el nivel de entrada: {{message}}","settings.recording.capsuleLabel":"Cápsula de grabación","settings.recording.capsuleDesc":"Muestra una cápsula en la parte inferior de la pantalla mientras grabas.","settings.recording.capsuleStyleTypeless":"Estilo compacto Typeless","settings.recording.capsuleStyleLabel":"Estilo de cápsula","settings.recording.capsuleStyleSiri":"Estilo luminoso Siri","settings.recording.capsuleStyleClassic":"Estilo predeterminado de OpenLess","settings.recording.muteDuringRecordingLabel":"Silenciar durante la grabación","settings.recording.muteDuringRecordingDesc":"Silencia temporalmente el sonido del sistema durante la entrada de voz para evitar el eco de los altavoces.","settings.recording.audioCueLabel":"Sonido al iniciar la grabación","settings.recording.audioCueDesc":"Reproduce un breve sonido sintetizado al pulsar el atajo para empezar a grabar, incluso si la cápsula está oculta.","settings.recording.audioCuePreview":"Escuchar","settings.recording.insertGroupTitle":"Inserción y portapapeles","settings.recording.restoreClipboardLabel":"Restaurar el portapapeles tras insertar","settings.recording.restoreClipboardDesc":"Restaura el contenido original del portapapeles después de pegar correctamente (solo Windows / Linux).","settings.recording.pasteShortcutLabel":"Atajo de pegado simulado","settings.recording.pasteShortcutDesc":"Combinación que se simula al insertar. Algunos terminales necesitan Ctrl+Shift+V (solo Windows / Linux).","settings.recording.pasteShortcutCtrlV":"Ctrl+V (predeterminado / mayoría de aplicaciones)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V (kitty / alacritty / wezterm / mayoría de terminales)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert (xterm / urxvt)","settings.recording.comboRecordLabel":"Grabar atajo","settings.recording.comboRecordDesc":"Pulsa aquí y después la combinación deseada (p. ej., ⌘⇧D). Admite los modos Alternar y Mantener para hablar.","settings.recording.comboRecordBtn":"Grabar atajo","settings.recording.comboResetBtn":"Restablecer","settings.recording.comboMenuToggle":"Más opciones","settings.recording.comboDisableHint":"No se puede desactivar el atajo principal: la grabación necesita un atajo","settings.recording.comboRecordHint":"Pulsa tu combinación de teclas…","settings.recording.comboNeedKey":"Usa una combinación de teclas (p. ej., ⌘⇧J); no basta con una tecla modificadora","settings.recording.comboRecorded":"Atajo registrado","settings.recording.comboClear":"Borrar","settings.recording.comboConflict":"Esta combinación de teclas no está disponible","settings.recording.allowNonTsfFallbackLabel":"Permitir alternativa sin TSF","settings.recording.allowNonTsfFallbackDesc":"Windows: si falla la inserción TSF, usa SendInput Unicode con pausas. Si también falla, copia el texto al portapapeles.","settings.recording.windowsInsertionModeLabel":"Método de inserción en Windows","settings.recording.windowsInsertionModeDesc":"Cómo se inserta el dictado donde está el cursor. El pegado usa el atajo simulado de arriba y conserva los saltos de línea.","settings.recording.windowsInsertionModeTsf":"IME TSF (predeterminado)","settings.recording.windowsInsertionModeSendInput":"Simulación de teclas con SendInput","settings.recording.windowsInsertionModePaste":"Pegado desde el portapapeles (Ctrl+V, etc.)","settings.recording.macosNewlineModeLabel":"Saltos de línea","settings.recording.macosNewlineModeDesc":"Automático usa Line Feed (U+000A / Ctrl+J) en terminales conocidos y Shift+Return en otras aplicaciones. Return por sí solo envía el mensaje.","settings.recording.macosNewlineModeAuto":"Automático (Line Feed en terminales)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return (salto de línea en chats)","settings.recording.macosNewlineModeLineFeed":"Line Feed (CLI del terminal / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return (dividir en mensajes)","settings.recording.windowsSendInputNewlineModeLabel":"Simulación de saltos de línea con SendInput","settings.recording.windowsSendInputNewlineModeDesc":"Cómo convierte SendInput los saltos de línea en teclas. Usa Shift+Enter en chats e Enter en Bloc de notas, VS Code y la mayoría de editores.","settings.recording.windowsSendInputNewlineModeEnter":"Enter (mayoría de editores)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter (campos de chat)","settings.recording.windowsSendInputNewlineModeCrLf":"Unicode CR+LF","settings.recording.windowsShowOpenlessInKeyboardListLabel":"Mostrar OpenLess en la lista de teclados","settings.recording.windowsShowOpenlessInKeyboardListDesc":"Al desactivarlo, Win+Space no pasará por OpenLess. No afecta a SendInput ni al pegado. Vuelve a activarlo para restaurar la entrada.","settings.recording.windowsShowOpenlessInKeyboardListError":"No se pudo actualizar la lista de teclados: el sistema rechazó el cambio del perfil de idioma de OpenLess.","settings.recording.historyGroupTitle":"Historial y contexto","settings.recording.historyRetentionLabel":"Conservación del historial (días)","settings.recording.historyRetentionDesc":"Al guardar nuevos registros, se eliminan los anteriores a este plazo. 0 = sin eliminación por antigüedad.","settings.recording.historyMaxEntriesLabel":"Máximo de registros","settings.recording.historyMaxEntriesDesc":"Máximo de sesiones guardadas en el dispositivo. Vacío = 200. Intervalo: 5–200.","settings.recording.polishContextWindowLabel":"Ventana de contexto para mejorar texto (minutos)","settings.recording.polishContextWindowDesc":"Usa las transcripciones mejoradas de los últimos N minutos como contexto de varias intervenciones. 0 = desactivado.","settings.recording.recordAudioForDebugLabel":"Conservar grabación original (depuración)","settings.recording.recordAudioForDebugDesc":"Guarda el audio original del micrófono en WAV para investigar problemas de reconocimiento.","settings.recording.audioRecordingMaxEntriesLabel":"Máximo de grabaciones originales","settings.recording.audioRecordingMaxEntriesDesc":"Máximo de archivos WAV guardados en el dispositivo. Vacío = 200.","settings.recording.startupGroupTitle":"Inicio","settings.recording.startMinimizedLabel":"Iniciar minimizado (sin ventana principal)","settings.recording.startMinimizedDesc":"Al iniciar, solo se muestra la barra de menús o la bandeja del sistema, nunca la ventana principal.","settings.recording.autoUpdateCheckLabel":"Buscar actualizaciones automáticamente","settings.recording.autoUpdateCheckDesc":"Busca actualizaciones al iniciar y cada 60 minutos.","settings.recording.marketplaceGroupTitle":"Catálogo de paquetes de estilos","settings.recording.marketplaceBaseUrlLabel":"URL del servidor","settings.recording.marketplaceBaseUrlDesc":"Dirección del servidor del catálogo. Vacío = predeterminada.","settings.recording.marketplaceDevLoginLabel":"Usuario de GitHub (identidad de autor)","settings.recording.marketplaceDevLoginDesc":"Identifica a quien sube los paquetes. Si está vacío, no podrás subir ni marcar «Me gusta».","settings.recording.startupAtBoot":"Abrir al iniciar sesión","settings.recording.startupAtBootDesc":"Inicia OpenLess automáticamente cuando inicies sesión.","settings.recording.startupAtBootError":"No se pudo cambiar el inicio automático: {{message}}","settings.channels.backToList":"Volver a los canales","settings.channels.done":"Hecho","settings.channels.llmTitle":"Canales de procesamiento de texto","settings.channels.asrTitle":"Canales de reconocimiento de voz","settings.channels.current":"En uso","settings.channels.enabled":"Activado","settings.channels.disabled":"Desactivado","settings.channels.enabledFor":"Activar {{name}}","settings.channels.modelNotSet":"No se ha indicado ningún modelo","settings.channels.localModelManaged":"Modelo administrado por el sistema o por Modelos locales","settings.channels.lastCheck":"Última comprobación","settings.channels.verifying":"Comprobando…","settings.channels.notVerified":"Sin comprobar","settings.channels.passed":"Comprobación correcta","settings.channels.failed":"Comprobación fallida · {{reason}}","settings.channels.elapsed":"Duración: {{ms}} ms","settings.channels.staleResult":"El resultado tiene más de 24 horas","settings.channels.connectionTitle":"Conexión al servicio","settings.channels.modelTitle":"Ajustes del modelo","settings.channels.modelHint":"Escribe el nombre de un modelo u obtén los modelos de tu proveedor y elige uno.","settings.channels.availableModels":"Modelos disponibles","settings.channels.validationTitle":"Comprobación de conexión","settings.channels.validationHint":"Envía manualmente una solicitud real para comprobar la configuración. Puede consumir saldo del servicio. Guardar los ajustes no ejecuta esta comprobación.","settings.channels.autoSaveHint":"Los cambios se guardan automáticamente. Después de configurar el servicio, puedes comprobar la conexión.","settings.channels.nameHint":"El nombre permite distinguir canales del mismo proveedor. No afecta al modelo ni a la conexión.","settings.channels.errModel":"Modelo","settings.channels.verify":"Verificar","settings.channels.verifyHint":"Hace una llamada real a la API para comprobar si este canal funciona ahora","settings.channels.errTimeout":"tiempo agotado","settings.channels.errNetwork":"red","settings.channels.errEndpoint":"dirección","settings.channels.errGeneric":"fallo","settings.channels.dragHint":"Arrastra para cambiar la prioridad","settings.channels.orderHint":"Se usa el primer canal activado. Arrastra para reordenar; los canales desactivados pasan al final.","settings.channels.empty":"Todavía no hay canales. Elige «Añadir canal» para conectar tu primer servicio.","settings.channels.add":"Añadir canal","settings.channels.edit":"Editar","settings.channels.createTitle":"Añadir canal","settings.channels.editTitle":"Editar canal","settings.channels.providerLabel":"Proveedor","settings.channels.nameLabel":"Nombre del canal (opcional)","settings.channels.namePlaceholder":"P. ej., SiliconFlow — clave principal","settings.channels.create":"Crear","settings.channels.delete":"Eliminar canal","settings.channels.deleteConfirm":"También se borrarán las claves guardadas para este canal.","settings.channels.confirmDelete":"Eliminar","settings.channels.justNow":"ahora mismo","settings.channels.minutesAgo":"hace {{count}}min","settings.channels.hoursAgo":"hace {{count}}h","settings.channels.daysAgo":"hace {{count}}d","settings.channels.localEngineModelHint":"Descarga y cambia modelos locales en Servicios y modelos de IA → Modelos locales.","settings.providers.localEngineNoCredentials":"Los motores locales no necesitan una clave API ni una dirección.","settings.providers.localModelLabel":"Modelo local","settings.providers.localModelEmpty":"Todavía no se ha descargado ningún modelo local","settings.providers.appleSpeechLocalNote":"Apple Speech usa el motor integrado del sistema; no es necesario elegir un modelo.","settings.providers.localEngineNote":"Puedes elegir los modelos descargados en la lista de arriba. Descarga y administra otros desde Modelos locales.","settings.providers.localTag":"Local","settings.providers.llmTitle":"LLM (mejora del texto)","settings.providers.llmDesc":"Protocolo compatible con OpenAI. Admite varios proveedores.","settings.providers.providerLabel":"Proveedor","settings.providers.llmProviderDesc":"Al elegir un preajuste, se completa la URL base predeterminada.","settings.providers.credentialStorageNotice":"Las credenciales se guardan en el almacén seguro del sistema operativo.","settings.providers.codexOAuthNotice":"Codex OAuth usa la sesión local de Codex (~/.codex/auth.json). OpenLess no guarda una clave API ni una URL base para este proveedor.","settings.providers.asrProviderDesc":"Al cambiar de proveedor, se cargan automáticamente sus credenciales.","settings.providers.asrTitle":"ASR (transcripción)","settings.providers.asrDesc":"Convierte las grabaciones de voz en texto.","settings.providers.omniTitle":"Modelo multimodal","settings.providers.omniDesc":"Un modelo convierte directamente el audio y las instrucciones en el texto final (flujo experimental).","settings.providers.pipelineModeLabel":"Modo de procesamiento","settings.providers.pipelineModeHint":"Tradicional: dos etapas, ASR + LLM. Multimodal: una sola pasada con un modelo que admite audio.","settings.providers.pipelineModeTraditional":"Tradicional","settings.providers.pipelineModeMultimodal":"Multimodal","settings.providers.pipelineIsolationNotice":"Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"TokenHub de Tencent Cloud","settings.providers.presets.customChatCompletions":"Personalizado · Chat Completions","settings.providers.presets.customResponses":"Personalizado · Responses","settings.providers.presets.customMessages":"Personalizado · Messages","settings.providers.presets.ark":"ARK (Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter (modelos gratuitos)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax (M3)","settings.providers.presets.stepfun":"StepFun","settings.providers.presets.custom":"Personalizado","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrTencentCloud":"ASR en tiempo real Hunyuan de Tencent Cloud","settings.providers.presets.asrBailian":"Alibaba Bailian ASR en tiempo real","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 ASR en tiempo real","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash (archivo grabado)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper (compatible)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"Personalizado compatible con OpenAI","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek ASR en tiempo real","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"sherpa-onnx local (experimental)","settings.providers.presets.asrFoundryLocalWhisper":"Whisper local (Foundry Local)","settings.providers.presets.asrLocalWhisper":"Whisper local (por lotes)","settings.providers.presets.asrLocalQwen3":"Qwen3-ASR local","settings.providers.presets.asrLocalQwen3Mlx":"Qwen3-ASR local (MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"Qwen3-ASR local (C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple Speech (macOS)","settings.providers.presets.omniOpenai":"OpenAI (con audio)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs sube el audio grabado a la dirección configurada para transcribirlo por lotes.","settings.providers.zenmuxVocabularyNote":"ZenMux usa un protocolo de transcripción JSON y no recibe las palabras clave del diccionario (prompt/hotwords). El diccionario sigue interviniendo en la mejora del texto, pero no influye en el reconocimiento de voz.","settings.providers.asrAdvancedNote":"Las opciones avanzadas de abajo solo afectan a los preajustes Personalizado compatible con OpenAI y ZenMux. Los demás conservan su comportamiento integrado.","settings.providers.asrAdvancedVerboseJsonLabel":"Métricas por segmento (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"Solicita métricas por segmento para filtrar alucinaciones si el servidor lo admite. Déjalo desactivado en servidores propios que no las admitan.","settings.providers.asrAdvancedChunkLabel":"Duración del fragmento (ms)","settings.providers.asrAdvancedChunkHint":"0 = sin dividir; envía la grabación completa. La división es útil para grabaciones largas o servidores con límites de duración por solicitud.","settings.providers.asrAdvancedEnableItnLabel":"Normalización numérica (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"Convierte números y unidades hablados en cifras (p. ej., «dos mil veintiséis» → «2026»). Desactívalo para conservar el texto original.","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"Clave API","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"Modo de autenticación","settings.providers.volcengineAuthModeAppIdToken":"Aplicación anterior (APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"Clave API (consola nueva)","settings.providers.volcengineMappingNote":"Actualmente no se necesita Secret Key. El Resource ID predeterminado es volc.seedasr.sauc.duration.","settings.providers.volcengineApiKeyNote":"Usa una clave API creada en la nueva consola de voz; no necesitas APP ID. Créala en Gestión de claves API: console.volcengine.com/speech/new/setting/apikeys. El Resource ID predeterminado es volc.seedasr.sauc.duration.","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"Clave API","settings.providers.xfyunNote":"Obtén AppID y API Key en la página del servicio de ASR en tiempo real de iFlytek Open Platform. El audio es PCM mono de 16 kHz / 16 bits. La API estándar no admite parámetros de palabras clave; configúralas en la consola de iFlytek. El idioma predeterminado es chino mandarín.","settings.providers.tencentCloudAppIdLabel":"AppID de Tencent Cloud","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Usa las credenciales del servicio de reconocimiento de voz de Tencent Cloud. El modelo predeterminado Hy-ASR-3.0-preview admite chino, inglés y 20 dialectos; Preview solo acepta PCM mono de 16 kHz de hasta 60 segundos y aún no admite contexto ni refuerzo de palabras clave.","settings.providers.tencentTokenHubNote":"Solo se muestran los modelos de lenguaje disponibles en línea. Algunos modelos usan siempre razonamiento; desactivarlo mantiene el comportamiento fijo de ese modelo.","settings.providers.localAsrActiveNotice":"El ASR local ({{name}}) está activo. Cámbialo o desactívalo desde la pestaña Avanzado.","settings.providers.localAsrTakeoverHint":"Al activar «{{name}}», este sustituirá al proveedor ASR.","settings.providers.asrProviderTakenOver":"Hay un motor local activo. Elige otro proveedor en la lista de arriba para cambiar; el motor local se detendrá automáticamente. Administra modelos en Servicios → Modelos locales.","settings.providers.localAsrHint":"Se ejecuta en este equipo y no necesita clave API. Descarga el modelo desde HuggingFace.","settings.providers.foundryLocalAsrHint":"Se ejecuta en este dispositivo y no necesita clave API de ASR. En el primer uso se descargan el entorno de ejecución y el modelo.","settings.providers.localAsrPerformanceWarning":"La inferencia local es más lenta que el ASR en la nube y puede reconocer el chino con menor precisión. Es adecuada para uso sin conexión o con datos sensibles.","settings.providers.localAsrReady":"{{model}} descargado","settings.providers.localAsrNotReady":"{{model}} sin descargar","settings.providers.localAsrGoDownload":"Abrir Modelos para descargar","settings.providers.localAsrManage":"Abrir Modelos","settings.providers.localAsrDownloadedTitle":"Modelos descargados","settings.providers.localAsrDelete":"Eliminar","settings.providers.fillDefault":"Usar valor predeterminado","settings.providers.readFailed":"No se pudo leer","settings.providers.apiKeyLabel":"Clave API","settings.providers.baseUrlLabel":"URL base","settings.providers.modelLabel":"Modelo","settings.providers.customModelLabel":"Modelo personalizado…","settings.providers.presetListLabel":"Volver a los preajustes","settings.providers.temperatureLabel":"Temperatura","settings.providers.temperaturePlaceholder":"Déjalo vacío para omitirlo. Intervalo: 0–2 inclusive, p. ej., 0.3","settings.providers.extraHeadersLabel":"Cabeceras adicionales","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"Razonamiento","settings.providers.thinkingModeOn":"Activado","settings.providers.thinkingModeOff":"Desactivado","settings.providers.requestFormatLabel":"Formato de solicitud","settings.providers.messagesThinkingLabel":"Modo de razonamiento","settings.providers.thinkingAdaptive":"Adaptativo","settings.providers.thinkingBudget":"Presupuesto fijo","settings.providers.maxTokensLabel":"Máximo de tokens de salida","settings.providers.thinkingBudgetLabel":"Presupuesto de tokens de razonamiento","settings.providers.responsesThinkingHint":"Algunos modelos solo permiten reducir el razonamiento, no desactivarlo. Las solicitudes de razonamiento omiten la temperatura.","settings.providers.messagesThinkingHint":"Los modelos antiguos o servicios compatibles pueden necesitar un presupuesto fijo inferior al límite de salida. Las solicitudes de razonamiento omiten la temperatura.","settings.providers.llmRequestFormatInvalid":"Formato de solicitud no válido. Selecciona uno compatible.","settings.providers.llmThinkingModeInvalid":"Modo de razonamiento no válido. Selecciona uno compatible.","settings.providers.llmTokenLimitInvalid":"Los límites de tokens deben ser números enteros positivos.","settings.providers.llmThinkingBudgetInvalid":"El presupuesto de razonamiento debe ser al menos 1024 y, en modo fijo, inferior al límite de salida.","settings.providers.llmResponseIncomplete":"La respuesta no se completó o alcanzó el límite de salida. Se conserva el texto ya mostrado.","settings.providers.llmProtocolHeaderConflict":"Messages establece automáticamente las cabeceras de autenticación y versión. Elimina x-api-key y anthropic-version de las cabeceras adicionales.","settings.providers.llmStreamError":"El servidor devolvió un error de transmisión. Comprueba el modelo y los parámetros de la solicitud.","settings.providers.saveProtocol":"Guardar ajustes del protocolo","settings.providers.thinkingModeHint":"Activa, desactiva o reduce el razonamiento mediante los parámetros admitidos por el formato y el modelo. No se añaden instrucciones de control al prompt.","settings.providers.bailianVocabularyIdLabel":"ID del vocabulario de palabras clave (opcional)","settings.providers.bailianVocabularyIdNote":"Si creaste un vocabulario en DashScope, introduce su ID vocab-... Déjalo vacío para no usar palabras clave.","settings.providers.bailianModelRealtimeHint":"Modelo en tiempo real: transcribe mientras hablas.","settings.providers.bailianModelSyncFileHint":"Modelo síncrono de grabación: transcribe al terminar (máx. 5 min por grabación).","settings.providers.bailianModelAsyncFileHint":"Modelo asíncrono de archivos: sube la grabación y espera a que termine la tarea de transcripción.","settings.providers.appIdLabel":"App ID","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Resource ID","settings.providers.toolsLabel":"Comprobación de conexión","settings.providers.toolsDesc":"Guarda los campos de arriba y después comprueba el modelo o consulta los modelos disponibles. Si la consulta falla, puedes escribir el modelo manualmente.","settings.providers.validate":"Comprobar","settings.providers.validating":"Comprobando…","settings.providers.fetchModels":"Obtener modelos","settings.providers.loadingModels":"Obteniendo modelos…","settings.providers.modelMissing":"No hay ningún modelo configurado. Introduce primero su ID.","settings.providers.modelsEmpty":"Las credenciales son válidas, pero no se recibieron modelos.","settings.providers.modelsLoaded":"Se han obtenido {{count}} modelos.","settings.providers.searchModels":"Buscar modelos…","settings.providers.noMatchingModels":"Sin modelos coincidentes","settings.providers.orcarouterCatalogHint":"Cargado desde /models de OrcaRouter. Selecciona un modelo del catálogo; los IDs manuales están desactivados para este proveedor.","settings.providers.orcarouterAsrCatalogHint":"Cargado desde /models de OrcaRouter y limitado a modelos Gemini compatibles con entrada de audio. Los IDs manuales están desactivados.","settings.providers.selectModel":"Elige un modelo para completar el campo de arriba","settings.providers.modelSaved":"Modelo {{model}} guardado.","settings.providers.validateSuccess":"Conexión comprobada correctamente.","settings.providers.validateFailed":"La comprobación de conexión falló.","settings.providers.providerHttpStatus":"El proveedor devolvió HTTP {{status}}. Comprueba los permisos de la clave API o la dirección.","settings.providers.endpointMustUseHttps":"Se permiten direcciones HTTP, pero la clave API y el audio pueden quedar expuestos durante la transmisión.","settings.providers.endpointHttpWarning":"Se permiten direcciones HTTP, pero la clave API y el contenido de las solicitudes pueden quedar expuestos durante la transmisión.","settings.providers.endpointInvalid":"El formato de la dirección no es válido.","settings.providers.bailianEndpointSchemeInvalid":"El ASR en tiempo real de Bailian usa la pasarela WebSocket de DashScope. La dirección debe empezar por wss:// (predeterminada: wss://dashscope.aliyuncs.com/api-ws/v1/inference/). Una URL https:// de modo compatible no funciona aquí.","settings.providers.qwen3EndpointSchemeInvalid":"El ASR en tiempo real de Qwen3 usa la pasarela Realtime WebSocket de DashScope. La dirección debe empezar por wss:// (predeterminada: wss://dashscope.aliyuncs.com/api-ws/v1/realtime). Una URL https:// no funciona aquí.","settings.providers.responseTooLarge":"La respuesta del proveedor es demasiado grande para comprobarla de forma segura.","settings.providers.asrInvalidJson":"La respuesta ASR no es JSON válido.","settings.providers.asrMissingTextField":"Falta el campo text en la respuesta ASR.","settings.providers.apiKeyMissing":"La clave API está vacía.","settings.providers.endpointMissing":"La dirección está vacía.","settings.providers.volcengineAppIdMissing":"APP ID está vacío.","settings.providers.volcengineAccessTokenMissing":"Access Token está vacío.","settings.providers.requestTimeout":"La solicitud agotó el tiempo de espera. Inténtalo más tarde.","settings.shortcuts.title":"Ajustes de atajos","settings.shortcuts.descAcc":"Todos los atajos funcionan globalmente. Concede el permiso de Accesibilidad en Permisos.","settings.shortcuts.descNoAcc":"Todos los atajos funcionan globalmente. Si no responden, comprueba el estado de los atajos globales en Permisos.","settings.shortcuts.startStop":"Iniciar / detener grabación","settings.shortcuts.cancel":"Cancelar la grabación actual","settings.shortcuts.confirm":"Confirmar inserción de la cápsula","settings.shortcuts.switchStyle":"Cambiar al estilo anterior","settings.shortcuts.openApp":"Abrir OpenLess","settings.shortcuts.stylePackTitle":"Atajos de estilos","settings.shortcuts.stylePackDesc":"Asigna atajos a tus paquetes favoritos para cambiar con una pulsación. Los paquetes desactivados se volverán a activar automáticamente.","settings.shortcuts.stylePackAdd":"Añadir atajo de estilo","settings.shortcuts.stylePackSelect":"Elegir paquete de estilos","settings.shortcuts.stylePackDisabledSuffix":" (desactivado)","settings.shortcuts.stylePackRemove":"Eliminar","settings.shortcuts.agentPolish":"Mejorar texto seleccionado","settings.shortcuts.agentPolishDesc":"Selecciona texto → pulsa el atajo → Claude lo mejora → se reemplaza la selección.","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"Mantén pulsada una tecla personalizada → habla → Claude ejecuta la tarea → el resultado aparece en una cápsula.","settings.shortcuts.agentVoiceHint":"Configura la tecla para hablar en Avanzado → Less Computer.","settings.shortcuts.agentVoiceTrigger":"Tecla para hablar con Less Computer","settings.shortcuts.enable":"Activar","settings.shortcuts.disable":"Desactivar","settings.shortcuts.confirmHint":"Pulsa ✓ en la cápsula","settings.shortcuts.notSupported":"Todavía no compatible","settings.shortcuts.androidReadOnly":"Los atajos globales no están disponibles en Android. Usa el botón de grabación de Resumen.","settings.permissions.title":"Permisos","settings.permissions.descAcc":"OpenLess necesita estos permisos del sistema. Tras concederlos, cierra la aplicación por completo y vuelve a abrirla.","settings.permissions.descNoAcc":"OpenLess necesita acceso al micrófono. El estado del detector de atajos globales permite comprobar que el componente nativo está activo.","settings.permissions.micLabel":"Micrófono","settings.permissions.micDesc":"Permite capturar tu voz.","settings.permissions.accLabel":"Accesibilidad","settings.permissions.accDesc":"Permite detectar el atajo global e insertar transcripciones donde está el cursor.","settings.permissions.hotkeyLabel":"Atajo global","settings.permissions.hotkeyDescWithAdapter":"Adaptador activo: {{adapter}}. Permite comprobar que el detector de atajos está instalado.","settings.permissions.hotkeyDescPlain":"Permite comprobar que el detector de atajos está instalado.","settings.permissions.networkLabel":"Red","settings.permissions.networkDesc":"Necesaria para los servicios ASR / LLM en la nube. Desactívala para usar solo funciones locales.","settings.permissions.networkOk":"Disponible","settings.permissions.networkOffline":"No disponible","settings.permissions.checking":"Comprobando…","settings.permissions.granted":"Concedido","settings.permissions.notApplicable":"No es necesario","settings.permissions.denied":"No concedido","settings.permissions.indeterminate":"Sin determinar","settings.permissions.micNoDevice":"No se ha detectado ningún micrófono","settings.permissions.openSystem":"Abrir Ajustes del Sistema","settings.permissions.restart":"Restablecer y reiniciar","settings.permissions.grant":"Conceder","settings.permissions.rerunAndroidSetup":"Repetir configuración inicial","settings.permissions.hotkeyInstalled":"Instalado","settings.permissions.hotkeyStarting":"Instalando…","settings.permissions.hotkeyFailed":"El detector falló","settings.permissions.windowsImeLabel":"Motor del método de entrada de Windows","settings.permissions.windowsImeDesc":"Cambia temporalmente al IME TSF de OpenLess durante las sesiones de voz para evitar las limitaciones del portapapeles.","settings.permissions.windowsImeInstalled":"Instalado","settings.permissions.windowsImeUnavailable":"No disponible","settings.permissions.androidImeLabel":"Método de entrada (IME)","settings.permissions.androidImeSelected":"Seleccionado","settings.permissions.androidImeEnabled":"Activado","settings.permissions.androidImeDisabled":"Sin activar","settings.permissions.androidOverlayLabel":"Ventana flotante","settings.permissions.androidAccessibilityLabel":"Servicio de accesibilidad","settings.permissions.androidAccessibilityImpact":"Actívalo para insertar los resultados en el campo actual sin cambiar de teclado. Si está desactivado, los resultados se copian al portapapeles para pegarlos manualmente.","settings.permissions.androidAccessibilityGrantedStale":"Autorizado, sin conexión","settings.permissions.androidAccessibilityMessages.not_android":"El estado de accesibilidad solo está disponible en Android.","settings.permissions.androidAccessibilityMessages.not_enabled":"Activa OpenLess en los ajustes de accesibilidad del sistema.","settings.permissions.androidAccessibilityMessages.operational":"El servicio de accesibilidad está en ejecución.","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"Accesibilidad está autorizada, pero no conectada. Vuelve a activar OpenLess en los ajustes del sistema.","settings.permissions.androidAccessibilityMessages.status_read_failed":"No se pudo consultar el estado de accesibilidad.","settings.permissions.androidShizukuLabel":"Mejoras con Shizuku","settings.permissions.androidShizukuHint":"Opcional. Intenta recuperar el servicio si los ajustes del fabricante bloquean los interruptores manuales; no elimina por completo los conflictos entre aplicaciones. Puede ser necesario reiniciar Shizuku después de reiniciar el dispositivo.","settings.permissions.androidShizukuOpenApp":"Abrir Shizuku","settings.permissions.androidShizukuRequestPermission":"Solicitar autorización","settings.permissions.androidShizukuRecover":"Recuperar accesibilidad","settings.permissions.androidShizukuRecoverConfirm":"¿Usar Shizuku para intentar reactivar el servicio de accesibilidad de OpenLess? Se conservarán los servicios activados al iniciar el cambio. Si el interruptor global está desactivado, activarlo también podría iniciar otros servicios registrados.","settings.permissions.androidShizukuYes":"sí","settings.permissions.androidShizukuNo":"no","settings.permissions.androidShizukuAccessibilityOperational":"Accesibilidad está registrada y en ejecución.","settings.permissions.androidShizukuAccessibilityRegistered":"Registrada: {{registered}} · En ejecución: {{operational}}","settings.permissions.androidShizukuState.notInstalled":"Sin instalar","settings.permissions.androidShizukuState.notRunning":"Sin ejecutar","settings.permissions.androidShizukuState.notAuthorized":"Sin autorizar","settings.permissions.androidShizukuState.authorized":"Autorizado","settings.permissions.androidShizukuState.binderDead":"Desconectado","settings.permissions.androidShizukuState.notAndroid":"No aplicable","settings.permissions.androidShizukuMessages.not_android":"Shizuku solo está disponible en Android.","settings.permissions.androidShizukuMessages.not_installed":"Shizuku o el motor Sui no están instalados.","settings.permissions.androidShizukuMessages.unsupported_backend":"Este motor de Shizuku es demasiado antiguo. Actualiza Shizuku o Sui a la versión 11 o posterior.","settings.permissions.androidShizukuMessages.not_running":"Shizuku no está en ejecución. Inicia primero Shizuku o Sui.","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku no está autorizado. Concede el permiso a OpenLess.","settings.permissions.androidShizukuMessages.binder_dead":"Se perdió la conexión con Shizuku. Reinícialo.","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku autorizado. Accesibilidad está en ejecución.","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku autorizado. Accesibilidad está registrada, pero no está en ejecución.","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku autorizado. Puedes intentar recuperar accesibilidad.","settings.permissions.androidShizukuMessages.operational":"Accesibilidad está registrada y en ejecución.","settings.permissions.androidShizukuMessages.registered_stale":"Accesibilidad está registrada, pero el servicio no está disponible ahora.","settings.permissions.androidShizukuMessages.not_registered":"Accesibilidad no está activada en los ajustes del sistema.","settings.permissions.androidShizukuMessages.already_granted":"El permiso de Shizuku ya estaba concedido.","settings.permissions.androidShizukuMessages.binder_unavailable":"La conexión Binder de Shizuku no estaba disponible al solicitar el permiso.","settings.permissions.androidShizukuMessages.request_cancelled":"Se canceló la solicitud de permiso de Shizuku.","settings.permissions.androidShizukuMessages.granted":"Permiso de Shizuku concedido.","settings.permissions.androidShizukuMessages.denied":"Permiso de Shizuku denegado.","settings.permissions.androidShizukuMessages.permission_permanently_denied":"La autorización de Shizuku está bloqueada. Abre Shizuku y permite OpenLess manualmente.","settings.permissions.androidShizukuMessages.launched":"Se ha abierto la autorización de Shizuku.","settings.permissions.androidShizukuMessages.launch_failed":"No se pudo abrir la autorización de Shizuku.","settings.permissions.androidShizukuMessages.open_shizuku":"Se ha abierto el administrador de Shizuku.","settings.permissions.androidShizukuMessages.jni_error":"No se pudo acceder al motor Shizuku de Android.","settings.permissions.androidShizukuMessages.status_parse_failed":"No se pudo interpretar el estado de Shizuku.","settings.permissions.androidShizukuMessages.user_not_confirmed":"La recuperación requiere confirmación del usuario.","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku no está autorizado o no está disponible.","settings.permissions.androidShizukuMessages.invalid_component":"El ID del componente del servicio de accesibilidad no es válido.","settings.permissions.androidShizukuMessages.service_connect_failed":"No se pudo conectar con el servicio privilegiado de Shizuku.","settings.permissions.androidShizukuMessages.recovery_in_progress":"Ya hay otra recuperación en curso.","settings.permissions.androidShizukuMessages.parse_failed":"No se pudo interpretar el resultado de la recuperación.","settings.permissions.androidShizukuMessages.service_not_bound":"Los ajustes se guardaron, pero accesibilidad aún no está en ejecución.","settings.permissions.androidShizukuMessages.success":"Servicio de accesibilidad recuperado.","settings.permissions.androidShizukuMessages.read_failed":"No se pudieron leer los ajustes de accesibilidad.","settings.permissions.androidShizukuMessages.read_enabled_failed":"No se pudo leer el indicador de activación de accesibilidad.","settings.permissions.androidShizukuMessages.merge_failed":"No se pudieron combinar los servicios de accesibilidad.","settings.permissions.androidShizukuMessages.write_services_failed":"No se pudieron guardar los servicios de accesibilidad activados.","settings.permissions.androidShizukuMessages.write_enabled_failed":"No se pudo activar accesibilidad.","settings.permissions.androidShizukuMessages.readback_failed":"No se pudieron verificar los ajustes de accesibilidad después de guardarlos.","settings.permissions.androidShizukuMessages.oem_rollback":"El fabricante revirtió el cambio de accesibilidad.","settings.permissions.androidShizukuMessages.concurrent_change":"Los ajustes de accesibilidad cambiaron durante la recuperación.","settings.permissions.androidShizukuMessages.partial_rollback":"La recuperación falló y solo se pudieron restaurar parte de los ajustes. Revisa la accesibilidad en los ajustes del sistema.","settings.permissions.androidShizukuMessages.manual_required":"La recuperación automática no puede activar accesibilidad de forma segura si hay otros servicios registrados y el interruptor global está desactivado. Usa los ajustes del sistema.","settings.permissions.androidShizukuMessages.max_retries":"La recuperación falló después de varios intentos.","settings.permissions.androidShizukuMessages.internal_error":"La recuperación falló por un error interno.","settings.permissions.androidShizukuMessages.unknown":"Estado de Shizuku desconocido.","settings.permissions.androidInsertStrategyLabel":"Estrategia de inserción de texto","settings.permissions.androidOverlayTriggerLabel":"Visibilidad de la ventana flotante","settings.permissions.androidOverlayActivationModeLabel":"Activación de la ventana flotante","settings.permissions.androidOverlayLeftSwipeActionLabel":"Acción al deslizar a la izquierda","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"Dirección de deslizamiento para cancelar","settings.permissions.androidOverlaySizeLabel":"Tamaño de la ventana flotante","settings.permissions.androidOverlaySizeHint":"Ajusta el diámetro del botón flotante sin cambiar su posición.","settings.permissions.androidInsertStrategy.accessibility":"Insertar automáticamente en el campo de texto","settings.permissions.androidInsertStrategy.clipboard":"Solo portapapeles","settings.permissions.androidInsertStrategyHint.accessibility":"Requiere accesibilidad; si no está disponible, usa el portapapeles.","settings.permissions.androidInsertStrategyHint.clipboard":"No requiere accesibilidad; solo copia para que pegues manualmente.","settings.permissions.androidOverlayTrigger.background":"Con la aplicación en segundo plano","settings.permissions.androidOverlayTrigger.keyboard":"Cuando aparece el teclado","settings.permissions.androidOverlayTrigger.always":"Siempre visible","settings.permissions.androidOverlayTriggerHint.background":"Sencillo y de bajo consumo; no muestra la ventana mientras escribes en otras aplicaciones.","settings.permissions.androidOverlayTriggerHint.keyboard":"Este modo se ha retirado. Los ajustes existentes vuelven al modo de segundo plano.","settings.permissions.androidOverlayTriggerHint.always":"Siempre disponible, pero permanece en pantalla.","settings.permissions.androidOverlayTriggerDisabled.keyboard":"La activación al mostrar el teclado se ha retirado. Se sustituirá por gestos en la ventana flotante.","settings.permissions.androidOverlayActivationMode.tap":"Pulsar para preparar","settings.permissions.androidOverlayActivationMode.long_press":"Mantener pulsado para preparar","settings.permissions.androidOverlayActivationModeHint.tap":"La primera pulsación prepara la ventana; la segunda inicia el dictado normal.","settings.permissions.androidOverlayActivationModeHint.long_press":"Mantén pulsado para preparar la ventana; al soltar, se detiene la grabación o la intervención de voz actual.","settings.permissions.androidOverlayLeftSwipeAction.translation":"Dictado con traducción","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"Cambiar paquete de estilos","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"Con la ventana preparada, desliza a la izquierda para empezar un dictado con traducción.","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"Con la ventana preparada, desliza a la izquierda para cambiar al paquete anterior.","settings.permissions.androidOverlayCancelSwipeDirection.up":"Deslizar hacia arriba","settings.permissions.androidOverlayCancelSwipeDirection.down":"Deslizar hacia abajo","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"Durante la grabación, desliza hacia arriba para cancelar sin transcribir ni insertar.","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"Durante la grabación, desliza hacia abajo para cancelar sin transcribir ni insertar.","settings.permissions.windowsIme.installed":"Instalado. La entrada de voz cambia temporalmente al IME de OpenLess.","settings.permissions.windowsIme.notInstalled":"Sin instalar. OpenLess usa la alternativa de portapapeles / WM_PASTE.","settings.permissions.windowsIme.registrationBroken":"El registro está dañado. Reinstala el IME de OpenLess.","settings.permissions.windowsIme.notWindows":"Solo disponible en Windows.","settings.advanced.multimodalPipelineTitle":"Reconocimiento multimodal (experimental)","settings.advanced.multimodalPipelineTitleHint":"Reconoce el audio en una sola pasada con un modelo multimodal; su configuración está completamente separada del ASR + LLM tradicional.","settings.advanced.multimodalPipelineLabel":"Activar procesamiento multimodal","settings.advanced.multimodalPipelineHint":"Añade un selector Tradicional / Multimodal a la página de proveedores de IA. Tradicional usa ASR + LLM y Multimodal usa un modelo con audio. Las configuraciones se guardan por separado y nunca comparten credenciales.","settings.advanced.streamingInsertTitle":"Inserción progresiva","settings.advanced.streamingInsertTitleLinux":"Inserción progresiva (experimental)","settings.advanced.streamingInsertDesc":"Inserta el texto carácter a carácter donde está el cursor para reducir la espera percibida. Si no se cumplen las condiciones, pega todo de una vez.","settings.advanced.streamingInsertLabel":"Inserción progresiva","settings.advanced.streamingInsertHintMac":"Cambia temporalmente la fuente de entrada a ABC para que los IME de chino, japonés o coreano no intercepten las teclas. Se restaura al terminar la sesión.","settings.advanced.streamingInsertHintWindows":"SendInput Unicode escribe directamente, sin pasar por TSF / IME y sin cambiar de método de entrada.","settings.advanced.streamingInsertHintLinux":"Usa el complemento fcitx5 para enviar texto; la inserción progresiva simula teclas mediante enigo + XTest.","settings.advanced.streamingInsertSaveClipboardLabel":"Copiar al portapapeles","settings.advanced.streamingInsertSaveClipboardHint":"Después de insertar correctamente, copia el texto final al portapapeles para poder pegarlo otra vez con Cmd+V. Desactivado: no se modifica el portapapeles.","settings.advanced.localAsrTitle":"Modelos ASR locales (experimental)","settings.advanced.localAsrDesc":"Sustituye el ASR en la nube por inferencia en el dispositivo. Para uso sin conexión o con datos sensibles.","settings.advanced.localAsrWarningShort":"La inferencia local es más lenta; un equipo poco potente puede omitir palabras.","settings.advanced.qwen3Desc":"Al activarlo, sustituirá al proveedor ASR.","settings.advanced.sherpaDesc":"Al activarlo, sustituirá al proveedor ASR.","settings.advanced.foundryDesc":"Al activarlo, sustituirá al proveedor ASR.","settings.advanced.notSupportedHere":"No es compatible con esta plataforma; no se incluye un módulo de inferencia.","settings.advanced.enable":"Activar","settings.advanced.alreadyActive":"Activo","settings.advanced.disableLocalLabel":"Desactivar ASR local","settings.advanced.disableLocalDesc":"Vuelve al ASR en la nube (Volcengine bigasr de forma predeterminada).","settings.advanced.disable":"Desactivar","settings.advanced.platformNotSupported":"Esta plataforma no admite la integración de modelos ASR locales.","settings.advanced.confirmEnableLocalTitle":"¿Activar ASR local?","settings.advanced.confirmEnableLocalBody":"La transcripción será más lenta que en la nube y podría ser menos precisa.","settings.advanced.confirm":"Activar","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"Idioma de la interfaz","settings.language.desc":"Cambia el idioma de la interfaz. Se aplica de inmediato y se conserva al volver a iniciar la aplicación.","settings.language.label":"Idioma","settings.language.labelDesc":"Elige «Seguir al sistema» para usar el idioma del sistema operativo al iniciar.","settings.language.followSystem":"Seguir al sistema","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"Algunos menús nativos, como la bandeja del sistema, pueden requerir reiniciar la aplicación para cambiar por completo.","settings.layout.title":"Diseño","settings.theme.title":"Apariencia","settings.theme.label":"Tema","settings.theme.activityHeatmapLabel":"Mostrar el mapa de actividad anual en Resumen","settings.theme.stackedRowLayoutLabel":"Diseño legible (ajuste de filas)","settings.theme.stackedRowLayoutDesc":"En pantallas pequeñas o con texto grande, los controles que no caben en una línea pasan a la siguiente para no desbordarse ni comprimir el texto.","settings.theme.conservativeLayoutLabel":"Diseño conservador","settings.theme.conservativeLayoutDesc":"Las páginas de ajustes y funciones usan una columna a todo el ancho para reducir los desbordamientos. No afecta a la página de inicio ni a las barras superior e inferior.","settings.theme.system":"Seguir al sistema","settings.theme.light":"Claro","settings.theme.dark":"Oscuro","settings.remoteInput.title":"Entrada remota","settings.remoteInput.enableLabel":"Activar entrada remota","settings.remoteInput.enableDesc":"Graba desde el navegador de un móvil o tableta en tu red local. El texto se escribirá donde esté el cursor del ordenador. Requiere HTTPS; confía en el certificado en la primera visita.","settings.remoteInput.portLabel":"Puerto","settings.remoteInput.defaultModeLabel":"Modo de grabación predeterminado","settings.remoteInput.modeToggle":"Pulsar para alternar","settings.remoteInput.modeHold":"Mantener para hablar","settings.remoteInput.urlLabel":"URL de acceso","settings.remoteInput.pinLabel":"Código de vinculación","settings.remoteInput.regeneratePin":"Generar de nuevo","settings.remoteInput.portInUse":"El puerto {{port}} está en uso. Elige otro","settings.remoteInput.startError":"No se pudo iniciar el servicio de entrada remota: {{reason}}","settings.remoteInput.securityHint":"Solo es accesible desde la misma red local y requiere el código de vinculación. Desactívalo cuando no lo uses.","settings.remoteInput.certHint":"Verifica la huella del certificado raíz antes de confiar en él por primera vez. Las versiones anteriores requieren una configuración única; después la confianza se mantiene al reiniciar o cambiar de IP.","settings.remoteInput.certFingerprintLabel":"SHA-256 de la CA raíz de este ordenador","settings.remoteInput.certFingerprintCopy":"Copiar huella completa","settings.remoteInput.certFingerprintCopied":"Huella copiada","settings.remoteInput.certFingerprintUnavailable":"La huella completa no está disponible. No instales ni confíes en un certificado descargado.","settings.remoteInput.certVerifyHint":"Antes de activar la confianza completa, busca el SHA-256 en los detalles del certificado del sistema del teléfono y compara los 64 caracteres con este valor (ignora espacios y dos puntos). Una página web, el nombre del perfil o un identificador no prueban la identidad. Si la huella difiere o no se puede ver completa, detente y elimina el perfil descargado o instalado.","settings.remoteInput.certProfileHint":"Debe haber exactamente un certificado raíz. No instales un perfil con certificados adicionales, VPN o ajustes de gestión de dispositivos.","settings.remoteInput.certTrustWarning":"La descarga inicial del certificado no puede verificar la identidad del ordenador: un dispositivo malicioso en la red local podría sustituir el certificado raíz en un ataque de intermediario. Instálalo solo en una red doméstica o privada de confianza, nunca en redes públicas o compartidas. La CA raíz puede emitir certificados y su clave privada permanece en este ordenador; elimínalo del teléfono cuando dejes de usarlo.","settings.remoteInput.certSetupLink":"Copiar enlace del certificado para iPhone","settings.remoteInput.waitingStart":"El servicio aún no está en ejecución. Desactiva el interruptor y vuelve a activarlo; no reinicies la aplicación.","settings.remoteInput.starting":"Iniciando el servicio de entrada remota…","settings.remoteInput.urlsStale":"Estas direcciones corresponden a la ejecución anterior y pueden estar desactualizadas.","settings.about.tagline":"Habla con naturalidad, escribe con precisión","settings.about.checkUpdate":"Buscar actualizaciones","settings.about.checkUpdateBtn":"Buscar","settings.about.checkStableUpdateBtn":"Buscar versión estable","settings.about.checkBetaUpdateBtn":"Buscar versión Beta","settings.about.checkingUpdate":"Buscando…","settings.about.upToDate":"Ya tienes la última versión.","settings.about.updateError":"No se pudo buscar o instalar la actualización. Inténtalo más tarde.","settings.about.retryBtn":"Reintentar","settings.about.openReleases":"Abrir versiones","settings.about.source":"Código fuente","settings.about.docs":"Documentación","settings.about.feedback":"Comentarios","settings.about.qq":"Grupo de la comunidad en QQ","settings.about.qqDesc":"Busca el número del grupo en QQ o escanea el código QR para unirte.","settings.about.copyQq":"Copiar número del grupo","settings.about.privacy":"Privacidad","settings.about.privacyDesc":"Las grabaciones pueden enviarse al proveedor en la nube que configures para transcribirlas.","settings.about.localFirst":"Prioridad al almacenamiento local","settings.about.linksTitle":"Documentación","settings.about.betaChannelLabel":"Unirse al canal Beta","settings.about.betaChannelToggleLabel":"Activar canal Beta","settings.about.betaChannelDesc":"Al activarlo, las actualizaciones automáticas siguen el canal Beta; al desactivarlo, siguen el estable. Puedes buscar una Beta manualmente con el botón de abajo.","settings.about.autoUpdateSectionTitle":"Actualización automática","settings.about.autoUpdateCheckLabelAndroid":"Buscar y descargar actualizaciones automáticamente","settings.about.autoUpdateCheckDescAndroid":"Busca al iniciar y cada 60 minutos. Si hay una actualización, la descarga y abre el instalador del sistema. Usa el canal indicado por el interruptor Beta de arriba.","settings.about.betaChannelFetching":"Obteniendo la última Beta…","settings.about.betaChannelFetchBtn":"Consultar última Beta","settings.about.betaChannelLatestPrefix":"Última Beta:","settings.about.betaChannelDownloadBtn":"Abrir página de descarga","settings.about.betaChannelRefresh":"Actualizar","settings.about.betaChannelNoBeta":"Todavía no se ha publicado ninguna versión Beta.","settings.about.betaChannelFetchError":"No se pudo consultar la versión Beta. Inténtalo más tarde.","settings.about.betaChannelUpToDate":"Actualizado","settings.about.betaChannelUpdateNow":"Actualizar ahora","settings.about.betaChannelUpdateNowTitle":"Busca y descarga la última Beta y muestra el diálogo de actualización","settings.about.betaChannelChecking":"Buscando…","settings.about.updateDialog.stableChannelSwitch.title":"Cambiar al canal estable","settings.about.updateDialog.stableChannelSwitch.desc":"Versión actual: OpenLess {{currentVersion}}\nVersión de destino: OpenLess {{version}}\nEsto cambia del canal beta al estable. ¿Continuar?","settings.about.updateDialog.available.title":"Actualización disponible","settings.about.updateDialog.available.desc":"OpenLess {{version}} está disponible. ¿Actualizar ahora?","settings.about.updateDialog.downloading.title":"Descargando actualización","settings.about.updateDialog.downloading.desc":"Descargando OpenLess {{version}}. Mantén la aplicación abierta.","settings.about.updateDialog.downloaded.title":"Actualización lista","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} se ha instalado. ¿Reiniciar automáticamente ahora para aplicarlo?","settings.about.updateDialog.installing.title":"Instalando actualización","settings.about.updateDialog.installing.desc":"Instalando OpenLess {{version}}. Mantén la aplicación abierta.","settings.about.updateDialog.install":"Actualizar ahora","settings.about.updateDialog.androidInstall":"Descargar y abrir instalador","settings.about.updateDialog.androidInstalled.title":"Instalador del sistema abierto","settings.about.updateDialog.androidInstalled.desc":"Sigue las indicaciones del sistema para terminar la instalación. Vuelve a abrir OpenLess para usar {{version}}.","settings.about.updateDialog.downloadingLabel":"Descargando…","settings.about.updateDialog.installingLabel":"Instalando…","settings.about.updateDialog.later":"Reiniciar manualmente más tarde","settings.about.updateDialog.restartNow":"Reiniciar ahora","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"{{downloaded}} descargados","settings.about.updateDialog.installError.title":"La actualización falló","settings.about.updateDialog.installError.desc":"La actualización automática no pudo terminar: {{error}}. Puedes descargar e instalar la última versión manualmente.","settings.about.updateDialog.manualDownload":"Descargar manualmente","startup.loading":"Iniciando OpenLess…","startup.loadingDesc":"Conectando con el servicio local y comprobando la compatibilidad.","startup.failed":"OpenLess no pudo iniciarse","startup.recovery":"Vuelve a comprobarlo. Si el problema continúa, cierra la aplicación por completo y ábrela otra vez. Si empezó después de actualizar, comprueba que todos los componentes de la aplicación tengan la misma versión.","startup.retry":"Volver a comprobar","startup.details":"Mostrar detalles del error","modal.serviceViews.label":"Ajustes de servicios","modal.serviceViews.llm":"Modelos de lenguaje","modal.serviceViews.asr":"Reconocimiento de voz","modal.serviceViews.omni":"Multimodal","modal.serviceViews.models":"Modelos locales","modal.serviceViews.connections":"Conexiones","modal.serviceViews.statusConfigured":"Configurado","modal.serviceViews.statusMissing":"Sin configurar","modal.searchPlaceholder":"Buscar una categoría de ajustes…","modal.clearSearch":"Borrar búsqueda","modal.categoriesLabel":"Categorías de ajustes","modal.searchResults":"Resultados de búsqueda","modal.searchCount":"Categorías encontradas: {{count}}","modal.noResults":"No se encontraron categorías. Prueba «micrófono», «modelos» o «tema».","modal.autoSaveHint":"Los cambios se guardan automáticamente","modal.backToAdvanced":"Volver a Experimentos y extensiones","modal.advancedPages.lessComputer":"Elige un agente y configura su modelo, permisos y directorio de trabajo.","modal.advancedPages.claudeConsole":"Detecta Claude Code y consulta la salida de las tareas de prueba.","modal.advancedPages.multimodal":"Administra la activación del reconocimiento multimodal experimental.","modal.advancedPages.debug":"Conserva grabaciones de depuración, inspecciona el contexto del cursor y exporta registros.","modal.descriptions.general":"Elige un micrófono, ajusta la grabación y la entrada de texto o conecta tu móvil.","modal.descriptions.shortcuts":"Configura atajos y elige qué sucede al seleccionar texto.","modal.descriptions.services":"Elige servicios de reconocimiento de voz y procesamiento de texto. Administra canales, modelos locales y conexiones.","modal.descriptions.appearance":"Ajusta el tema, el diseño y el idioma de la interfaz para leer con comodidad.","modal.descriptions.privacy":"Comprueba los permisos y las conexiones. Administra el historial, las grabaciones y los datos locales.","modal.descriptions.advanced":"Configura Less Computer, el procesamiento multimodal y la depuración según tus necesidades.","modal.descriptions.about":"Consulta tu versión, el canal y los ajustes de actualización automática.","modal.searchKeywords.general":"micrófono grabación entrada teléfono remoto LAN PIN cápsula silenciar inicio automático","modal.searchKeywords.shortcuts":"atajo tecla combinación selección mejorar voz edición","modal.searchKeywords.services":"ASR LLM API canal modelo nube local sin conexión red proxy catálogo","modal.searchKeywords.appearance":"tema oscuro claro idioma fuente texto tamaño diseño mapa actividad","modal.searchKeywords.privacy":"permiso micrófono accesibilidad historial grabación almacenamiento privacidad exportar","modal.searchKeywords.advanced":"Less Computer Claude agente multimodal Omni depuración registros experimento","modal.searchKeywords.about":"versión Beta estable actualización actualizar","modal.sections.appearance":"Apariencia e idioma","modal.sections.shortcuts":"Atajos y selección","modal.sections.general":"Grabación y entrada","modal.sections.services":"Servicios y modelos de IA","modal.sections.privacy":"Permisos y datos","modal.sections.advanced":"Experimentos y extensiones","modal.sections.personalize":"Personalización","modal.sections.about":"Acerca de y actualizaciones","modal.sections.helpCenter":"Centro de ayuda","modal.sections.releaseNotes":"Notas de la versión","modal.personalize.font":"Tamaño de fuente","modal.personalize.fontDesc":"Cambia el tamaño del texto de toda la interfaz de inmediato.","modal.personalize.fontSmall":"Pequeño","modal.personalize.fontMedium":"Mediano","modal.personalize.fontLarge":"Grande","modal.personalize.blur":"Intensidad del efecto de cristal","modal.personalize.blurDesc":"Afecta al filtro de fondo interno. La capa esmerilada del sistema macOS no se puede ajustar durante la ejecución.","modal.about.tagline":"Habla con naturalidad, escribe con precisión","modal.about.checkUpdate":"Buscar actualizaciones","modal.about.checkUpdateBtn":"Buscar","modal.about.docs":"Documentación","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"Canal de comentarios","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"Código fuente","modal.about.qq":"Grupo de la comunidad en QQ","modal.about.qqDesc":"Busca el número del grupo en QQ o escanea el código QR para unirte.","modal.about.copyQq":"Copiar número del grupo","modal.about.exportErrorLog":"Exportar registro de errores","modal.about.exportErrorLogDesc":"Guarda el registro de la sesión actual en disco para investigar problemas o enviarnos comentarios.","modal.about.exportErrorLogBtn":"Exportar","modal.about.exporting":"Exportando…","modal.about.exportSuccess":"Guardado","modal.about.exportFailed":"No se pudo exportar","modal.about.privacy":"Privacidad","modal.about.privacyDesc":"Las transcripciones permanecen en este dispositivo. Los proveedores en la nube configurados pueden recibir el audio para transcribirlo.","modal.about.localFirst":"Prioridad al almacenamiento local","windowChrome.restore":"Restaurar","windowChrome.minimize":"Minimizar","windowChrome.maximize":"Maximizar","windowChrome.close":"Cerrar","hotkey.triggers.rightOption":"Option derecha","hotkey.triggers.leftOption":"Option izquierda","hotkey.triggers.rightControl":"Control derecho","hotkey.triggers.leftControl":"Control izquierdo","hotkey.triggers.rightCommand":"Command derecha","hotkey.triggers.leftCommand":"Command izquierda","hotkey.triggers.leftShift":"Shift izquierda","hotkey.triggers.rightShift":"Shift derecha","hotkey.triggers.fn":"Fn (tecla del globo)","hotkey.triggers.rightAlt":"Alt derecha","hotkey.triggers.mediaPlayPause":"⏯ Reproducir / pausar multimedia","hotkey.triggers.custom":"Combinación personalizada…","hotkey.fallback":"Atajo global","hotkey.modeHoldSuffix":" (mantener para hablar)","hotkey.modeToggleSuffix":" (iniciar / detener)","hotkey.modeAutoSuffix":" (detección automática)","hotkey.usageHold":"Mantén pulsado {{trigger}} para hablar y suéltalo para detener.","hotkey.usageToggle":"Pulsa {{trigger}} para empezar y vuelve a pulsarlo para detener.","hotkey.usageAuto":"Pulsa {{trigger}} para iniciar o detener; mantenlo pulsado para hablar y suéltalo para detener.","hotkey.adapter.macEventTap":"Event Tap de macOS","hotkey.adapter.windowsLowLevel":"Detector de teclado de bajo nivel de Windows","hotkey.adapter.fcitx5":"Complemento de entrada fcitx5","hotkey.adapter.unavailable":"No disponible","localAsr.kicker":"ASR LOCAL","localAsr.title":"Modelos","localAsr.desc":"Administra los modelos de reconocimiento de voz del dispositivo.","localAsr.storageTitle":"Ubicación de los modelos","localAsr.storageBaseDir":"Carpeta superior seleccionada","localAsr.storageModelsRoot":"Carpeta real de modelos","localAsr.storageDefault":"Carpeta predeterminada del sistema","localAsr.storageChoose":"Cambiar carpeta","localAsr.storageReset":"Restaurar ubicación predeterminada","localAsr.storageReveal":"Abrir carpeta de modelos","localAsr.storageDesc":"La ubicación personalizada crea OpenLess/models dentro de la carpeta elegida y traslada los modelos existentes. Antes de moverlos, OpenLess cancela las descargas y libera los modelos cargados.","localAsr.storageChooseTitle":"Elegir carpeta superior para los modelos locales","localAsr.storageChangeConfirm":"Los modelos locales se trasladarán a {{path}}/OpenLess/models. Primero se cancelarán las descargas y se liberarán los modelos cargados. ¿Continuar?","localAsr.storageResetConfirm":"Los modelos locales volverán a la carpeta predeterminada del sistema. Carpeta actual: {{path}}. ¿Continuar?","localAsr.modelDir":"Directorio del modelo","localAsr.revealDir":"Abrir directorio","localAsr.deleteConfirm":"¿Eliminar los archivos locales de {{name}}? Esta acción no se puede deshacer.","localAsr.appleSpeechTitle":"Reconocimiento Apple Speech (macOS)","localAsr.appleSpeechDesc":"Transcribe localmente con el reconocimiento de voz integrado de macOS: sin descargar modelos, claves API ni red. Es una alternativa local sin credenciales si tu ASR en la nube falla. macOS pedirá permiso de reconocimiento de voz en el primer uso.","localAsr.appleSpeechUse":"Usar Apple Speech","localAsr.qwenTitle":"Administrador de modelos Qwen3-ASR","localAsr.qwenExperimentalBadge":"Experimental","localAsr.engineUnavailable":"Esta plataforma no incluye el motor Qwen3-ASR. Puedes descargar los modelos, pero todavía no puedes activarlos aquí.","localAsr.qwenUnavailableOnWindows":"Qwen3-ASR todavía no es compatible con Windows. Usa Foundry Local Whisper, más arriba.","localAsr.foundryTitle":"Foundry Local Whisper para Windows","localAsr.foundryDesc":"Reconocimiento de voz en el dispositivo, sin clave API de ASR. El primer uso requiere descargar el entorno de ejecución y el modelo.","localAsr.foundryAvailable":"Disponible en Windows","localAsr.foundryUnavailable":"Solo Windows","localAsr.foundryRuntimeReady":"Entorno de ejecución descargado","localAsr.foundryRuntimeMissing":"Entorno de ejecución sin descargar","localAsr.foundryRuntimeSourceLabel":"Origen del entorno de ejecución","localAsr.foundryRuntimeSourceAuto":"Automático (prioridad a NuGet)","localAsr.foundryRuntimeSourceNuget":"Repositorio oficial de NuGet","localAsr.foundryRuntimeSourceOrtNightly":"Repositorio ORT-Nightly de Microsoft","localAsr.foundryRuntimeSourceDesc":"Los componentes del entorno se descargan antes del primer uso.","localAsr.foundrySelectedModel":"Modelo seleccionado","localAsr.foundryActiveModel":"Alias predeterminado actual","localAsr.foundryLoadedModel":"Modelo cargado","localAsr.foundryNotLoaded":"Sin cargar","localAsr.foundryError":"Estado de Foundry","localAsr.foundrySetDefault":"Usar como predeterminado / Activar ASR local de Windows","localAsr.foundryEnabling":"Activando…","localAsr.foundryPrepare":"Preparar / Descargar / Cargar","localAsr.foundryPreparing":"Preparando…","localAsr.foundryReleasing":"Liberando…","localAsr.foundryRetryPrepare":"Continuar / Reintentar preparación","localAsr.foundryCancelPrepare":"Cancelar preparación","localAsr.foundryCancelRequested":"Cancelación solicitada","localAsr.foundryCancelling":"Cancelando…","localAsr.foundryCancelBestEffort":"Cancelación solicitada. Se detendrá cuando termine el paso actual. Inténtalo de nuevo más tarde.","localAsr.foundryPrepareRuntime":"Preparar entorno de ejecución","localAsr.foundryPrepareModel":"Descargar modelo","localAsr.foundryPrepareLoad":"Cargar modelo","localAsr.foundryPrepareModelSkipped":"El modelo ya está descargado; se omite la descarga","localAsr.foundryPrepareDone":"Hecho","localAsr.foundryPrepareWaiting":"En espera","localAsr.foundryApproxSizeMb":"unos {{mb}} MB","localAsr.foundryLanguageLabel":"Idioma de reconocimiento","localAsr.foundryLanguageAuto":"Automático","localAsr.foundryLanguageZh":"Chino zh","localAsr.foundryLanguageEn":"Inglés en","localAsr.foundryLanguageDesc":"Elige Chino para dictar en chino y Automático si combinas idiomas.","localAsr.foundryModelSmall":"Whisper Small (predeterminado / equilibrado)","localAsr.foundryModelSmallDesc":"Opción predeterminada que equilibra calidad y consumo de recursos.","localAsr.foundryModelMedium":"Whisper Medium (mayor calidad)","localAsr.foundryModelMediumDesc":"Mayor precisión para equipos potentes que admitan descargas más grandes e inferencia más lenta.","localAsr.foundryModelLarge":"Whisper Large V3 Turbo (máxima calidad)","localAsr.foundryModelLargeDesc":"Modelo grande para equipos de gama alta y usos que priorizan la calidad.","localAsr.foundryModelBase":"Whisper Base (más rápido / menos recursos)","localAsr.foundryModelBaseDesc":"Más rápido y con menor consumo de recursos para el dictado diario.","localAsr.foundryModelTiny":"Whisper Tiny (el más rápido / prueba básica)","localAsr.foundryModelTinyDesc":"La opción más rápida para comprobar que Foundry funciona.","localAsr.sherpaTitle":"sherpa-onnx local para Windows (experimental)","localAsr.sherpaDesc":"Windows usa sherpa-onnx para reconocer grabaciones por lotes sin conexión, sin clave API de ASR.","localAsr.sherpaRuntimeReady":"Modelo cargado","localAsr.sherpaRuntimeMissing":"Modelo sin cargar","localAsr.sherpaSetDefault":"Usar como predeterminado / Activar sherpa-onnx","localAsr.sherpaPrepare":"Comprobar archivos locales / Cargar","localAsr.sherpaPreparing":"Cargando…","localAsr.sherpaPrepareLocalFiles":"Comprobar archivos locales del modelo","localAsr.sherpaModelDir":"Directorio del modelo","localAsr.sherpaRevealDir":"Abrir directorio del modelo","localAsr.sherpaError":"Estado de sherpa-onnx","localAsr.sherpaLanguageJa":"Japonés ja","localAsr.sherpaLanguageKo":"Coreano ko","localAsr.sherpaLanguageYue":"Cantonés yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small (predeterminado / prioridad al chino)","localAsr.sherpaModelSenseVoiceDesc":"Modelo experimental predeterminado para dictado en chino o combinando chino e inglés.","localAsr.sherpaModelParaformer":"Paraformer para chino","localAsr.sherpaModelParaformerDesc":"Modelo experimental centrado en el chino.","localAsr.sherpaModelWhisper":"Whisper Small multilingüe","localAsr.sherpaModelWhisperDesc":"Alternativa experimental multilingüe con el comportamiento de la familia Whisper.","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3 (multilingüe)","localAsr.sherpaModelWhisperLargeV3Desc":"La versión multilingüe de código abierto más avanzada de Whisper: gran calidad y descarga de gran tamaño.","localAsr.sherpaModelZipformer":"Zipformer en streaming (zh/en)","localAsr.sherpaModelZipformerDesc":"Modelo en streaming de chino e inglés con la latencia más baja: el texto aparece mientras hablas.","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"Modelo Qwen3-ASR convertido para sherpa-onnx, con reconocimiento multilingüe y mejor tratamiento del contexto en textos largos.","localAsr.modelSelectTitle":"Modelos de este dispositivo","localAsr.modelSelectDesc":"Consulta las descargas, administra archivos o carga un modelo para probarlo.","localAsr.modelSelectPlaceholder":"Selecciona un modelo descargado…","localAsr.modelSelectEmpty":"Todavía no hay modelos descargados. Descarga uno desde «Descargar y administrar».","localAsr.groupDownload":"Descargar y administrar","localAsr.groupOther":"Otros","localAsr.mirrorLabel":"Servidor de descarga","localAsr.mirrorDesc":"huggingface.co es el origen oficial; hf-mirror.com es una réplica de la comunidad que suele funcionar mejor en China continental.","localAsr.mirrorHuggingface":"HuggingFace oficial (huggingface.co)","localAsr.mirrorHfMirror":"Réplica para China continental (hf-mirror.com)","localAsr.activeBadge":"En uso","localAsr.downloadedBadge":"Descargado","localAsr.notDownloadedBadge":"Sin descargar","localAsr.download":"Descargar","localAsr.resume":"Reanudar","localAsr.cancel":"Cancelar","localAsr.delete":"Eliminar","localAsr.setActive":"Usar como predeterminado","localAsr.failed":"Fallido","localAsr.cancelled":"Cancelado","localAsr.files":"archivos","localAsr.sizeLoading":"Consultando tamaño…","localAsr.sizeUnknown":"Tamaño desconocido","localAsr.performanceWarning":"El ASR local es adecuado para uso sin conexión o con datos sensibles. El primer uso requiere descargar un modelo.","localAsr.test":"Cargar y probar","localAsr.testRunning":"Probando…","localAsr.testHeading":"Prueba de audio integrada","localAsr.testExpected":"Esperado","localAsr.testActual":"Obtenido","localAsr.testStats":"Audio {{audio}}s · Carga {{load}}s · Transcripción {{transcribe}}s · Motor {{backend}}","localAsr.testFailed":"La prueba falló","localAsr.engineStatusLabel":"Motor en memoria","localAsr.engineLoaded":"Cargado: {{model}}","localAsr.engineUnloaded":"Sin cargar (la primera transcripción tendrá que cargar el modelo)","localAsr.loadNow":"Cargar ahora","localAsr.releaseNow":"Liberar ahora","localAsr.keepLoadedLabel":"Mantener cargado durante","localAsr.keepLoadedDesc":"Tiempo que Qwen3-ASR permanece en memoria después del último uso antes de liberarse.","localAsr.keepImmediate":"Liberar inmediatamente","localAsr.keep1min":"1 minuto tras el último uso","localAsr.keep5min":"5 minutos tras el último uso (predeterminado)","localAsr.keep30min":"30 minutos tras el último uso","localAsr.keepForever":"No liberar nunca (siempre cargado)","localAsr.sidebarTitle":"Descargados y en descarga","localAsr.activePill":"Activo","localAsr.setDefault":"Usar como predeterminado","localAsr.downloading":"Descargando","localAsr.startDownload":"Iniciar descarga","localAsr.downloadNewModel":"Descargar nuevo modelo","localAsr.activeModelLabel":"Modelo en uso","localAsr.pickerNoModelDownloaded":"Aún no hay modelos descargados; descarga primero uno en la página de modelos locales.","localAsr.partialDownloadsLabel":"Descargas incompletas","localAsr.partialDownloadsDesc":"Las descargas interrumpidas dejaron archivos temporales; límpialos sin afectar a los modelos instalados.","localAsr.cleanupIncomplete":"Limpiar descarga incompleta","localAsr.languagesLabel":"Idiomas","localAsr.partialBytesLabel":"Archivos residuales","localAsr.downloadDialogTitle":"Descargar modelo","localAsr.downloadDialogAlreadyHave":"Los archivos del modelo ya están descargados. Vuelve a su página para cargarlo y probarlo, o elige su proveedor en Transcripción ASR.","localAsr.downloadDialogDesc":"Compara los tamaños y las descripciones, y descarga el modelo que elijas. Cuando esté listo, selecciona su servicio local en Reconocimiento de voz.","localAsr.detailRepo":"Repositorio","localAsr.hfDownloads":"Descargas","localAsr.hfLikes":"Me gusta","localAsr.hfDescription":"Acerca de","localAsr.hfNoDescription":"Todavía no hay descripción","localAsr.hfCardFailed":"No se pudo cargar la información del modelo","localAsr.detailFiles":"archivos","localAsr.detailDownloaded":"Descargado","localAsr.detailEmpty":"Selecciona un modelo para ver sus detalles","localAsr.foundryLanguage":"Idioma","localAsr.foundryRuntimeSource":"Origen del entorno de ejecución","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"Mantener cargado","localAsr.downloadSettingsTitle":"Descarga y almacenamiento","localAsr.downloadSettingsDesc":"Servidor de descarga · ubicación de modelos · motor en memoria","localAsr.libraryEmptyTitle":"Todavía no hay modelos locales","localAsr.libraryEmptyDesc":"Descarga un modelo de reconocimiento de voz para procesar el audio en este dispositivo. Si falta un modelo existente, vuelve a cargar el catálogo.","localAsr.catalogTitle":"Catálogo de modelos","localAsr.catalogEmpty":"No hay modelos disponibles para mostrar. Vuelve a cargar el catálogo e inténtalo de nuevo.","localAsr.reloadCatalog":"Volver a cargar el catálogo","localAsr.engineLabel":"Motor de reconocimiento","localAsr.sizeLabel":"Tamaño del modelo","localAsr.allEngines":"Todos","localAsr.backToCatalog":"Volver al catálogo","localAsr.detailsTitle":"Detalles del modelo","localAsr.testActivateHint":"Cargar y probar activa este modelo y después ejecuta la prueba de audio integrada.","localAsr.downloadProgressHint":"Después de iniciarla, consulta el progreso o cancela la descarga desde la página del modelo.","localAsr.errorDetails":"Detalles del error"},"fr":{"cloudSync.title":"Synchronisation cloud","cloudSync.description":"Utilisez votre compte GitHub pour synchroniser le dictionnaire, les styles et les préférences entre vos appareils.","cloudSync.signIn":"Se connecter avec GitHub","cloudSync.account":"Compte de synchronisation","cloudSync.refresh":"Actualiser l’état","cloudSync.loading":"Vérification de l’état du cloud…","cloudSync.noBackup":"Aucune sauvegarde cloud pour le moment","cloudSync.available":"Sauvegarde cloud disponible","cloudSync.summary":"{{dictionary}} mots · {{corrections}} corrections · {{stylePacks}} styles","cloudSync.updated":"Mis à jour {{time}}","cloudSync.upload":"Sauvegarder dans le cloud","cloudSync.restore":"Restaurer depuis le cloud","cloudSync.delete":"Supprimer la sauvegarde cloud","cloudSync.working":"Synchronisation…","cloudSync.uploadSuccess":"Sauvegarde cloud enregistrée","cloudSync.restoreSuccess":"Réglages restaurés depuis le cloud","cloudSync.deleteSuccess":"Sauvegarde cloud supprimée","cloudSync.failed":"Échec de la synchronisation : {{error}}","cloudSync.conflict":"La copie cloud a changé. Actualisez son état avant de choisir de sauvegarder ou de restaurer.","cloudSync.unavailable":"Le service officiel de synchronisation est actuellement indisponible. Réessayez plus tard.","cloudSync.signInRequired":"Connectez-vous d’abord avec GitHub.","cloudSync.restoreTitle":"Restaurer la sauvegarde cloud ?","cloudSync.restoreDescription":"Les entrées du dictionnaire, corrections, styles et préférences synchronisées du cloud remplaceront leurs équivalents locaux. Les clés API, chemins et autorisations restent sur cet appareil.","cloudSync.deleteTitle":"Supprimer la sauvegarde cloud ?","cloudSync.deleteDescription":"Seule la sauvegarde cloud de ce compte GitHub sera supprimée. Les données locales sont conservées.","cloudSync.confirmRestore":"Restaurer et remplacer","cloudSync.confirmDelete":"Supprimer la sauvegarde","cloudSync.scope":"Synchronise le dictionnaire, les corrections, les icônes de styles et les préférences communes. Les clés API, identifiants et réglages propres à l’appareil restent ici.","macDictationKey.Changed":"Le raccourci a changé pendant l'enregistrement. Réessayez.","macDictationKey.label":"Touche de dictée Mac","macDictationKey.description":"Remplace le raccourci de dictée actuel par la touche micro. En quittant OpenLess, la touche est rendue à macOS.","macDictationKey.Permission":"Autorisez OpenLess dans « Confidentialité et sécurité → Accessibilité » de macOS, puis réessayez.","macDictationKey.Busy":"Terminez la dictée en cours avant de modifier le raccourci.","macDictationKey.Unavailable":"Impossible d'activer le raccourci ; l'association enregistrée est inchangée. Réessayez ou choisissez une autre touche.","app.name":"OpenLess","app.tagline":"Parlez naturellement, écrivez avec précision","common.loading":"Chargement…","common.retry":"Réessayer","common.settingsLoadFailed":"Impossible de charger les réglages","common.refresh":"Actualiser","common.clear":"Effacer","common.copy":"Copier","common.delete":"Supprimer","common.later":"Plus tard","common.cancel":"Annuler","common.close":"Fermer","common.show":"Afficher","common.hide":"Masquer","common.saved":"Enregistré","common.saving":"Enregistrement…","common.experimental":"Expérimental","common.copied":"Copié","common.operationFailed":"L’opération a échoué","common.add":"Ajouter","common.durationSeconds":"{{value}}s","common.durationMillis":"{{value}}ms","common.durationMinutes":"{{value}}min","capsule.thinking":"réflexion","capsule.using":"action","capsule.cancelled":"Annulé","capsule.error":"Une erreur s’est produite","capsule.inserted":"{{count}} insérés","capsule.translating":"Traduction","capsule.selectionPolish.polishing":"Amélioration du texte…","capsule.selectionPolish.replaced":"Remplacé","capsule.selectionPolish.noSelection":"Aucun texte sélectionné","capsule.selectionPolish.failed":"L’amélioration a échoué. Réessayez","selectionPolishPreview.title":"Aperçu du texte amélioré","selectionPolishPreview.subtitle":"Vous pouvez modifier le résultat. La sélection d’origine ne sera remplacée qu’après confirmation.","selectionPolishPreview.cancel":"Annuler","selectionPolishPreview.resultLabel":"Texte amélioré","selectionPolishPreview.sourcePrefix":"Original : ","selectionPolishPreview.applyError":"Impossible d’appliquer : ","selectionPolishPreview.confirmReplace":"Confirmer et remplacer","selectionVoiceIntent.title":"Que souhaitez-vous faire ?","selectionVoiceIntent.subtitle":"Votre instruction vocale a été reconnue. Choisissez comment poursuivre.","selectionVoiceIntent.loading":"Chargement…","selectionVoiceIntent.sourcePrefix":"Sélection : ","selectionVoiceIntent.errorPrefix":"Impossible de continuer : ","selectionVoiceIntent.question":"Poser une question","selectionVoiceIntent.edit":"Modifier la sélection","selectionVoiceIntent.cancel":"Annuler","qa.title":"Questions","qa.headerHint":"Posez une question à tout moment","qa.thinking":"Réflexion…","qa.error":"Une erreur s’est produite. Réessayez.","qa.errorRetry":"Réessayer","qa.errorRetryHint":"Veuillez réessayer.","qa.pinTooltip":"Épingler (garder ouvert)","qa.unpinTooltip":"Détacher","qa.closeTooltip":"Fermer","qa.micLabel":"Poser une question à voix haute","qa.micStop":"Arrêter l’enregistrement","qa.selectionPreview":"À partir du texte sélectionné :","qa.emptyTitle":"Comment puis-je vous aider ?","qa.emptyDesc":"Sélectionnez un texte pour poser une question à son sujet, ou saisissez votre question ci-dessous. Les réponses apparaissent ici et vous pouvez poursuivre la conversation.","qa.recordingHint":"Enregistrement… appuyez de nouveau sur {{recordHotkey}} pour envoyer","qa.mobileRecordLabel":"bouton d’enregistrement","qa.mobileRecordStart":"Démarrer l’enregistrement","qa.mobileRecordStop":"Arrêter et envoyer","qa.composerPlaceholder":"Saisissez une question. Appuyez sur Entrée pour envoyer","qa.composerSend":"Envoyer","qa.statusIdle":"Appuyez sur {{recordHotkey}} pour poser une question","qa.statusRecording":"Enregistrement","qa.statusThinking":"Réflexion","qa.statusError":"Erreur","qa.jumpToLatest":"Aller au dernier message","qa.editApplyReplace":"Aperçu et confirmation de l’insertion","qa.editApplyUnavailable":"Aucun résultat à appliquer","qa.editRevertPrevious":"Conserver la version précédente","qa.editInstructionMode":"Instruction de modification","lessComputer.title":"Less Computer","lessComputer.subtitle":"Que doit faire votre ordinateur ?","lessComputer.you":"Vous","lessComputer.working":"Action en cours…","lessComputer.tool":"{{name}} utilisé","lessComputer.compaction":"Contexte résumé","lessComputer.done":"Terminé","lessComputer.cost":"${{cost}}","lessComputer.error":"Échec. Réessayez.","lessComputer.closeTooltip":"Fermer","lessComputer.jumpToLatest":"Aller au dernier message","lessComputer.inputPlaceholder":"Saisissez une instruction. Appuyez sur Entrée pour envoyer","lessComputer.send":"Envoyer","lessComputer.approvalTitle":"Exécuter la commande bloquée ?","lessComputer.approvalRerunWarning":"L’approbation relance la commande dans un espace de travail déjà modifié. Répéter une opération non idempotente peut produire des effets supplémentaires.","lessComputer.approve":"Approuver","lessComputer.deny":"Refuser","lessComputer.approved":"Approuvé","lessComputer.denied":"Refusé","nav.overview":"Vue d’ensemble","nav.history":"Historique","nav.vocab":"Dictionnaire","nav.style":"Style","nav.marketplace":"Catalogue","nav.translation":"Traduction","nav.selectionAsk":"Questions","nav.corrections":"Corrections","nav.polishMode":"Mode de rédaction","nav.group.style":"Style","nav.group.tools":"Outils","nav.localAsr":"Modèles","nav.more":"Plus","marketplace.kicker":"CATALOGUE","marketplace.title":"Catalogue de packs de styles","marketplace.desc":"Parcourez, installez et partagez les packs de styles de la communauté.","marketplace.searchPlaceholder":"Rechercher un nom, une description ou des étiquettes…","marketplace.sortPopular":"Populaires","marketplace.sortNew":"Récents","marketplace.uploadBtn":"Publier","marketplace.uploadDisabledHint":"Connectez-vous d’abord à GitHub dans Réglages → Catalogue","marketplace.refreshBtn":"Actualiser","marketplace.empty":"Aucun pack de styles pour le moment","marketplace.emptyHint":"Essayez un autre mot-clé ou publiez votre propre pack","marketplace.loadFailed":"Échec du chargement : {{err}}","marketplace.noDescription":"(sans description)","marketplace.installBtn":"Installer","marketplace.installingBtn":"Installation…","marketplace.downloadZipBtn":"Télécharger le ZIP","marketplace.downloadingZipBtn":"Téléchargement…","marketplace.downloadAria":"Télécharger le ZIP de « {{name}} »","marketplace.likeBtn":"J’aime","marketplace.installed":"« {{name}} » installé sur cet appareil","marketplace.downloaded":"ZIP de « {{name}} » téléchargé","marketplace.uploaded":"Envoyé ; en attente de validation","marketplace.uploadTitle":"Choisissez un pack de styles à publier","marketplace.uploadHint":"Envoi sous le nom {{login}}. Le contenu rejoint la file de validation dans le cloud.","marketplace.uploadNoLocal":"Aucun pack local à publier","marketplace.errors.detail":"Impossible de charger les détails : {{err}}","marketplace.errors.install":"Échec de l’installation : {{err}}","marketplace.errors.download":"Impossible de télécharger le ZIP : {{err}}","marketplace.errors.like":"Impossible d’ajouter la mention « J’aime » : {{err}}","marketplace.errors.upload":"Échec de l’envoi : {{err}}","marketplace.errors.loadLocal":"Impossible de charger les packs locaux : {{err}}","marketplace.sortLiked":"Aimés","marketplace.likedEmpty":"Vous n’avez pas encore aimé de pack de styles","marketplace.likedEmptyHint":"Ouvrez un pack et cliquez sur l’étoile ; les packs aimés apparaîtront ici","marketplace.derivativeBadge":"Dérivé de @{{login}}","marketplace.detail.withdrawBtn":"Retirer","marketplace.detail.withdrawConfirm":"Retirer « {{name}} » du catalogue ? Votre copie locale sera conservée.","marketplace.detail.withdrawSuccess":"Retiré du catalogue","marketplace.detail.withdrawFailed":"Impossible de retirer le pack : {{err}}","marketplace.myPacks.buttonLabel":"Mes packs","marketplace.myPacks.buttonTitle":"Voir les publications de {{login}}","marketplace.myPacks.buttonTitleEmpty":"Définissez d’abord votre identité d’auteur dans Réglages → Catalogue","marketplace.myPacks.searchPlaceholder":"Rechercher un nom ou des étiquettes","marketplace.myPacks.notLoggedIn":"Définissez d’abord votre identité d’auteur dans Réglages → Catalogue","marketplace.myPacks.emptyTitle":"Vous n’avez pas encore publié de pack de styles","marketplace.myPacks.emptyHint":"Modifiez un pack dans la page Style et cliquez sur « Publier dans le catalogue », ou envoyez un pack local depuis le coin supérieur droit.","marketplace.myPacks.noMatch":"Aucun pack de styles correspondant","marketplace.myPacks.summary":"{{count}} publiés","marketplace.myPacks.summaryPending":"{{count}} publiés · {{pending}} en attente de validation","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"Mettre à jour","marketplace.myPacks.actions.withdraw":"Retirer","marketplace.myPacks.loadFailed":"Impossible de charger vos packs : {{err}}","marketplace.myPacks.loadingTitle":"Chargement…","marketplace.myPacks.loadingHint":"Récupération de vos dernières publications dans le catalogue.","marketplace.myPacks.loadErrorTitle":"Échec du chargement","marketplace.myPacks.loadErrorRetry":"Réessayer","marketplace.upload.confirmBtn":"Confirmer l’envoi","marketplace.upload.updateTitle":"Mettre à jour « {{name}} »","marketplace.upload.updateHint":"Choisissez la version locale la plus récente, puis cliquez sur « Confirmer l’envoi ». Le pack portant le même nom est présélectionné.","marketplace.upload.recommendedBadge":"Recommandé","marketplace.state.pending":"En attente","marketplace.state.approved":"Publié","marketplace.state.rejected":"Refusé","marketplace.state.withdrawn":"Retiré","marketplace.state.superseded":"Remplacé","marketplace.state.unknown":"Inconnu","marketplace.oauth.title":"Se connecter avec GitHub","marketplace.oauth.generating":"Génération du code de l’appareil…","marketplace.oauth.browserHint":"Ouvrez {{uri}} dans votre navigateur et saisissez ce code :","marketplace.oauth.copyBtn":"Copier","marketplace.oauth.copied":"Code de l’appareil copié","marketplace.oauth.copyFailed":"Impossible de copier : {{err}}","marketplace.oauth.openBrowserBtn":"Ouvrir le navigateur","marketplace.oauth.cancelBtn":"Annuler","marketplace.oauth.waiting":"En attente de l’autorisation dans le navigateur…","marketplace.oauth.successAs":"Connecté en tant que @{{login}}","marketplace.oauth.retryBtn":"Réessayer","marketplace.oauth.closeBtn":"Fermer","marketplace.oauth.loginBtn":"Se connecter","marketplace.oauth.loginTooltip":"Se connecter avec GitHub","marketplace.oauth.reloginTooltip":"Cliquez pour vous reconnecter ou changer de compte (actuel : @{{login}})","marketplace.modal.loggedIn":"Identité de connexion actuelle ; modifiez-la dans Réglages → Enregistrement → Catalogue","marketplace.modal.notLoggedIn":"Non connecté ; définissez votre nom d’auteur dans Réglages → Enregistrement → Catalogue","marketplace.modal.notLoggedInLabel":"Non connecté","shell.shortcutLabel":"Raccourci d’enregistrement","shell.shortcutHint":"Démarrer / Arrêter","shell.betaTag":"BETA","shell.betaNote":"Stockage local, sauvegarde cloud facultative","shell.navHint.overview":"Vue d’ensemble : statistiques d’utilisation et état des services et autorisations","shell.navHint.history":"Historique des dictées : recherchez, réécoutez et copiez les transcriptions précédentes","shell.navHint.vocab":"Dictionnaire : mots personnalisés pour mieux reconnaître les noms propres","shell.navHint.style":"Styles de rédaction : gérez les styles de sortie et les instructions personnalisées","shell.navHint.translation":"Traduction : maintenez Maj pendant que vous parlez pour insérer le texte dans une autre langue","shell.navHint.selectionAsk":"Questions sur la sélection : sélectionnez du texte, puis posez une question à voix haute","shell.navHint.settings":"Préférences : raccourcis, fournisseurs, confidentialité et mises à jour","shell.footer.account":"Compte","shell.footer.feedback":"Commentaires","shell.footer.settings":"Réglages","shell.footer.help":"Aide","shell.footer.version":"Version {{version}}","shell.footer.helpPopover.tagline":"Saisie vocale privilégiant le traitement local","shell.footer.helpPopover.releaseNotes":"Notes de version ↗","shell.footer.helpPopover.docs":"Centre d’aide ↗","shell.providerPrompt.title":"Configurer les services vocaux","shell.providerPrompt.body":"Aucun service ASR ni LLM n’est encore configuré. Ajoutez des identifiants pour utiliser la saisie vocale et l’amélioration du texte.","shell.providerPrompt.later":"Plus tard","shell.providerPrompt.openSettings":"Ouvrir les réglages","shell.hotkeyModePrompt.title":"Vérifier le mode d’enregistrement","shell.hotkeyModePrompt.body":"Le mode par défaut est désormais Basculer. Si vous aviez changé le mode de déclenchement, vérifiez-le dans les réglages d’enregistrement.","shell.hotkeyModePrompt.later":"Me le rappeler plus tard","shell.hotkeyModePrompt.openSettings":"Ouvrir Enregistrement","onboarding.welcome":"Bienvenue dans OpenLess","onboarding.intro":"Parlez et écrivez depuis votre appareil. Deux autorisations système sont nécessaires avant de commencer.","onboarding.accessibilityTitle":"Accessibilité","onboarding.hotkeyTitle":"Raccourci global","onboarding.accessibilityDesc":"Permet de détecter le raccourci global (par défaut : {{trigger}}) et d’insérer les transcriptions à l’emplacement du curseur.","onboarding.hotkeyDesc":"Permet de vérifier que le détecteur de raccourcis globaux est disponible.","onboarding.micTitle":"Microphone","onboarding.micDesc":"Permet de capter votre voix.","onboarding.actionNotApplicable":"Non nécessaire","onboarding.actionGranted":"Accordé","onboarding.actionOpenSystem":"Ouvrir Réglages Système","onboarding.actionRestart":"Réinitialiser l’accessibilité et redémarrer OpenLess","onboarding.actionGrant":"Accorder","onboarding.actionRequestMic":"Demander l’accès","onboarding.micNoDeviceHint":"Aucun microphone détecté. Connectez et activez un microphone, puis réessayez.","onboarding.accessibilityHint":"Après avoir accordé l’autorisation, vous devez **quitter complètement OpenLess**, puis le rouvrir (exigence TCC de macOS).","onboarding.footerHint":"Cette configuration se ferme quand les deux autorisations sont accordées. Sinon, quittez OpenLess depuis la barre des menus et relancez-le.","onboarding.continueToSettings":"Ouvrir uniquement les réglages (sans voix ni raccourcis globaux)","onboarding.androidContinue":"Continuer vers l’application","onboarding.androidFooterHint":"La dictée nécessite l’accès au microphone. Cliquez sur « Demander l’accès » ci-dessus ou continuez pour l’accorder plus tard dans Vue d’ensemble.","onboarding.androidTitle":"Configurer OpenLess","onboarding.androidIntro":"Configurez progressivement les autorisations et services du mobile.","onboarding.androidStepCounter":"Étape {{current}} sur {{total}}","onboarding.androidBack":"Retour","onboarding.androidNext":"Suivant","onboarding.androidFinish":"Terminer et ouvrir","onboarding.androidSteps.microphoneTitle":"Autorisation du microphone","onboarding.androidSteps.microphoneDesc":"Ouvrez le dialogue d’autorisation Android et autorisez OpenLess à enregistrer votre voix.","onboarding.androidSteps.accessibilityTitle":"Service d’accessibilité","onboarding.androidSteps.accessibilityDesc":"Insère les résultats dans le champ actif et aide à détecter le contexte de saisie.","onboarding.androidSteps.overlayPermissionTitle":"Autorisation de fenêtre flottante","onboarding.androidSteps.overlayPermissionDesc":"Autorisez OpenLess à afficher le contrôle d’enregistrement au-dessus des autres applications.","onboarding.androidSteps.overlayConfigTitle":"Réglages de la fenêtre flottante","onboarding.androidSteps.overlayConfigDesc":"Configurez la visibilité, l’activation, les gestes de balayage et la taille du bouton.","onboarding.androidSteps.asrTitle":"Service ASR dans le cloud","onboarding.androidSteps.asrDesc":"Configurez le fournisseur de reconnaissance vocale, la clé, l’adresse et le modèle.","onboarding.androidSteps.llmTitle":"Service LLM","onboarding.androidSteps.llmDesc":"Configurez le modèle de langage pour améliorer le texte, traduire et répondre aux questions.","overview.refresh":"Actualiser l’état","overview.servicesTitle":"Services vocaux actuels","overview.statsTitle":"Votre activité","overview.omniKind":"Voix multimodale","overview.omniName":"Modèle Omni actuel","overview.statusLoading":"Lecture de la configuration des services…","overview.configureProvider":"Configurer","overview.manageProvider":"Gérer le service","overview.recentEmptyHint":"Aucune dictée pour le moment. Essayez avec le guide ci-dessus ; le résultat apparaîtra ici.","overview.providerHelp.asr":"Transforme votre voix en texte.","overview.providerHelp.llm":"Organise et améliore le texte selon votre style.","overview.providerHelp.omni":"Un même modèle reconnaît la voix et traite le texte.","overview.actions.refresh":"Réessayer","overview.actions.services":"Services et modèles d’IA","overview.actions.general":"Enregistrement et saisie","overview.actions.shortcuts":"Raccourcis","overview.actions.privacy":"Autorisations et données","overview.guide.nextStep":"Étape suivante","overview.guide.loadingTitle":"Lecture de votre configuration","overview.guide.loadingDesc":"Vos services actuels et la prochaine étape apparaîtront dans un instant.","overview.guide.unavailableTitle":"L’état des services est indisponible","overview.guide.unavailableDesc":"Réessayez ou ouvrez les services d’IA pour vérifier votre configuration.","overview.guide.servicesTitle":"Configurez vos services vocaux","overview.guide.servicesDesc":"Commencez par choisir les services de reconnaissance vocale et de traitement du texte. En mode Omni, seul le modèle multimodal actif doit être configuré.","overview.guide.permissionsTitle":"Vérifiez l’état de vos raccourcis","overview.guide.permissionsDesc":"L’adaptateur de raccourcis est indisponible. Ouvrez Autorisations et données pour voir son état et les options disponibles.","overview.guide.shortcutsTitle":"Choisissez un raccourci d’enregistrement","overview.guide.shortcutsDesc":"Choisissez un raccourci pratique pour commencer à dicter pendant que vous écrivez.","overview.guide.recordingTitle":"Choisissez comment enregistrer","overview.guide.recordingDesc":"La configuration du service est enregistrée. Ouvrez les réglages d’enregistrement pour choisir le microphone et le mode.","overview.guide.tryDictationTitle":"Essayez une dictée","overview.guide.tryDictationDesc":"Placez le curseur à l’endroit où vous voulez écrire. {{shortcut}}","overview.guide.permissionsHint":"La voix ou les raccourcis ne répondent pas ? Vérifiez les autorisations, l’accès au microphone et l’état des raccourcis dans Autorisations et données.","overview.kicker":"TABLEAU DE BORD","overview.title":"Vue d’ensemble du jour","overview.desc":"Statistiques de dictée du jour et état du système.","overview.pressPrefix":"Appuyez sur","overview.pressSuffix":"pour commencer","overview.asrKind":"Reconnaissance vocale","overview.llmKind":"Traitement du texte","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"Compatible OpenAI","overview.llmConfigured":"LLM actif configuré","overview.llmNotConfigured":"Non configuré","overview.statusConfigured":"Configuré","overview.statusNotConfigured":"Non configuré","overview.statusUnknown":"Indisponible","overview.credentialsLoadError":"Impossible de lire l’état des identifiants","overview.metricChars":"Caractères aujourd’hui","overview.metricSegments":"{{count}} segments","overview.metricDuration":"Durée totale du jour","overview.metricAvg":"Moyenne par segment","overview.metricAvgTrend":"Moyenne du jour","overview.metricNoData":"Aucune donnée","overview.historyLoadError":"Impossible de charger l’historique","overview.metricTotal":"Nombre total d’entrées","overview.metricTotalTrend":"Archive locale (200 maximum)","overview.activityTitle":"Activité annuelle","overview.activityCount":"{{count}} dictée(s)","overview.activityLoadError":"Impossible de charger l’activité","overview.period.ariaLabel":"Période du rapport","overview.period.last7Days":"7 derniers jours","overview.period.last30Days":"30 derniers jours","overview.period.dailyAverage":"{{value}} / jour","overview.period.minutes":"{{value}} min","overview.period.hoursMinutes":"{{hours}} h {{minutes}} min","overview.metricName.ariaLabel":"Indicateur","overview.metricName.count":"Nombre","overview.metricName.chars":"Caractères","overview.metricName.duration":"Durée","overview.recentTitle":"Transcriptions récentes","overview.recentAll":"Tout voir →","overview.recentEmpty":"Aucune entrée pour le moment. Appuyez sur {{trigger}} pour démarrer votre premier enregistrement.","overview.recentLoadFailed":"Impossible de charger les transcriptions récentes. Réessayez.","overview.historyRetry":"Réessayer","overview.weekDays.0":"Dim","overview.weekDays.1":"Lun","overview.weekDays.2":"Mar","overview.weekDays.3":"Mer","overview.weekDays.4":"Jeu","overview.weekDays.5":"Ven","overview.weekDays.6":"Sam","overview.inAppDictation.title":"Dictée dans l’application","overview.inAppDictation.start":"Démarrer l’enregistrement","overview.inAppDictation.stop":"Arrêter l’enregistrement","overview.inAppDictation.idle":"Appuyez pour commencer à enregistrer","overview.inAppDictation.recording":"Enregistrement…","overview.inAppDictation.processing":"Traitement…","overview.androidMicBanner.title":"Autorisation du microphone nécessaire","overview.androidMicBanner.desc":"Accordez l’accès au microphone pour utiliser la dictée et la saisie vocale dans l’application.","overview.androidMicBanner.grant":"Demander l’accès","overview.androidMicBanner.openSettings":"Ouvrir les réglages","history.exportError":"Impossible d’exporter l’enregistrement. Réessayez.","history.kicker":"HISTORIQUE","history.title":"Historique","history.desc":"Transcriptions enregistrées sur cet appareil.","history.filterAll":"Tout","history.summary":"{{total}} au total · {{shown}} affichées","history.searchPlaceholder":"Rechercher dans les transcriptions… ({{shortcut}})","history.searchNoMatch":"Aucune entrée ne correspond à « {{query}} ».","history.empty":"Aucun historique. Appuyez sur {{trigger}} pour enregistrer.","history.loadFailed":"Impossible de charger l’historique : {{err}}","history.retry":"Réessayer","history.clearFailed":"Impossible d’effacer l’historique : {{err}}","history.deleteFailed":"Impossible de supprimer l’entrée : {{err}}","history.copyFailed":"Impossible de copier : {{err}}","history.playRecording":"Lire l’enregistrement","history.audioLoading":"Chargement…","history.audioDecodeFailed":"Impossible de décoder l’audio : {{err}}","history.exportRecording":"Exporter l’enregistrement","history.exportFailed":"Échec de l’exportation : {{err}}","history.retranscribe":"Retranscrire","history.retranscribing":"Transcription…","history.retranscribeFailed":"Échec de la nouvelle transcription : {{err}}","history.rawLabel":"Brut","history.rawEmpty":"(vide)","history.selectHint":"Sélectionnez une entrée à gauche pour afficher ses détails.","history.recorded":"Enregistrement : {{duration}}","history.stepAsr":"Transcription","history.multimodalPipeline":"Multimodal","history.stepAsrHint":"Temps d’attente de la transcription après avoir relâché la touche. La reconnaissance en temps réel transcrit pendant que vous parlez ; ce délai est donc généralement bien plus court que l’enregistrement.","history.stepPolish":"Amélioration","history.stepInsert":"Insertion","history.chars":"{{count}} caractères","history.vocabHits":"{{count}} correspondances du dictionnaire","history.inserted":"Inséré","history.pasteSent":"Collage envoyé","history.copiedFallback":"Copié (utilisez {{shortcut}})","history.insertFailed":"Échec de l’insertion","history.confirmClear":"Supprimer les {{count}} entrées de l’historique ? Cette action est irréversible.","history.backToList":"Retour à la liste","history.repolish.title":"Améliorer à nouveau","history.repolish.hint":"Améliore à nouveau la transcription ci-dessus. Les résultats ne sont visibles que pendant cette visite et ne modifient pas l’entrée. Si le pack d’origine a été supprimé ou si l’entrée est antérieure aux packs de styles, le style actuel sera utilisé.","history.repolish.retry":"Réessayer avec le même style","history.repolish.retrying":"Nouvel essai…","history.repolish.apply":"Appliquer","history.repolish.applying":"Amélioration du texte…","history.repolish.pickStyle":"Choisir un pack de styles","history.repolish.noPacks":"Aucun pack de styles disponible.","history.repolish.packsLoadFailed":"Impossible de charger les packs de styles : {{err}}","history.repolish.failed":"La nouvelle amélioration a échoué : {{err}}","history.repolish.timeout":"Le fournisseur LLM n’a pas répondu sous 30 secondes. Choisissez un fournisseur plus rapide ou réessayez plus tard ; les modèles gratuits ont souvent une file d’attente.","history.repolish.resultTitle":"Résultat de {{name}}","history.repolish.retryResultTitle":"Résultat du nouvel essai","history.repolish.empty":"(le modèle a renvoyé un résultat vide)","history.repolish.clear":"Effacer les résultats","vocabCard.title":"Mémoriser ce mot ?","vocabCard.accept":"Mémoriser","vocabCard.reject":"Ignorer","insertFallbackCard.copy":"Copier","insertFallbackCard.copied":"Copié","insertFallbackCard.copyFailed":"Impossible de copier","insertFallbackCard.dismiss":"Fermer","vocab.selectAllVisible":"Sélectionner les résultats actuels","vocab.selectedCount":"{{count}} mots sélectionnés","vocab.selectWord":"Sélectionner « {{phrase}} »","vocab.deleteSelected":"Supprimer la sélection ({{count}})","vocab.batchDeleteFailed":"Impossible de supprimer {{count}} mots. Ils restent sélectionnés pour que vous puissiez réessayer.","vocab.kicker":"DICTIONNAIRE","vocab.title":"Dictionnaire","vocab.desc":"Ajoutez des termes ou du jargon pour améliorer la précision de la reconnaissance.","vocab.sectionTitle":"Entrées","vocab.placeholder":"Saisissez un mot, puis appuyez sur Entrée ou cliquez sur Ajouter…","vocab.tip":"Chinois et anglais combinés acceptés · les préfixes numériques sont comparés littéralement · les correspondances sont comptées automatiquement","vocab.loadFailed":"Échec du chargement : {{err}}","vocab.empty":"Aucune entrée pour le moment. Ajoutez un terme ou une expression spécialisée ci-dessus pour que le modèle les privilégie.","vocab.tipDisabled":"Cliquez pour désactiver cette entrée","vocab.tipEnabled":"Cliquez pour activer cette entrée","vocab.removeAria":"Supprimer","vocab.edit":"Modifier","vocab.editTitle":"Modifier le mot","vocab.editSave":"Enregistrer","vocab.editEmpty":"Le mot ne peut pas être vide.","vocab.filter.all":"Tous","vocab.filter.auto":"Ajoutés automatiquement","vocab.filter.manual":"Ajoutés manuellement","vocab.searchPlaceholder":"Rechercher","vocab.searchEmpty":"Aucun mot correspondant.","vocab.newWord":"Nouveau mot","vocab.newWordTitle":"Ajouter des mots","vocab.newWordDesc":"Saisissez un mot ou importez plusieurs mots à partir de modèles prédéfinis.","vocab.newWordInputPlaceholder":"Saisissez un mot, puis appuyez sur Entrée pour l’ajouter…","vocab.newWordTemplates":"Modèles prédéfinis","vocab.newWordTemplateCount":"{{count}} mots","vocab.newWordAddSelected":"Ajouter la sélection","vocab.learnedSection":"Collectés automatiquement ({{count}})","vocab.removeAllLearned":"Tout supprimer","vocab.corrections.title":"Règles de correction","vocab.corrections.tip":"Corrige les erreurs fréquentes de reconnaissance vocale. Accepte le caractère générique numérique {num}.","vocab.corrections.patternPlaceholder":"Texte erroné, p. ex. {num} voies","vocab.corrections.replacementPlaceholder":"Texte souhaité, p. ex. {num} voix","vocab.corrections.empty":"Aucune règle de correction pour le moment.","vocab.corrections.invalid":"Seuls les remplacements littéraux ou un caractère générique numérique {num} sont acceptés, par exemple {num} voies → {num} voix.","vocab.corrections.tipDisabled":"Cliquez pour désactiver cette règle","vocab.corrections.tipEnabled":"Cliquez pour activer cette règle","vocab.corrections.removeAria":"Supprimer la règle de correction","vocab.corrections.learnedBadge":"auto","vocab.corrections.learnedTip":"Collectée automatiquement à partir de vos corrections. Vous pouvez la supprimer à tout moment.","vocab.corrections.onlyLearned":"Automatiques uniquement ({{count}})","vocab.corrections.removeAllLearned":"Supprimer toutes les règles automatiques","vocab.corrections.suggestTitle":"Mémoriser cette correction ?","vocab.corrections.suggestAccept":"Mémoriser","vocab.corrections.suggestDismiss":"Non merci","vocab.presets.title":"Préréglages par contexte","vocab.presets.tip":"Sélectionnez-en plusieurs pour les appliquer ensemble. Vous pouvez les modifier ou en créer.","vocab.presets.create":"Nouveau préréglage","vocab.presets.apply":"Appliquer la sélection","vocab.presets.save":"Enregistrer le préréglage","vocab.presets.edit":"Modifier {{name}}","vocab.presets.newPreset":"Nouveau préréglage","vocab.presets.namePlaceholder":"Nom du préréglage","vocab.presets.wordsPlaceholder":"Termes séparés par des virgules ou des retours à la ligne","style.kicker":"STYLE","style.title":"Style de sortie","style.desc":"Choisissez le style de sortie par défaut pour les enregistrements.","style.masterToggle":"Interrupteur principal","style.currentDefault":"Style par défaut actuel","style.ariaSetDefault":"Définir par défaut","style.saveFailed":"Échec de l’enregistrement : {{error}}","style.customPromptTitle":"Instructions personnalisées","style.customPromptPlaceholder":"Facultatif. S’ajoutent aux instructions système intégrées à ce style.","style.customPromptHint":"Laissez vide pour conserver le comportement actuel. Après enregistrement, les instructions s’appliquent à la dictée et aux améliorations ultérieures. Vous pouvez aussi enregistrer avec Ctrl/Cmd+Enter.","style.customPromptSave":"Enregistrer les instructions","style.customPromptDirty":"Non enregistré","style.systemPromptMovedHint":"L’édition des instructions système complètes se trouve désormais dans Réglages → Fournisseurs. Cette page gère seulement les styles actifs et le style par défaut.","style.modes.raw.name":"Brut","style.modes.raw.desc":"Ajoute uniquement la ponctuation et les pauses naturelles, sans réécrire ni développer.","style.modes.raw.sample":"Conserve le rythme oral et les phrases d’origine ; supprime les hésitations comme « euh » ou « vous savez ».","style.modes.light.name":"Retouche légère","style.modes.light.desc":"Supprime les hésitations, ajoute la ponctuation et produit un texte naturel prêt à envoyer.","style.modes.light.sample":"Fluidifie la transcription sans lui donner un ton artificiel ; vos habitudes et votre ton sont préservés.","style.modes.structured.name":"Structuré","style.modes.structured.desc":"Structure les échanges de programmation, le dépannage et les retours produit avec une terminologie précise.","style.modes.structured.sample":"1. Premier sujet\na. Point\nb. Point\n2. Deuxième sujet\na. Point\nb. Point","style.modes.formal.name":"Formel","style.modes.formal.desc":"Un ton adapté aux courriels et au travail : plus complet et professionnel.","style.modes.formal.sample":"Détecte les salutations et les formules de clôture des courriels, sans ajouter de politesses inutiles.","style.pack.builtinTags.minimalEdits":"Retouches minimales","style.pack.builtinTags.strongCorrection":"Correction renforcée","style.pack.builtinTags.communication":"Communication","style.pack.builtinTags.natural":"Naturel","style.pack.builtinTags.organized":"Structuré","style.pack.builtinTags.workplaceCommunication":"Communication professionnelle","style.pack.builtinTags.aiCoding":"Programmation avec l’IA","style.pack.builtinTags.technicalStructure":"Structure technique","style.pack.newName":"Style sans titre","style.pack.newDescription":"Décrivez brièvement quand utiliser ce style.","style.pack.uploadIcon":"Importer une icône SVG pour {{name}}","style.pack.resetIcon":"Rétablir l’icône par défaut","style.pack.iconSaved":"Icône enregistrée","style.pack.iconInvalid":"Choisissez une icône SVG valide, sans ressources externes (256 Ko maximum).","style.pack.iconSaveFailed":"Impossible d’enregistrer l’icône. Réessayez.","style.pack.selectionListTitle":"Styles pour la sélection","style.pack.selectionListDesc":"Améliore la grammaire, la clarté et la mise en forme du texte sélectionné, sans reconnaissance vocale. Choisissez séparément son style et ses instructions.","style.pack.dictationTab":"Styles d’enregistrement / ASR","style.pack.selectionTab":"Amélioration de la sélection","style.pack.current":"Actuel","style.pack.useForSelection":"Utiliser pour la sélection","style.pack.writtenPolish":"Amélioration du texte écrit","style.pack.selectionPromptTitle":"Instructions pour la sélection (sans ASR)","style.pack.selectionPromptHint":"Pour du texte écrit sélectionné par l’utilisateur, pas une transcription. Ne le traitez pas comme une dictée et ne répondez pas à ses questions.","style.pack.selectionPromptEditorDesc":"Modifiez les instructions destinées au texte écrit explicitement sélectionné par l’utilisateur, sans ASR.","style.pack.dictationPromptEditorDesc":"Modifiez les instructions du style d’enregistrement / ASR ; l’entrée est le texte transcrit après la dictée.","style.pack.dictationPromptTitle":"Instructions d’enregistrement / ASR","style.pack.dictationPromptHint":"Pour les transcriptions après dictée. Définissez ici les règles de nettoyage du langage oral, de correction des erreurs ASR et de restauration des termes.","style.pack.selectionPromptFallback":"Aucune instruction pour le texte écrit ; une configuration sûre par défaut sera utilisée.","style.pack.selectionActivated":"« {{name}} » sera utilisé pour améliorer les sélections.","style.pack.selectionActivateFailed":"Impossible de changer le style de sélection : {{err}}","style.pack.selectionChars":"{{count}} caractères","style.pack.kicker":"PACKS DE STYLES","style.pack.title":"Packs de styles","style.pack.desc":"Gérez vos packs de styles locaux.","style.pack.marketplaceBtn":"Catalogue","style.pack.loadFailed":"Impossible de charger les packs de styles : {{err}}","style.pack.importZip":"Importer un ZIP","style.pack.exportZip":"Exporter en ZIP","style.pack.exportShort":"Exporter","style.pack.publishMarketplace":"Publier dans le catalogue","style.pack.updateMarketplace":"Mettre à jour la version du catalogue","style.pack.publishDisabledHint":"Configurez d’abord votre connexion GitHub dans Réglages → Catalogue","style.pack.publishSuccess":"Publié ; en attente de validation dans le catalogue","style.pack.publishFailed":"Échec de la publication : {{err}}","style.pack.publishBuiltinRejected":"Les packs intégrés ne peuvent pas être publiés. Créez d’abord une copie dans l’éditeur.","style.pack.builtin":"Intégré","style.pack.imported":"Importé","style.pack.active":"Actif","style.pack.activate":"Activer","style.pack.edit":"Modifier","style.pack.closeEditor":"Fermer","style.pack.unsaved":"Non enregistré","style.pack.listTitle":"Packs locaux","style.pack.listDesc":"Parcourez les packs et changez de style.","style.pack.listCount":"{{count}} packs","style.pack.addPackTileTitle":"Nouveau pack","style.pack.addPackTileHint":"Commencez avec un modèle vierge.","style.pack.createSuccess":"Nouveau pack créé.","style.pack.createFailed":"Impossible de créer le pack : {{err}}","style.pack.save":"Enregistrer","style.pack.revert":"Rétablir","style.pack.saveSuccess":"Pack de styles enregistré.","style.pack.saveFailed":"Impossible d’enregistrer le pack : {{err}}","style.pack.activateSuccess":"« {{name}} » est désormais le pack actuel.","style.pack.activateFailed":"Impossible de définir le pack actuel : {{err}}","style.pack.importSuccess":"« {{name}} » importé.","style.pack.importFailed":"Impossible d’importer le ZIP : {{err}}","style.pack.exportSuccess":"Exporté vers {{path}}","style.pack.exportFailed":"Impossible d’exporter le ZIP : {{err}}","style.pack.exportDirtyFirst":"Enregistrez ce pack avant de l’exporter en ZIP.","style.pack.resetBuiltin":"Réinitialiser","style.pack.resetSuccess":"« {{name}} » réinitialisé.","style.pack.resetFailed":"Impossible de réinitialiser le pack : {{err}}","style.pack.deleteImported":"Supprimer","style.pack.deleteConfirm":"Supprimer « {{name}} » ? Cette action est irréversible.","style.pack.deleteSuccess":"« {{name}} » supprimé.","style.pack.deleteFailed":"Impossible de supprimer le pack : {{err}}","style.pack.summaryCurrentEmpty":"Aucun pack sélectionné","style.pack.editorTitle":"Modifier le pack","style.pack.editorDesc":"Modifiez ce pack.","style.pack.metaTitle":"Informations d’installation","style.pack.metaSource":"Origine","style.pack.metaBaseMode":"Mode de base","style.pack.metaUpdatedAt":"Mis à jour","style.pack.fieldName":"Nom","style.pack.fieldAuthor":"Auteur","style.pack.fieldAuthorPlaceholder":"Libellé d’origine facultatif","style.pack.fieldVersion":"Version","style.pack.fieldTags":"Étiquettes","style.pack.fieldTagsPlaceholder":"Étiquettes séparées par des virgules, p. ex. communauté, voix off, formel","style.pack.fieldDescription":"Description","style.pack.fieldModel":"Modèle recommandé (métadonnées)","style.pack.fieldModelPlaceholder":"Facultatif, p. ex. gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"Métadonnées uniquement. Ne change pas le modèle.","style.pack.fieldCompatibility":"Version compatible de l’application","style.pack.fieldCompatibilityPlaceholder":"Facultatif, p. ex. >=1.3.0","style.pack.fullPromptTitle":"Instructions système","style.pack.fullPromptHint":"Les instructions propres à ce pack.","style.pack.promptChars":"{{count}} caractères","style.pack.runtimeTitle":"Directives d’exécution d’OpenLess","style.pack.runtimeDesc":"Compléments d’exécution en lecture seule.","style.pack.runtimeContextTitle":"Éléments de contexte","style.pack.runtimeContextDesc":"D’après la langue et le contexte de l’application","style.pack.runtimeContextEmpty":"Non ajouté dans cet aperçu.","style.pack.runtimeHotwordTitle":"Bloc de mots-clés","style.pack.runtimeHotwordDesc":"D’après les mots-clés activés","style.pack.runtimeHotwordEmpty":"Non ajouté dans cet aperçu.","style.pack.runtimeHistoryTitle":"Règles pour l’historique de conversation","style.pack.runtimeHistoryDesc":"Uniquement pour l’amélioration sur plusieurs interventions","style.pack.runtimeHistoryEmpty":"Ajouté uniquement en présence d’interventions précédentes.","style.pack.runtimeActive":"Actif","style.pack.runtimeInactive":"Inactif","style.pack.runtimePreviewFailed":"Impossible de générer l’aperçu d’exécution : {{err}}","style.pack.runtimePreviewOmittedFrontApp":"L’aperçu omet le nom de l’application au premier plan.","style.pack.examplesTitle":"Exemples de résultats","style.pack.examplesDesc":"Exportés avec le pack.","style.pack.addExample":"Ajouter un exemple","style.pack.examplesEmpty":"Aucun exemple pour le moment.","style.pack.exampleTitlePlaceholder":"Titre de l’exemple {{index}}","style.pack.exampleInput":"Entrée","style.pack.exampleOutput":"Sortie","style.pack.examplesCount":"{{count}} exemples","style.pack.discardCloseConfirm":"Abandonner les modifications non enregistrées et fermer l’éditeur ?","style.pack.discardSwitchConfirm":"Abandonner les modifications non enregistrées et passer à « {{name}} » ?","style.pack.derivativeBadge":"Dérivé de @{{login}}","translation.searchLanguages":"Rechercher une langue…","translation.noMatchingLanguages":"Aucune langue correspondante","translation.selectedLanguages":"{{count}} langues sélectionnées","translation.languageSupportHint":"Les langues de reconnaissance disponibles dépendent du fournisseur. Les langues de traduction sont indépendantes de celle de l’application.","translation.kicker":"TRADUCTION","translation.title":"Traduction","translation.desc":"Traduit automatiquement les enregistrements dans la langue choisie avant d’insérer le texte.","translation.statusEnabled":"Activée","translation.statusDisabled":"Désactivée","translation.working.title":"Langues habituelles","translation.working.desc":"Sélectionnez les langues que vous utilisez régulièrement pour améliorer la rédaction et la traduction.","translation.target.title":"Langue de traduction","translation.target.desc":"Appuyez sur Maj pendant l’enregistrement pour traduire. Si la traduction est désactivée, Maj n’a aucun effet.","translation.target.disabled":"Désactivée (Maj sans effet)","translation.target.sameAsWorking":"La langue cible correspond à votre seule langue habituelle : la traduction sera sans effet et Maj améliorera simplement le texte. Choisissez une autre cible ou ajoutez une langue habituelle ci-dessus.","translation.style.title":"Style de traduction","translation.style.desc":"Hérite automatiquement du pack actif dans la page Style.","translation.style.unavailable":"Indisponible","translation.save.workingFailed":"Impossible d’enregistrer les langues habituelles. Réessayez.","translation.save.targetFailed":"Impossible d’enregistrer la langue de traduction. Réessayez.","translation.save.hotkeyRegisterFailed":"Impossible d’enregistrer le raccourci de traduction auprès du système. La préférence n’a pas été sauvegardée.","translation.save.hotkeySaveFailed":"Impossible de sauvegarder le raccourci de traduction. Réessayez.","translation.howto.title":"Mode d’emploi","translation.howto.step1":"Placez le curseur dans un champ de texte.","translation.howto.step2":"Appuyez sur {{trigger}} pour démarrer l’enregistrement.","translation.howto.step3":"Appuyez une fois sur {{shortcut}} pendant l’enregistrement pour activer la traduction.","translation.howto.step4":"Appuyez de nouveau sur {{trigger}} pour arrêter.","translation.howto.step5":"Le texte traduit est inséré à l’emplacement du curseur.","translation.howto.indicatorTitle":"Comment vérifier que la traduction est active","translation.howto.indicatorDesc":"Un indicateur bleu « Traduction » apparaît en bas de l’écran après avoir appuyé sur Maj.","translation.howto.fallbackTitle":"Solution de repli","translation.howto.fallbackDesc":"Si la traduction échoue, la transcription brute est insérée à la place.","selectionAsk.title":"Questions sur la sélection","selectionAsk.desc":"Sélectionnez du texte et posez des questions à voix haute, puis poursuivez la conversation.","selectionAsk.shortcutSettings":"Réglages des raccourcis","selectionAsk.guide.openTitle":"Ouvrez le panneau","selectionAsk.guide.openDesc":"Appuyez sur {{hotkey}} pour commencer une conversation.","selectionAsk.guide.unsetDesc":"Attribuez d’abord un raccourci aux questions sur la sélection dans Réglages des raccourcis.","selectionAsk.guide.selectTitle":"Sélectionnez un texte à explorer","selectionAsk.guide.askTitle":"Posez votre question à voix haute","selectionAsk.guide.askDesc":"Appuyez sur {{recordHotkey}} pour enregistrer, puis de nouveau pour envoyer.","selectionAsk.guide.followup":"Réutilisez le raccourci d’enregistrement pour poser une autre question.","selectionAsk.guide.dismiss":"Fermer le panneau et terminer cette conversation","selectionAsk.hotkey.title":"Raccourci pour ouvrir le panneau","selectionAsk.save.historySaveFailed":"Impossible d’enregistrer le réglage d’historique des questions. Réessayez.","selectionAsk.history.title":"Conserver l’historique","selectionAsk.history.desc":"Enregistre les conversations sur cet appareil. Désactivé par défaut.","selectionAsk.howto.title":"Mode d’emploi","selectionAsk.howto.step2":"Sélectionnez du texte dans une application.","settings.selectionWorkspace.title":"Assistant de sélection","settings.selectionWorkspace.hint":"Sélectionnez du texte, puis utilisez un seul raccourci : amélioration directe sans édition vocale ; sinon, maintenez et parlez, puis choisissez Question ou Modification.","settings.selectionWorkspace.polishHotkey":"Raccourci de l’assistant de sélection","settings.selectionWorkspace.polishHotkeyDesc":"Améliore directement le texte sans édition vocale ; sinon, maintenez pour parler. L’enregistrement suit les réglages généraux.","settings.selectionWorkspace.polishDelivery":"Traitement du résultat","settings.selectionWorkspace.voiceDeliveryDesc":"Après une modification vocale, remplacez directement la sélection ou vérifiez le résultat dans le panneau Questions avant de confirmer.","settings.selectionWorkspace.voiceEnable":"Modification vocale","settings.selectionWorkspace.voiceEnableDesc":"Utilise le même raccourci ci-dessus. L’enregistrement suit les réglages généraux (actuel : {{recordingLabel}}).","settings.selectionWorkspace.autoIntent":"Détecter automatiquement l’intention","settings.selectionWorkspace.autoIntentDesc":"Le modèle configuré distingue les questions des modifications. S’il échoue, la détection repose sur les mots interrogatifs.","settings.selectionWorkspace.editKeywords":"Indices de question supplémentaires","settings.selectionWorkspace.editKeywordsDesc":"Uniquement lorsque la détection automatique est désactivée. Un indice par ligne force le mode Question ; sinon, « ? » et les mots interrogatifs servent d’indices.","settings.selectionPolish.title":"Amélioration de la sélection","settings.selectionPolish.hotkey":"Raccourci de déclenchement","settings.selectionPolish.hotkeyDesc":"Le raccourci prend effet immédiatement. Les conflits avec l’enregistrement, les questions ou d’autres raccourcis globaux sont refusés.","settings.selectionPolish.delivery":"Traitement du résultat","settings.selectionPolish.hint":"Déclenchez après avoir sélectionné du texte. Aucun microphone ni ASR n’est nécessaire. Le pack actuel est utilisé avec ses instructions dédiées aux sélections.","settings.selectionPolish.directReplace":"Remplacer directement","settings.selectionPolish.directReplaceHint":"Remplace la sélection d’origine de façon sûre lorsque le modèle a terminé.","settings.selectionPolish.previewConfirm":"Vérifier et confirmer","settings.selectionPolish.previewConfirmHint":"Vérifiez le résultat dans une fenêtre modifiable, puis confirmez pour remplacer la sélection d’origine.","settings.kicker":"RÉGLAGES","settings.title":"Réglages","settings.desc":"Enregistrement, fournisseurs, raccourcis et autorisations.","settings.network.title":"Réseau","settings.network.useSystemProxyLabel":"Utiliser le proxy système","settings.network.useSystemProxyDesc":"Les requêtes suivent le proxy système si cette option est activée. Sinon, elles se connectent directement, ce qui réduit souvent la latence des services locaux, mais peut empêcher l’accès à GitHub ou aux mises à jour dans certaines régions. Les flux vocaux en temps réel et Less Computer ne sont pas concernés.","settings.dataStorage.title":"Stockage des données","settings.dataStorage.desc":"Historique des conversations et contexte conservés sur cet appareil.","settings.dataStorage.cursorContextLabel":"Contexte du curseur (expérimental)","settings.dataStorage.cursorContextDesc":"Lors de l’amélioration du texte, lit le contenu autour du curseur dans votre document pour distinguer les homophones, noms propres et pronoms. S’il est activé, ce texte accompagne la requête au fournisseur LLM configuré. Sinon, rien n’est lu. Les champs de mot de passe, la saisie sécurisée, les gestionnaires de mots de passe et les terminaux sont toujours exclus. macOS uniquement.","settings.codingConsole.title":"Console Claude","settings.codingConsole.desc":"Détectez Claude Code et le MCP de contrôle de l’ordinateur, puis exécutez Claude sans interface avec des protections. Consultez la sortie progressive et le coût.","settings.codingConsole.guardNote":"Les actions réversibles sont autorisées par défaut ; les commandes à risque comme rm -rf, sudo et force push sont bloquées. Si le dossier est un dépôt Git, un instantané est créé avant chaque exécution pour permettre un retour arrière.","settings.codingConsole.status":"État","settings.codingConsole.detect":"Détecter","settings.codingConsole.detecting":"Détection…","settings.codingConsole.installed":"Claude détecté","settings.codingConsole.notInstalled":"claude introuvable","settings.codingConsole.notInstalledHint":"Installez d’abord Claude Code (voir docs.anthropic.com/claude-code) ou saisissez le chemin complet de son exécutable ci-dessous.","settings.codingConsole.mcpServers":"{{count}} serveur(s) MCP configuré(s)","settings.codingConsole.computerUsePresent":"MCP de contrôle du bureau configuré","settings.codingConsole.computerUseAbsent":"Aucun MCP de contrôle du bureau ; non requis pour les actions simples comme copier et coller via Bash","settings.codingConsole.exePath":"Exécutable","settings.codingConsole.workdir":"Répertoire de travail","settings.codingConsole.workdirDesc":"Facultatif. Claude s’exécute dans ce dossier. Un dépôt Git permet de créer un instantané avant l’exécution pour revenir en arrière.","settings.codingConsole.workdirPlaceholder":"Vide = utiliser un dossier temporaire","settings.codingConsole.permissionMode":"Mode d’autorisation","settings.codingConsole.mode.acceptEdits":"Autoriser les actions réversibles","settings.codingConsole.mode.plan":"Lecture seule / plan","settings.codingConsole.mode.default":"Par défaut (toujours demander)","settings.codingConsole.mode.bypassPermissions":"Ignorer toutes les autorisations (risqué)","settings.codingConsole.promptPlaceholder":"Demandez une action à Claude, par exemple lister les fichiers du dossier actuel","settings.codingConsole.run":"Exécuter","settings.codingConsole.running":"Exécution…","settings.codingConsole.cancel":"Annuler","settings.codingConsole.clear":"Effacer","settings.codingConsole.riskWarn":"Intention à haut risque détectée : {{reason}}. La protection bloque les commandes à haut risque pendant l’exécution.","settings.codingConsole.toolUse":"outil {{name}}","settings.codingConsole.done":"Terminé","settings.codingConsole.doneCost":"Terminé · coût ${{cost}}","settings.codingConsole.cancelled":"Annulé","settings.codingConsole.outputPlaceholder":"La sortie s’affichera progressivement ici…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"Maintenez une touche et parlez pour que l’agent choisi agisse sur votre ordinateur. macOS uniquement.","settings.codingAgent.enable":"Activer Less Computer","settings.codingAgent.comingSoonNote":"La configuration est enregistrée ; le déclenchement par raccourci et le flux d’exécution arriveront dans une version ultérieure.","settings.codingAgent.hotkeyHint":"Maintenez le raccourci pour parler. À son relâchement, l’agent choisi affiche le résultat dans la capsule.","settings.codingAgent.voiceHotkey":"Touche à maintenir pour parler","settings.codingAgent.voiceHotkeyDesc":"Maintenez pour parler, relâchez pour exécuter. Accepte Ctrl, Option ou Fn seuls. Consultez ses fonctions dans les réglages avancés.","settings.codingAgent.provider":"Moteur de l’agent","settings.codingAgent.opencodeReady":"OpenCode v{{version}} détecté.","settings.codingAgent.opencodeMissing":"Commande opencode introuvable. Installez-la avec npm i -g opencode-ai et connectez-vous avec opencode auth login avant utilisation.","settings.codingAgent.cliReady":"{{name}} v{{version}} détecté.","settings.codingAgent.cliMissing":"Commande {{name}} introuvable. Installez-la et connectez-vous d’abord, ou indiquez son chemin absolu dans Exécutable.","settings.codingAgent.sandboxGuardHint":"Ce moteur propose seulement des niveaux généraux d’isolation, sans liste de commandes à haut risque. Lorsqu’une limite est atteinte, l’erreur est affichée telle quelle, sans carte d’approbation de commande.","settings.codingAgent.codexModelHint":"Saisissez un modèle Codex (p. ex. gpt-5) ou laissez vide pour utiliser ~/.codex/config.toml.","settings.codingAgent.codexBudgetHint":"Codex ne propose pas de plafond en USD par exécution ; les frais dépendent du fournisseur configuré.","settings.codingAgent.codexMode.plan":"Lecture seule / plan","settings.codingAgent.codexMode.workspaceWrite":"Autoriser l’écriture dans l’espace de travail","settings.codingAgent.codexModelPlaceholder":"Vide = choix par défaut de Codex","settings.codingAgent.dshModelHint":"Le profil sans interface de dsh ne permet pas de changer de modèle. Le modèle est défini dans le propre profil de dsh.","settings.codingAgent.panelHotkey":"Raccourci du panneau (agent vocal)","settings.codingAgent.panelHotkeyDesc":"Enregistrement vocal → ASR → Claude → sortie progressive dans un panneau. Par défaut : Cmd/Ctrl+Shift+Enter.","settings.codingAgent.quickHotkey":"Raccourci d’action rapide","settings.codingAgent.quickHotkeyDesc":"Envoie le texte sélectionné à Claude et insère le résultat au curseur. Sans panneau, pour aller plus vite.","settings.codingAgent.model":"Modèle","settings.codingAgent.modelPlaceholder":"Par défaut : sonnet","settings.codingAgent.modelDefault":"Par défaut (sonnet automatique)","settings.codingAgent.modelHint":"Haiku = le plus rapide · Sonnet = équilibré · Opus = le plus puissant","settings.codingAgent.opencodeModelDefault":"Utiliser le modèle par défaut d’OpenCode","settings.codingAgent.opencodeModelHint":"Récupère automatiquement les fournisseurs et modèles disponibles pour le compte OpenCode actuel, puis enregistre immédiatement votre choix.","settings.codingAgent.opencodeModelsRefresh":"Actualiser les modèles","settings.codingAgent.opencodeModelsRefreshing":"Récupération des modèles OpenCode…","settings.codingAgent.opencodeModelsLoaded":"{{count}} modèles récupérés.","settings.codingAgent.opencodeModelsEmpty":"Aucun modèle reçu. Connectez-vous à OpenCode ou configurez d’abord un fournisseur.","settings.codingAgent.opencodeModelsError":"Impossible de récupérer les modèles : {{message}}","settings.codingAgent.exe":"Chemin de l’exécutable","settings.codingAgent.openPanel":"Test par texte","settings.codingAgent.openPanelHint":"Ouvrez Less Computer et vérifiez l’agent et le modèle actuels avec une instruction écrite.","settings.codingAgent.openPanelAction":"Ouvrir Less Computer","settings.debug.cursorLabel":"Curseur","settings.debug.title":"Outils de débogage","settings.debug.desc":"Pour diagnostiquer les problèmes de reconnaissance ; désactivés par défaut.","settings.debug.cursorProbeLabel":"Sonde du contexte du curseur","settings.debug.cursorProbeDesc":"Cliquez, puis passez à l’application cible et placez le curseur dans un champ avant la fin du décompte. La sonde lit le texte autour du curseur pour vérifier quelles applications sont lisibles et lesquelles sont bloquées par les protections. Une seule lecture, sans envoi à un fournisseur.","settings.debug.cursorProbeBtn":"Sonder dans 5 s","settings.debug.cursorProbeCountdown":"Lecture dans {{n}}s…","settings.marketplace.title":"Catalogue","settings.marketplace.desc":"Identité d’auteur pour publier des packs. Parcourez et installez les styles dans la page Styles.","settings.marketplace.github.signIn":"Se connecter avec GitHub","settings.marketplace.github.signedIn":"Connecté avec GitHub","settings.marketplace.github.signedOut":"Connectez-vous pour publier des styles et aimer des packs.","settings.marketplace.github.signOut":"Se déconnecter","settings.marketplace.github.starting":"Connexion…","settings.marketplace.github.codeHint":"Saisissez ce code sur la page GitHub qui vient de s’ouvrir :","settings.marketplace.github.openGithub":"Ouvrir GitHub","settings.marketplace.github.waiting":"GitHub est ouvert ; la connexion suivra votre autorisation…","settings.marketplace.github.failed":"Échec de la connexion. Réessayez","settings.recording.title":"Enregistrement et saisie","settings.recording.desc":"Raccourci global d’enregistrement et mode de déclenchement.","settings.recording.hotkeyLabel":"Raccourci d’enregistrement","settings.recording.hotkeyDescAcc":"Appuyez pour enregistrer votre voix depuis toute application (autorisation d’accessibilité requise).","settings.recording.hotkeyDescNoAcc":"Appuyez pour enregistrer votre voix depuis toute application.","settings.recording.modeLabel":"Mode de déclenchement","settings.recording.modeDesc":"Basculer : appuyez une fois pour démarrer, puis à nouveau pour arrêter. Maintenir pour parler : enregistre tant que la touche est enfoncée.","settings.recording.modeToggle":"Basculer","settings.recording.modeHold":"Maintenir pour parler","settings.recording.modeAuto":"Automatique","settings.recording.silenceAutoStopLabel":"Arrêter après un silence","settings.recording.silenceAutoStopDesc":"En mode Basculer uniquement. Après avoir détecté la voix, arrête et envoie l’enregistrement lorsque le silence dure le temps choisi. Désactivé par défaut ; le raccourci et Échap restent disponibles.","settings.recording.silenceAutoStopSecondsLabel":"Durée du silence","settings.recording.silenceAutoStopSecondsValue":"{{value}}s","settings.recording.migrationNoticeTitle":"Le mode d’enregistrement par défaut est désormais Basculer","settings.recording.migrationNoticeDesc":"Cette mise à jour change le mode par défaut. Si vous préférez maintenir la touche pour parler, rétablissez ce mode ici.","settings.recording.microphoneLabel":"Microphone préféré","settings.recording.microphoneDesc":"Choisissez le périphérique d’entrée préféré. S’il est indisponible, celui par défaut du système est utilisé.","settings.recording.microphoneDefault":"Microphone par défaut du système","settings.recording.microphoneDefaultDesc":"Utiliser le périphérique d’entrée par défaut du système","settings.recording.microphoneSystemDefault":"par défaut du système","settings.recording.microphoneUnavailable":"indisponible","settings.recording.microphoneLoadError":"Impossible de charger les microphones : {{message}}","settings.recording.microphoneDialogTitle":"Microphone","settings.recording.microphoneDialogDesc":"Choisissez un microphone capable de capter votre voix.","settings.recording.microphoneMonitorError":"Impossible de surveiller le niveau d’entrée : {{message}}","settings.recording.capsuleLabel":"Capsule d’enregistrement","settings.recording.capsuleDesc":"Affiche une capsule en bas de l’écran pendant l’enregistrement.","settings.recording.capsuleStyleTypeless":"Style compact Typeless","settings.recording.capsuleStyleLabel":"Style de capsule","settings.recording.capsuleStyleSiri":"Style lumineux Siri","settings.recording.capsuleStyleClassic":"Style par défaut d’OpenLess","settings.recording.muteDuringRecordingLabel":"Couper le son pendant l’enregistrement","settings.recording.muteDuringRecordingDesc":"Coupe temporairement le son système pendant la saisie vocale pour éviter l’écho des haut-parleurs.","settings.recording.audioCueLabel":"Son de début d’enregistrement","settings.recording.audioCueDesc":"Joue un bref son synthétisé lorsque vous appuyez sur le raccourci pour enregistrer, même si la capsule est masquée.","settings.recording.audioCuePreview":"Écouter","settings.recording.insertGroupTitle":"Insertion et presse-papiers","settings.recording.restoreClipboardLabel":"Restaurer le presse-papiers après insertion","settings.recording.restoreClipboardDesc":"Restaure le contenu initial du presse-papiers après un collage réussi (Windows / Linux uniquement).","settings.recording.pasteShortcutLabel":"Raccourci de collage simulé","settings.recording.pasteShortcutDesc":"Combinaison simulée pour insérer le texte. Certains terminaux nécessitent Ctrl+Shift+V (Windows / Linux uniquement).","settings.recording.pasteShortcutCtrlV":"Ctrl+V (par défaut / plupart des applications)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V (kitty / alacritty / wezterm / plupart des terminaux)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert (xterm / urxvt)","settings.recording.comboRecordLabel":"Enregistrer un raccourci","settings.recording.comboRecordDesc":"Cliquez, puis appuyez sur la combinaison souhaitée (p. ex. ⌘⇧D). Accepte les modes Basculer et Maintenir pour parler.","settings.recording.comboRecordBtn":"Enregistrer un raccourci","settings.recording.comboResetBtn":"Réinitialiser","settings.recording.comboMenuToggle":"Plus d’options","settings.recording.comboDisableHint":"Le raccourci principal ne peut pas être désactivé : l’enregistrement a besoin d’un raccourci","settings.recording.comboRecordHint":"Appuyez sur votre combinaison…","settings.recording.comboNeedKey":"Utilisez une combinaison (p. ex. ⌘⇧J) ; une touche de modification seule ne suffit pas","settings.recording.comboRecorded":"Raccourci enregistré","settings.recording.comboClear":"Effacer","settings.recording.comboConflict":"Cette combinaison n’est pas disponible","settings.recording.allowNonTsfFallbackLabel":"Autoriser une solution de repli sans TSF","settings.recording.allowNonTsfFallbackDesc":"Windows : si l’insertion TSF échoue, utilise SendInput Unicode avec des pauses. En cas de nouvel échec, copie le texte dans le presse-papiers.","settings.recording.windowsInsertionModeLabel":"Méthode d’insertion sous Windows","settings.recording.windowsInsertionModeDesc":"Définit comment la dictée est insérée au curseur. Le collage utilise le raccourci simulé ci-dessus et conserve les sauts de ligne.","settings.recording.windowsInsertionModeTsf":"IME TSF (par défaut)","settings.recording.windowsInsertionModeSendInput":"Simulation de touches SendInput","settings.recording.windowsInsertionModePaste":"Collage depuis le presse-papiers (Ctrl+V, etc.)","settings.recording.macosNewlineModeLabel":"Sauts de ligne","settings.recording.macosNewlineModeDesc":"Automatique utilise Line Feed (U+000A / Ctrl+J) dans les terminaux connus et Shift+Return ailleurs. Return seul envoie le message.","settings.recording.macosNewlineModeAuto":"Automatique (Line Feed dans les terminaux)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return (nouvelle ligne dans les chats)","settings.recording.macosNewlineModeLineFeed":"Line Feed (CLI de terminal / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return (séparer en messages)","settings.recording.windowsSendInputNewlineModeLabel":"Simulation des sauts de ligne avec SendInput","settings.recording.windowsSendInputNewlineModeDesc":"Définit comment SendInput convertit les sauts de ligne en touches. Utilisez Shift+Enter dans les chats, et Enter dans Bloc-notes, VS Code et la plupart des éditeurs.","settings.recording.windowsSendInputNewlineModeEnter":"Enter (plupart des éditeurs)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter (champs de discussion)","settings.recording.windowsSendInputNewlineModeCrLf":"Unicode CR+LF","settings.recording.windowsShowOpenlessInKeyboardListLabel":"Afficher OpenLess dans la liste des claviers","settings.recording.windowsShowOpenlessInKeyboardListDesc":"Si cette option est désactivée, Win+Space ne passe plus par OpenLess. SendInput et le collage ne sont pas affectés. Réactivez-la pour restaurer l’entrée.","settings.recording.windowsShowOpenlessInKeyboardListError":"Impossible de mettre à jour la liste des claviers : le système a refusé la modification du profil linguistique d’OpenLess.","settings.recording.historyGroupTitle":"Historique et contexte","settings.recording.historyRetentionLabel":"Conservation de l’historique (jours)","settings.recording.historyRetentionDesc":"Les entrées plus anciennes sont supprimées lors de nouveaux enregistrements. 0 = pas de suppression selon l’ancienneté.","settings.recording.historyMaxEntriesLabel":"Nombre maximal d’entrées","settings.recording.historyMaxEntriesDesc":"Nombre maximal de sessions conservées localement. Vide = 200. Plage : 5–200.","settings.recording.polishContextWindowLabel":"Fenêtre de contexte d’amélioration (minutes)","settings.recording.polishContextWindowDesc":"Utilise les transcriptions améliorées des N dernières minutes comme contexte de plusieurs interventions. 0 = désactivé.","settings.recording.recordAudioForDebugLabel":"Conserver l’enregistrement brut (débogage)","settings.recording.recordAudioForDebugDesc":"Enregistre l’audio brut du microphone en WAV pour diagnostiquer les problèmes de reconnaissance.","settings.recording.audioRecordingMaxEntriesLabel":"Nombre maximal d’enregistrements bruts","settings.recording.audioRecordingMaxEntriesDesc":"Nombre maximal de fichiers WAV conservés localement. Vide = 200.","settings.recording.startupGroupTitle":"Démarrage","settings.recording.startMinimizedLabel":"Démarrer réduit (sans fenêtre principale)","settings.recording.startMinimizedDesc":"Au lancement, seule la barre des menus ou la zone de notification est affichée, jamais la fenêtre principale.","settings.recording.autoUpdateCheckLabel":"Rechercher automatiquement les mises à jour","settings.recording.autoUpdateCheckDesc":"Recherche les mises à jour au lancement, puis toutes les 60 minutes.","settings.recording.marketplaceGroupTitle":"Catalogue de packs de styles","settings.recording.marketplaceBaseUrlLabel":"URL du serveur","settings.recording.marketplaceBaseUrlDesc":"Adresse du serveur du catalogue. Vide = adresse par défaut.","settings.recording.marketplaceDevLoginLabel":"Identifiant GitHub (identité d’auteur)","settings.recording.marketplaceDevLoginDesc":"Identifie l’auteur des publications. Si le champ est vide, la publication et les mentions « J’aime » sont désactivées.","settings.recording.startupAtBoot":"Ouvrir à la connexion","settings.recording.startupAtBootDesc":"Démarre automatiquement OpenLess à l’ouverture de votre session.","settings.recording.startupAtBootError":"Impossible de modifier le démarrage automatique : {{message}}","settings.channels.backToList":"Retour aux canaux","settings.channels.done":"Terminé","settings.channels.llmTitle":"Canaux de traitement du texte","settings.channels.asrTitle":"Canaux de reconnaissance vocale","settings.channels.current":"En cours d’utilisation","settings.channels.enabled":"Activé","settings.channels.disabled":"Désactivé","settings.channels.enabledFor":"Activer {{name}}","settings.channels.modelNotSet":"Aucun modèle explicitement défini","settings.channels.localModelManaged":"Modèle géré par le système ou par Modèles locaux","settings.channels.lastCheck":"Dernière vérification","settings.channels.verifying":"Vérification…","settings.channels.notVerified":"Pas encore vérifié","settings.channels.passed":"Vérification réussie","settings.channels.failed":"Échec de la vérification · {{reason}}","settings.channels.elapsed":"Durée : {{ms}} ms","settings.channels.staleResult":"Le résultat date de plus de 24 heures","settings.channels.connectionTitle":"Connexion au service","settings.channels.modelTitle":"Réglages du modèle","settings.channels.modelHint":"Saisissez le nom du modèle ou récupérez les modèles de votre fournisseur pour en choisir un.","settings.channels.availableModels":"Modèles disponibles","settings.channels.validationTitle":"Vérification de la connexion","settings.channels.validationHint":"Envoyez manuellement une requête réelle pour vérifier cette configuration. Elle peut consommer des crédits du service. L’enregistrement des réglages ne lance pas de vérification.","settings.channels.autoSaveHint":"Les modifications sont enregistrées automatiquement. Une fois le service configuré, vous pouvez vérifier la connexion.","settings.channels.nameHint":"Ce nom distingue les canaux d’un même fournisseur. Il n’affecte ni le modèle ni la connexion.","settings.channels.errModel":"Modèle","settings.channels.verify":"Vérifier","settings.channels.verifyHint":"Effectue un appel réel à l’API pour vérifier que ce canal fonctionne actuellement","settings.channels.errTimeout":"délai dépassé","settings.channels.errNetwork":"réseau","settings.channels.errEndpoint":"adresse","settings.channels.errGeneric":"échec","settings.channels.dragHint":"Faites glisser pour changer la priorité","settings.channels.orderHint":"Les requêtes utilisent le premier canal activé. Faites glisser pour réordonner ; les canaux désactivés passent en bas.","settings.channels.empty":"Aucun canal pour le moment. Choisissez « Ajouter un canal » pour connecter votre premier service.","settings.channels.add":"Ajouter un canal","settings.channels.edit":"Modifier","settings.channels.createTitle":"Ajouter un canal","settings.channels.editTitle":"Modifier le canal","settings.channels.providerLabel":"Fournisseur","settings.channels.nameLabel":"Nom du canal (facultatif)","settings.channels.namePlaceholder":"P. ex. SiliconFlow — clé principale","settings.channels.create":"Créer","settings.channels.delete":"Supprimer le canal","settings.channels.deleteConfirm":"La suppression efface aussi les clés enregistrées pour ce canal.","settings.channels.confirmDelete":"Supprimer","settings.channels.justNow":"à l’instant","settings.channels.minutesAgo":"il y a {{count}}min","settings.channels.hoursAgo":"il y a {{count}}h","settings.channels.daysAgo":"il y a {{count}}j","settings.channels.localEngineModelHint":"Téléchargez et changez les modèles locaux dans Services et modèles d’IA → Modèles locaux.","settings.providers.localEngineNoCredentials":"Les moteurs locaux n’ont besoin ni de clé API ni d’adresse.","settings.providers.localModelLabel":"Modèle local","settings.providers.localModelEmpty":"Aucun modèle local téléchargé","settings.providers.appleSpeechLocalNote":"Apple Speech utilise le moteur intégré du système ; aucun modèle à sélectionner.","settings.providers.localEngineNote":"Sélectionnez les modèles téléchargés dans la liste ci-dessus. Téléchargez-en d’autres et gérez-les depuis Modèles locaux.","settings.providers.localTag":"Local","settings.providers.llmTitle":"LLM (amélioration du texte)","settings.providers.llmDesc":"Protocole compatible OpenAI. Plusieurs fournisseurs sont acceptés.","settings.providers.providerLabel":"Fournisseur","settings.providers.llmProviderDesc":"Le choix d’un préréglage renseigne automatiquement l’URL de base.","settings.providers.credentialStorageNotice":"Les identifiants sont conservés dans le coffre sécurisé du système d’exploitation.","settings.providers.codexOAuthNotice":"Codex OAuth utilise la session locale de Codex (~/.codex/auth.json). OpenLess ne conserve ni clé API ni URL de base pour ce fournisseur.","settings.providers.asrProviderDesc":"Changer de fournisseur charge automatiquement les identifiants correspondants.","settings.providers.asrTitle":"ASR (transcription)","settings.providers.asrDesc":"Convertit la voix enregistrée en texte.","settings.providers.omniTitle":"Modèle multimodal","settings.providers.omniDesc":"Un modèle transforme directement l’audio et les instructions en texte final (flux expérimental).","settings.providers.pipelineModeLabel":"Mode de traitement","settings.providers.pipelineModeHint":"Traditionnel : deux étapes, ASR + LLM. Multimodal : un seul passage avec un modèle capable de traiter l’audio.","settings.providers.pipelineModeTraditional":"Traditionnel","settings.providers.pipelineModeMultimodal":"Multimodal","settings.providers.pipelineIsolationNotice":"Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"TokenHub Tencent Cloud","settings.providers.presets.customChatCompletions":"Personnalisé · Chat Completions","settings.providers.presets.customResponses":"Personnalisé · Responses","settings.providers.presets.customMessages":"Personnalisé · Messages","settings.providers.presets.ark":"ARK (Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter (modèles gratuits)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax (M3)","settings.providers.presets.stepfun":"StepFun","settings.providers.presets.custom":"Personnalisé","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrTencentCloud":"ASR temps réel Hunyuan de Tencent Cloud","settings.providers.presets.asrBailian":"Alibaba Bailian ASR en temps réel","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 ASR en temps réel","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash (fichier enregistré)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper (compatible)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"Personnalisé compatible OpenAI","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek ASR en temps réel","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"sherpa-onnx local (expérimental)","settings.providers.presets.asrFoundryLocalWhisper":"Whisper local (Foundry Local)","settings.providers.presets.asrLocalWhisper":"Whisper local (par lots)","settings.providers.presets.asrLocalQwen3":"Qwen3-ASR local","settings.providers.presets.asrLocalQwen3Mlx":"Qwen3-ASR local (MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"Qwen3-ASR local (C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple Speech (macOS)","settings.providers.presets.omniOpenai":"OpenAI (avec audio)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs envoie l’audio enregistré à l’adresse configurée pour une transcription par lots.","settings.providers.zenmuxVocabularyNote":"ZenMux utilise un protocole de transcription JSON et ne reçoit pas les mots-clés du dictionnaire (prompt/hotwords). Le dictionnaire reste utilisé pour améliorer le texte, mais n’influence pas la reconnaissance vocale.","settings.providers.asrAdvancedNote":"Les options avancées ci-dessous concernent uniquement les préréglages Personnalisé compatible OpenAI et ZenMux. Les autres fournisseurs conservent leur comportement intégré.","settings.providers.asrAdvancedVerboseJsonLabel":"Métriques des segments (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"Demande les métriques de segments pour filtrer les hallucinations si le serveur le permet. Désactivez cette option sur les serveurs auto-hébergés qui ne la prennent pas en charge.","settings.providers.asrAdvancedChunkLabel":"Durée des fragments (ms)","settings.providers.asrAdvancedChunkHint":"0 = aucun découpage ; envoie l’enregistrement complet. Le découpage convient aux longs enregistrements ou aux serveurs limitant la durée des requêtes.","settings.providers.asrAdvancedEnableItnLabel":"Normalisation des nombres (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"Convertit les nombres et unités prononcés en chiffres (p. ex. « deux mille vingt-six » → « 2026 »). Désactivez-la pour conserver le texte brut.","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"Clé API","settings.providers.volcengineResourceIdLabel":"Resource ID","settings.providers.volcengineAuthModeLabel":"Mode d’authentification","settings.providers.volcengineAuthModeAppIdToken":"Ancienne application (APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"Clé API (nouvelle console)","settings.providers.volcengineMappingNote":"Secret Key n’est pas nécessaire actuellement. Le Resource ID par défaut est volc.seedasr.sauc.duration.","settings.providers.volcengineApiKeyNote":"Utilisez une clé API créée dans la nouvelle console vocale, sans APP ID. Créez-la dans la gestion des clés API : console.volcengine.com/speech/new/setting/apikeys. Le Resource ID par défaut est volc.seedasr.sauc.duration.","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"Clé API","settings.providers.xfyunNote":"Obtenez AppID et API Key sur la page du service ASR en temps réel d’iFlytek Open Platform. L’audio est en PCM mono 16 kHz / 16 bits. L’API standard n’accepte pas de paramètre de mots-clés ; configurez-les dans la console iFlytek. La langue par défaut est le chinois mandarin.","settings.providers.tencentCloudAppIdLabel":"AppID Tencent Cloud","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Utilise les identifiants du service de reconnaissance vocale de Tencent Cloud. Le modèle par défaut Hy-ASR-3.0-preview prend en charge le chinois, l’anglais et 20 dialectes ; Preview n’accepte que le PCM mono 16 kHz jusqu’à 60 secondes et ne gère pas encore le contexte ni le renforcement de mots-clés.","settings.providers.tencentTokenHubNote":"Seuls les modèles de langage disponibles en ligne sont listés. Certains modèles raisonnent toujours ; désactiver le raisonnement conserve le comportement fixe du modèle.","settings.providers.localAsrActiveNotice":"L’ASR local ({{name}}) est actif. Changez-le ou désactivez-le dans l’onglet Avancé.","settings.providers.localAsrTakeoverHint":"Une fois activé, « {{name}} » remplacera le fournisseur ASR.","settings.providers.asrProviderTakenOver":"Un moteur local est actif. Choisissez un autre fournisseur ci-dessus pour changer ; le moteur local s’arrêtera automatiquement. Gérez les modèles dans Services → Modèles locaux.","settings.providers.localAsrHint":"Fonctionne sur cet ordinateur, sans clé API. Téléchargez le modèle depuis HuggingFace.","settings.providers.foundryLocalAsrHint":"Fonctionne sur cet appareil, sans clé API ASR. Le premier usage télécharge le moteur et le modèle.","settings.providers.localAsrPerformanceWarning":"L’inférence locale est plus lente que l’ASR dans le cloud et peut être moins précise en chinois. Elle convient à l’usage hors ligne ou aux données sensibles.","settings.providers.localAsrReady":"{{model}} téléchargé","settings.providers.localAsrNotReady":"{{model}} non téléchargé","settings.providers.localAsrGoDownload":"Ouvrir Modèles pour télécharger","settings.providers.localAsrManage":"Ouvrir Modèles","settings.providers.localAsrDownloadedTitle":"Modèles téléchargés","settings.providers.localAsrDelete":"Supprimer","settings.providers.fillDefault":"Renseigner la valeur par défaut","settings.providers.readFailed":"Échec de la lecture","settings.providers.apiKeyLabel":"Clé API","settings.providers.baseUrlLabel":"URL de base","settings.providers.modelLabel":"Modèle","settings.providers.customModelLabel":"Modèle personnalisé…","settings.providers.presetListLabel":"Retour aux préréglages","settings.providers.temperatureLabel":"Température","settings.providers.temperaturePlaceholder":"Laissez vide pour omettre ce paramètre. Plage : 0–2 inclus, p. ex. 0.3","settings.providers.extraHeadersLabel":"En-têtes supplémentaires","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"Raisonnement","settings.providers.thinkingModeOn":"Activé","settings.providers.thinkingModeOff":"Désactivé","settings.providers.requestFormatLabel":"Format de requête","settings.providers.messagesThinkingLabel":"Mode de raisonnement","settings.providers.thinkingAdaptive":"Adaptatif","settings.providers.thinkingBudget":"Budget fixe","settings.providers.maxTokensLabel":"Nombre maximal de jetons en sortie","settings.providers.thinkingBudgetLabel":"Budget de jetons de raisonnement","settings.providers.responsesThinkingHint":"Certains modèles permettent seulement de réduire le raisonnement, pas de le désactiver. Les requêtes de raisonnement omettent la température.","settings.providers.messagesThinkingHint":"Les anciens modèles ou passerelles compatibles peuvent nécessiter un budget fixe inférieur à la limite de sortie. Les requêtes de raisonnement omettent la température.","settings.providers.llmRequestFormatInvalid":"Format de requête non valide. Sélectionnez un format pris en charge.","settings.providers.llmThinkingModeInvalid":"Mode de raisonnement non valide. Sélectionnez un mode pris en charge.","settings.providers.llmTokenLimitInvalid":"Les limites de jetons doivent être des entiers positifs.","settings.providers.llmThinkingBudgetInvalid":"Le budget de raisonnement doit être au moins de 1024 et, en mode fixe, inférieur à la limite de sortie.","settings.providers.llmResponseIncomplete":"La réponse est incomplète ou a atteint la limite de sortie. Le texte déjà affiché est conservé.","settings.providers.llmProtocolHeaderConflict":"Messages définit automatiquement les en-têtes d’authentification et de version. Retirez x-api-key et anthropic-version des en-têtes supplémentaires.","settings.providers.llmStreamError":"Le serveur a renvoyé une erreur de flux. Vérifiez le modèle et les paramètres de la requête.","settings.providers.saveProtocol":"Enregistrer les réglages du protocole","settings.providers.thinkingModeHint":"Activez, désactivez ou réduisez le raisonnement avec les paramètres pris en charge par le format et le modèle. Aucune instruction de contrôle n’est ajoutée au prompt.","settings.providers.bailianVocabularyIdLabel":"ID du vocabulaire de mots-clés (facultatif)","settings.providers.bailianVocabularyIdNote":"Si vous avez créé un vocabulaire dans DashScope, saisissez son ID vocab-... Laissez vide pour ne pas utiliser de mots-clés.","settings.providers.bailianModelRealtimeHint":"Modèle en temps réel : transcrit pendant que vous parlez.","settings.providers.bailianModelSyncFileHint":"Modèle d’enregistrement synchrone : transcrit une fois l’enregistrement terminé (5 min maximum).","settings.providers.bailianModelAsyncFileHint":"Modèle de fichiers asynchrone : envoie l’enregistrement et attend la fin de la transcription.","settings.providers.appIdLabel":"App ID","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Resource ID","settings.providers.toolsLabel":"Vérification de la connexion","settings.providers.toolsDesc":"Enregistrez les champs ci-dessus, puis vérifiez le modèle choisi ou récupérez les modèles disponibles. Vous pouvez saisir un modèle manuellement si la récupération échoue.","settings.providers.validate":"Vérifier","settings.providers.validating":"Vérification…","settings.providers.fetchModels":"Récupérer les modèles","settings.providers.loadingModels":"Récupération des modèles…","settings.providers.modelMissing":"Aucun modèle configuré. Saisissez d’abord un ID de modèle.","settings.providers.modelsEmpty":"Les identifiants sont valides, mais aucun modèle n’a été renvoyé.","settings.providers.modelsLoaded":"{{count}} modèles récupérés.","settings.providers.searchModels":"Rechercher des modèles…","settings.providers.noMatchingModels":"Aucun modèle correspondant","settings.providers.orcarouterCatalogHint":"Chargé depuis /models d’OrcaRouter. Sélectionnez un modèle du catalogue ; les IDs manuels sont désactivés pour ce fournisseur.","settings.providers.orcarouterAsrCatalogHint":"Chargé depuis /models d’OrcaRouter et limité aux modèles Gemini compatibles avec l’entrée audio. Les IDs manuels sont désactivés.","settings.providers.selectModel":"Sélectionnez un modèle pour remplir le champ ci-dessus","settings.providers.modelSaved":"Modèle {{model}} enregistré.","settings.providers.validateSuccess":"Connexion vérifiée avec succès.","settings.providers.validateFailed":"Échec de la vérification de connexion.","settings.providers.providerHttpStatus":"Le fournisseur a renvoyé HTTP {{status}}. Vérifiez les autorisations de la clé API ou l’adresse.","settings.providers.endpointMustUseHttps":"Les adresses HTTP sont autorisées, mais les clés API et l’audio peuvent être exposés pendant le transfert.","settings.providers.endpointHttpWarning":"Les adresses HTTP sont autorisées, mais les clés API et le contenu des requêtes peuvent être exposés pendant le transfert.","settings.providers.endpointInvalid":"Le format de l’adresse est invalide.","settings.providers.bailianEndpointSchemeInvalid":"L’ASR en temps réel de Bailian utilise la passerelle WebSocket de DashScope. L’adresse doit commencer par wss:// (par défaut : wss://dashscope.aliyuncs.com/api-ws/v1/inference/). Une URL https:// de mode compatible ne fonctionne pas ici.","settings.providers.qwen3EndpointSchemeInvalid":"L’ASR en temps réel de Qwen3 utilise la passerelle Realtime WebSocket de DashScope. L’adresse doit commencer par wss:// (par défaut : wss://dashscope.aliyuncs.com/api-ws/v1/realtime). Une URL https:// ne fonctionne pas ici.","settings.providers.responseTooLarge":"La réponse du fournisseur est trop volumineuse pour être vérifiée de façon sûre.","settings.providers.asrInvalidJson":"La réponse ASR n’est pas un JSON valide.","settings.providers.asrMissingTextField":"Le champ text est absent de la réponse ASR.","settings.providers.apiKeyMissing":"La clé API est vide.","settings.providers.endpointMissing":"L’adresse est vide.","settings.providers.volcengineAppIdMissing":"APP ID est vide.","settings.providers.volcengineAccessTokenMissing":"Access Token est vide.","settings.providers.requestTimeout":"Le délai de la requête est dépassé. Réessayez plus tard.","settings.shortcuts.title":"Réglages des raccourcis","settings.shortcuts.descAcc":"Tous les raccourcis sont globaux. L’autorisation d’accessibilité doit être accordée dans Autorisations.","settings.shortcuts.descNoAcc":"Tous les raccourcis sont globaux. S’ils ne répondent pas, vérifiez l’état du raccourci global dans Autorisations.","settings.shortcuts.startStop":"Démarrer / arrêter l’enregistrement","settings.shortcuts.cancel":"Annuler l’enregistrement actuel","settings.shortcuts.confirm":"Confirmer l’insertion de la capsule","settings.shortcuts.switchStyle":"Passer au style précédent","settings.shortcuts.openApp":"Ouvrir OpenLess","settings.shortcuts.stylePackTitle":"Raccourcis de styles","settings.shortcuts.stylePackDesc":"Associez un raccourci à chaque pack favori pour changer d’une seule pression. Les packs désactivés sont réactivés automatiquement.","settings.shortcuts.stylePackAdd":"Ajouter un raccourci de style","settings.shortcuts.stylePackSelect":"Choisir un pack de styles","settings.shortcuts.stylePackDisabledSuffix":" (désactivé)","settings.shortcuts.stylePackRemove":"Supprimer","settings.shortcuts.agentPolish":"Améliorer le texte sélectionné","settings.shortcuts.agentPolishDesc":"Sélectionnez du texte → appuyez → Claude l’améliore → la sélection est remplacée.","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"Maintenez une touche personnalisée → parlez → Claude exécute la tâche → le résultat apparaît dans une capsule.","settings.shortcuts.agentVoiceHint":"Définissez la touche à maintenir dans Avancé → Less Computer.","settings.shortcuts.agentVoiceTrigger":"Touche à maintenir pour Less Computer","settings.shortcuts.enable":"Activer","settings.shortcuts.disable":"Désactiver","settings.shortcuts.confirmHint":"Cliquez sur ✓ dans la capsule","settings.shortcuts.notSupported":"Pas encore pris en charge","settings.shortcuts.androidReadOnly":"Les raccourcis globaux ne sont pas disponibles sur Android. Utilisez le bouton d’enregistrement de Vue d’ensemble.","settings.permissions.title":"Autorisations","settings.permissions.descAcc":"OpenLess a besoin des autorisations système suivantes. Après les avoir accordées, quittez complètement l’application et relancez-la.","settings.permissions.descNoAcc":"OpenLess a besoin du microphone et utilise l’état du détecteur de raccourcis globaux pour vérifier que le composant natif fonctionne.","settings.permissions.micLabel":"Microphone","settings.permissions.micDesc":"Permet de capter votre voix.","settings.permissions.accLabel":"Accessibilité","settings.permissions.accDesc":"Permet de détecter le raccourci global et d’insérer les transcriptions au curseur.","settings.permissions.hotkeyLabel":"Raccourci global","settings.permissions.hotkeyDescWithAdapter":"Adaptateur actif : {{adapter}}. Permet de vérifier que le détecteur de raccourcis est installé.","settings.permissions.hotkeyDescPlain":"Permet de vérifier que le détecteur de raccourcis est installé.","settings.permissions.networkLabel":"Réseau","settings.permissions.networkDesc":"Nécessaire aux services ASR / LLM dans le cloud. Désactivez-le pour un usage entièrement local.","settings.permissions.networkOk":"Disponible","settings.permissions.networkOffline":"Indisponible","settings.permissions.checking":"Vérification…","settings.permissions.granted":"Accordé","settings.permissions.notApplicable":"Non nécessaire","settings.permissions.denied":"Non accordé","settings.permissions.indeterminate":"Indéterminé","settings.permissions.micNoDevice":"Aucun microphone détecté","settings.permissions.openSystem":"Ouvrir Réglages Système","settings.permissions.restart":"Réinitialiser et redémarrer","settings.permissions.grant":"Accorder","settings.permissions.rerunAndroidSetup":"Relancer la configuration","settings.permissions.hotkeyInstalled":"Installé","settings.permissions.hotkeyStarting":"Installation…","settings.permissions.hotkeyFailed":"Échec du détecteur","settings.permissions.windowsImeLabel":"Moteur de saisie Windows","settings.permissions.windowsImeDesc":"Passe temporairement à l’IME TSF d’OpenLess pendant les sessions vocales pour éviter les limites du presse-papiers.","settings.permissions.windowsImeInstalled":"Installé","settings.permissions.windowsImeUnavailable":"Indisponible","settings.permissions.androidImeLabel":"Méthode de saisie (IME)","settings.permissions.androidImeSelected":"Sélectionnée","settings.permissions.androidImeEnabled":"Activée","settings.permissions.androidImeDisabled":"Non activée","settings.permissions.androidOverlayLabel":"Fenêtre flottante","settings.permissions.androidAccessibilityLabel":"Service d’accessibilité","settings.permissions.androidAccessibilityImpact":"Activez-le pour insérer les résultats dans le champ actuel sans changer de clavier. Sinon, les résultats sont copiés dans le presse-papiers pour un collage manuel.","settings.permissions.androidAccessibilityGrantedStale":"Autorisé, non connecté","settings.permissions.androidAccessibilityMessages.not_android":"L’état de l’accessibilité n’est disponible que sur Android.","settings.permissions.androidAccessibilityMessages.not_enabled":"Activez OpenLess dans les réglages d’accessibilité du système.","settings.permissions.androidAccessibilityMessages.operational":"Le service d’accessibilité fonctionne.","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"L’accessibilité est autorisée, mais non connectée. Réactivez OpenLess dans les réglages système.","settings.permissions.androidAccessibilityMessages.status_read_failed":"Impossible de lire l’état de l’accessibilité.","settings.permissions.androidShizukuLabel":"Améliorations Shizuku","settings.permissions.androidShizukuHint":"Facultatif. Tente de rétablir le service lorsque les réglages du fabricant bloquent les commandes manuelles, sans éliminer tous les conflits entre applications. Il peut être nécessaire de relancer Shizuku après un redémarrage.","settings.permissions.androidShizukuOpenApp":"Ouvrir Shizuku","settings.permissions.androidShizukuRequestPermission":"Demander l’autorisation","settings.permissions.androidShizukuRecover":"Rétablir l’accessibilité","settings.permissions.androidShizukuRecoverConfirm":"Utiliser Shizuku pour tenter de réactiver le service d’accessibilité d’OpenLess ? Les services déjà actifs au début de l’écriture seront conservés. Si l’interrupteur global est désactivé, l’activer peut aussi démarrer d’autres services enregistrés.","settings.permissions.androidShizukuYes":"oui","settings.permissions.androidShizukuNo":"non","settings.permissions.androidShizukuAccessibilityOperational":"L’accessibilité est enregistrée et fonctionne.","settings.permissions.androidShizukuAccessibilityRegistered":"Enregistrée : {{registered}} · En fonctionnement : {{operational}}","settings.permissions.androidShizukuState.notInstalled":"Non installé","settings.permissions.androidShizukuState.notRunning":"Non démarré","settings.permissions.androidShizukuState.notAuthorized":"Non autorisé","settings.permissions.androidShizukuState.authorized":"Autorisé","settings.permissions.androidShizukuState.binderDead":"Déconnecté","settings.permissions.androidShizukuState.notAndroid":"Sans objet","settings.permissions.androidShizukuMessages.not_android":"Shizuku n’est disponible que sur Android.","settings.permissions.androidShizukuMessages.not_installed":"Shizuku ou le moteur Sui n’est pas installé.","settings.permissions.androidShizukuMessages.unsupported_backend":"Ce moteur Shizuku est trop ancien. Mettez Shizuku ou Sui à jour vers la version 11 ou ultérieure.","settings.permissions.androidShizukuMessages.not_running":"Shizuku ne fonctionne pas. Démarrez d’abord Shizuku ou Sui.","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku n’est pas autorisé. Accordez l’autorisation à OpenLess.","settings.permissions.androidShizukuMessages.binder_dead":"Connexion Shizuku perdue. Relancez Shizuku.","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku autorisé. L’accessibilité fonctionne.","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku autorisé. L’accessibilité est enregistrée, mais ne fonctionne pas.","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku autorisé. Vous pouvez tenter de rétablir l’accessibilité.","settings.permissions.androidShizukuMessages.operational":"L’accessibilité est enregistrée et fonctionne.","settings.permissions.androidShizukuMessages.registered_stale":"L’accessibilité est enregistrée, mais le service est actuellement indisponible.","settings.permissions.androidShizukuMessages.not_registered":"L’accessibilité n’est pas activée dans les réglages système.","settings.permissions.androidShizukuMessages.already_granted":"L’autorisation Shizuku était déjà accordée.","settings.permissions.androidShizukuMessages.binder_unavailable":"La connexion Binder de Shizuku était indisponible lors de la demande d’autorisation.","settings.permissions.androidShizukuMessages.request_cancelled":"La demande d’autorisation Shizuku a été annulée.","settings.permissions.androidShizukuMessages.granted":"Autorisation Shizuku accordée.","settings.permissions.androidShizukuMessages.denied":"Autorisation Shizuku refusée.","settings.permissions.androidShizukuMessages.permission_permanently_denied":"L’autorisation Shizuku est bloquée. Ouvrez Shizuku et autorisez OpenLess manuellement.","settings.permissions.androidShizukuMessages.launched":"La page d’autorisation Shizuku est ouverte.","settings.permissions.androidShizukuMessages.launch_failed":"Impossible d’ouvrir l’autorisation Shizuku.","settings.permissions.androidShizukuMessages.open_shizuku":"Le gestionnaire Shizuku est ouvert.","settings.permissions.androidShizukuMessages.jni_error":"Impossible d’accéder au moteur Shizuku d’Android.","settings.permissions.androidShizukuMessages.status_parse_failed":"Impossible d’interpréter l’état de Shizuku.","settings.permissions.androidShizukuMessages.user_not_confirmed":"Le rétablissement nécessite votre confirmation.","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku n’est pas autorisé ou est indisponible.","settings.permissions.androidShizukuMessages.invalid_component":"L’ID du composant du service d’accessibilité est invalide.","settings.permissions.androidShizukuMessages.service_connect_failed":"Impossible de se connecter au service privilégié Shizuku.","settings.permissions.androidShizukuMessages.recovery_in_progress":"Un autre rétablissement est déjà en cours.","settings.permissions.androidShizukuMessages.parse_failed":"Impossible d’interpréter le résultat du rétablissement.","settings.permissions.androidShizukuMessages.service_not_bound":"Les réglages ont été enregistrés, mais l’accessibilité ne fonctionne pas encore.","settings.permissions.androidShizukuMessages.success":"Service d’accessibilité rétabli.","settings.permissions.androidShizukuMessages.read_failed":"Impossible de lire les réglages d’accessibilité.","settings.permissions.androidShizukuMessages.read_enabled_failed":"Impossible de lire l’indicateur d’activation de l’accessibilité.","settings.permissions.androidShizukuMessages.merge_failed":"Impossible de fusionner les services d’accessibilité.","settings.permissions.androidShizukuMessages.write_services_failed":"Impossible d’enregistrer les services d’accessibilité activés.","settings.permissions.androidShizukuMessages.write_enabled_failed":"Impossible d’activer l’accessibilité.","settings.permissions.androidShizukuMessages.readback_failed":"Impossible de vérifier les réglages d’accessibilité après l’écriture.","settings.permissions.androidShizukuMessages.oem_rollback":"Le fabricant a annulé la modification de l’accessibilité.","settings.permissions.androidShizukuMessages.concurrent_change":"Les réglages d’accessibilité ont changé pendant le rétablissement.","settings.permissions.androidShizukuMessages.partial_rollback":"Le rétablissement a échoué et les réglages n’ont été que partiellement restaurés. Vérifiez l’accessibilité dans les réglages système.","settings.permissions.androidShizukuMessages.manual_required":"Le rétablissement automatique ne peut pas activer l’accessibilité sans risque si d’autres services sont enregistrés alors que l’interrupteur global est désactivé. Utilisez les réglages système.","settings.permissions.androidShizukuMessages.max_retries":"Le rétablissement a échoué après plusieurs tentatives.","settings.permissions.androidShizukuMessages.internal_error":"Le rétablissement a échoué en raison d’une erreur interne.","settings.permissions.androidShizukuMessages.unknown":"État Shizuku inconnu.","settings.permissions.androidInsertStrategyLabel":"Stratégie d’insertion du texte","settings.permissions.androidOverlayTriggerLabel":"Visibilité de la fenêtre flottante","settings.permissions.androidOverlayActivationModeLabel":"Activation de la fenêtre flottante","settings.permissions.androidOverlayLeftSwipeActionLabel":"Action du balayage vers la gauche","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"Direction du balayage d’annulation","settings.permissions.androidOverlaySizeLabel":"Taille de la fenêtre flottante","settings.permissions.androidOverlaySizeHint":"Ajuste le diamètre du bouton flottant sans changer sa position.","settings.permissions.androidInsertStrategy.accessibility":"Insertion automatique dans le champ de saisie","settings.permissions.androidInsertStrategy.clipboard":"Presse-papiers uniquement","settings.permissions.androidInsertStrategyHint.accessibility":"Nécessite l’accessibilité ; utilise le presse-papiers si elle est indisponible.","settings.permissions.androidInsertStrategyHint.clipboard":"Ne nécessite pas l’accessibilité ; copie uniquement pour un collage manuel.","settings.permissions.androidOverlayTrigger.background":"Lorsque l’application est en arrière-plan","settings.permissions.androidOverlayTrigger.keyboard":"À l’apparition du clavier","settings.permissions.androidOverlayTrigger.always":"Toujours visible","settings.permissions.androidOverlayTriggerHint.background":"Simple et économe en batterie ; aucune fenêtre flottante pendant la saisie dans d’autres applications.","settings.permissions.androidOverlayTriggerHint.keyboard":"Ce mode a été retiré. Les réglages existants repassent en mode arrière-plan.","settings.permissions.androidOverlayTriggerHint.always":"Toujours disponible, mais reste affichée en permanence.","settings.permissions.androidOverlayTriggerDisabled.keyboard":"L’affichage déclenché par le clavier a été retiré. Des gestes sur la fenêtre remplaceront la détection du clavier.","settings.permissions.androidOverlayActivationMode.tap":"Appuyer pour préparer","settings.permissions.androidOverlayActivationMode.long_press":"Maintenir pour préparer","settings.permissions.androidOverlayActivationModeHint.tap":"Le premier appui prépare la fenêtre ; le second démarre une dictée normale.","settings.permissions.androidOverlayActivationModeHint.long_press":"Maintenez pour préparer la fenêtre ; relâchez pour arrêter l’enregistrement ou la question vocale en cours.","settings.permissions.androidOverlayLeftSwipeAction.translation":"Dictée avec traduction","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"Changer de pack de styles","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"Lorsque la fenêtre est préparée, balayez vers la gauche pour démarrer une dictée traduite.","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"Lorsque la fenêtre est préparée, balayez vers la gauche pour passer au pack précédent.","settings.permissions.androidOverlayCancelSwipeDirection.up":"Balayer vers le haut","settings.permissions.androidOverlayCancelSwipeDirection.down":"Balayer vers le bas","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"Balayez vers le haut pendant l’enregistrement pour annuler sans transcription ni insertion.","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"Balayez vers le bas pendant l’enregistrement pour annuler sans transcription ni insertion.","settings.permissions.windowsIme.installed":"Installé. La saisie vocale passe temporairement à l’IME d’OpenLess.","settings.permissions.windowsIme.notInstalled":"Non installé. OpenLess utilise le presse-papiers / WM_PASTE comme solution de repli.","settings.permissions.windowsIme.registrationBroken":"L’enregistrement système est endommagé. Réinstallez l’IME d’OpenLess.","settings.permissions.windowsIme.notWindows":"Disponible uniquement sous Windows.","settings.advanced.multimodalPipelineTitle":"Reconnaissance multimodale (expérimentale)","settings.advanced.multimodalPipelineTitleHint":"Reconnaît l’audio en un seul passage avec un modèle multimodal. Sa configuration est entièrement séparée de l’ASR + LLM traditionnel.","settings.advanced.multimodalPipelineLabel":"Activer le traitement multimodal","settings.advanced.multimodalPipelineHint":"Ajoute un sélecteur Traditionnel / Multimodal à la page des fournisseurs d’IA. Traditionnel utilise ASR + LLM ; Multimodal utilise un modèle avec audio. Les configurations sont séparées et ne partagent jamais d’identifiants.","settings.advanced.streamingInsertTitle":"Insertion progressive","settings.advanced.streamingInsertTitleLinux":"Insertion progressive (expérimentale)","settings.advanced.streamingInsertDesc":"Insère le texte caractère par caractère au curseur pour réduire l’attente ressentie. Si les conditions ne sont pas réunies, le texte est collé en une seule fois.","settings.advanced.streamingInsertLabel":"Insertion progressive","settings.advanced.streamingInsertHintMac":"Passe temporairement à la source de saisie ABC pour empêcher les IME chinois, japonais et coréens d’intercepter les touches. La source précédente est restaurée à la fin.","settings.advanced.streamingInsertHintWindows":"SendInput Unicode écrit directement, sans passer par TSF / IME et sans changer de méthode de saisie.","settings.advanced.streamingInsertHintLinux":"Utilise le complément fcitx5 pour envoyer le texte ; l’insertion progressive simule les touches avec enigo + XTest.","settings.advanced.streamingInsertSaveClipboardLabel":"Copier dans le presse-papiers","settings.advanced.streamingInsertSaveClipboardHint":"Après une insertion réussie, copie le texte final dans le presse-papiers pour pouvoir le recoller avec Cmd+V. Désactivé : le presse-papiers n’est jamais modifié.","settings.advanced.localAsrTitle":"Modèles ASR locaux (expérimentaux)","settings.advanced.localAsrDesc":"Remplace l’ASR dans le cloud par l’inférence sur l’appareil. Pour l’usage hors ligne ou les données sensibles.","settings.advanced.localAsrWarningShort":"L’inférence locale est plus lente ; un appareil insuffisamment puissant peut omettre des mots.","settings.advanced.qwen3Desc":"Une fois activé, il remplace le fournisseur ASR.","settings.advanced.sherpaDesc":"Une fois activé, il remplace le fournisseur ASR.","settings.advanced.foundryDesc":"Une fois activé, il remplace le fournisseur ASR.","settings.advanced.notSupportedHere":"Non pris en charge sur cette plateforme ; aucun module d’inférence n’est inclus.","settings.advanced.enable":"Activer","settings.advanced.alreadyActive":"Actif","settings.advanced.disableLocalLabel":"Désactiver l’ASR local","settings.advanced.disableLocalDesc":"Revenir à l’ASR dans le cloud (Volcengine bigasr par défaut).","settings.advanced.disable":"Désactiver","settings.advanced.platformNotSupported":"L’intégration des modèles ASR locaux n’est pas prise en charge sur cette plateforme.","settings.advanced.confirmEnableLocalTitle":"Activer l’ASR local ?","settings.advanced.confirmEnableLocalBody":"La transcription sera plus lente que dans le cloud et peut être moins précise.","settings.advanced.confirm":"Activer","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"Langue de l’interface","settings.language.desc":"Change la langue de l’interface immédiatement et conserve ce choix au prochain lancement.","settings.language.label":"Langue","settings.language.labelDesc":"Choisissez « Suivre le système » pour utiliser la langue du système au lancement.","settings.language.followSystem":"Suivre le système","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"Certains menus natifs, comme la zone de notification, peuvent nécessiter un redémarrage pour changer complètement de langue.","settings.layout.title":"Disposition","settings.theme.title":"Apparence","settings.theme.label":"Thème","settings.theme.activityHeatmapLabel":"Afficher la carte d’activité annuelle dans Vue d’ensemble","settings.theme.stackedRowLayoutLabel":"Disposition lisible (retour à la ligne)","settings.theme.stackedRowLayoutDesc":"Sur les petits écrans ou avec du texte agrandi, les contrôles trop larges passent à la ligne suivante plutôt que de déborder ou comprimer le texte.","settings.theme.conservativeLayoutLabel":"Disposition prudente","settings.theme.conservativeLayoutDesc":"Les pages de réglages et de fonctions utilisent une seule colonne pleine largeur pour limiter les débordements, sauf l’accueil et les barres supérieure et inférieure.","settings.theme.system":"Suivre le système","settings.theme.light":"Clair","settings.theme.dark":"Sombre","settings.remoteInput.title":"Saisie à distance","settings.remoteInput.enableLabel":"Activer la saisie à distance","settings.remoteInput.enableDesc":"Enregistrez depuis le navigateur d’un téléphone ou d’une tablette sur votre réseau local. Le texte est inséré au curseur de l’ordinateur. HTTPS est requis ; approuvez le certificat à la première visite.","settings.remoteInput.portLabel":"Port","settings.remoteInput.defaultModeLabel":"Mode d’enregistrement par défaut","settings.remoteInput.modeToggle":"Appuyer pour basculer","settings.remoteInput.modeHold":"Maintenir pour parler","settings.remoteInput.urlLabel":"URL d’accès","settings.remoteInput.pinLabel":"Code d’association","settings.remoteInput.regeneratePin":"Régénérer","settings.remoteInput.portInUse":"Le port {{port}} est utilisé. Choisissez-en un autre","settings.remoteInput.startError":"Impossible de démarrer la saisie à distance : {{reason}}","settings.remoteInput.securityHint":"Accessible uniquement sur le même réseau local, avec le code d’association. Désactivez-la lorsque vous ne l’utilisez pas.","settings.remoteInput.certHint":"Vérifiez l’empreinte du certificat racine avant de lui faire confiance la première fois. Les anciennes versions exigent un réglage unique ; ensuite la confiance est conservée après redémarrage ou changement d’adresse IP.","settings.remoteInput.certFingerprintLabel":"SHA-256 de la CA racine de cet ordinateur","settings.remoteInput.certFingerprintCopy":"Copier l’empreinte complète","settings.remoteInput.certFingerprintCopied":"Empreinte copiée","settings.remoteInput.certFingerprintUnavailable":"L’empreinte complète n’est pas disponible. N’installez et n’approuvez aucun certificat téléchargé.","settings.remoteInput.certVerifyHint":"Avant d’activer la confiance complète, retrouvez le SHA-256 dans les détails du certificat du système du téléphone et comparez les 64 caractères avec cette valeur (espaces et deux-points ignorés). Une page web, le nom du profil ou un identifiant ne prouvent pas l’identité. Si l’empreinte diffère ou n’est pas entièrement visible, arrêtez et supprimez le profil téléchargé ou installé.","settings.remoteInput.certProfileHint":"Il ne doit y avoir exactement qu’un certificat racine. N’installez pas un profil contenant des certificats supplémentaires, un VPN ou des réglages de gestion d’appareils.","settings.remoteInput.certTrustWarning":"Le téléchargement initial du certificat ne permet pas de vérifier l’identité de l’ordinateur : un appareil malveillant du réseau local pourrait remplacer le certificat racine par une attaque de l’homme du milieu. N’installez le certificat que sur un réseau domestique ou privé de confiance, jamais sur un réseau public ou partagé. La CA racine peut émettre des certificats et sa clé privée reste sur cet ordinateur ; supprimez-la de votre téléphone lorsque vous ne l’utilisez plus.","settings.remoteInput.certSetupLink":"Copier le lien du certificat iPhone","settings.remoteInput.waitingStart":"Le service ne fonctionne pas encore. Désactivez puis réactivez l’interrupteur, sans redémarrer l’application.","settings.remoteInput.starting":"Démarrage de la saisie à distance…","settings.remoteInput.urlsStale":"Ces adresses proviennent du lancement précédent et peuvent être obsolètes.","settings.about.tagline":"Parlez naturellement, écrivez avec précision","settings.about.checkUpdate":"Rechercher des mises à jour","settings.about.checkUpdateBtn":"Rechercher","settings.about.checkStableUpdateBtn":"Rechercher une version stable","settings.about.checkBetaUpdateBtn":"Rechercher une version Beta","settings.about.checkingUpdate":"Recherche…","settings.about.upToDate":"Vous utilisez déjà la dernière version.","settings.about.updateError":"La recherche ou l’installation de la mise à jour a échoué. Réessayez plus tard.","settings.about.retryBtn":"Réessayer","settings.about.openReleases":"Ouvrir les versions","settings.about.source":"Code source","settings.about.docs":"Documentation","settings.about.feedback":"Commentaires","settings.about.qq":"Groupe communautaire QQ","settings.about.qqDesc":"Recherchez le numéro du groupe dans QQ ou scannez le code QR pour le rejoindre.","settings.about.copyQq":"Copier le numéro du groupe","settings.about.privacy":"Confidentialité","settings.about.privacyDesc":"Les enregistrements peuvent être envoyés au fournisseur cloud configuré pour la transcription.","settings.about.localFirst":"Priorité au traitement local","settings.about.linksTitle":"Documentation","settings.about.betaChannelLabel":"Rejoindre le canal Beta","settings.about.betaChannelToggleLabel":"Activer le canal Beta","settings.about.betaChannelDesc":"Si cette option est activée, les mises à jour automatiques suivent le canal Beta ; sinon, le canal stable. Le bouton ci-dessous permet de rechercher une Beta manuellement.","settings.about.autoUpdateSectionTitle":"Mise à jour automatique","settings.about.autoUpdateCheckLabelAndroid":"Rechercher et télécharger les mises à jour automatiquement","settings.about.autoUpdateCheckDescAndroid":"Vérifie au lancement, puis toutes les 60 minutes. Si une mise à jour est disponible, la télécharge et ouvre l’installateur système. Le canal suit l’interrupteur Beta ci-dessus.","settings.about.betaChannelFetching":"Récupération de la dernière Beta…","settings.about.betaChannelFetchBtn":"Consulter la dernière Beta","settings.about.betaChannelLatestPrefix":"Dernière Beta :","settings.about.betaChannelDownloadBtn":"Ouvrir la page de téléchargement","settings.about.betaChannelRefresh":"Actualiser","settings.about.betaChannelNoBeta":"Aucune version Beta n’a encore été publiée.","settings.about.betaChannelFetchError":"Impossible de récupérer les informations de la Beta. Réessayez plus tard.","settings.about.betaChannelUpToDate":"À jour","settings.about.betaChannelUpdateNow":"Mettre à jour maintenant","settings.about.betaChannelUpdateNowTitle":"Recherche et télécharge la dernière Beta, puis affiche le dialogue de mise à jour","settings.about.betaChannelChecking":"Recherche…","settings.about.updateDialog.stableChannelSwitch.title":"Passer au canal stable","settings.about.updateDialog.stableChannelSwitch.desc":"Version actuelle : OpenLess {{currentVersion}}\nVersion cible : OpenLess {{version}}\nVous allez passer du canal bêta au canal stable. Continuer ?","settings.about.updateDialog.available.title":"Mise à jour disponible","settings.about.updateDialog.available.desc":"OpenLess {{version}} est disponible. Mettre à jour maintenant ?","settings.about.updateDialog.downloading.title":"Téléchargement de la mise à jour","settings.about.updateDialog.downloading.desc":"Téléchargement d’OpenLess {{version}}. Gardez l’application ouverte.","settings.about.updateDialog.downloaded.title":"Mise à jour prête","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} est installé. Redémarrer automatiquement maintenant pour l’appliquer ?","settings.about.updateDialog.installing.title":"Installation de la mise à jour","settings.about.updateDialog.installing.desc":"Installation d’OpenLess {{version}}. Gardez l’application ouverte.","settings.about.updateDialog.install":"Mettre à jour maintenant","settings.about.updateDialog.androidInstall":"Télécharger et ouvrir l’installateur","settings.about.updateDialog.androidInstalled.title":"Installateur système ouvert","settings.about.updateDialog.androidInstalled.desc":"Suivez les indications du système pour terminer l’installation. Rouvrez OpenLess pour utiliser {{version}}.","settings.about.updateDialog.downloadingLabel":"Téléchargement…","settings.about.updateDialog.installingLabel":"Installation…","settings.about.updateDialog.later":"Redémarrer manuellement plus tard","settings.about.updateDialog.restartNow":"Redémarrer maintenant","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"{{downloaded}} téléchargés","settings.about.updateDialog.installError.title":"Échec de la mise à jour","settings.about.updateDialog.installError.desc":"La mise à jour automatique n’a pas abouti : {{error}}. Vous pouvez télécharger et installer la dernière version manuellement.","settings.about.updateDialog.manualDownload":"Télécharger manuellement","startup.loading":"Démarrage d’OpenLess…","startup.loadingDesc":"Connexion au service local et vérification de la compatibilité.","startup.failed":"OpenLess n’a pas pu démarrer","startup.recovery":"Vérifiez à nouveau. Si le problème persiste, quittez complètement l’application et rouvrez-la. S’il est apparu après une mise à jour, vérifiez que tous les composants utilisent la même version.","startup.retry":"Vérifier à nouveau","startup.details":"Afficher les détails de l’erreur","modal.serviceViews.label":"Réglages des services","modal.serviceViews.llm":"Modèles de langage","modal.serviceViews.asr":"Reconnaissance vocale","modal.serviceViews.omni":"Multimodal","modal.serviceViews.models":"Modèles locaux","modal.serviceViews.connections":"Connexions","modal.serviceViews.statusConfigured":"Configuré","modal.serviceViews.statusMissing":"Non configuré","modal.searchPlaceholder":"Rechercher une catégorie de réglages…","modal.clearSearch":"Effacer la recherche","modal.categoriesLabel":"Catégories de réglages","modal.searchResults":"Résultats de recherche","modal.searchCount":"Catégories trouvées : {{count}}","modal.noResults":"Aucune catégorie correspondante. Essayez « microphone », « modèles » ou « thème ».","modal.autoSaveHint":"Les modifications sont enregistrées automatiquement","modal.backToAdvanced":"Retour à Expériences et extensions","modal.advancedPages.lessComputer":"Choisissez un agent et configurez son modèle, ses autorisations et son répertoire de travail.","modal.advancedPages.claudeConsole":"Détectez Claude Code et consultez la sortie des tâches de test.","modal.advancedPages.multimodal":"Gérez l’activation de la reconnaissance multimodale expérimentale.","modal.advancedPages.debug":"Conservez des enregistrements de débogage, inspectez le contexte du curseur et exportez des journaux.","modal.descriptions.general":"Choisissez un microphone, réglez l’enregistrement et la saisie de texte, ou connectez votre téléphone.","modal.descriptions.shortcuts":"Configurez les raccourcis et choisissez l’action appliquée au texte sélectionné.","modal.descriptions.services":"Choisissez les services de reconnaissance vocale et de traitement du texte. Gérez les canaux, modèles locaux et connexions.","modal.descriptions.appearance":"Réglez le thème, la disposition et la langue de l’interface pour une lecture confortable.","modal.descriptions.privacy":"Vérifiez les autorisations et les connexions. Gérez l’historique, les enregistrements et les données locales.","modal.descriptions.advanced":"Configurez Less Computer, le traitement multimodal et le débogage selon vos besoins.","modal.descriptions.about":"Consultez votre version, le canal et les réglages de mise à jour automatique.","modal.searchKeywords.general":"microphone enregistrement saisie téléphone distant réseau local LAN PIN capsule muet démarrage automatique","modal.searchKeywords.shortcuts":"raccourci touche combinaison sélection amélioration voix modification","modal.searchKeywords.services":"ASR LLM API canal modèle cloud local hors ligne réseau proxy catalogue","modal.searchKeywords.appearance":"thème sombre clair langue police texte taille disposition carte activité","modal.searchKeywords.privacy":"autorisation microphone accessibilité historique enregistrement stockage confidentialité exporter","modal.searchKeywords.advanced":"Less Computer Claude agent multimodal Omni débogage journaux expérience","modal.searchKeywords.about":"version Beta stable mise à jour actualisation","modal.sections.appearance":"Apparence et langue","modal.sections.shortcuts":"Raccourcis et sélection","modal.sections.general":"Enregistrement et saisie","modal.sections.services":"Services et modèles d’IA","modal.sections.privacy":"Autorisations et données","modal.sections.advanced":"Expériences et extensions","modal.sections.personalize":"Personnalisation","modal.sections.about":"À propos et mises à jour","modal.sections.helpCenter":"Centre d’aide","modal.sections.releaseNotes":"Notes de version","modal.personalize.font":"Taille de la police","modal.personalize.fontDesc":"Redimensionne immédiatement le texte de toute l’interface.","modal.personalize.fontSmall":"Petite","modal.personalize.fontMedium":"Moyenne","modal.personalize.fontLarge":"Grande","modal.personalize.blur":"Intensité de l’effet de verre","modal.personalize.blurDesc":"Modifie le filtre de flou interne. La couche dépolie du système macOS ne peut pas être réglée pendant l’exécution.","modal.about.tagline":"Parlez naturellement, écrivez avec précision","modal.about.checkUpdate":"Rechercher des mises à jour","modal.about.checkUpdateBtn":"Rechercher","modal.about.docs":"Documentation","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"Canal de commentaires","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"Code source","modal.about.qq":"Groupe communautaire QQ","modal.about.qqDesc":"Recherchez le numéro du groupe dans QQ ou scannez le code QR pour le rejoindre.","modal.about.copyQq":"Copier le numéro du groupe","modal.about.exportErrorLog":"Exporter le journal d’erreurs","modal.about.exportErrorLogDesc":"Enregistre le journal de la session actuelle sur disque pour le diagnostic ou pour nous envoyer un signalement.","modal.about.exportErrorLogBtn":"Exporter","modal.about.exporting":"Exportation…","modal.about.exportSuccess":"Enregistré","modal.about.exportFailed":"Échec de l’exportation","modal.about.privacy":"Confidentialité","modal.about.privacyDesc":"Les transcriptions restent sur cet appareil. Les fournisseurs cloud configurés peuvent recevoir l’audio pour le transcrire.","modal.about.localFirst":"Priorité au traitement local","windowChrome.restore":"Rétablir","windowChrome.minimize":"Réduire","windowChrome.maximize":"Agrandir","windowChrome.close":"Fermer","hotkey.triggers.rightOption":"Option droite","hotkey.triggers.leftOption":"Option gauche","hotkey.triggers.rightControl":"Contrôle droit","hotkey.triggers.leftControl":"Contrôle gauche","hotkey.triggers.rightCommand":"Commande droite","hotkey.triggers.leftCommand":"Commande gauche","hotkey.triggers.leftShift":"Maj gauche","hotkey.triggers.rightShift":"Maj droite","hotkey.triggers.fn":"Fn (touche Globe)","hotkey.triggers.rightAlt":"Alt droite","hotkey.triggers.mediaPlayPause":"⏯ Lecture / pause multimédia","hotkey.triggers.custom":"Combinaison personnalisée…","hotkey.fallback":"Raccourci global","hotkey.modeHoldSuffix":" (maintenir pour parler)","hotkey.modeToggleSuffix":" (démarrer / arrêter)","hotkey.modeAutoSuffix":" (détection automatique)","hotkey.usageHold":"Maintenez {{trigger}} pour parler, puis relâchez pour arrêter.","hotkey.usageToggle":"Appuyez sur {{trigger}} pour démarrer, puis de nouveau pour arrêter.","hotkey.usageAuto":"Appuyez sur {{trigger}} pour démarrer ou arrêter ; maintenez pour parler et relâchez pour arrêter.","hotkey.adapter.macEventTap":"Event Tap de macOS","hotkey.adapter.windowsLowLevel":"Détecteur clavier bas niveau de Windows","hotkey.adapter.fcitx5":"Complément de saisie fcitx5","hotkey.adapter.unavailable":"Indisponible","localAsr.kicker":"ASR LOCAL","localAsr.title":"Modèles","localAsr.desc":"Gérez les modèles de reconnaissance vocale de cet appareil.","localAsr.storageTitle":"Emplacement des modèles","localAsr.storageBaseDir":"Dossier parent sélectionné","localAsr.storageModelsRoot":"Dossier réel des modèles","localAsr.storageDefault":"Dossier par défaut du système","localAsr.storageChoose":"Changer de dossier","localAsr.storageReset":"Rétablir l’emplacement par défaut","localAsr.storageReveal":"Ouvrir le dossier des modèles","localAsr.storageDesc":"Le stockage personnalisé crée OpenLess/models dans le dossier choisi et y déplace les modèles existants. OpenLess annule les téléchargements et libère les modèles chargés avant de déplacer les fichiers.","localAsr.storageChooseTitle":"Choisir le dossier parent des modèles locaux","localAsr.storageChangeConfirm":"Les modèles locaux seront déplacés vers {{path}}/OpenLess/models. Les téléchargements seront d’abord annulés et les modèles chargés libérés. Continuer ?","localAsr.storageResetConfirm":"Les modèles locaux seront replacés dans le dossier système par défaut. Dossier actuel : {{path}}. Continuer ?","localAsr.modelDir":"Répertoire du modèle","localAsr.revealDir":"Ouvrir le répertoire","localAsr.deleteConfirm":"Supprimer les fichiers locaux de {{name}} ? Cette action est irréversible.","localAsr.appleSpeechTitle":"Reconnaissance Apple Speech (macOS)","localAsr.appleSpeechDesc":"Transcrit localement avec le moteur vocal intégré de macOS, sans téléchargement de modèle, clé API ni réseau. Une solution locale sans identifiants si votre ASR cloud est peu fiable. macOS demandera l’autorisation de reconnaissance vocale lors du premier usage.","localAsr.appleSpeechUse":"Utiliser Apple Speech","localAsr.qwenTitle":"Gestionnaire de modèles Qwen3-ASR","localAsr.qwenExperimentalBadge":"Expérimental","localAsr.engineUnavailable":"Le moteur d’inférence Qwen3-ASR n’est pas inclus sur cette plateforme. Vous pouvez télécharger les modèles, mais pas encore les activer ici.","localAsr.qwenUnavailableOnWindows":"Qwen3-ASR n’est pas encore pris en charge sous Windows. Utilisez Foundry Local Whisper ci-dessus.","localAsr.foundryTitle":"Foundry Local Whisper pour Windows","localAsr.foundryDesc":"Reconnaissance vocale sur l’appareil, sans clé API ASR. Le premier usage nécessite le téléchargement du moteur et du modèle.","localAsr.foundryAvailable":"Disponible sous Windows","localAsr.foundryUnavailable":"Windows uniquement","localAsr.foundryRuntimeReady":"Composants d’exécution téléchargés","localAsr.foundryRuntimeMissing":"Composants d’exécution non téléchargés","localAsr.foundryRuntimeSourceLabel":"Source des composants d’exécution","localAsr.foundryRuntimeSourceAuto":"Automatique (priorité à NuGet)","localAsr.foundryRuntimeSourceNuget":"Dépôt officiel NuGet","localAsr.foundryRuntimeSourceOrtNightly":"Dépôt Microsoft ORT-Nightly","localAsr.foundryRuntimeSourceDesc":"Les composants d’exécution sont téléchargés avant le premier usage.","localAsr.foundrySelectedModel":"Modèle sélectionné","localAsr.foundryActiveModel":"Alias par défaut actuel","localAsr.foundryLoadedModel":"Modèle chargé","localAsr.foundryNotLoaded":"Non chargé","localAsr.foundryError":"État de Foundry","localAsr.foundrySetDefault":"Définir par défaut / Activer l’ASR local Windows","localAsr.foundryEnabling":"Activation…","localAsr.foundryPrepare":"Préparer / Télécharger / Charger","localAsr.foundryPreparing":"Préparation…","localAsr.foundryReleasing":"Libération…","localAsr.foundryRetryPrepare":"Continuer / Réessayer la préparation","localAsr.foundryCancelPrepare":"Annuler la préparation","localAsr.foundryCancelRequested":"Annulation demandée","localAsr.foundryCancelling":"Annulation…","localAsr.foundryCancelBestEffort":"Annulation demandée. L’arrêt aura lieu à la fin de l’étape actuelle. Réessayez plus tard.","localAsr.foundryPrepareRuntime":"Préparer les composants d’exécution","localAsr.foundryPrepareModel":"Télécharger le modèle","localAsr.foundryPrepareLoad":"Charger le modèle","localAsr.foundryPrepareModelSkipped":"Le modèle est déjà téléchargé ; téléchargement ignoré","localAsr.foundryPrepareDone":"Terminé","localAsr.foundryPrepareWaiting":"En attente","localAsr.foundryApproxSizeMb":"environ {{mb}} Mo","localAsr.foundryLanguageLabel":"Langue de reconnaissance","localAsr.foundryLanguageAuto":"Automatique","localAsr.foundryLanguageZh":"Chinois zh","localAsr.foundryLanguageEn":"Anglais en","localAsr.foundryLanguageDesc":"Choisissez Chinois pour la dictée en chinois et Automatique si vous alternez les langues.","localAsr.foundryModelSmall":"Whisper Small (par défaut / équilibré)","localAsr.foundryModelSmallDesc":"Option par défaut équilibrant la qualité et l’utilisation des ressources.","localAsr.foundryModelMedium":"Whisper Medium (qualité supérieure)","localAsr.foundryModelMediumDesc":"Plus précis, pour les appareils puissants capables de gérer des téléchargements volumineux et une inférence plus lente.","localAsr.foundryModelLarge":"Whisper Large V3 Turbo (qualité maximale)","localAsr.foundryModelLargeDesc":"Grand modèle pour les appareils haut de gamme et les usages privilégiant la qualité.","localAsr.foundryModelBase":"Whisper Base (plus rapide / moins de ressources)","localAsr.foundryModelBaseDesc":"Plus rapide et moins gourmand pour la dictée quotidienne légère.","localAsr.foundryModelTiny":"Whisper Tiny (le plus rapide / test de base)","localAsr.foundryModelTinyDesc":"L’option la plus rapide pour vérifier que Foundry fonctionne.","localAsr.sherpaTitle":"sherpa-onnx local pour Windows (expérimental)","localAsr.sherpaDesc":"Windows utilise sherpa-onnx pour la reconnaissance locale par lots, hors ligne et sans clé API ASR.","localAsr.sherpaRuntimeReady":"Modèle chargé","localAsr.sherpaRuntimeMissing":"Modèle non chargé","localAsr.sherpaSetDefault":"Définir par défaut / Activer sherpa-onnx","localAsr.sherpaPrepare":"Vérifier les fichiers locaux / Charger","localAsr.sherpaPreparing":"Chargement…","localAsr.sherpaPrepareLocalFiles":"Vérifier les fichiers locaux du modèle","localAsr.sherpaModelDir":"Répertoire du modèle","localAsr.sherpaRevealDir":"Ouvrir le répertoire du modèle","localAsr.sherpaError":"État de sherpa-onnx","localAsr.sherpaLanguageJa":"Japonais ja","localAsr.sherpaLanguageKo":"Coréen ko","localAsr.sherpaLanguageYue":"Cantonais yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small (par défaut / priorité au chinois)","localAsr.sherpaModelSenseVoiceDesc":"Modèle expérimental par défaut pour la dictée en chinois ou mêlant chinois et anglais.","localAsr.sherpaModelParaformer":"Paraformer chinois","localAsr.sherpaModelParaformerDesc":"Modèle expérimental spécialisé en chinois.","localAsr.sherpaModelWhisper":"Whisper Small multilingue","localAsr.sherpaModelWhisperDesc":"Solution expérimentale multilingue au comportement conforme à la famille Whisper.","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3 (multilingue)","localAsr.sherpaModelWhisperLargeV3Desc":"La version multilingue open source la plus avancée de Whisper : grande qualité et téléchargement volumineux.","localAsr.sherpaModelZipformer":"Zipformer streaming (zh/en)","localAsr.sherpaModelZipformerDesc":"Modèle streaming chinois-anglais à la latence la plus faible — le texte apparaît pendant que vous parlez.","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"Modèle Qwen3-ASR converti pour sherpa-onnx, avec reconnaissance multilingue et meilleure gestion du contexte long.","localAsr.modelSelectTitle":"Modèles sur cet appareil","localAsr.modelSelectDesc":"Suivez les téléchargements, gérez les fichiers ou chargez un modèle pour le tester.","localAsr.modelSelectPlaceholder":"Sélectionnez un modèle téléchargé…","localAsr.modelSelectEmpty":"Aucun modèle téléchargé. Téléchargez-en un dans « Télécharger et gérer ».","localAsr.groupDownload":"Télécharger et gérer","localAsr.groupOther":"Autres","localAsr.mirrorLabel":"Serveur de téléchargement","localAsr.mirrorDesc":"huggingface.co est la source officielle ; hf-mirror.com est un miroir communautaire souvent plus accessible depuis la Chine continentale.","localAsr.mirrorHuggingface":"HuggingFace officiel (huggingface.co)","localAsr.mirrorHfMirror":"Miroir pour la Chine continentale (hf-mirror.com)","localAsr.activeBadge":"En cours d’utilisation","localAsr.downloadedBadge":"Téléchargé","localAsr.notDownloadedBadge":"Non téléchargé","localAsr.download":"Télécharger","localAsr.resume":"Reprendre","localAsr.cancel":"Annuler","localAsr.delete":"Supprimer","localAsr.setActive":"Définir par défaut","localAsr.failed":"Échec","localAsr.cancelled":"Annulé","localAsr.files":"fichiers","localAsr.sizeLoading":"Récupération de la taille…","localAsr.sizeUnknown":"Taille inconnue","localAsr.performanceWarning":"L’ASR local convient à l’usage hors ligne ou aux données sensibles. Le premier usage nécessite le téléchargement d’un modèle.","localAsr.test":"Charger et tester","localAsr.testRunning":"Test…","localAsr.testHeading":"Test audio intégré","localAsr.testExpected":"Attendu","localAsr.testActual":"Obtenu","localAsr.testStats":"Audio {{audio}}s · Chargement {{load}}s · Transcription {{transcribe}}s · Moteur {{backend}}","localAsr.testFailed":"Échec du test","localAsr.engineStatusLabel":"Moteur en mémoire","localAsr.engineLoaded":"Chargé : {{model}}","localAsr.engineUnloaded":"Non chargé (la première transcription devra charger le modèle)","localAsr.loadNow":"Charger maintenant","localAsr.releaseNow":"Libérer maintenant","localAsr.keepLoadedLabel":"Conserver en mémoire pendant","localAsr.keepLoadedDesc":"Durée pendant laquelle Qwen3-ASR reste en mémoire après sa dernière utilisation avant d’être libéré.","localAsr.keepImmediate":"Libérer immédiatement","localAsr.keep1min":"1 minute après la dernière utilisation","localAsr.keep5min":"5 minutes après la dernière utilisation (par défaut)","localAsr.keep30min":"30 minutes après la dernière utilisation","localAsr.keepForever":"Ne jamais libérer (toujours chargé)","localAsr.sidebarTitle":"Téléchargés et en téléchargement","localAsr.activePill":"Actif","localAsr.setDefault":"Définir par défaut","localAsr.downloading":"Téléchargement","localAsr.startDownload":"Démarrer le téléchargement","localAsr.downloadNewModel":"Télécharger un nouveau modèle","localAsr.activeModelLabel":"Modèle utilisé","localAsr.pickerNoModelDownloaded":"Aucun modèle téléchargé pour l’instant — commencez par la page des modèles locaux.","localAsr.partialDownloadsLabel":"Téléchargements incomplets","localAsr.partialDownloadsDesc":"Des téléchargements interrompus ont laissé des fichiers temporaires ; nettoyez-les sans toucher aux modèles installés.","localAsr.cleanupIncomplete":"Nettoyer le téléchargement incomplet","localAsr.languagesLabel":"Langues","localAsr.partialBytesLabel":"Fichiers restants","localAsr.downloadDialogTitle":"Télécharger un modèle","localAsr.downloadDialogAlreadyHave":"Les fichiers du modèle sont téléchargés. Revenez à sa page pour le charger et le tester, ou choisissez son fournisseur dans Transcription ASR.","localAsr.downloadDialogDesc":"Comparez les tailles et descriptions, puis téléchargez le modèle choisi. Lorsqu’il est prêt, sélectionnez le service local correspondant dans Reconnaissance vocale.","localAsr.detailRepo":"Dépôt","localAsr.hfDownloads":"Téléchargements","localAsr.hfLikes":"Mentions J’aime","localAsr.hfDescription":"À propos","localAsr.hfNoDescription":"Aucune description pour le moment","localAsr.hfCardFailed":"Impossible de charger les informations du modèle","localAsr.detailFiles":"fichiers","localAsr.detailDownloaded":"Téléchargé","localAsr.detailEmpty":"Sélectionnez un modèle pour afficher ses détails","localAsr.foundryLanguage":"Langue","localAsr.foundryRuntimeSource":"Source des composants d’exécution","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"Conserver en mémoire","localAsr.downloadSettingsTitle":"Téléchargement et stockage","localAsr.downloadSettingsDesc":"Serveur de téléchargement · emplacement des modèles · moteur en mémoire","localAsr.libraryEmptyTitle":"Aucun modèle local pour le moment","localAsr.libraryEmptyDesc":"Téléchargez un modèle de reconnaissance vocale pour traiter l’audio sur cet appareil. Si un modèle existant manque, rechargez le catalogue.","localAsr.catalogTitle":"Catalogue de modèles","localAsr.catalogEmpty":"Aucun modèle à afficher. Rechargez le catalogue et réessayez.","localAsr.reloadCatalog":"Recharger le catalogue","localAsr.engineLabel":"Moteur de reconnaissance","localAsr.sizeLabel":"Taille du modèle","localAsr.allEngines":"Tous","localAsr.backToCatalog":"Retour au catalogue","localAsr.detailsTitle":"Détails du modèle","localAsr.testActivateHint":"Charger et tester active ce modèle, puis exécute le test audio intégré.","localAsr.downloadProgressHint":"Après le démarrage, suivez la progression ou annulez le téléchargement depuis la page du modèle.","localAsr.errorDetails":"Détails de l’erreur"},"de":{"cloudSync.title":"Cloud-Synchronisierung","cloudSync.description":"Synchronisiere dein Wörterbuch, deine Stile und Einstellungen über dein GitHub-Konto auf mehreren Geräten.","cloudSync.signIn":"Mit GitHub anmelden","cloudSync.account":"Synchronisierungskonto","cloudSync.refresh":"Status aktualisieren","cloudSync.loading":"Cloud-Status wird geprüft…","cloudSync.noBackup":"Noch keine Cloud-Sicherung","cloudSync.available":"Cloud-Sicherung verfügbar","cloudSync.summary":"Wörter: {{dictionary}} · Korrekturen: {{corrections}} · Stile: {{stylePacks}}","cloudSync.updated":"Aktualisiert: {{time}}","cloudSync.upload":"In der Cloud sichern","cloudSync.restore":"Aus der Cloud wiederherstellen","cloudSync.delete":"Cloud-Sicherung löschen","cloudSync.working":"Wird synchronisiert…","cloudSync.uploadSuccess":"Cloud-Sicherung gespeichert","cloudSync.restoreSuccess":"Cloud-Einstellungen wiederhergestellt","cloudSync.deleteSuccess":"Cloud-Sicherung gelöscht","cloudSync.failed":"Synchronisierung fehlgeschlagen: {{error}}","cloudSync.conflict":"Die Cloud-Kopie wurde geändert. Aktualisiere ihren Status, bevor du sie sicherst oder wiederherstellst.","cloudSync.unavailable":"Der offizielle Synchronisierungsdienst ist derzeit nicht verfügbar. Versuche es später erneut.","cloudSync.signInRequired":"Melde dich zuerst mit GitHub an.","cloudSync.restoreTitle":"Cloud-Sicherung wiederherstellen?","cloudSync.restoreDescription":"Wörterbucheinträge, Korrekturen, Stile und synchronisierte Einstellungen aus der Cloud ersetzen die entsprechenden lokalen Daten. API-Schlüssel, Gerätepfade und Berechtigungen bleiben auf diesem Gerät.","cloudSync.deleteTitle":"Cloud-Sicherung löschen?","cloudSync.deleteDescription":"Dadurch wird nur die Cloud-Sicherung dieses GitHub-Kontos gelöscht. Die lokalen Daten bleiben erhalten.","cloudSync.confirmRestore":"Wiederherstellen und ersetzen","cloudSync.confirmDelete":"Sicherung löschen","cloudSync.scope":"Synchronisiert Wörterbucheinträge, Korrekturen, Stilsymbole und allgemeine Einstellungen. API-Schlüssel, Zugangsdaten und Geräteeinstellungen bleiben auf diesem Gerät.","macDictationKey.Changed":"Der Shortcut wurde während des Speicherns geändert. Bitte erneut versuchen.","macDictationKey.label":"Mac-Diktat-Taste","macDictationKey.description":"Ersetzt den aktuellen Diktat-Shortcut durch die Mikrofontaste. Beim Beenden von OpenLess wird die Taste an macOS zurückgegeben.","macDictationKey.Permission":"Erlaube OpenLess unter macOS „Datenschutz & Sicherheit → Bedienungshilfen“ und versuche es erneut.","macDictationKey.Busy":"Beende erst die aktuelle Diktat-Sitzung, bevor du den Shortcut änderst.","macDictationKey.Unavailable":"Der Shortcut konnte nicht aktiviert werden; die gespeicherte Zuordnung ist unverändert. Versuche es erneut oder wähle eine andere Taste.","app.name":"OpenLess","app.tagline":"Natürlich sprechen, klar schreiben","common.loading":"Wird geladen…","common.retry":"Erneut versuchen","common.settingsLoadFailed":"Einstellungen konnten nicht geladen werden","common.refresh":"Aktualisieren","common.clear":"Leeren","common.copy":"Kopieren","common.delete":"Löschen","common.later":"Später","common.cancel":"Abbrechen","common.close":"Schließen","common.show":"Einblenden","common.hide":"Ausblenden","common.saved":"Gespeichert","common.saving":"Wird gespeichert…","common.experimental":"Experimentell","common.copied":"Kopiert","common.operationFailed":"Vorgang fehlgeschlagen","common.add":"Hinzufügen","common.durationSeconds":"{{value}}s","common.durationMillis":"{{value}}ms","common.durationMinutes":"{{value}}m","capsule.thinking":"denkt nach","capsule.using":"verwendet","capsule.cancelled":"Abgebrochen","capsule.error":"Ein Fehler ist aufgetreten","capsule.inserted":"{{count}} eingefügt","capsule.translating":"Wird übersetzt","capsule.selectionPolish.polishing":"Wird überarbeitet…","capsule.selectionPolish.replaced":"Ersetzt","capsule.selectionPolish.noSelection":"Kein Text ausgewählt","capsule.selectionPolish.failed":"Überarbeitung fehlgeschlagen. Erneut versuchen","selectionPolishPreview.title":"Vorschau der Textüberarbeitung","selectionPolishPreview.subtitle":"Das Ergebnis lässt sich bearbeiten. Die ursprüngliche Auswahl wird erst nach deiner Bestätigung ersetzt.","selectionPolishPreview.cancel":"Abbrechen","selectionPolishPreview.resultLabel":"Überarbeitetes Ergebnis","selectionPolishPreview.sourcePrefix":"Original: ","selectionPolishPreview.applyError":"Anwenden fehlgeschlagen: ","selectionPolishPreview.confirmReplace":"Bestätigen und ersetzen","selectionVoiceIntent.title":"Was möchtest du tun?","selectionVoiceIntent.subtitle":"Deine gesprochene Anweisung wurde erkannt. Wähle den nächsten Schritt.","selectionVoiceIntent.loading":"Wird geladen…","selectionVoiceIntent.sourcePrefix":"Auswahl: ","selectionVoiceIntent.errorPrefix":"Fortsetzen fehlgeschlagen: ","selectionVoiceIntent.question":"Eine Frage stellen","selectionVoiceIntent.edit":"Auswahl bearbeiten","selectionVoiceIntent.cancel":"Abbrechen","qa.title":"Nachfragen","qa.headerHint":"Jederzeit fragen","qa.thinking":"Denkt nach…","qa.error":"Ein Fehler ist aufgetreten. Versuche es erneut.","qa.errorRetry":"Erneut versuchen","qa.errorRetryHint":"Versuche es erneut.","qa.pinTooltip":"Anheften (geöffnet lassen)","qa.unpinTooltip":"Anheften aufheben","qa.closeTooltip":"Schließen","qa.micLabel":"Frage sprechen","qa.micStop":"Aufnahme beenden","qa.selectionPreview":"Aus dem ausgewählten Text:","qa.emptyTitle":"Wie kann ich helfen?","qa.emptyDesc":"Wähle einen Text aus, zu dem du etwas fragen möchtest, oder gib unten deine Frage ein. Die Antworten erscheinen hier. Du kannst beliebig oft nachfragen.","qa.recordingHint":"Aufnahme läuft… Zum Senden erneut {{recordHotkey}} drücken","qa.mobileRecordLabel":"Aufnahmetaste","qa.mobileRecordStart":"Aufnahme starten","qa.mobileRecordStop":"Beenden und senden","qa.composerPlaceholder":"Frage eingeben. Mit Enter senden","qa.composerSend":"Senden","qa.statusIdle":"Zum Fragen {{recordHotkey}} drücken","qa.statusRecording":"Aufnahme läuft","qa.statusThinking":"Denkt nach","qa.statusError":"Fehler","qa.jumpToLatest":"Zur neuesten Nachricht","qa.editApplyReplace":"Einfügen prüfen und bestätigen","qa.editApplyUnavailable":"Kein bearbeitetes Ergebnis zum Anwenden","qa.editRevertPrevious":"Vorherige Version behalten","qa.editInstructionMode":"Bearbeitungsanweisung","lessComputer.title":"Less Computer","lessComputer.subtitle":"Was soll dein Computer tun?","lessComputer.you":"Du","lessComputer.working":"Wird ausgeführt…","lessComputer.tool":"{{name}} verwendet","lessComputer.compaction":"Kontext zusammengefasst","lessComputer.done":"Fertig","lessComputer.cost":"${{cost}}","lessComputer.error":"Fehlgeschlagen. Erneut versuchen.","lessComputer.closeTooltip":"Schließen","lessComputer.jumpToLatest":"Zur neuesten Nachricht","lessComputer.inputPlaceholder":"Befehl eingeben, mit Enter senden","lessComputer.send":"Senden","lessComputer.approvalTitle":"Blockierten Befehl ausführen?","lessComputer.approvalRerunWarning":"Hinweis: Die Freigabe führt den Befehl im bereits veränderten Arbeitsbereich erneut aus. Bei Vorgängen, die sich nicht unverändert wiederholen lassen, können zusätzliche Änderungen entstehen.","lessComputer.approve":"Freigeben","lessComputer.deny":"Ablehnen","lessComputer.approved":"Freigegeben","lessComputer.denied":"Abgelehnt","nav.overview":"Übersicht","nav.history":"Verlauf","nav.vocab":"Wörterbuch","nav.style":"Stil","nav.marketplace":"Marktplatz","nav.translation":"Übersetzung","nav.selectionAsk":"Nachfragen","nav.corrections":"Korrekturen","nav.polishMode":"Überarbeitungsmodus","nav.group.style":"Stil","nav.group.tools":"Werkzeuge","nav.localAsr":"Modelle","nav.more":"Mehr","marketplace.kicker":"MARKTPLATZ","marketplace.title":"Stilpaket-Marktplatz","marketplace.desc":"Stilpakete der Community entdecken, installieren und teilen.","marketplace.searchPlaceholder":"Name, Beschreibung oder Schlagwörter suchen…","marketplace.sortPopular":"Beliebt","marketplace.sortNew":"Neueste","marketplace.uploadBtn":"Hochladen","marketplace.uploadDisabledHint":"Melde dich zuerst unter Einstellungen → Marktplatz mit GitHub an","marketplace.refreshBtn":"Aktualisieren","marketplace.empty":"Noch keine Stilpakete","marketplace.emptyHint":"Versuche einen anderen Suchbegriff oder lade ein eigenes Paket hoch","marketplace.loadFailed":"Laden fehlgeschlagen: {{err}}","marketplace.noDescription":"(keine Beschreibung)","marketplace.installBtn":"Installieren","marketplace.installingBtn":"Wird installiert…","marketplace.downloadZipBtn":"ZIP herunterladen","marketplace.downloadingZipBtn":"Wird heruntergeladen…","marketplace.downloadAria":"„{{name}}“ als ZIP herunterladen","marketplace.likeBtn":"Gefällt mir","marketplace.installed":"„{{name}}“ lokal installiert","marketplace.downloaded":"„{{name}}“ als ZIP heruntergeladen","marketplace.uploaded":"Hochgeladen – Prüfung ausstehend","marketplace.uploadTitle":"Stilpaket zum Hochladen wählen","marketplace.uploadHint":"Du lädst als {{login}} hoch. Der Inhalt wird zur Prüfung in die Cloud übertragen.","marketplace.uploadNoLocal":"Keine lokalen Stilpakete zum Hochladen","marketplace.errors.detail":"Details konnten nicht geladen werden: {{err}}","marketplace.errors.install":"Installation fehlgeschlagen: {{err}}","marketplace.errors.download":"ZIP-Download fehlgeschlagen: {{err}}","marketplace.errors.like":"Markieren fehlgeschlagen: {{err}}","marketplace.errors.upload":"Hochladen fehlgeschlagen: {{err}}","marketplace.errors.loadLocal":"Lokale Pakete konnten nicht geladen werden: {{err}}","marketplace.sortLiked":"Gefällt mir","marketplace.likedEmpty":"Du hast noch keine Stilpakete mit „Gefällt mir“ markiert","marketplace.likedEmptyHint":"Öffne ein Paket und klicke auf den Stern. Markierte Pakete erscheinen hier","marketplace.derivativeBadge":"Abgeleitet von @{{login}}","marketplace.detail.withdrawBtn":"Zurückziehen","marketplace.detail.withdrawConfirm":"„{{name}}“ vom Marktplatz zurückziehen? Deine lokale Kopie bleibt erhalten.","marketplace.detail.withdrawSuccess":"Vom Marktplatz zurückgezogen","marketplace.detail.withdrawFailed":"Zurückziehen fehlgeschlagen: {{err}}","marketplace.myPacks.buttonLabel":"Meine Pakete","marketplace.myPacks.buttonTitle":"Veröffentlichungen von {{login}} ansehen","marketplace.myPacks.buttonTitleEmpty":"Lege zuerst unter Einstellungen → Marktplatz dein Veröffentlichungsprofil fest","marketplace.myPacks.searchPlaceholder":"Nach Namen oder Schlagwörtern suchen","marketplace.myPacks.notLoggedIn":"Lege zuerst unter Einstellungen → Marktplatz dein Veröffentlichungsprofil fest","marketplace.myPacks.emptyTitle":"Du hast noch keine Stilpakete veröffentlicht","marketplace.myPacks.emptyHint":"Bearbeite ein Paket auf der Seite „Stil“ und klicke auf „Auf dem Marktplatz veröffentlichen“, oder lade oben rechts ein lokales Paket hoch.","marketplace.myPacks.noMatch":"Keine passenden Stilpakete","marketplace.myPacks.summary":"{{count}} veröffentlicht","marketplace.myPacks.summaryPending":"{{count}} veröffentlicht · {{pending}} warten auf Prüfung","marketplace.myPacks.versionDate":"v{{version}} · {{date}}","marketplace.myPacks.stats":"★ {{likes}} · ↓ {{downloads}}","marketplace.myPacks.actions.update":"Aktualisieren","marketplace.myPacks.actions.withdraw":"Zurückziehen","marketplace.myPacks.loadFailed":"Meine Pakete konnten nicht geladen werden: {{err}}","marketplace.myPacks.loadingTitle":"Wird geladen…","marketplace.myPacks.loadingHint":"Deine neuesten Veröffentlichungen werden vom Marktplatz abgerufen.","marketplace.myPacks.loadErrorTitle":"Laden fehlgeschlagen","marketplace.myPacks.loadErrorRetry":"Erneut versuchen","marketplace.upload.confirmBtn":"Hochladen bestätigen","marketplace.upload.updateTitle":"„{{name}}“ aktualisieren","marketplace.upload.updateHint":"Wähle die neuere lokale Version und klicke auf „Hochladen bestätigen“. Ein Paket mit demselben Namen ist bereits ausgewählt.","marketplace.upload.recommendedBadge":"Empfohlen","marketplace.state.pending":"Ausstehend","marketplace.state.approved":"Veröffentlicht","marketplace.state.rejected":"Abgelehnt","marketplace.state.withdrawn":"Zurückgezogen","marketplace.state.superseded":"Ersetzt","marketplace.state.unknown":"Unbekannt","marketplace.oauth.title":"Mit GitHub anmelden","marketplace.oauth.generating":"Gerätecode wird erstellt…","marketplace.oauth.browserHint":"Öffne {{uri}} im Browser und gib diesen Code ein:","marketplace.oauth.copyBtn":"Kopieren","marketplace.oauth.copied":"Gerätecode kopiert","marketplace.oauth.copyFailed":"Kopieren fehlgeschlagen: {{err}}","marketplace.oauth.openBrowserBtn":"Browser öffnen","marketplace.oauth.cancelBtn":"Abbrechen","marketplace.oauth.waiting":"Warten auf Freigabe im Browser…","marketplace.oauth.successAs":"Als @{{login}} angemeldet","marketplace.oauth.retryBtn":"Erneut versuchen","marketplace.oauth.closeBtn":"Schließen","marketplace.oauth.loginBtn":"Anmelden","marketplace.oauth.loginTooltip":"Mit GitHub anmelden","marketplace.oauth.reloginTooltip":"Erneut anmelden oder Konto wechseln (aktuell @{{login}})","marketplace.modal.loggedIn":"Aktuelles Anmeldeprofil – unter Einstellungen → Aufnahme → Marktplatz ändern","marketplace.modal.notLoggedIn":"Nicht angemeldet – lege unter Einstellungen → Aufnahme → Marktplatz deinen Veröffentlichungsnamen fest","marketplace.modal.notLoggedInLabel":"Nicht angemeldet","shell.shortcutLabel":"Aufnahmekurzbefehl","shell.shortcutHint":"Starten / Beenden","shell.betaTag":"BETA","shell.betaNote":"Lokale Speicherung, optionale Cloud-Sicherung","shell.navHint.overview":"Statusübersicht: Nutzungsstatistik, Dienste und Berechtigungen","shell.navHint.history":"Diktatverlauf: frühere Transkripte suchen, abspielen und kopieren","shell.navHint.vocab":"Wörterbuch: eigene Begriffe für eine bessere Erkennung von Eigennamen","shell.navHint.style":"Textstile: Ausgabestile und eigene Prompts verwalten","shell.navHint.translation":"Übersetzung: Beim Sprechen Shift gedrückt halten, um Text in der Zielsprache einzufügen","shell.navHint.selectionAsk":"Zum ausgewählten Text fragen: Text auswählen und eine Frage dazu sprechen","shell.navHint.settings":"Einstellungen: Kurzbefehle, Dienste, Datenschutz und Updates","shell.footer.account":"Konto","shell.footer.feedback":"Rückmeldung","shell.footer.settings":"Einstellungen","shell.footer.help":"Hilfe","shell.footer.version":"Version {{version}}","shell.footer.helpPopover.tagline":"Spracheingabe mit lokalem Schwerpunkt","shell.footer.helpPopover.releaseNotes":"Versionshinweise ↗","shell.footer.helpPopover.docs":"Hilfezentrum ↗","shell.providerPrompt.title":"Sprachdienste einrichten","shell.providerPrompt.body":"Ein ASR- oder LLM-Dienst ist noch nicht eingerichtet. Spracheingabe und Textüberarbeitung sind erst nach dem Hinzufügen der Zugangsdaten verfügbar.","shell.providerPrompt.later":"Später","shell.providerPrompt.openSettings":"Einstellungen öffnen","shell.hotkeyModePrompt.title":"Aufnahmemodus prüfen","shell.hotkeyModePrompt.body":"Standardmäßig wird die Aufnahme jetzt per Tastendruck ein- und ausgeschaltet. Falls du den Auslösemodus zuvor geändert hast, prüfe ihn bitte in den Aufnahmeeinstellungen.","shell.hotkeyModePrompt.later":"Später erinnern","shell.hotkeyModePrompt.openSettings":"Aufnahmeeinstellungen öffnen","onboarding.welcome":"Willkommen bei OpenLess","onboarding.intro":"Lokal sprechen, lokal schreiben. Vor dem Start werden zwei Systemberechtigungen benötigt.","onboarding.accessibilityTitle":"Bedienungshilfen","onboarding.hotkeyTitle":"Globaler Kurzbefehl","onboarding.accessibilityDesc":"Erfasst den globalen Kurzbefehl (Standard: {{trigger}}) und fügt Transkripte an der Cursorposition ein.","onboarding.hotkeyDesc":"Prüft, ob die Überwachung globaler Kurzbefehle verfügbar ist.","onboarding.micTitle":"Mikrofon","onboarding.micDesc":"Erfasst deine Spracheingabe.","onboarding.actionNotApplicable":"Nicht erforderlich","onboarding.actionGranted":"Erlaubt","onboarding.actionOpenSystem":"Systemeinstellungen öffnen","onboarding.actionRestart":"Bedienungshilfen zurücksetzen und OpenLess neu starten","onboarding.actionGrant":"Erlauben","onboarding.actionRequestMic":"Zugriff anfordern","onboarding.micNoDeviceHint":"Kein Mikrofon erkannt. Schließe ein Mikrofon an, aktiviere es und versuche es erneut.","onboarding.accessibilityHint":"Nach der Freigabe musst du **OpenLess vollständig beenden** und erneut öffnen (Vorgabe von macOS TCC).","onboarding.footerHint":"Diese Einführung schließt sich automatisch, sobald beide Berechtigungen erteilt wurden. Falls sie weiterhin angezeigt wird, beende OpenLess über die Menüleiste und starte es erneut.","onboarding.continueToSettings":"Nur Einstellungen öffnen (Spracheingabe und globale Kurzbefehle nicht verfügbar)","onboarding.androidContinue":"Zur App","onboarding.androidFooterHint":"Für Diktate ist Mikrofonzugriff erforderlich. Tippe oben auf „Zugriff anfordern“ oder fahre fort und erteile ihn später in der Übersicht.","onboarding.androidTitle":"OpenLess einrichten","onboarding.androidIntro":"Richte Berechtigungen und Dienste für Mobilgeräte Schritt für Schritt ein.","onboarding.androidStepCounter":"Schritt {{current}} von {{total}}","onboarding.androidBack":"Zurück","onboarding.androidNext":"Weiter","onboarding.androidFinish":"Abschließen und öffnen","onboarding.androidSteps.microphoneTitle":"Mikrofonberechtigung","onboarding.androidSteps.microphoneDesc":"Öffne den Android-Berechtigungsdialog und erlaube OpenLess, Sprache aufzunehmen.","onboarding.androidSteps.accessibilityTitle":"Bedienungshilfendienst","onboarding.androidSteps.accessibilityDesc":"Fügt Erkennungsergebnisse in das aktive Eingabefeld ein und hilft, den Eingabekontext zu erkennen.","onboarding.androidSteps.overlayPermissionTitle":"Berechtigung für schwebende Fenster","onboarding.androidSteps.overlayPermissionDesc":"Erlaube OpenLess, die Aufnahmesteuerung über anderen Apps anzuzeigen.","onboarding.androidSteps.overlayConfigTitle":"Einstellungen für schwebende Fenster","onboarding.androidSteps.overlayConfigDesc":"Sichtbarkeit, Aktivierung, Wischaktionen und Tastengröße einstellen.","onboarding.androidSteps.asrTitle":"ASR-Clouddienst","onboarding.androidSteps.asrDesc":"Dienst für Spracherkennung, Schlüssel, Endpunkt und Modell einrichten.","onboarding.androidSteps.llmTitle":"LLM-Dienst","onboarding.androidSteps.llmDesc":"Das Sprachmodell für Textüberarbeitung, Übersetzung und Fragen einrichten.","overview.refresh":"Status aktualisieren","overview.servicesTitle":"Aktuelle Sprachdienste","overview.statsTitle":"Deine Aktivität","overview.omniKind":"Multimodale Spracheingabe","overview.omniName":"Aktuelles Omni-Modell","overview.statusLoading":"Dienstkonfiguration wird gelesen…","overview.configureProvider":"Einrichten","overview.manageProvider":"Dienst verwalten","overview.recentEmptyHint":"Noch keine Diktate. Probiere es mit der Anleitung oben aus. Dein Ergebnis erscheint hier.","overview.providerHelp.asr":"Wandelt deine Sprache in Text um.","overview.providerHelp.llm":"Gliedert und überarbeitet Text in deinem Stil.","overview.providerHelp.omni":"Ein Modell übernimmt Spracherkennung und Textverarbeitung.","overview.actions.refresh":"Erneut versuchen","overview.actions.services":"KI-Dienste und Modelle","overview.actions.general":"Aufnahme und Eingabe","overview.actions.shortcuts":"Kurzbefehle","overview.actions.privacy":"Berechtigungen und Daten","overview.guide.nextStep":"Nächster Schritt","overview.guide.loadingTitle":"Deine Konfiguration wird gelesen","overview.guide.loadingDesc":"Deine aktuellen Dienste und der nächste Schritt erscheinen gleich.","overview.guide.unavailableTitle":"Dienststatus nicht verfügbar","overview.guide.unavailableDesc":"Lies den Status erneut oder prüfe deine Konfiguration unter „KI-Dienste“.","overview.guide.servicesTitle":"Sprachdienste einrichten","overview.guide.servicesDesc":"Beginne hier: Wähle Dienste für Spracherkennung und Textverarbeitung. Im Omni-Modus muss nur das aktive multimodale Modell eingerichtet werden.","overview.guide.permissionsTitle":"Kurzbefehlstatus prüfen","overview.guide.permissionsDesc":"Der Kurzbefehladapter ist nicht verfügbar. Unter „Berechtigungen und Daten“ findest du seinen Status und die verfügbaren Optionen.","overview.guide.shortcutsTitle":"Aufnahmekurzbefehl wählen","overview.guide.shortcutsDesc":"Wähle einen leicht erreichbaren Kurzbefehl, um beim Schreiben ein Diktat zu starten.","overview.guide.recordingTitle":"Aufnahmeart wählen","overview.guide.recordingDesc":"Deine Dienstkonfiguration ist gespeichert. Wähle in den Aufnahmeeinstellungen dein Mikrofon und den Aufnahmemodus.","overview.guide.tryDictationTitle":"Diktat ausprobieren","overview.guide.tryDictationDesc":"Setze den Cursor an die gewünschte Eingabestelle. {{shortcut}}","overview.guide.permissionsHint":"Aufnahme oder Kurzbefehle reagieren nicht? Prüfe Berechtigungen, Mikrofonzugriff und Kurzbefehlstatus unter „Berechtigungen und Daten“.","overview.kicker":"ÜBERSICHT","overview.title":"Heutige Übersicht","overview.desc":"Diktatstatistik und Systemstatus für heute.","overview.pressPrefix":"Drücke","overview.pressSuffix":"zum Starten","overview.asrKind":"Spracherkennung","overview.llmKind":"Textverarbeitung","overview.asrName":"Volcengine","overview.asrSubname":"bigmodel","overview.llmName":"OpenAI-kompatibel","overview.llmConfigured":"Aktives LLM eingerichtet","overview.llmNotConfigured":"Nicht eingerichtet","overview.statusConfigured":"Eingerichtet","overview.statusNotConfigured":"Nicht eingerichtet","overview.statusUnknown":"Nicht verfügbar","overview.credentialsLoadError":"Status der Zugangsdaten konnte nicht gelesen werden","overview.metricChars":"Zeichen heute","overview.metricSegments":"Abschnitte: {{count}}","overview.metricDuration":"Gesamtdauer heute","overview.metricAvg":"Durchschnitt pro Abschnitt","overview.metricAvgTrend":"Heutiger Durchschnitt","overview.metricNoData":"Keine Daten","overview.historyLoadError":"Verlauf konnte nicht geladen werden","overview.metricTotal":"Einträge insgesamt","overview.metricTotalTrend":"Lokales Archiv (max. 200)","overview.activityTitle":"Aktivität im Jahr","overview.activityCount":"Diktate: {{count}}","overview.activityLoadError":"Aktivitätsdaten konnten nicht geladen werden","overview.period.ariaLabel":"Auswertungszeitraum","overview.period.last7Days":"Letzte 7 Tage","overview.period.last30Days":"Letzte 30 Tage","overview.period.dailyAverage":"{{value}} / Tag","overview.period.minutes":"{{value}} Min.","overview.period.hoursMinutes":"{{hours}} Std. {{minutes}} Min.","overview.metricName.ariaLabel":"Kennzahl","overview.metricName.count":"Anzahl","overview.metricName.chars":"Zeichen","overview.metricName.duration":"Dauer","overview.recentTitle":"Neueste Transkripte","overview.recentAll":"Alle anzeigen →","overview.recentEmpty":"Noch keine Einträge. Drücke {{trigger}}, um deine erste Aufnahme zu starten.","overview.recentLoadFailed":"Neueste Transkripte konnten nicht geladen werden. Versuche es erneut.","overview.historyRetry":"Erneut versuchen","overview.weekDays.0":"So","overview.weekDays.1":"Mo","overview.weekDays.2":"Di","overview.weekDays.3":"Mi","overview.weekDays.4":"Do","overview.weekDays.5":"Fr","overview.weekDays.6":"Sa","overview.inAppDictation.title":"Diktat in der App","overview.inAppDictation.start":"Aufnahme starten","overview.inAppDictation.stop":"Aufnahme beenden","overview.inAppDictation.idle":"Zum Aufnehmen tippen","overview.inAppDictation.recording":"Aufnahme läuft…","overview.inAppDictation.processing":"Wird verarbeitet…","overview.androidMicBanner.title":"Mikrofonberechtigung erforderlich","overview.androidMicBanner.desc":"Erlaube den Mikrofonzugriff, um Diktate und Spracheingabe in der App zu nutzen.","overview.androidMicBanner.grant":"Zugriff anfordern","overview.androidMicBanner.openSettings":"Einstellungen öffnen","history.exportError":"Die Aufnahme konnte nicht exportiert werden. Versuche es erneut.","history.kicker":"VERLAUF","history.title":"Verlauf","history.desc":"Lokal gespeicherte Transkripte.","history.filterAll":"Alle","history.summary":"{{total}} insgesamt · {{shown}} angezeigt","history.searchPlaceholder":"Transkripte durchsuchen… ({{shortcut}})","history.searchNoMatch":"Keine Einträge für „{{query}}“.","history.empty":"Noch kein Verlauf. Drücke {{trigger}}, um etwas aufzunehmen.","history.loadFailed":"Verlauf konnte nicht geladen werden: {{err}}","history.retry":"Erneut versuchen","history.clearFailed":"Verlauf konnte nicht geleert werden: {{err}}","history.deleteFailed":"Eintrag konnte nicht gelöscht werden: {{err}}","history.copyFailed":"Kopieren fehlgeschlagen: {{err}}","history.playRecording":"Aufnahme abspielen","history.audioLoading":"Wird geladen…","history.audioDecodeFailed":"Audio konnte nicht dekodiert werden: {{err}}","history.exportRecording":"Aufnahme exportieren","history.exportFailed":"Export fehlgeschlagen: {{err}}","history.retranscribe":"Erneut transkribieren","history.retranscribing":"Wird transkribiert…","history.retranscribeFailed":"Erneute Transkription fehlgeschlagen: {{err}}","history.rawLabel":"Rohtext","history.rawEmpty":"(leer)","history.selectHint":"Wähle links einen Eintrag aus, um die Details anzuzeigen.","history.recorded":"Aufnahmedauer: {{duration}}","history.stepAsr":"Transkribieren","history.multimodalPipeline":"Multimodal","history.stepAsrHint":"Wartezeit auf das Transkript nach dem Loslassen der Taste. Die laufende Spracherkennung arbeitet bereits während des Sprechens, daher ist diese Zeit meist deutlich kürzer als die Aufnahme.","history.stepPolish":"Überarbeiten","history.stepInsert":"Einfügen","history.chars":"{{count}} Zeichen","history.vocabHits":"{{count}} Wörterbuchtreffer","history.inserted":"Eingefügt","history.pasteSent":"Einfügebefehl gesendet","history.copiedFallback":"Kopiert (mit {{shortcut}} einfügen)","history.insertFailed":"Einfügen fehlgeschlagen","history.confirmClear":"Alle {{count}} Verlaufseinträge löschen? Dies kann nicht rückgängig gemacht werden.","history.backToList":"Zurück zur Liste","history.repolish.title":"Erneut überarbeiten","history.repolish.hint":"Überarbeitet das obige Transkript erneut. Die Ergebnisse werden nur für diesen Aufruf angezeigt und nicht im Eintrag gespeichert. Wurde das ursprüngliche Stilpaket gelöscht oder stammt der Eintrag aus der Zeit vor Stilpaketen, wird der aktuelle Stil verwendet.","history.repolish.retry":"Mit demselben Stil wiederholen","history.repolish.retrying":"Wird erneut versucht…","history.repolish.apply":"Anwenden","history.repolish.applying":"Wird überarbeitet…","history.repolish.pickStyle":"Stilpaket wählen","history.repolish.noPacks":"Keine Stilpakete verfügbar.","history.repolish.packsLoadFailed":"Stilpakete konnten nicht geladen werden: {{err}}","history.repolish.failed":"Erneute Überarbeitung fehlgeschlagen: {{err}}","history.repolish.timeout":"Der aktuelle LLM-Dienst hat nicht innerhalb von 30 Sekunden geantwortet. Wähle einen schnelleren Dienst oder versuche es später erneut. Bei kostenlosen Modellangeboten entstehen häufig Wartezeiten.","history.repolish.resultTitle":"Ergebnis von {{name}}","history.repolish.retryResultTitle":"Ergebnis des erneuten Versuchs","history.repolish.empty":"(das Modell hat ein leeres Ergebnis zurückgegeben)","history.repolish.clear":"Ergebnisse leeren","vocabCard.title":"Dieses Wort merken?","vocabCard.accept":"Merken","vocabCard.reject":"Überspringen","insertFallbackCard.copy":"Kopieren","insertFallbackCard.copied":"Kopiert","insertFallbackCard.copyFailed":"Kopieren fehlgeschlagen","insertFallbackCard.dismiss":"Ausblenden","vocab.selectAllVisible":"Aktuelle Ergebnisse auswählen","vocab.selectedCount":"Ausgewählte Wörter: {{count}}","vocab.selectWord":"„{{phrase}}“ auswählen","vocab.deleteSelected":"Auswahl löschen ({{count}})","vocab.batchDeleteFailed":"Löschen fehlgeschlagen. Betroffene Wörter: {{count}}. Sie bleiben für einen erneuten Versuch ausgewählt.","vocab.kicker":"WÖRTERBUCH","vocab.title":"Wörterbuch","vocab.desc":"Füge Begriffe oder Fachwörter hinzu, um die Erkennungsgenauigkeit zu verbessern.","vocab.sectionTitle":"Einträge","vocab.placeholder":"Wort eingeben, Enter drücken oder auf „Hinzufügen“ klicken…","vocab.tip":"Chinesisch und Englisch mischbar · Zahlenpräfixe werden wörtlich abgeglichen · Treffer werden automatisch gezählt","vocab.loadFailed":"Laden fehlgeschlagen: {{err}}","vocab.empty":"Noch keine Einträge. Füge oben einen Begriff oder ein Fachwort hinzu, damit das Modell es bevorzugt erkennt.","vocab.tipDisabled":"Klicken, um diesen Eintrag zu deaktivieren","vocab.tipEnabled":"Klicken, um diesen Eintrag zu aktivieren","vocab.removeAria":"Entfernen","vocab.edit":"Bearbeiten","vocab.editTitle":"Wort bearbeiten","vocab.editSave":"Speichern","vocab.editEmpty":"Das Wort darf nicht leer sein.","vocab.filter.all":"Alle","vocab.filter.auto":"Automatisch hinzugefügt","vocab.filter.manual":"Manuell hinzugefügt","vocab.searchPlaceholder":"Suchen","vocab.searchEmpty":"Keine passenden Wörter.","vocab.newWord":"Neues Wort","vocab.newWordTitle":"Neue Wörter hinzufügen","vocab.newWordDesc":"Gib ein Wort direkt ein oder importiere mehrere Begriffe aus Vorlagen.","vocab.newWordInputPlaceholder":"Wort eingeben und mit Enter hinzufügen…","vocab.newWordTemplates":"Vorlagen","vocab.newWordTemplateCount":"Wörter: {{count}}","vocab.newWordAddSelected":"Auswahl hinzufügen","vocab.learnedSection":"Automatisch gesammelt ({{count}})","vocab.removeAllLearned":"Alle entfernen","vocab.corrections.title":"Korrekturregeln","vocab.corrections.tip":"Behebt häufige ASR-Fehler. Unterstützt den Zahlenplatzhalter {num}.","vocab.corrections.patternPlaceholder":"Fehlerhafter Text, z. B. {num} mahl","vocab.corrections.replacementPlaceholder":"Zieltext, z. B. {num} mal","vocab.corrections.empty":"Noch keine Korrekturregeln.","vocab.corrections.invalid":"Unterstützt werden nur wörtliche Ersetzungen oder ein einzelner Zahlenplatzhalter {num}, beispielsweise {num} mahl → {num} mal.","vocab.corrections.tipDisabled":"Klicken, um diese Regel zu deaktivieren","vocab.corrections.tipEnabled":"Klicken, um diese Regel zu aktivieren","vocab.corrections.removeAria":"Korrekturregel entfernen","vocab.corrections.learnedBadge":"auto","vocab.corrections.learnedTip":"Automatisch aus deinen eigenen Änderungen gelernt. Du kannst die Regel jederzeit löschen.","vocab.corrections.onlyLearned":"Nur automatisch gesammelte ({{count}})","vocab.corrections.removeAllLearned":"Alle automatisch gesammelten löschen","vocab.corrections.suggestTitle":"Diese Korrektur merken?","vocab.corrections.suggestAccept":"Merken","vocab.corrections.suggestDismiss":"Nein, danke","vocab.presets.title":"Vorlagen für Anwendungsfälle","vocab.presets.tip":"Wähle mehrere Vorlagen aus, um sie gemeinsam anzuwenden. Vorlagen lassen sich bearbeiten und neu erstellen.","vocab.presets.create":"Neue Vorlage","vocab.presets.apply":"Auswahl anwenden","vocab.presets.save":"Vorlage speichern","vocab.presets.edit":"{{name}} bearbeiten","vocab.presets.newPreset":"Neue Vorlage","vocab.presets.namePlaceholder":"Name der Vorlage","vocab.presets.wordsPlaceholder":"Begriffe (durch Kommas oder Zeilenumbrüche getrennt)","style.kicker":"STIL","style.title":"Ausgabestil","style.desc":"Wähle den Standardstil für Aufnahmeergebnisse.","style.masterToggle":"Hauptschalter","style.currentDefault":"Aktueller Standard","style.ariaSetDefault":"Als Standard festlegen","style.saveFailed":"Speichern fehlgeschlagen: {{error}}","style.customPromptTitle":"Eigener Prompt","style.customPromptPlaceholder":"Optional. Wird an den integrierten System-Prompt dieses Stils angehängt.","style.customPromptHint":"Leer lassen, um das aktuelle Verhalten beizubehalten. Nach dem Speichern gilt der Prompt für die laufende Überarbeitung und das erneute Überarbeiten mit diesem Stil. Speichern ist auch mit Ctrl/Cmd+Enter möglich.","style.customPromptSave":"Prompt speichern","style.customPromptDirty":"Nicht gespeichert","style.systemPromptMovedHint":"Der vollständige System-Prompt wird jetzt unter Einstellungen -> Dienste bearbeitet. Hier legst du nur noch fest, welche Stile aktiv sind und welcher als Standard dient.","style.modes.raw.name":"Rohtext","style.modes.raw.desc":"Ergänzt nur Satzzeichen und natürliche Absätze, ohne den Text umzuschreiben oder zu erweitern.","style.modes.raw.sample":"Behält den gesprochenen Rhythmus bei. Füllwörter wie „äh“ oder „weißt du“ entfallen, die Sätze bleiben erhalten.","style.modes.light.name":"Leicht überarbeiten","style.modes.light.desc":"Entfernt Füllwörter, ergänzt Satzzeichen und formuliert natürlich lesbaren Text.","style.modes.light.sample":"Macht das Transkript flüssiger, ohne einstudiert zu wirken. Dein Ton und deine Ausdrucksweise bleiben erhalten.","style.modes.structured.name":"Strukturiert","style.modes.structured.desc":"Strukturiert Programmierfragen, Fehleranalysen und Produktfeedback mit präziser Fachsprache.","style.modes.structured.sample":"1. Erstes Thema\na. Punkt\nb. Punkt\n2. Zweites Thema\na. Punkt\nb. Punkt","style.modes.formal.name":"Formell","style.modes.formal.desc":"Passend für E-Mails und den Beruf: vollständiger und professioneller formuliert.","style.modes.formal.sample":"Erkennt Begrüßungen und Grußformeln in E-Mails und vermeidet leere Höflichkeitsfloskeln.","style.pack.builtinTags.minimalEdits":"Minimale Änderungen","style.pack.builtinTags.strongCorrection":"Gründliche Korrektur","style.pack.builtinTags.communication":"Kommunikation","style.pack.builtinTags.natural":"Natürlich","style.pack.builtinTags.organized":"Übersichtlich","style.pack.builtinTags.workplaceCommunication":"Berufliche Kommunikation","style.pack.builtinTags.aiCoding":"KI-Programmierung","style.pack.builtinTags.technicalStructure":"Technische Struktur","style.pack.newName":"Unbenannter Stil","style.pack.newDescription":"Beschreibe kurz, wofür sich dieser Stil eignet.","style.pack.uploadIcon":"SVG-Symbol für {{name}} hochladen","style.pack.resetIcon":"Standardsymbol wiederherstellen","style.pack.iconSaved":"Symbol gespeichert","style.pack.iconInvalid":"Wähle ein gültiges SVG-Symbol ohne externe Ressourcen (bis 256 KB).","style.pack.iconSaveFailed":"Das Symbol konnte nicht gespeichert werden. Versuche es erneut.","style.pack.selectionListTitle":"Stile für ausgewählten Text","style.pack.selectionListDesc":"Überarbeitet bereits geschriebenen Text ohne ASR: Grammatik, Klarheit und Formatierung. Wähle dafür einen eigenen Stil und Prompt.","style.pack.dictationTab":"Aufnahme- / ASR-Stile","style.pack.selectionTab":"Auswahl überarbeiten","style.pack.current":"Aktuell","style.pack.useForSelection":"Für Auswahl verwenden","style.pack.writtenPolish":"Geschriebenen Text überarbeiten","style.pack.selectionPromptTitle":"Prompt für Textauswahl (ohne ASR)","style.pack.selectionPromptHint":"Für ausgewählten geschriebenen Text, nicht für ASR-Ausgabe. Behandle ihn nicht als Transkript und beantworte keine darin enthaltenen Fragen.","style.pack.selectionPromptEditorDesc":"Bearbeitet den Prompt für Textauswahl. Die Eingabe ist aktiv ausgewählter geschriebener Text, ohne ASR.","style.pack.dictationPromptEditorDesc":"Bearbeitet den Aufnahme-/ASR-Prompt. Die Eingabe ist das nach dem Diktat erkannte Transkript.","style.pack.dictationPromptTitle":"Aufnahme- / ASR-Prompt","style.pack.dictationPromptHint":"Für ASR-Text nach dem Diktat. Lege hier Regeln für gesprochene Sprache, Erkennungsfehler und die Wiederherstellung von Fachbegriffen fest.","style.pack.selectionPromptFallback":"Noch kein Prompt für geschriebenen Text eingerichtet. Eine sichere Standardeinstellung wird verwendet.","style.pack.selectionActivated":"„{{name}}“ für die Überarbeitung von Textauswahl festgelegt.","style.pack.selectionActivateFailed":"Stil für Textauswahl konnte nicht gewechselt werden: {{err}}","style.pack.selectionChars":"{{count}} Zeichen","style.pack.kicker":"STILPAKETE","style.pack.title":"Stilpakete","style.pack.desc":"Lokale Stilpakete verwalten.","style.pack.marketplaceBtn":"Marktplatz","style.pack.loadFailed":"Stilpakete konnten nicht geladen werden: {{err}}","style.pack.importZip":"ZIP importieren","style.pack.exportZip":"ZIP exportieren","style.pack.exportShort":"Exportieren","style.pack.publishMarketplace":"Auf dem Marktplatz veröffentlichen","style.pack.updateMarketplace":"Marktplatzversion aktualisieren","style.pack.publishDisabledHint":"Melde dich zuerst unter Einstellungen → Marktplatz mit GitHub an","style.pack.publishSuccess":"Veröffentlicht – wartet auf Prüfung im Marktplatz","style.pack.publishFailed":"Veröffentlichen fehlgeschlagen: {{err}}","style.pack.publishBuiltinRejected":"Integrierte Pakete können nicht veröffentlicht werden. Erstelle zuerst über „Bearbeiten“ eine Kopie.","style.pack.builtin":"Integriert","style.pack.imported":"Importiert","style.pack.active":"Aktiv","style.pack.activate":"Aktivieren","style.pack.edit":"Bearbeiten","style.pack.closeEditor":"Schließen","style.pack.unsaved":"Nicht gespeichert","style.pack.listTitle":"Lokale Pakete","style.pack.listDesc":"Pakete ansehen und wechseln.","style.pack.listCount":"Pakete: {{count}}","style.pack.addPackTileTitle":"Neues Paket","style.pack.addPackTileHint":"Mit einer leeren Vorlage beginnen.","style.pack.createSuccess":"Neues Paket erstellt.","style.pack.createFailed":"Paket konnte nicht erstellt werden: {{err}}","style.pack.save":"Speichern","style.pack.revert":"Zurücksetzen","style.pack.saveSuccess":"Stilpaket gespeichert.","style.pack.saveFailed":"Stilpaket konnte nicht gespeichert werden: {{err}}","style.pack.activateSuccess":"„{{name}}“ als aktuelles Paket festgelegt.","style.pack.activateFailed":"Aktuelles Stilpaket konnte nicht festgelegt werden: {{err}}","style.pack.importSuccess":"„{{name}}“ importiert.","style.pack.importFailed":"ZIP-Import fehlgeschlagen: {{err}}","style.pack.exportSuccess":"Nach {{path}} exportiert","style.pack.exportFailed":"ZIP-Export fehlgeschlagen: {{err}}","style.pack.exportDirtyFirst":"Speichere dieses Paket vor dem ZIP-Export.","style.pack.resetBuiltin":"Zurücksetzen","style.pack.resetSuccess":"„{{name}}“ zurückgesetzt.","style.pack.resetFailed":"Paket konnte nicht zurückgesetzt werden: {{err}}","style.pack.deleteImported":"Löschen","style.pack.deleteConfirm":"„{{name}}“ löschen? Dies kann nicht rückgängig gemacht werden.","style.pack.deleteSuccess":"„{{name}}“ gelöscht.","style.pack.deleteFailed":"Paket konnte nicht gelöscht werden: {{err}}","style.pack.summaryCurrentEmpty":"Noch kein Paket ausgewählt","style.pack.editorTitle":"Paket bearbeiten","style.pack.editorDesc":"Dieses Paket bearbeiten.","style.pack.metaTitle":"Installationsinformationen","style.pack.metaSource":"Quelle","style.pack.metaBaseMode":"Basismodus","style.pack.metaUpdatedAt":"Aktualisiert","style.pack.fieldName":"Name","style.pack.fieldAuthor":"Autor","style.pack.fieldAuthorPlaceholder":"Optionale Quellenangabe","style.pack.fieldVersion":"Version","style.pack.fieldTags":"Schlagwörter","style.pack.fieldTagsPlaceholder":"Durch Kommas getrennt, z. B. Community, Kommentar, formell","style.pack.fieldDescription":"Beschreibung","style.pack.fieldModel":"Empfohlenes Modell (Metadaten)","style.pack.fieldModelPlaceholder":"Optional, z. B. gpt-4.1 / deepseek-v3","style.pack.fieldModelHint":"Nur Metadaten. Wechselt nicht das Modell.","style.pack.fieldCompatibility":"Kompatible App-Version","style.pack.fieldCompatibilityPlaceholder":"Optional, z. B. >=1.3.0","style.pack.fullPromptTitle":"System-Prompt","style.pack.fullPromptHint":"Der zu diesem Paket gehörende Prompt.","style.pack.promptChars":"{{count}} Zeichen","style.pack.runtimeTitle":"OpenLess-Laufzeitanweisungen","style.pack.runtimeDesc":"Schreibgeschützte Ergänzungen zur Laufzeit.","style.pack.runtimeContextTitle":"Kontextgrundlage","style.pack.runtimeContextDesc":"Aus Sprach- und App-Kontext","style.pack.runtimeContextEmpty":"In der aktuellen Vorschau nicht ergänzt.","style.pack.runtimeHotwordTitle":"Begriffsblock","style.pack.runtimeHotwordDesc":"Aus aktivierten Wörterbucheinträgen","style.pack.runtimeHotwordEmpty":"In der aktuellen Vorschau nicht ergänzt.","style.pack.runtimeHistoryTitle":"Regeln für mehrstufigen Verlauf","style.pack.runtimeHistoryDesc":"Nur bei laufender Überarbeitung über mehrere Gesprächsrunden","style.pack.runtimeHistoryEmpty":"Wird nur ergänzt, wenn vorherige Gesprächsrunden existieren.","style.pack.runtimeActive":"Aktiv","style.pack.runtimeInactive":"Inaktiv","style.pack.runtimePreviewFailed":"Laufzeitvorschau konnte nicht erstellt werden: {{err}}","style.pack.runtimePreviewOmittedFrontApp":"Die Vorschau enthält keinen Namen der aktiven App.","style.pack.examplesTitle":"Beispiele","style.pack.examplesDesc":"Werden mit dem Paket exportiert.","style.pack.addExample":"Beispiel hinzufügen","style.pack.examplesEmpty":"Noch keine Beispiele.","style.pack.exampleTitlePlaceholder":"Titel für Beispiel {{index}}","style.pack.exampleInput":"Eingabe","style.pack.exampleOutput":"Ausgabe","style.pack.examplesCount":"Beispiele: {{count}}","style.pack.discardCloseConfirm":"Ungespeicherte Änderungen verwerfen und den Editor schließen?","style.pack.discardSwitchConfirm":"Ungespeicherte Änderungen verwerfen und zu „{{name}}“ wechseln?","style.pack.derivativeBadge":"Abgeleitet von @{{login}}","translation.searchLanguages":"Sprachen suchen…","translation.noMatchingLanguages":"Keine passenden Sprachen","translation.selectedLanguages":"Ausgewählte Sprachen: {{count}}","translation.languageSupportHint":"Die verfügbaren Erkennungssprachen hängen vom Dienst ab. Übersetzungsziele sind unabhängig von der App-Sprache.","translation.kicker":"ÜBERSETZUNG","translation.title":"Übersetzung","translation.desc":"Aufnahmen vor dem Einfügen automatisch in eine Zielsprache übersetzen.","translation.statusEnabled":"Aktiviert","translation.statusDisabled":"Deaktiviert","translation.working.title":"Arbeitssprachen","translation.working.desc":"Wähle regelmäßig verwendete Sprachen, um Überarbeitung und Übersetzung zu verbessern.","translation.target.title":"Zielsprache der Übersetzung","translation.target.desc":"Drücke während der Aufnahme Shift, um die Übersetzung zu aktivieren. Bei „Deaktiviert“ hat Shift keine Wirkung.","translation.target.disabled":"Deaktiviert (Shift ohne Wirkung)","translation.target.sameAsWorking":"Das Übersetzungsziel entspricht deiner einzigen Arbeitssprache. Shift löst deshalb nur die normale Textüberarbeitung aus. Wähle eine andere Zielsprache oder füge oben eine weitere Arbeitssprache hinzu.","translation.style.title":"Übersetzungsstil","translation.style.desc":"Übernimmt automatisch das aktive Stilpaket von der Seite „Stil“.","translation.style.unavailable":"Nicht verfügbar","translation.save.workingFailed":"Arbeitssprachen konnten nicht gespeichert werden. Versuche es erneut.","translation.save.targetFailed":"Übersetzungsziel konnte nicht gespeichert werden. Versuche es erneut.","translation.save.hotkeyRegisterFailed":"Der Übersetzungskurzbefehl konnte nicht registriert werden. Die Einstellung wurde nicht gespeichert.","translation.save.hotkeySaveFailed":"Übersetzungskurzbefehl konnte nicht gespeichert werden. Versuche es erneut.","translation.howto.title":"So funktioniert es","translation.howto.step1":"Setze den Cursor in ein beliebiges Textfeld.","translation.howto.step2":"Drücke {{trigger}}, um die Aufnahme zu starten.","translation.howto.step3":"Drücke während der Aufnahme einmal {{shortcut}}, um die Übersetzung zu aktivieren.","translation.howto.step4":"Drücke erneut {{trigger}}, um die Aufnahme zu beenden.","translation.howto.step5":"Der übersetzte Text wird an der Cursorposition eingefügt.","translation.howto.indicatorTitle":"So erkennst du den Übersetzungsmodus","translation.howto.indicatorDesc":"Nach dem Drücken von Shift erscheint unten auf dem Bildschirm die blaue Anzeige „Wird übersetzt“.","translation.howto.fallbackTitle":"Verhalten bei Fehlern","translation.howto.fallbackDesc":"Schlägt die Übersetzung fehl, wird stattdessen das ursprüngliche Transkript eingefügt.","selectionAsk.title":"Zum ausgewählten Text fragen","selectionAsk.desc":"Text auswählen und Fragen dazu sprechen, auch mit mehreren Rückfragen.","selectionAsk.shortcutSettings":"Kurzbefehlseinstellungen","selectionAsk.guide.openTitle":"Fenster öffnen","selectionAsk.guide.openDesc":"Drücke {{hotkey}}, um ein Gespräch zu beginnen.","selectionAsk.guide.unsetDesc":"Lege zuerst in den Kurzbefehlseinstellungen einen Kurzbefehl für Fragen zur Textauswahl fest.","selectionAsk.guide.selectTitle":"Text zum Nachfragen auswählen","selectionAsk.guide.askTitle":"Frage sprechen","selectionAsk.guide.askDesc":"Drücke {{recordHotkey}} zum Aufnehmen und erneut zum Senden.","selectionAsk.guide.followup":"Verwende den Aufnahmekurzbefehl erneut, um nachzufragen.","selectionAsk.guide.dismiss":"Fenster schließen und dieses Gespräch beenden","selectionAsk.hotkey.title":"Kurzbefehl zum Öffnen des Fensters","selectionAsk.save.historySaveFailed":"Die Einstellung für den Frageverlauf konnte nicht gespeichert werden. Versuche es erneut.","selectionAsk.history.title":"Verlauf speichern","selectionAsk.history.desc":"Speichert Fragen und Antworten lokal. Standardmäßig deaktiviert.","selectionAsk.howto.title":"So funktioniert es","selectionAsk.howto.step2":"Wähle Text in einer beliebigen App aus.","settings.selectionWorkspace.title":"Assistent für Textauswahl","settings.selectionWorkspace.hint":"Wähle Text aus und verwende einen Kurzbefehl: Ohne Sprachbearbeitung wird der Text direkt überarbeitet. Mit Sprachbearbeitung hältst du die Taste gedrückt und sprichst; danach wählst du „Fragen“ oder „Bearbeiten“.","settings.selectionWorkspace.polishHotkey":"Kurzbefehl für den Auswahlassistenten","settings.selectionWorkspace.polishHotkeyDesc":"Überarbeitet direkt, wenn Sprachbearbeitung aus ist. Andernfalls zum Sprechen gedrückt halten. Für die Aufnahme gelten die globalen Einstellungen.","settings.selectionWorkspace.polishDelivery":"Ergebnis anwenden","settings.selectionWorkspace.voiceDeliveryDesc":"Nach der Sprachbearbeitung: Auswahl direkt ersetzen oder im Fragefenster prüfen und bestätigen.","settings.selectionWorkspace.voiceEnable":"Sprachbearbeitung","settings.selectionWorkspace.voiceEnableDesc":"Verwendet denselben Kurzbefehl wie oben. Die Aufnahme folgt den globalen Einstellungen (aktuell: {{recordingLabel}}).","settings.selectionWorkspace.autoIntent":"Absicht automatisch erkennen","settings.selectionWorkspace.autoIntentDesc":"Das eingerichtete Modell unterscheidet standardmäßig zwischen Fragen und Bearbeitungsaufträgen. Bei Modellfehlern wird anhand von Fragewörtern entschieden.","settings.selectionWorkspace.editKeywords":"Weitere Hinweise auf Fragen","settings.selectionWorkspace.editKeywordsDesc":"Nur bei deaktivierter automatischer Erkennung. Ein Hinweis pro Zeile erzwingt den Fragemodus. Ansonsten wird anhand von „?“ und Fragewörtern entschieden.","settings.selectionPolish.title":"Textauswahl überarbeiten","settings.selectionPolish.hotkey":"Auslösender Kurzbefehl","settings.selectionPolish.hotkeyDesc":"Aufgezeichnete Kurzbefehle gelten sofort. Konflikte mit Aufnahme-, Frage- oder anderen globalen Kurzbefehlen werden abgelehnt.","settings.selectionPolish.delivery":"Ergebnis anwenden","settings.selectionPolish.hint":"Nach dem Auswählen eines Textes auslösen. Benötigt weder Mikrofon noch ASR und verwendet das aktuelle Stilpaket mit dem eigenen Prompt für Textauswahl.","settings.selectionPolish.directReplace":"Direkt ersetzen","settings.selectionPolish.directReplaceHint":"Ersetzt die ursprüngliche Auswahl nach Abschluss des Modells.","settings.selectionPolish.previewConfirm":"Prüfen und bestätigen","settings.selectionPolish.previewConfirmHint":"Prüfe und bearbeite das Ergebnis im Vorschaufenster und bestätige anschließend das Ersetzen.","settings.kicker":"EINSTELLUNGEN","settings.title":"Einstellungen","settings.desc":"Aufnahme, Dienste, Kurzbefehle und Berechtigungen.","settings.network.title":"Netzwerk","settings.network.useSystemProxyLabel":"Systemproxy verwenden","settings.network.useSystemProxyDesc":"Anfragen verwenden den Systemproxy, wenn diese Option aktiv ist. Andernfalls werden alle Anfragen direkt gesendet, was bei inländischen Diensten meist schneller ist. Ausländische Dienste wie GitHub-Anmeldung und Updates können dann fehlschlagen. Echtzeit-Sprachstreams und Less Computer sind davon unabhängig.","settings.dataStorage.title":"Datenspeicherung","settings.dataStorage.desc":"Gesprächsverlauf und Kontext, die auf diesem Gerät gespeichert werden.","settings.dataStorage.cursorContextLabel":"Cursorkontext (experimentell)","settings.dataStorage.cursorContextDesc":"Liest beim Überarbeiten den Text rund um den Cursor im aktuellen Dokument, damit das Modell gleich klingende Wörter, Eigennamen und Pronomen unterscheiden kann. Bei Aktivierung wird dieser Text mit der Anfrage an deinen LLM-Dienst gesendet; andernfalls wird nichts gelesen. Passwortfelder, Secure Input, Passwortmanager und Terminals werden nie gelesen. Nur unter macOS.","settings.codingConsole.title":"Claude-Konsole","settings.codingConsole.desc":"Prüft den lokalen Status von Claude Code und MCP (Computersteuerung). Führt Claude unter festgelegten Einschränkungen ohne eigene Oberfläche aus und zeigt Ausgabe und Kosten laufend an.","settings.codingConsole.guardNote":"Umkehrbare Aktionen sind standardmäßig erlaubt. Riskante Befehle wie rm -rf, sudo oder force push werden blockiert. Ist das Arbeitsverzeichnis ein Git-Repository, wird vor jeder Ausführung eine Sicherung für das Zurücksetzen erstellt.","settings.codingConsole.status":"Status","settings.codingConsole.detect":"Erkennen","settings.codingConsole.detecting":"Wird erkannt…","settings.codingConsole.installed":"Claude erkannt","settings.codingConsole.notInstalled":"claude nicht gefunden","settings.codingConsole.notInstalledHint":"Installiere zuerst Claude Code (siehe docs.anthropic.com/claude-code) oder gib unten den vollständigen Pfad zur ausführbaren Datei ein.","settings.codingConsole.mcpServers":"{{count}} MCP-Server eingerichtet","settings.codingConsole.computerUsePresent":"MCP für Desktopsteuerung (Computer Use) eingerichtet","settings.codingConsole.computerUseAbsent":"Kein MCP für Desktopsteuerung eingerichtet (einfache Aktionen wie Kopieren und Einfügen funktionieren über Bash; MCP ist dafür nicht erforderlich)","settings.codingConsole.exePath":"Ausführbare Datei","settings.codingConsole.workdir":"Arbeitsverzeichnis","settings.codingConsole.workdirDesc":"Optional. Claude arbeitet in diesem Verzeichnis. In einem Git-Repository wird vor der Ausführung eine Sicherung zum Zurücksetzen erstellt.","settings.codingConsole.workdirPlaceholder":"Leer = in einem temporären Verzeichnis ausführen","settings.codingConsole.permissionMode":"Berechtigungsmodus","settings.codingConsole.mode.acceptEdits":"Erlauben (umkehrbar)","settings.codingConsole.mode.plan":"Nur lesen / planen","settings.codingConsole.mode.default":"Standard (jedes Mal fragen)","settings.codingConsole.mode.bypassPermissions":"Alle Prüfungen umgehen (riskant)","settings.codingConsole.promptPlaceholder":"Gib Claude einen Auftrag, z. B. Dateien im aktuellen Verzeichnis auflisten","settings.codingConsole.run":"Ausführen","settings.codingConsole.running":"Wird ausgeführt…","settings.codingConsole.cancel":"Abbrechen","settings.codingConsole.clear":"Leeren","settings.codingConsole.riskWarn":"Riskante Absicht erkannt: {{reason}}. Die Schutzfunktion blockiert riskante Befehle bei der Ausführung.","settings.codingConsole.toolUse":"Werkzeug {{name}}","settings.codingConsole.done":"Fertig","settings.codingConsole.doneCost":"Fertig · Kosten ${{cost}}","settings.codingConsole.cancelled":"Abgebrochen","settings.codingConsole.outputPlaceholder":"Die laufende Ausgabe erscheint hier…","settings.codingAgent.title":"Less Computer","settings.codingAgent.desc":"Halte eine Taste gedrückt und sprich. Der gewählte Agent bedient deinen Computer. Nur unter macOS.","settings.codingAgent.enable":"Less Computer aktivieren","settings.codingAgent.comingSoonNote":"Die Konfiguration wird bereits gespeichert. Kurzbefehl und Ausführungsablauf folgen in einer späteren Version.","settings.codingAgent.hotkeyHint":"Halte nach der Aktivierung den Kurzbefehl zum Sprechen gedrückt. Nach dem Loslassen zeigt der gewählte Agent das Ergebnis in der Kapsel an.","settings.codingAgent.voiceHotkey":"Sprechtaste","settings.codingAgent.voiceHotkeyDesc":"Zum Sprechen gedrückt halten, zum Ausführen loslassen. Unterstützt einzelne Tasten wie Ctrl/Option/Fn. Die Funktionsbeschreibung steht unter „Erweitert“.","settings.codingAgent.provider":"Agent-Backend","settings.codingAgent.opencodeReady":"OpenCode v{{version}} erkannt.","settings.codingAgent.opencodeMissing":"Der Befehl opencode wurde nicht gefunden. Installiere ihn mit npm i -g opencode-ai und melde dich vor der Verwendung mit opencode auth login an.","settings.codingAgent.cliReady":"{{name}} v{{version}} erkannt.","settings.codingAgent.cliMissing":"Der Befehl {{name}} wurde nicht gefunden. Installiere das Programm und melde dich an, oder gib unten bei „Ausführbare Datei“ den absoluten Pfad ein.","settings.codingAgent.sandboxGuardHint":"Dieses Backend bietet nur allgemeine Sandbox-Stufen und keine Liste riskanter Einzelbefehle. Erreicht es eine Grenze, meldet es den Fehler direkt, ohne eine Freigabekarte für den Befehl anzuzeigen.","settings.codingAgent.codexModelHint":"Gib einen Codex-Modellnamen ein (z. B. gpt-5). Leer lassen, um die Einstellung aus ~/.codex/config.toml zu verwenden.","settings.codingAgent.codexBudgetHint":"Codex bietet keine Kostenobergrenze in USD pro Ausführung. Die Gebühren hängen vom eingerichteten Anbieter ab.","settings.codingAgent.codexMode.plan":"Nur lesen / planen","settings.codingAgent.codexMode.workspaceWrite":"Schreiben im Arbeitsbereich erlauben","settings.codingAgent.codexModelPlaceholder":"Leer = Codex-Standard","settings.codingAgent.dshModelHint":"Das Profil von dsh ohne Oberfläche bietet keinen Modellwechsel. Das Modell wird im eigenen dsh-Profil festgelegt und kann hier nicht geändert werden.","settings.codingAgent.panelHotkey":"Fensterkurzbefehl (Sprachagent)","settings.codingAgent.panelHotkeyDesc":"Sprache aufnehmen → ASR → Claude → laufende Ausgabe im Fenster. Standard: Cmd/Ctrl+Shift+Enter.","settings.codingAgent.quickHotkey":"Kurzbefehl für Schnellauftrag","settings.codingAgent.quickHotkeyDesc":"Ausgewählten Text → Claude → Ergebnis an der Cursorposition. Ohne Fenster, für schnellere Abläufe.","settings.codingAgent.model":"Modell","settings.codingAgent.modelPlaceholder":"Standard: sonnet","settings.codingAgent.modelDefault":"Standard (automatisch sonnet)","settings.codingAgent.modelHint":"Haiku = am schnellsten · Sonnet = ausgewogen · Opus = am leistungsfähigsten","settings.codingAgent.opencodeModelDefault":"OpenCode-Standardmodell verwenden","settings.codingAgent.opencodeModelHint":"Ruft automatisch die verfügbaren Anbieter und Modelle des aktuellen OpenCode-Kontos ab und speichert deine Auswahl sofort.","settings.codingAgent.opencodeModelsRefresh":"Modelle aktualisieren","settings.codingAgent.opencodeModelsRefreshing":"OpenCode-Modelle werden abgerufen…","settings.codingAgent.opencodeModelsLoaded":"Abgerufene Modelle: {{count}}.","settings.codingAgent.opencodeModelsEmpty":"Es wurden keine Modelle zurückgegeben. Melde dich zuerst bei OpenCode an oder richte einen Modellanbieter ein.","settings.codingAgent.opencodeModelsError":"Modelle konnten nicht abgerufen werden: {{message}}","settings.codingAgent.exe":"Pfad zur ausführbaren Datei","settings.codingAgent.openPanel":"Texttest","settings.codingAgent.openPanelHint":"Öffne das Less Computer-Fenster und prüfe den aktuellen Agenten und das Modell mit einer Texteingabe.","settings.codingAgent.openPanelAction":"Less Computer öffnen","settings.debug.cursorLabel":"Cursor","settings.debug.title":"Diagnosewerkzeuge","settings.debug.desc":"Zur Untersuchung von Erkennungsproblemen. Standardmäßig deaktiviert.","settings.debug.cursorProbeLabel":"Cursorkontext prüfen","settings.debug.cursorProbeDesc":"Klicke hier, wechsle dann zur Ziel-App und klicke vor Ablauf des Countdowns in ein Textfeld. Die Prüfung liest den Text rund um den Cursor. So erkennst du, welche Apps lesbar sind und welche die Schutzfunktion blockiert. Einmaliger Lesezugriff, ohne Übertragung an einen Anbieter.","settings.debug.cursorProbeBtn":"Prüfen (in 5s)","settings.debug.cursorProbeCountdown":"Lesen in {{n}}s…","settings.marketplace.title":"Marktplatz","settings.marketplace.desc":"Veröffentlichungsprofil für den Stilmarktplatz. Stile kannst du auf der Seite „Stile“ ansehen und installieren.","settings.marketplace.github.signIn":"Mit GitHub anmelden","settings.marketplace.github.signedIn":"Mit GitHub angemeldet","settings.marketplace.github.signedOut":"Melde dich an, um Stile hochzuladen und Pakete mit „Gefällt mir“ zu markieren.","settings.marketplace.github.signOut":"Abmelden","settings.marketplace.github.starting":"Anmeldung wird gestartet…","settings.marketplace.github.codeHint":"Gib diesen Code auf der gerade geöffneten GitHub-Seite ein:","settings.marketplace.github.openGithub":"GitHub öffnen","settings.marketplace.github.waiting":"GitHub geöffnet – nach deiner Freigabe wirst du angemeldet…","settings.marketplace.github.failed":"Anmeldung fehlgeschlagen. Versuche es erneut","settings.recording.title":"Aufnahme und Eingabe","settings.recording.desc":"Globaler Aufnahmekurzbefehl und Auslösemodus.","settings.recording.hotkeyLabel":"Aufnahmekurzbefehl","settings.recording.hotkeyDescAcc":"Drücken, um überall Sprache aufzunehmen (Berechtigung für Bedienungshilfen erforderlich).","settings.recording.hotkeyDescNoAcc":"Drücken, um überall Sprache aufzunehmen.","settings.recording.modeLabel":"Auslösemodus","settings.recording.modeDesc":"Umschalten = einmal drücken zum Starten, erneut drücken zum Beenden. Gedrückt halten = aufnehmen, solange die Taste gehalten wird.","settings.recording.modeToggle":"Umschalten","settings.recording.modeHold":"Gedrückt halten","settings.recording.modeAuto":"Automatisch","settings.recording.silenceAutoStopLabel":"Bei Stille automatisch beenden","settings.recording.silenceAutoStopDesc":"Nur im Umschaltmodus. Nach erkannter Sprache wird die Aufnahme automatisch beendet und gesendet, sobald die gewählte Dauer ohne Sprache verstrichen ist. Standardmäßig aus. Erneuter Tastendruck und Esc funktionieren weiterhin.","settings.recording.silenceAutoStopSecondsLabel":"Dauer der Stille","settings.recording.silenceAutoStopSecondsValue":"{{value}}s","settings.recording.migrationNoticeTitle":"Standard-Aufnahmemodus ist jetzt „Umschalten“","settings.recording.migrationNoticeDesc":"Dieses Update ändert die Standardeinstellung. Falls du lieber die Taste gedrückt hältst, kannst du hier zurückwechseln.","settings.recording.microphoneLabel":"Bevorzugtes Mikrofon","settings.recording.microphoneDesc":"Wähle das bevorzugte Eingabegerät. Ist es nicht verfügbar, wird der Systemstandard verwendet.","settings.recording.microphoneDefault":"Standardmikrofon des Systems","settings.recording.microphoneDefaultDesc":"Standardeingabegerät des Systems verwenden","settings.recording.microphoneSystemDefault":"Systemstandard","settings.recording.microphoneUnavailable":"nicht verfügbar","settings.recording.microphoneLoadError":"Mikrofone konnten nicht geladen werden: {{message}}","settings.recording.microphoneDialogTitle":"Mikrofon","settings.recording.microphoneDialogDesc":"Wähle ein Mikrofon, das deine Stimme aufnehmen kann.","settings.recording.microphoneMonitorError":"Eingangspegel konnte nicht überwacht werden: {{message}}","settings.recording.capsuleLabel":"Aufnahmekapsel","settings.recording.capsuleDesc":"Zeigt während der Aufnahme eine halbtransparente Kapsel am unteren Bildschirmrand.","settings.recording.capsuleStyleTypeless":"Kompakter Typeless-Stil","settings.recording.capsuleStyleLabel":"Kapselstil","settings.recording.capsuleStyleSiri":"Schimmernder Siri-Stil","settings.recording.capsuleStyleClassic":"OpenLess-Standardstil","settings.recording.muteDuringRecordingLabel":"Während der Aufnahme stummschalten","settings.recording.muteDuringRecordingDesc":"Schaltet die Systemausgabe während der Spracheingabe vorübergehend stumm, um Lautsprecherechos zu vermeiden.","settings.recording.audioCueLabel":"Ton bei Aufnahmestart","settings.recording.audioCueDesc":"Spielt einen kurzen synthetischen Ton ab, wenn du die Aufnahme per Kurzbefehl startest. Auch bei ausgeblendeter Kapsel.","settings.recording.audioCuePreview":"Anhören","settings.recording.insertGroupTitle":"Einfügen und Zwischenablage","settings.recording.restoreClipboardLabel":"Zwischenablage nach dem Einfügen wiederherstellen","settings.recording.restoreClipboardDesc":"Stellt die ursprüngliche Zwischenablage nach erfolgreichem Einfügen wieder her (nur Windows / Linux).","settings.recording.pasteShortcutLabel":"Simulierter Einfügekurzbefehl","settings.recording.pasteShortcutDesc":"Tastenkombination zum Einfügen. Einige Terminals benötigen Ctrl+Shift+V (nur Windows / Linux).","settings.recording.pasteShortcutCtrlV":"Ctrl+V (Standard / die meisten Apps)","settings.recording.pasteShortcutCtrlShiftV":"Ctrl+Shift+V (kitty / alacritty / wezterm / die meisten Terminals)","settings.recording.pasteShortcutShiftInsert":"Shift+Insert (xterm / urxvt)","settings.recording.comboRecordLabel":"Kurzbefehl aufzeichnen","settings.recording.comboRecordDesc":"Klicke hier und drücke die gewünschte Tastenkombination (z. B. ⌘⇧D). Unterstützt die Modi „Umschalten“ und „Gedrückt halten“.","settings.recording.comboRecordBtn":"Kurzbefehl aufzeichnen","settings.recording.comboResetBtn":"Zurücksetzen","settings.recording.comboMenuToggle":"Weitere Optionen","settings.recording.comboDisableHint":"Der zentrale Kurzbefehl kann nicht deaktiviert werden – für Aufnahmen wird ein Kurzbefehl benötigt","settings.recording.comboRecordHint":"Drücke deine Tastenkombination…","settings.recording.comboNeedKey":"Verwende eine Tastenkombination (z. B. ⌘⇧J). Eine einzelne Modifikatortaste reicht nicht aus","settings.recording.comboRecorded":"Aufgezeichnet","settings.recording.comboClear":"Leeren","settings.recording.comboConflict":"Diese Tastenkombination ist nicht verfügbar","settings.recording.allowNonTsfFallbackLabel":"Alternative ohne TSF erlauben","settings.recording.allowNonTsfFallbackDesc":"Windows: Falls das Einfügen über TSF fehlschlägt, wird Unicode-Text dosiert über SendInput eingegeben. Schlägt auch das fehl, wird der Text in die Zwischenablage kopiert.","settings.recording.windowsInsertionModeLabel":"Einfügemethode unter Windows","settings.recording.windowsInsertionModeDesc":"Legt fest, wie Diktatergebnisse an der Cursorposition eingefügt werden. Einfügen über die Zwischenablage nutzt den obigen Kurzbefehl und erhält Zeilenumbrüche.","settings.recording.windowsInsertionModeTsf":"TSF-Eingabemethode (Standard)","settings.recording.windowsInsertionModeSendInput":"SendInput-Tastensimulation","settings.recording.windowsInsertionModePaste":"Über Zwischenablage einfügen (Ctrl+V usw.)","settings.recording.macosNewlineModeLabel":"Zeilenumbrüche","settings.recording.macosNewlineModeDesc":"„Automatisch“ verwendet Line Feed (U+000A / Ctrl+J) in bekannten Terminal-Apps und sonst Shift+Return. Ein einzelnes Return sendet die Nachricht.","settings.recording.macosNewlineModeAuto":"Automatisch (Line Feed in Terminals)","settings.recording.macosNewlineModeShiftReturn":"Shift+Return (Zeilenumbruch im Chat)","settings.recording.macosNewlineModeLineFeed":"Line Feed (Terminal-CLI / Ctrl+J)","settings.recording.macosNewlineModeReturn":"Return (auf mehrere Nachrichten aufteilen)","settings.recording.windowsSendInputNewlineModeLabel":"Zeilenumbrüche mit SendInput","settings.recording.windowsSendInputNewlineModeDesc":"Legt fest, welche Tasten SendInput für Zeilenumbrüche simuliert. Nutze Shift+Enter für Chatfelder und Enter für Notepad / VS Code und die meisten Editoren.","settings.recording.windowsSendInputNewlineModeEnter":"Enter (die meisten Editoren)","settings.recording.windowsSendInputNewlineModeShiftEnter":"Shift+Enter (Chateingabefelder)","settings.recording.windowsSendInputNewlineModeCrLf":"CR+LF Unicode","settings.recording.windowsShowOpenlessInKeyboardListLabel":"OpenLess in der Tastaturliste anzeigen","settings.recording.windowsShowOpenlessInKeyboardListDesc":"Wenn deaktiviert, wechselt Win+Space nicht zu OpenLess. SendInput und Einfügen über die Zwischenablage funktionieren weiterhin. Aktiviere die Option erneut, um den Eintrag wiederherzustellen.","settings.recording.windowsShowOpenlessInKeyboardListError":"Die Tastaturliste konnte nicht aktualisiert werden: Das System hat die Änderung des OpenLess-Sprachprofils abgelehnt.","settings.recording.historyGroupTitle":"Verlauf und Kontext","settings.recording.historyRetentionLabel":"Verlauf aufbewahren (Tage)","settings.recording.historyRetentionDesc":"Ältere Einträge werden beim Speichern neuer Einträge entfernt. 0 = keine zeitabhängige Bereinigung.","settings.recording.historyMaxEntriesLabel":"Maximale Verlaufseinträge","settings.recording.historyMaxEntriesDesc":"Maximal lokal gespeicherte Sitzungen. Leer = 200. Bereich: 5–200.","settings.recording.polishContextWindowLabel":"Kontextfenster für Überarbeitung (Minuten)","settings.recording.polishContextWindowDesc":"Verwendet überarbeitete Transkripte der letzten N Minuten als Gesprächskontext. 0 = deaktiviert.","settings.recording.recordAudioForDebugLabel":"Rohaufnahmen behalten (Diagnose)","settings.recording.recordAudioForDebugDesc":"Speichert das rohe Mikrofonaudio als WAV, um Erkennungsprobleme zu untersuchen.","settings.recording.audioRecordingMaxEntriesLabel":"Maximale Rohaufnahmen","settings.recording.audioRecordingMaxEntriesDesc":"Maximale Anzahl lokal gespeicherter WAV-Dateien. Leer = 200.","settings.recording.startupGroupTitle":"Startverhalten","settings.recording.startMinimizedLabel":"Minimiert starten (ohne Hauptfenster)","settings.recording.startMinimizedDesc":"Zeigt bei keinem Startweg das Hauptfenster an. Nur Menüleiste / Infobereich.","settings.recording.autoUpdateCheckLabel":"Automatisch nach Updates suchen","settings.recording.autoUpdateCheckDesc":"Sucht beim Start und alle 60 Minuten nach Updates.","settings.recording.marketplaceGroupTitle":"Stilpaket-Marktplatz","settings.recording.marketplaceBaseUrlLabel":"Backend-URL","settings.recording.marketplaceBaseUrlDesc":"URL des Marktplatz-Backends. Leer lassen, um den Standard zu verwenden.","settings.recording.marketplaceDevLoginLabel":"GitHub-Anmeldename (Veröffentlichungsprofil)","settings.recording.marketplaceDevLoginDesc":"Identifiziert die hochladende Person. Leer lassen, um Hochladen und „Gefällt mir“ zu deaktivieren.","settings.recording.startupAtBoot":"Bei Anmeldung starten","settings.recording.startupAtBootDesc":"Startet OpenLess automatisch, wenn du dich anmeldest.","settings.recording.startupAtBootError":"Start bei Anmeldung konnte nicht geändert werden: {{message}}","settings.channels.backToList":"Zurück zu den Kanälen","settings.channels.done":"Fertig","settings.channels.llmTitle":"Kanäle für Textverarbeitung","settings.channels.asrTitle":"Kanäle für Spracherkennung","settings.channels.current":"Aktuell verwendet","settings.channels.enabled":"Aktiviert","settings.channels.disabled":"Deaktiviert","settings.channels.enabledFor":"{{name}} aktivieren","settings.channels.modelNotSet":"Kein Modell ausdrücklich festgelegt","settings.channels.localModelManaged":"Das Modell wird vom System oder unter „Lokale Modelle“ verwaltet","settings.channels.lastCheck":"Letzte Prüfung","settings.channels.verifying":"Wird geprüft…","settings.channels.notVerified":"Noch nicht geprüft","settings.channels.passed":"Prüfung bestanden","settings.channels.failed":"Prüfung fehlgeschlagen · {{reason}}","settings.channels.elapsed":"Dauer: {{ms}} ms","settings.channels.staleResult":"Das Ergebnis ist älter als 24 Stunden","settings.channels.connectionTitle":"Dienstverbindung","settings.channels.modelTitle":"Modelleinstellungen","settings.channels.modelHint":"Gib einen Modellnamen direkt ein oder rufe die Modelle des Anbieters ab und wähle eines aus.","settings.channels.availableModels":"Verfügbare Modelle","settings.channels.validationTitle":"Verbindungsprüfung","settings.channels.validationHint":"Sendet manuell eine echte Anfrage, um diese Konfiguration zu prüfen. Dabei kann Dienstguthaben verbraucht werden. Das Speichern der Einstellungen führt keine Prüfung aus.","settings.channels.autoSaveHint":"Änderungen werden automatisch gespeichert. Anschließend kannst du die Verbindung manuell prüfen.","settings.channels.nameHint":"Dieser Name unterscheidet Kanäle desselben Anbieters. Er beeinflusst weder Modell noch Verbindung.","settings.channels.errModel":"Modell","settings.channels.verify":"Prüfen","settings.channels.verifyHint":"Prüft diesen Kanal mit einem echten API-Aufruf auf aktuelle Funktionsfähigkeit","settings.channels.errTimeout":"Zeitüberschreitung","settings.channels.errNetwork":"Netzwerk","settings.channels.errEndpoint":"Endpunkt","settings.channels.errGeneric":"fehlgeschlagen","settings.channels.dragHint":"Zum Ändern der Priorität ziehen","settings.channels.orderHint":"Anfragen nutzen den ersten aktivierten Kanal. Ziehen ändert die Reihenfolge; deaktivierte Kanäle werden nach unten verschoben.","settings.channels.empty":"Noch keine Kanäle. Verbinde deinen ersten Dienst über „Kanal hinzufügen“.","settings.channels.add":"Kanal hinzufügen","settings.channels.edit":"Bearbeiten","settings.channels.createTitle":"Kanal hinzufügen","settings.channels.editTitle":"Kanal bearbeiten","settings.channels.providerLabel":"Anbieter","settings.channels.nameLabel":"Kanalname (optional)","settings.channels.namePlaceholder":"z. B. SiliconFlow – Hauptschlüssel","settings.channels.create":"Erstellen","settings.channels.delete":"Kanal löschen","settings.channels.deleteConfirm":"Beim Löschen werden auch die für diesen Kanal gespeicherten Schlüssel entfernt.","settings.channels.confirmDelete":"Löschen","settings.channels.justNow":"gerade eben","settings.channels.minutesAgo":"vor {{count}} Min.","settings.channels.hoursAgo":"vor {{count}} Std.","settings.channels.daysAgo":"vor {{count}} Tagen","settings.channels.localEngineModelHint":"Lokale Modelle lassen sich unter KI-Dienste und Modelle → Lokale Modelle herunterladen und wechseln.","settings.providers.localEngineNoCredentials":"Lokale Engines benötigen weder API-Schlüssel noch Endpunkt.","settings.providers.localModelLabel":"Lokales Modell","settings.providers.localModelEmpty":"Noch kein lokales Modell heruntergeladen","settings.providers.appleSpeechLocalNote":"Apple Speech verwendet die integrierte System-Engine. Eine Modellauswahl ist nicht erforderlich.","settings.providers.localEngineNote":"Heruntergeladene lokale Modelle können oben direkt gewählt werden. Weitere Modelle findest du unter „Lokale Modelle“.","settings.providers.localTag":"Lokal","settings.providers.llmTitle":"LLM (Textüberarbeitung)","settings.providers.llmDesc":"OpenAI-kompatibles Protokoll mit Unterstützung für mehrere Anbieter.","settings.providers.providerLabel":"Anbieter","settings.providers.llmProviderDesc":"Die Auswahl einer Vorlage trägt automatisch die Standard-Basis-URL ein.","settings.providers.credentialStorageNotice":"Zugangsdaten werden im geschützten Zugangsspeicher des Betriebssystems gespeichert.","settings.providers.codexOAuthNotice":"Codex OAuth nutzt die lokale Codex-Anmeldung (~/.codex/auth.json). OpenLess speichert dafür weder API-Schlüssel noch Basis-URL.","settings.providers.asrProviderDesc":"Beim Anbieterwechsel werden automatisch die zugehörigen Zugangsdaten geladen.","settings.providers.asrTitle":"ASR (Transkription)","settings.providers.asrDesc":"Wandelt aufgenommene Sprache in Text um.","settings.providers.omniTitle":"Multimodales Modell","settings.providers.omniDesc":"Ein Modell erzeugt direkt aus Audio und Prompt den fertigen Text (experimenteller Ablauf).","settings.providers.pipelineModeLabel":"Verarbeitungsmodus","settings.providers.pipelineModeHint":"Klassisch = ASR + LLM in zwei Schritten. Multimodal = ein audiotaugliches Modell in einem Durchgang.","settings.providers.pipelineModeTraditional":"Klassisch","settings.providers.pipelineModeMultimodal":"Multimodal","settings.providers.pipelineIsolationNotice":"Die beiden Modi speichern ihre Zugangsdaten vollständig getrennt. Beim Wechsel bleiben die Daten des anderen Modus gespeichert und werden beim Zurückwechseln wieder verwendet.","settings.providers.presets.opencode":"OpenCode Zen","settings.providers.presets.tencentTokenHub":"Tencent Cloud TokenHub","settings.providers.presets.customChatCompletions":"Benutzerdefiniert · Chat Completions","settings.providers.presets.customResponses":"Benutzerdefiniert · Responses","settings.providers.presets.customMessages":"Benutzerdefiniert · Messages","settings.providers.presets.ark":"ARK (Volcengine Ark)","settings.providers.presets.deepseek":"DeepSeek","settings.providers.presets.siliconflow":"SiliconFlow","settings.providers.presets.atlascloud":"Atlas Cloud","settings.providers.presets.openai":"OpenAI","settings.providers.presets.gemini":"Google Gemini","settings.providers.presets.codexOAuth":"Codex OAuth","settings.providers.presets.mimo":"Xiaomi MiMo","settings.providers.presets.cometapi":"CometAPI","settings.providers.presets.openrouterFree":"OpenRouter (kostenlose Modelle)","settings.providers.presets.orcarouter":"OrcaRouter","settings.providers.presets.alibabaCoding":"Alibaba Cloud Coding Plan","settings.providers.presets.codingPlanX":"CodingPlanX","settings.providers.presets.minimax":"MiniMax (M3)","settings.providers.presets.stepfun":"StepFun","settings.providers.presets.custom":"Benutzerdefiniert","settings.providers.presets.asrVolcengine":"Volcengine bigasr","settings.providers.presets.asrTencentCloud":"Tencent Cloud Hunyuan Echtzeit-ASR","settings.providers.presets.asrBailian":"Alibaba Bailian Echtzeit-ASR","settings.providers.presets.asrBailianQwen3":"Bailian Qwen3 Realtime ASR","settings.providers.presets.asrBailianFunAsrFlash":"Bailian Fun-ASR-Flash (Aufnahmedatei)","settings.providers.presets.asrSiliconflow":"SiliconFlow SenseVoice","settings.providers.presets.asrStepfun":"StepFun StepAudio ASR","settings.providers.presets.asrZhipu":"Zhipu GLM-ASR","settings.providers.presets.asrGroq":"Groq Whisper-large-v3","settings.providers.presets.asrWhisper":"OpenAI Whisper (kompatibel)","settings.providers.presets.asrOpenrouter":"OpenRouter Whisper","settings.providers.presets.asrZenmux":"ZenMux","settings.providers.presets.asrOpenAiCompatible":"Eigener OpenAI-kompatibler Dienst","settings.providers.presets.asrXiaomiMimo":"Xiaomi MiMo ASR","settings.providers.presets.asrIflytek":"iFlytek Echtzeit-ASR","settings.providers.presets.asrElevenLabs":"ElevenLabs Scribe","settings.providers.presets.asrSherpaOnnxLocal":"Lokales sherpa-onnx (experimentell)","settings.providers.presets.asrFoundryLocalWhisper":"Lokales Whisper (Foundry Local)","settings.providers.presets.asrLocalWhisper":"Lokales Whisper (Stapelverarbeitung)","settings.providers.presets.asrLocalQwen3":"Lokales Qwen3-ASR","settings.providers.presets.asrLocalQwen3Mlx":"Lokales Qwen3-ASR (MLX / Metal)","settings.providers.presets.asrLocalQwen3C":"Lokales Qwen3-ASR (C / CPU)","settings.providers.presets.asrAppleSpeech":"Apple Speech (macOS)","settings.providers.presets.omniOpenai":"OpenAI (mit Audiounterstützung)","settings.providers.presets.omniGemini":"Google Gemini","settings.providers.presets.omniDashscope":"Alibaba DashScope Omni","settings.providers.elevenLabsUploadNotice":"ElevenLabs lädt die Audioaufnahme zur Stapeltranskription an den eingerichteten Endpunkt hoch.","settings.providers.zenmuxVocabularyNote":"ZenMux verwendet ein JSON-Transkriptionsprotokoll und erhält keine Wörterbuchbegriffe (prompt/hotwords). Das Wörterbuch wird weiterhin bei der Überarbeitung verwendet, beeinflusst aber nicht die Spracherkennung.","settings.providers.asrAdvancedNote":"Die folgenden erweiterten Optionen gelten nur für die Vorlagen „Eigener OpenAI-kompatibler Dienst“ und „ZenMux“. Andere Anbietervorlagen behalten ihr integriertes Verhalten.","settings.providers.asrAdvancedVerboseJsonLabel":"Abschnittsmetriken (verbose_json)","settings.providers.asrAdvancedVerboseJsonHint":"Fordert Abschnittsmetriken zur Filterung erfundener Inhalte an, sofern der Server dies unterstützt. Für selbst betriebene Server ohne diese Funktion deaktiviert lassen.","settings.providers.asrAdvancedChunkLabel":"Abschnittsdauer (ms)","settings.providers.asrAdvancedChunkHint":"0 = keine Aufteilung; die gesamte Aufnahme wird auf einmal gesendet. Aufgeteilte Anfragen eignen sich für lange Aufnahmen oder Server mit Zeitlimits pro Anfrage.","settings.providers.asrAdvancedEnableItnLabel":"Zahlennormalisierung (enable_itn)","settings.providers.asrAdvancedEnableItnHint":"Wandelt gesprochene Zahlen und Einheiten in Ziffern um, z. B. „zweitausendsechsundzwanzig“ → „2026“. Deaktivieren, um den Rohtext zu behalten.","settings.providers.volcengineAppKeyLabel":"APP ID","settings.providers.volcengineAccessKeyLabel":"Access Token","settings.providers.volcengineApiKeyLabel":"API-Schlüssel","settings.providers.volcengineResourceIdLabel":"Ressourcen-ID","settings.providers.volcengineAuthModeLabel":"Anmeldemethode","settings.providers.volcengineAuthModeAppIdToken":"Bisherige App-Anmeldung (APP ID + Access Token)","settings.providers.volcengineAuthModeApiKey":"API-Schlüssel (neue Konsole)","settings.providers.volcengineMappingNote":"Ein Secret Key wird derzeit nicht benötigt. Standard-Ressourcen-ID: volc.seedasr.sauc.duration.","settings.providers.volcengineApiKeyNote":"Verwendet einen API-Schlüssel aus der neuen Sprachkonsole. Eine APP ID ist nicht erforderlich. Erstelle ihn in der API-Schlüsselverwaltung: console.volcengine.com/speech/new/setting/apikeys. Standard-Ressourcen-ID: volc.seedasr.sauc.duration.","settings.providers.xfyunAppIdLabel":"AppID","settings.providers.xfyunApiKeyLabel":"API-Schlüssel","settings.providers.xfyunNote":"AppID und API-Schlüssel findest du auf der Dienstseite „Realtime ASR“ der iFlytek Open Platform. Audioformat: 16 kHz / 16 Bit / Mono-PCM. Die Standard-API hat keinen Begriffsparameter; persönliche Begriffe werden in der iFlytek-Konsole eingerichtet. Standardsprache ist Mandarin-Chinesisch.","settings.providers.tencentCloudAppIdLabel":"Tencent Cloud AppID","settings.providers.tencentCloudSecretIdLabel":"SecretID","settings.providers.tencentCloudSecretKeyLabel":"SecretKey","settings.providers.tencentCloudNote":"Verwendet die Zugangsdaten der Tencent-Cloud-Spracherkennung. Das Standardmodell Hy-ASR-3.0-preview unterstützt Chinesisch, Englisch und 20 Dialekte; Preview nimmt nur Mono-PCM mit 16 kHz bis 60 Sekunden an und unterstützt noch keinen Kontext oder Hotword-Boosting.","settings.providers.tencentTokenHubNote":"Es werden nur aktuell verfügbare Sprachmodelle angezeigt. Einige Modelle nutzen immer Reasoning; das Ausschalten von Reasoning behält das feste Verhalten des jeweiligen Modells bei.","settings.providers.localAsrActiveNotice":"Lokale ASR ({{name}}) ist aktuell aktiv. Wechsle oder deaktiviere sie unter „Erweitert“.","settings.providers.localAsrTakeoverHint":"Sobald „{{name}}“ aktiviert ist, übernimmt dieses Modell die Spracherkennung.","settings.providers.asrProviderTakenOver":"Eine lokale Engine ist aktiv. Wähle oben einen anderen Anbieter, um zu wechseln; die lokale Engine stoppt automatisch. Lokale Modelle verwaltest du unter Dienste → Lokale Modelle.","settings.providers.localAsrHint":"Läuft auf diesem Computer und benötigt keinen API-Schlüssel. Lade das Modell von HuggingFace herunter.","settings.providers.foundryLocalAsrHint":"Läuft auf diesem Gerät und benötigt keinen ASR-API-Schlüssel. Bei der ersten Verwendung werden Laufzeitkomponenten und Modell heruntergeladen.","settings.providers.localAsrPerformanceWarning":"Lokale Inferenz ist langsamer als Cloud-ASR und kann Chinesisch weniger genau erkennen. Besonders geeignet für Offline-Nutzung oder sensible Daten.","settings.providers.localAsrReady":"{{model}} heruntergeladen","settings.providers.localAsrNotReady":"{{model}} nicht heruntergeladen","settings.providers.localAsrGoDownload":"Zum Herunterladen die Modellseite öffnen","settings.providers.localAsrManage":"Modellseite öffnen","settings.providers.localAsrDownloadedTitle":"Heruntergeladene Modelle","settings.providers.localAsrDelete":"Löschen","settings.providers.fillDefault":"Standardwert eintragen","settings.providers.readFailed":"Lesen fehlgeschlagen","settings.providers.apiKeyLabel":"API-Schlüssel","settings.providers.baseUrlLabel":"Basis-URL","settings.providers.modelLabel":"Modell","settings.providers.customModelLabel":"Eigenes Modell…","settings.providers.presetListLabel":"Zurück zu den Vorlagen","settings.providers.temperatureLabel":"Temperatur","settings.providers.temperaturePlaceholder":"Leer = nicht senden; Bereich 0–2 einschließlich, z. B. 0.3","settings.providers.extraHeadersLabel":"Zusätzliche Header","settings.providers.extraHeadersPlaceholder":"{\"custom-head\":\"...\"}","settings.providers.thinkingModeLabel":"Denkmodus","settings.providers.thinkingModeOn":"Ein","settings.providers.thinkingModeOff":"Aus","settings.providers.requestFormatLabel":"Anfrageformat","settings.providers.messagesThinkingLabel":"Denkmodus","settings.providers.thinkingAdaptive":"Adaptiv","settings.providers.thinkingBudget":"Festes Budget","settings.providers.maxTokensLabel":"Maximale Ausgabetokens","settings.providers.thinkingBudgetLabel":"Tokenbudget für das Denken","settings.providers.responsesThinkingHint":"Bei einigen Modellen lässt sich das Denken nur reduzieren, nicht abschalten. Anfragen mit Denken senden keinen Temperaturparameter.","settings.providers.messagesThinkingHint":"Ältere Modelle oder kompatible Gateways benötigen möglicherweise ein festes Budget unterhalb der Ausgabegrenze. Anfragen mit Denken senden keinen Temperaturparameter.","settings.providers.llmRequestFormatInvalid":"Ungültiges Anfrageformat. Wähle ein unterstütztes Format.","settings.providers.llmThinkingModeInvalid":"Ungültiger Denkmodus. Wähle einen unterstützten Modus.","settings.providers.llmTokenLimitInvalid":"Tokenlimits müssen positive ganze Zahlen sein.","settings.providers.llmThinkingBudgetInvalid":"Das Denkbudget muss mindestens 1024 betragen und im festen Modus unter der Ausgabegrenze liegen.","settings.providers.llmResponseIncomplete":"Die Antwort ist unvollständig oder hat die Ausgabegrenze erreicht. Bereits ausgegebener Text bleibt erhalten.","settings.providers.llmProtocolHeaderConflict":"Messages setzt die Authentifizierungs- und Versionsheader automatisch. Entferne x-api-key und anthropic-version aus den zusätzlichen Headern.","settings.providers.llmStreamError":"Der Server hat einen Streamfehler gemeldet. Prüfe das Modell und die Anfrageparameter.","settings.providers.saveProtocol":"Protokolleinstellungen speichern","settings.providers.thinkingModeHint":"Aktiviere, deaktiviere oder reduziere das Denken mit den vom Anfrageformat und Modell unterstützten Parametern. Dem Prompt werden keine Steueranweisungen hinzugefügt.","settings.providers.bailianVocabularyIdLabel":"Wörterbuch-ID für Begriffe (optional)","settings.providers.bailianVocabularyIdNote":"Wenn du bei DashScope ein Begriffswörterbuch erstellt hast, gib seine vocab-... ID ein. Leer lassen, um keine Begriffe zu übergeben.","settings.providers.bailianModelRealtimeHint":"Echtzeitmodell · transkribiert während des Sprechens.","settings.providers.bailianModelSyncFileHint":"Synchrones Aufnahmemodell · transkribiert nach dem Beenden (einzelne Aufnahme ≤ 5 Min.).","settings.providers.bailianModelAsyncFileHint":"Asynchrones Dateimodell · lädt die Aufnahme hoch und wartet auf den Transkriptionsauftrag.","settings.providers.appIdLabel":"App-ID","settings.providers.accessKeyLabel":"Access Key","settings.providers.resourceIdLabel":"Ressourcen-ID","settings.providers.toolsLabel":"Verbindungsprüfung","settings.providers.toolsDesc":"Speichere die Felder oben und prüfe anschließend das gewählte Modell oder rufe Modelle ab. Falls das Abrufen fehlschlägt, bleibt die manuelle Eingabe möglich.","settings.providers.validate":"Prüfen","settings.providers.validating":"Wird geprüft…","settings.providers.fetchModels":"Modelle abrufen","settings.providers.loadingModels":"Modelle werden abgerufen…","settings.providers.modelMissing":"Kein Modell eingerichtet. Gib zuerst eine Modell-ID ein.","settings.providers.modelsEmpty":"Die Zugangsdaten sind gültig, aber es wurden keine Modelle zurückgegeben.","settings.providers.modelsLoaded":"Abgerufene Modelle: {{count}}.","settings.providers.searchModels":"Modelle suchen…","settings.providers.noMatchingModels":"Keine passenden Modelle","settings.providers.orcarouterCatalogHint":"Geladen aus OrcaRouter /models. Wähle ein Katalog-Modell; manuelle Modell-IDs sind für diesen Anbieter deaktiviert.","settings.providers.orcarouterAsrCatalogHint":"Geladen aus OrcaRouter /models, begrenzt auf Gemini-Modelle mit Audio-Eingabe. Manuelle Modell-IDs sind deaktiviert.","settings.providers.selectModel":"Wähle ein Modell, um das Feld oben auszufüllen","settings.providers.modelSaved":"Modell {{model}} gespeichert.","settings.providers.validateSuccess":"Verbindungsprüfung bestanden.","settings.providers.validateFailed":"Verbindungsprüfung fehlgeschlagen.","settings.providers.providerHttpStatus":"Der Anbieter hat HTTP {{status}} zurückgegeben. Prüfe die Berechtigungen des API-Schlüssels oder den Endpunkt.","settings.providers.endpointMustUseHttps":"HTTP-Endpunkte sind erlaubt, aber API-Schlüssel und Audioinhalte können bei der Übertragung abgefangen werden.","settings.providers.endpointHttpWarning":"HTTP-Endpunkte sind erlaubt, aber API-Schlüssel und Anfrageinhalte können bei der Übertragung abgefangen werden.","settings.providers.endpointInvalid":"Das Endpunktformat ist ungültig.","settings.providers.bailianEndpointSchemeInvalid":"Bailian Echtzeit-ASR verwendet das DashScope-WebSocket-Gateway. Der Endpunkt muss mit wss:// beginnen (Standard: wss://dashscope.aliyuncs.com/api-ws/v1/inference/). Eine https://-URL im Kompatibilitätsmodus funktioniert hier nicht.","settings.providers.qwen3EndpointSchemeInvalid":"Qwen3 Echtzeit-ASR verwendet das DashScope-Realtime-WebSocket-Gateway. Der Endpunkt muss mit wss:// beginnen (Standard: wss://dashscope.aliyuncs.com/api-ws/v1/realtime). Eine https://-URL funktioniert hier nicht.","settings.providers.responseTooLarge":"Die Anbieterantwort ist zu groß, um sie sicher zu prüfen.","settings.providers.asrInvalidJson":"Die ASR-Antwort ist kein gültiges JSON.","settings.providers.asrMissingTextField":"In der ASR-Antwort fehlt das Feld text.","settings.providers.apiKeyMissing":"Der API-Schlüssel ist leer.","settings.providers.endpointMissing":"Der Endpunkt ist leer.","settings.providers.volcengineAppIdMissing":"Die APP ID ist leer.","settings.providers.volcengineAccessTokenMissing":"Der Access Token ist leer.","settings.providers.requestTimeout":"Zeitüberschreitung bei der Anfrage. Versuche es später erneut.","settings.shortcuts.title":"Kurzbefehlseinstellungen","settings.shortcuts.descAcc":"Alle Kurzbefehle gelten global. Unter „Berechtigungen“ muss der Zugriff auf Bedienungshilfen erlaubt sein.","settings.shortcuts.descNoAcc":"Alle Kurzbefehle gelten global. Falls sie nicht reagieren, prüfe ihren Status unter „Berechtigungen“.","settings.shortcuts.startStop":"Aufnahme starten / beenden","settings.shortcuts.cancel":"Aktuelle Aufnahme abbrechen","settings.shortcuts.confirm":"Einfügen über die Kapsel bestätigen","settings.shortcuts.switchStyle":"Zum vorherigen Stil wechseln","settings.shortcuts.openApp":"OpenLess öffnen","settings.shortcuts.stylePackTitle":"Stilkurzbefehle","settings.shortcuts.stylePackDesc":"Weise deinen Lieblingsstilpaketen Kurzbefehle zu, um mit einem Tastendruck zu wechseln. Deaktivierte Pakete werden dabei automatisch wieder aktiviert.","settings.shortcuts.stylePackAdd":"Stilkurzbefehl hinzufügen","settings.shortcuts.stylePackSelect":"Stilpaket wählen","settings.shortcuts.stylePackDisabledSuffix":" (deaktiviert)","settings.shortcuts.stylePackRemove":"Entfernen","settings.shortcuts.agentPolish":"Ausgewählten Text überarbeiten","settings.shortcuts.agentPolishDesc":"Text auswählen → drücken → Claude überarbeitet ihn → Auswahl wird ersetzt.","settings.shortcuts.agentVoice":"Less Computer","settings.shortcuts.agentVoiceDesc":"Eigene Taste gedrückt halten → sprechen → Claude führt den Auftrag aus → Ergebnis erscheint in der Kapsel.","settings.shortcuts.agentVoiceHint":"Lege die Sprechtaste unter Erweitert → Less Computer fest.","settings.shortcuts.agentVoiceTrigger":"Sprechtaste für Less Computer","settings.shortcuts.enable":"Aktivieren","settings.shortcuts.disable":"Deaktivieren","settings.shortcuts.confirmHint":"Klicke auf ✓ in der Kapsel","settings.shortcuts.notSupported":"Noch nicht unterstützt","settings.shortcuts.androidReadOnly":"Globale Kurzbefehle sind auf Android nicht verfügbar. Nutze die Aufnahmetaste auf der Übersichtsseite.","settings.permissions.title":"Berechtigungen","settings.permissions.descAcc":"OpenLess benötigt die folgenden Systemberechtigungen. Beende die App nach der Freigabe vollständig und starte sie erneut, damit Änderungen wirksam werden.","settings.permissions.descNoAcc":"OpenLess benötigt Mikrofonzugriff und prüft anhand des globalen Kurzbefehlstatus, ob der native Hook läuft.","settings.permissions.micLabel":"Mikrofon","settings.permissions.micDesc":"Erfasst deine Spracheingabe.","settings.permissions.accLabel":"Bedienungshilfen","settings.permissions.accDesc":"Erfasst den globalen Kurzbefehl und fügt Transkripte an der Cursorposition ein.","settings.permissions.hotkeyLabel":"Globaler Kurzbefehl","settings.permissions.hotkeyDescWithAdapter":"Aktiver Adapter: {{adapter}}. Prüft, ob die Kurzbefehlüberwachung installiert ist.","settings.permissions.hotkeyDescPlain":"Prüft, ob die Kurzbefehlüberwachung installiert ist.","settings.permissions.networkLabel":"Netzwerk","settings.permissions.networkDesc":"Für Cloud-ASR- und LLM-Anfragen erforderlich. Für rein lokale Nutzung deaktivieren.","settings.permissions.networkOk":"Verfügbar","settings.permissions.networkOffline":"Nicht verfügbar","settings.permissions.checking":"Wird geprüft…","settings.permissions.granted":"Erlaubt","settings.permissions.notApplicable":"Nicht erforderlich","settings.permissions.denied":"Nicht erlaubt","settings.permissions.indeterminate":"Unbestimmt","settings.permissions.micNoDevice":"Kein Mikrofon erkannt","settings.permissions.openSystem":"Systemeinstellungen öffnen","settings.permissions.restart":"Zurücksetzen und neu starten","settings.permissions.grant":"Erlauben","settings.permissions.rerunAndroidSetup":"Einrichtung erneut ausführen","settings.permissions.hotkeyInstalled":"Installiert","settings.permissions.hotkeyStarting":"Wird installiert…","settings.permissions.hotkeyFailed":"Kurzbefehlüberwachung fehlgeschlagen","settings.permissions.windowsImeLabel":"Windows-Eingabemethode","settings.permissions.windowsImeDesc":"Wechselt während Sprachsitzungen vorübergehend zur OpenLess-TSF-Eingabemethode, um Einschränkungen der Zwischenablage zu umgehen.","settings.permissions.windowsImeInstalled":"Installiert","settings.permissions.windowsImeUnavailable":"Nicht verfügbar","settings.permissions.androidImeLabel":"Eingabemethode (IME)","settings.permissions.androidImeSelected":"Ausgewählt","settings.permissions.androidImeEnabled":"Aktiviert","settings.permissions.androidImeDisabled":"Nicht aktiviert","settings.permissions.androidOverlayLabel":"Schwebendes Fenster","settings.permissions.androidAccessibilityLabel":"Bedienungshilfendienst","settings.permissions.androidAccessibilityImpact":"Aktiviere diesen Dienst, um Ergebnisse ohne Tastaturwechsel im aktuellen Eingabefeld auszugeben. Andernfalls werden sie zum manuellen Einfügen in die Zwischenablage kopiert.","settings.permissions.androidAccessibilityGrantedStale":"Erlaubt, nicht verbunden","settings.permissions.androidAccessibilityMessages.not_android":"Der Bedienungshilfenstatus ist nur auf Android verfügbar.","settings.permissions.androidAccessibilityMessages.not_enabled":"Aktiviere OpenLess in den Bedienungshilfeneinstellungen des Systems.","settings.permissions.androidAccessibilityMessages.operational":"Der Bedienungshilfendienst läuft.","settings.permissions.androidAccessibilityMessages.authorized_not_connected":"Bedienungshilfen sind erlaubt, aber nicht verbunden. Aktiviere OpenLess in den Systemeinstellungen erneut.","settings.permissions.androidAccessibilityMessages.status_read_failed":"Der Bedienungshilfenstatus konnte nicht gelesen werden.","settings.permissions.androidShizukuLabel":"Shizuku-Erweiterung","settings.permissions.androidShizukuHint":"Optional. Versucht die Wiederherstellung, wenn Herstellereinstellungen manuelle Schalter blockieren. Gleichzeitige Änderungen durch andere Apps lassen sich nicht vollständig vermeiden. Shizuku muss nach einem Geräteneustart möglicherweise neu gestartet werden.","settings.permissions.androidShizukuOpenApp":"Shizuku öffnen","settings.permissions.androidShizukuRequestPermission":"Freigabe anfordern","settings.permissions.androidShizukuRecover":"Bedienungshilfen wiederherstellen","settings.permissions.androidShizukuRecoverConfirm":"Den OpenLess-Bedienungshilfendienst mit Shizuku erneut aktivieren? OpenLess berücksichtigt dabei die beim Schreiben bereits aktivierten Dienste. Ist der globale Bedienungshilfenschalter aus, können beim Aktivieren auch andere registrierte Dienste starten.","settings.permissions.androidShizukuYes":"ja","settings.permissions.androidShizukuNo":"nein","settings.permissions.androidShizukuAccessibilityOperational":"Bedienungshilfen sind registriert und laufen.","settings.permissions.androidShizukuAccessibilityRegistered":"Registriert: {{registered}} · Aktiv: {{operational}}","settings.permissions.androidShizukuState.notInstalled":"Nicht installiert","settings.permissions.androidShizukuState.notRunning":"Nicht gestartet","settings.permissions.androidShizukuState.notAuthorized":"Nicht freigegeben","settings.permissions.androidShizukuState.authorized":"Freigegeben","settings.permissions.androidShizukuState.binderDead":"Verbindung getrennt","settings.permissions.androidShizukuState.notAndroid":"Nicht zutreffend","settings.permissions.androidShizukuMessages.not_android":"Shizuku ist nur auf Android verfügbar.","settings.permissions.androidShizukuMessages.not_installed":"Shizuku oder das Sui-Backend ist nicht installiert.","settings.permissions.androidShizukuMessages.unsupported_backend":"Dieses Shizuku-Backend ist zu alt. Aktualisiere Shizuku oder Sui auf v11 oder neuer.","settings.permissions.androidShizukuMessages.not_running":"Shizuku läuft nicht. Starte zuerst Shizuku oder Sui.","settings.permissions.androidShizukuMessages.not_authorized":"Shizuku ist nicht freigegeben. Erteile OpenLess die Berechtigung.","settings.permissions.androidShizukuMessages.binder_dead":"Verbindung zu Shizuku verloren. Starte Shizuku neu.","settings.permissions.androidShizukuMessages.authorized_operational":"Shizuku freigegeben. Bedienungshilfen laufen.","settings.permissions.androidShizukuMessages.authorized_registered_stale":"Shizuku freigegeben. Bedienungshilfen sind registriert, laufen aber nicht.","settings.permissions.androidShizukuMessages.authorized_can_recover":"Shizuku freigegeben. Du kannst die Wiederherstellung der Bedienungshilfen versuchen.","settings.permissions.androidShizukuMessages.operational":"Bedienungshilfen sind registriert und laufen.","settings.permissions.androidShizukuMessages.registered_stale":"Bedienungshilfen sind registriert, der Dienst ist derzeit aber nicht verfügbar.","settings.permissions.androidShizukuMessages.not_registered":"Bedienungshilfen sind in den Systemeinstellungen nicht aktiviert.","settings.permissions.androidShizukuMessages.already_granted":"Die Shizuku-Berechtigung wurde bereits erteilt.","settings.permissions.androidShizukuMessages.binder_unavailable":"Der Shizuku-Binder war während der Berechtigungsanfrage nicht verfügbar.","settings.permissions.androidShizukuMessages.request_cancelled":"Die Shizuku-Berechtigungsanfrage wurde abgebrochen.","settings.permissions.androidShizukuMessages.granted":"Shizuku-Berechtigung erteilt.","settings.permissions.androidShizukuMessages.denied":"Shizuku-Berechtigung verweigert.","settings.permissions.androidShizukuMessages.permission_permanently_denied":"Die Shizuku-Freigabe wurde blockiert. Öffne Shizuku und erlaube OpenLess den Zugriff manuell.","settings.permissions.androidShizukuMessages.launched":"Shizuku-Freigabe geöffnet.","settings.permissions.androidShizukuMessages.launch_failed":"Shizuku-Freigabe konnte nicht geöffnet werden.","settings.permissions.androidShizukuMessages.open_shizuku":"Shizuku-Verwaltung geöffnet.","settings.permissions.androidShizukuMessages.jni_error":"Das Android-Shizuku-Backend konnte nicht erreicht werden.","settings.permissions.androidShizukuMessages.status_parse_failed":"Shizuku-Status konnte nicht ausgewertet werden.","settings.permissions.androidShizukuMessages.user_not_confirmed":"Die Wiederherstellung muss bestätigt werden.","settings.permissions.androidShizukuMessages.shizuku_unavailable":"Shizuku ist nicht freigegeben oder nicht verfügbar.","settings.permissions.androidShizukuMessages.invalid_component":"Ungültige Komponenten-ID des Bedienungshilfendienstes.","settings.permissions.androidShizukuMessages.service_connect_failed":"Verbindung zum privilegierten Shizuku-Dienst fehlgeschlagen.","settings.permissions.androidShizukuMessages.recovery_in_progress":"Eine andere Wiederherstellung läuft bereits.","settings.permissions.androidShizukuMessages.parse_failed":"Das Wiederherstellungsergebnis konnte nicht ausgewertet werden.","settings.permissions.androidShizukuMessages.service_not_bound":"Die Einstellungen wurden geschrieben, aber die Bedienungshilfen laufen noch nicht.","settings.permissions.androidShizukuMessages.success":"Bedienungshilfendienst wiederhergestellt.","settings.permissions.androidShizukuMessages.read_failed":"Bedienungshilfeneinstellungen konnten nicht gelesen werden.","settings.permissions.androidShizukuMessages.read_enabled_failed":"Der Aktivierungsstatus der Bedienungshilfen konnte nicht gelesen werden.","settings.permissions.androidShizukuMessages.merge_failed":"Bedienungshilfendienste konnten nicht zusammengeführt werden.","settings.permissions.androidShizukuMessages.write_services_failed":"Aktivierte Bedienungshilfendienste konnten nicht gespeichert werden.","settings.permissions.androidShizukuMessages.write_enabled_failed":"Bedienungshilfen konnten nicht aktiviert werden.","settings.permissions.androidShizukuMessages.readback_failed":"Bedienungshilfeneinstellungen konnten nach dem Schreiben nicht überprüft werden.","settings.permissions.androidShizukuMessages.oem_rollback":"Der Gerätehersteller hat die Änderung der Bedienungshilfen zurückgesetzt.","settings.permissions.androidShizukuMessages.concurrent_change":"Die Bedienungshilfeneinstellungen wurden während der Wiederherstellung geändert.","settings.permissions.androidShizukuMessages.partial_rollback":"Die Wiederherstellung ist fehlgeschlagen und die Einstellungen konnten nur teilweise zurückgesetzt werden. Prüfe die Bedienungshilfeneinstellungen des Systems.","settings.permissions.androidShizukuMessages.manual_required":"Die automatische Wiederherstellung kann Bedienungshilfen nicht sicher aktivieren, solange bei ausgeschaltetem Hauptschalter andere registrierte Dienste vorhanden sind. Verwende die Systemeinstellungen.","settings.permissions.androidShizukuMessages.max_retries":"Wiederherstellung nach mehreren Versuchen fehlgeschlagen.","settings.permissions.androidShizukuMessages.internal_error":"Wiederherstellung wegen eines internen Fehlers fehlgeschlagen.","settings.permissions.androidShizukuMessages.unknown":"Unbekannter Shizuku-Status.","settings.permissions.androidInsertStrategyLabel":"Texteinfügemethode","settings.permissions.androidOverlayTriggerLabel":"Sichtbarkeit des schwebenden Fensters","settings.permissions.androidOverlayActivationModeLabel":"Aktivierung des schwebenden Fensters","settings.permissions.androidOverlayLeftSwipeActionLabel":"Aktion beim Wischen nach links","settings.permissions.androidOverlayCancelSwipeDirectionLabel":"Wischrichtung zum Abbrechen","settings.permissions.androidOverlaySizeLabel":"Größe des schwebenden Fensters","settings.permissions.androidOverlaySizeHint":"Ändert den Durchmesser der schwebenden Taste und behält ihre Position bei.","settings.permissions.androidInsertStrategy.accessibility":"Automatisch im Eingabefeld ausgeben","settings.permissions.androidInsertStrategy.clipboard":"Nur Zwischenablage","settings.permissions.androidInsertStrategyHint.accessibility":"Benötigt Bedienungshilfen. Bei Nichtverfügbarkeit wird die Zwischenablage verwendet.","settings.permissions.androidInsertStrategyHint.clipboard":"Keine Bedienungshilfenberechtigung nötig. Kopiert nur zum manuellen Einfügen.","settings.permissions.androidOverlayTrigger.background":"Wenn die App im Hintergrund ist","settings.permissions.androidOverlayTrigger.keyboard":"Wenn die Tastatur erscheint","settings.permissions.androidOverlayTrigger.always":"Immer sichtbar","settings.permissions.androidOverlayTriggerHint.background":"Einfach und energiesparend. Kein schwebendes Fenster beim Tippen in anderen Apps.","settings.permissions.androidOverlayTriggerHint.keyboard":"Dieser Modus wird nicht weitergeführt. Bestehende Einstellungen werden auf „Im Hintergrund“ zurückgesetzt.","settings.permissions.androidOverlayTriggerHint.always":"Jederzeit verfügbar, aber dauerhaft auf dem Bildschirm.","settings.permissions.androidOverlayTriggerDisabled.keyboard":"Die Anzeige beim Öffnen der Tastatur wird nicht weitergeführt. Fenstergesten sollen die Tastaturerkennung ersetzen.","settings.permissions.androidOverlayActivationMode.tap":"Zum Aktivieren tippen","settings.permissions.androidOverlayActivationMode.long_press":"Zum Aktivieren lange drücken","settings.permissions.androidOverlayActivationModeHint.tap":"Erstes Tippen aktiviert das Fenster, zweites Tippen startet ein normales Diktat.","settings.permissions.androidOverlayActivationModeHint.long_press":"Gedrückt halten, um das Fenster zu aktivieren. Loslassen beendet die aktuelle Aufnahme oder Fragerunde.","settings.permissions.androidOverlayLeftSwipeAction.translation":"Diktat mit Übersetzung","settings.permissions.androidOverlayLeftSwipeAction.style_pack":"Stilpaket wechseln","settings.permissions.androidOverlayLeftSwipeActionHint.translation":"Nach dem Aktivieren nach links wischen, um ein Übersetzungsdiktat zu starten.","settings.permissions.androidOverlayLeftSwipeActionHint.style_pack":"Nach dem Aktivieren nach links wischen, um zum vorherigen Stilpaket zu wechseln.","settings.permissions.androidOverlayCancelSwipeDirection.up":"Nach oben wischen","settings.permissions.androidOverlayCancelSwipeDirection.down":"Nach unten wischen","settings.permissions.androidOverlayCancelSwipeDirectionHint.up":"Während der Aufnahme nach oben wischen, um ohne Transkription oder Einfügen abzubrechen.","settings.permissions.androidOverlayCancelSwipeDirectionHint.down":"Während der Aufnahme nach unten wischen, um ohne Transkription oder Einfügen abzubrechen.","settings.permissions.windowsIme.installed":"Installiert. Die Spracheingabe wechselt vorübergehend zur OpenLess-Eingabemethode.","settings.permissions.windowsIme.notInstalled":"Nicht installiert. OpenLess verwendet die Alternative über Zwischenablage/WM_PASTE.","settings.permissions.windowsIme.registrationBroken":"Die Registrierung ist beschädigt. Installiere die OpenLess-Eingabemethode erneut.","settings.permissions.windowsIme.notWindows":"Nur unter Windows verfügbar.","settings.advanced.multimodalPipelineTitle":"Multimodale Spracherkennung (experimentell)","settings.advanced.multimodalPipelineTitleHint":"Spracherkennung in einem Durchgang mit einem multimodalen Modell. Die klassische ASR- und LLM-Konfiguration ist vollständig getrennt.","settings.advanced.multimodalPipelineLabel":"Multimodale Verarbeitung aktivieren","settings.advanced.multimodalPipelineHint":"Ergänzt auf der KI-Diensteseite den Schalter „Klassisch / Multimodal“. Klassisch = ASR + LLM; multimodal = ein audiotaugliches Modell. Beide Konfigurationen werden getrennt gespeichert und teilen keine Zugangsdaten.","settings.advanced.streamingInsertTitle":"Laufend einfügen","settings.advanced.streamingInsertTitleLinux":"Laufend einfügen (experimentell)","settings.advanced.streamingInsertDesc":"Fügt den Text Zeichen für Zeichen an der Cursorposition ein und verkürzt so die wahrgenommene Wartezeit. Sind die Voraussetzungen nicht erfüllt, wird der Text auf einmal eingefügt.","settings.advanced.streamingInsertLabel":"Laufend einfügen","settings.advanced.streamingInsertHintMac":"Wechselt die Eingabequelle vorübergehend zu ABC, damit chinesische, japanische oder koreanische Eingabemethoden die Tasten nicht abfangen. Nach der Sitzung wird die ursprüngliche Quelle wiederhergestellt.","settings.advanced.streamingInsertHintWindows":"SendInput gibt Unicode direkt ein und umgeht TSF / IME. Ein Wechsel der Eingabemethode ist nicht erforderlich.","settings.advanced.streamingInsertHintLinux":"Verwendet das fcitx5-Plugin zur Textübermittlung. Laufendes Einfügen simuliert Tasten über enigo + XTest.","settings.advanced.streamingInsertSaveClipboardLabel":"In Zwischenablage kopieren","settings.advanced.streamingInsertSaveClipboardHint":"Kopiert nach erfolgreichem Einfügen den fertigen Text in die Zwischenablage, damit du ihn mit Cmd+V erneut einfügen kannst. Bei „Aus“ bleibt die Zwischenablage unberührt.","settings.advanced.localAsrTitle":"Lokale ASR-Modelle (experimentell)","settings.advanced.localAsrDesc":"Verlagert die Transkription von Cloud-ASR auf das Gerät. Für Offline-Nutzung oder sensible Daten.","settings.advanced.localAsrWarningShort":"Lokale Inferenz ist langsamer. Zu schwache Hardware kann Wörter verlieren.","settings.advanced.qwen3Desc":"Nach der Aktivierung übernimmt dieses Modell die Spracherkennung.","settings.advanced.sherpaDesc":"Nach der Aktivierung übernimmt dieses Modell die Spracherkennung.","settings.advanced.foundryDesc":"Nach der Aktivierung übernimmt dieses Modell die Spracherkennung.","settings.advanced.notSupportedHere":"Auf dieser Plattform nicht unterstützt. Kein Inferenzmodul enthalten.","settings.advanced.enable":"Aktivieren","settings.advanced.alreadyActive":"Aktiv","settings.advanced.disableLocalLabel":"Lokale ASR deaktivieren","settings.advanced.disableLocalDesc":"Zur Cloud-ASR zurückwechseln (Standard: Volcengine bigasr).","settings.advanced.disable":"Deaktivieren","settings.advanced.platformNotSupported":"Lokale ASR-Modelle werden auf dieser Plattform nicht unterstützt.","settings.advanced.confirmEnableLocalTitle":"Lokale ASR aktivieren?","settings.advanced.confirmEnableLocalBody":"Die Transkription wird langsamer als in der Cloud und möglicherweise weniger genau.","settings.advanced.confirm":"Aktivieren","settings.language.es":"Español","settings.language.fr":"Français","settings.language.de":"Deutsch","settings.language.title":"Sprache der Oberfläche","settings.language.desc":"Wechselt die Sprache der Oberfläche sofort und speichert die Auswahl für spätere Starts.","settings.language.label":"Sprache","settings.language.labelDesc":"Wähle „Systemsprache“, um beim Start die Sprache des Betriebssystems zu übernehmen.","settings.language.followSystem":"Systemsprache","settings.language.zh":"简体中文","settings.language.zhTW":"繁體中文","settings.language.en":"English","settings.language.ja":"日本語 (Beta)","settings.language.ko":"한국어 (Beta)","settings.language.restartHint":"Einige native Menüs, etwa im Infobereich, wechseln möglicherweise erst nach einem App-Neustart vollständig die Sprache.","settings.layout.title":"Layout","settings.theme.title":"Darstellung","settings.theme.label":"Design","settings.theme.activityHeatmapLabel":"Jährliche Aktivitätsübersicht in der Übersicht anzeigen","settings.theme.stackedRowLayoutLabel":"Lesbares Layout (Zeilen umbrechen)","settings.theme.stackedRowLayoutDesc":"Auf kleinen Bildschirmen oder bei großer Schrift wechseln Tasten und Steuerelemente, die nicht mehr in eine Zeile passen, in die nächste Zeile. So läuft nichts über und Text wird nicht zusammengedrückt.","settings.theme.conservativeLayoutLabel":"Konservatives Layout","settings.theme.conservativeLayoutDesc":"Außer auf der Startseite sowie in der oberen und unteren Leiste verwenden Einstellungs- und Funktionsseiten eine einzige Spalte über die volle Breite, um horizontalen Überlauf zu vermeiden.","settings.theme.system":"Systemeinstellung","settings.theme.light":"Hell","settings.theme.dark":"Dunkel","settings.remoteInput.title":"Ferneingabe","settings.remoteInput.enableLabel":"Ferneingabe aktivieren","settings.remoteInput.enableDesc":"Nimm über den Browser eines Smartphones oder Tablets im lokalen Netzwerk auf. Der Text wird am Cursor deines Computers eingefügt (HTTPS erforderlich; dem Zertifikat beim ersten Besuch vertrauen).","settings.remoteInput.portLabel":"Port","settings.remoteInput.defaultModeLabel":"Standard-Aufnahmemodus","settings.remoteInput.modeToggle":"Zum Umschalten tippen","settings.remoteInput.modeHold":"Zum Sprechen gedrückt halten","settings.remoteInput.urlLabel":"Zugriffs-URL","settings.remoteInput.pinLabel":"Kopplungscode","settings.remoteInput.regeneratePin":"Neu erstellen","settings.remoteInput.portInUse":"Port {{port}} ist belegt. Wähle einen anderen Port","settings.remoteInput.startError":"Der Ferneingabedienst konnte nicht gestartet werden: {{reason}}","settings.remoteInput.securityHint":"Nur im selben lokalen Netzwerk erreichbar und durch den Kopplungscode geschützt. Bei Nichtgebrauch ausschalten.","settings.remoteInput.certHint":"Prüfe vor der erstmaligen Vertrauensstellung den Fingerabdruck des Root-Zertifikats. Ältere Versionen erfordern eine einmalige Einrichtung; danach bleibt die Vertrauensstellung über Neustarts und IP-Wechsel hinweg erhalten.","settings.remoteInput.certFingerprintLabel":"Root-CA-SHA-256 dieses Computers","settings.remoteInput.certFingerprintCopy":"Vollständigen Fingerabdruck kopieren","settings.remoteInput.certFingerprintCopied":"Fingerabdruck kopiert","settings.remoteInput.certFingerprintUnavailable":"Der vollständige Fingerabdruck ist nicht verfügbar. Installiere oder vertraue kein heruntergeladenes Zertifikat.","settings.remoteInput.certVerifyHint":"Suche vor dem Aktivieren der vollständigen Vertrauensstellung den SHA-256 in den Zertifikatdetails des Telefons und vergleiche alle 64 Zeichen mit diesem Wert (Leerzeichen und Doppelpunkte ignorieren). Webseite, Profilname oder Bezeichner können die Identität nicht beweisen. Weicht der Fingerabdruck ab oder ist nicht vollständig einsehbar, brich ab und entferne das heruntergeladene oder installierte Profil.","settings.remoteInput.certProfileHint":"Erwarte genau ein Root-Zertifikat. Installiere kein Profil mit zusätzlichen Zertifikaten, VPN- oder Geräteverwaltungseinstellungen.","settings.remoteInput.certTrustWarning":"Beim ersten Zertifikatsdownload kann die Identität des Computers nicht geprüft werden: Ein bösartiges Gerät im lokalen Netzwerk könnte das Root-Zertifikat in einem Man-in-the-Middle-Angriff ersetzen. Installiere es nur in einem vertrauenswürdigen Heim- oder Privatnetzwerk, niemals in öffentlichen oder geteilten Netzwerken. Die Root-CA kann Zertifikate ausstellen, ihr privater Schlüssel bleibt auf diesem Computer; entferne sie vom Smartphone, wenn du sie nicht mehr brauchst.","settings.remoteInput.certSetupLink":"iPhone-Zertifikatslink kopieren","settings.remoteInput.waitingStart":"Der Dienst läuft noch nicht. Schalte die Funktion aus und wieder ein. Ein App-Neustart ist nicht erforderlich.","settings.remoteInput.starting":"Ferneingabedienst wird gestartet…","settings.remoteInput.urlsStale":"Diese Adressen stammen vom vorherigen Start und sind möglicherweise veraltet.","settings.about.tagline":"Natürlich sprechen, klar schreiben","settings.about.checkUpdate":"Nach Updates suchen","settings.about.checkUpdateBtn":"Prüfen","settings.about.checkStableUpdateBtn":"Stabile Version prüfen","settings.about.checkBetaUpdateBtn":"Beta-Version prüfen","settings.about.checkingUpdate":"Wird geprüft…","settings.about.upToDate":"Du verwendest bereits die neueste Version.","settings.about.updateError":"Updatesuche oder Installation fehlgeschlagen. Versuche es später erneut.","settings.about.retryBtn":"Erneut versuchen","settings.about.openReleases":"Veröffentlichungen öffnen","settings.about.source":"Quellcode","settings.about.docs":"Dokumentation","settings.about.feedback":"Rückmeldung","settings.about.qq":"QQ-Community-Gruppe","settings.about.qqDesc":"Suche in QQ nach der Gruppennummer oder scanne den QR-Code, um beizutreten.","settings.about.copyQq":"Gruppennummer kopieren","settings.about.privacy":"Datenschutz","settings.about.privacyDesc":"Aufnahmen können zur Transkription an den von dir eingerichteten Cloud-Anbieter gesendet werden.","settings.about.localFirst":"Lokal orientiert","settings.about.linksTitle":"Dokumentation","settings.about.betaChannelLabel":"Beta-Kanal verwenden","settings.about.betaChannelToggleLabel":"Beta-Kanal aktivieren","settings.about.betaChannelDesc":"Bei Aktivierung verwendet die automatische Updatesuche den Beta-Kanal, andernfalls die stabile Version. Über die Taste unten kannst du jederzeit manuell nach Betas suchen.","settings.about.autoUpdateSectionTitle":"Automatische Updates","settings.about.autoUpdateCheckLabelAndroid":"Automatisch nach Updates suchen und herunterladen","settings.about.autoUpdateCheckDescAndroid":"Prüft beim Start und alle 60 Minuten. Verfügbare Updates werden heruntergeladen und im Systeminstallationsprogramm geöffnet. Der Kanal folgt dem Beta-Schalter oben.","settings.about.betaChannelFetching":"Neueste Beta wird abgerufen…","settings.about.betaChannelFetchBtn":"Neueste Beta suchen","settings.about.betaChannelLatestPrefix":"Neueste Beta:","settings.about.betaChannelDownloadBtn":"Downloadseite öffnen","settings.about.betaChannelRefresh":"Aktualisieren","settings.about.betaChannelNoBeta":"Es wurde noch keine Beta-Version veröffentlicht.","settings.about.betaChannelFetchError":"Beta-Versionsinformationen konnten nicht abgerufen werden. Versuche es später erneut.","settings.about.betaChannelUpToDate":"Aktuell","settings.about.betaChannelUpdateNow":"Jetzt aktualisieren","settings.about.betaChannelUpdateNowTitle":"Neueste Beta prüfen und herunterladen, anschließend den Updatedialog anzeigen","settings.about.betaChannelChecking":"Wird geprüft…","settings.about.updateDialog.stableChannelSwitch.title":"Zum stabilen Kanal wechseln","settings.about.updateDialog.stableChannelSwitch.desc":"Aktuelle Version: OpenLess {{currentVersion}}\nZielversion: OpenLess {{version}}\nDadurch wechselst du vom Beta-Kanal zum stabilen Kanal. Fortfahren?","settings.about.updateDialog.available.title":"Update verfügbar","settings.about.updateDialog.available.desc":"OpenLess {{version}} ist verfügbar. Jetzt aktualisieren?","settings.about.updateDialog.downloading.title":"Update wird heruntergeladen","settings.about.updateDialog.downloading.desc":"OpenLess {{version}} wird heruntergeladen. Lass die App geöffnet.","settings.about.updateDialog.downloaded.title":"Update bereit","settings.about.updateDialog.downloaded.desc":"OpenLess {{version}} wurde installiert. Jetzt automatisch neu starten, um es anzuwenden?","settings.about.updateDialog.installing.title":"Update wird installiert","settings.about.updateDialog.installing.desc":"OpenLess {{version}} wird installiert. Lass die App geöffnet.","settings.about.updateDialog.install":"Jetzt aktualisieren","settings.about.updateDialog.androidInstall":"Herunterladen und Installation öffnen","settings.about.updateDialog.androidInstalled.title":"Systeminstallation geöffnet","settings.about.updateDialog.androidInstalled.desc":"Folge den Systemhinweisen, um die Installation abzuschließen. Öffne OpenLess erneut, um {{version}} zu verwenden.","settings.about.updateDialog.downloadingLabel":"Wird heruntergeladen…","settings.about.updateDialog.installingLabel":"Wird installiert…","settings.about.updateDialog.later":"Später manuell neu starten","settings.about.updateDialog.restartNow":"Jetzt neu starten","settings.about.updateDialog.progress":"{{progress}}% · {{downloaded}} / {{total}}","settings.about.updateDialog.progressUnknown":"{{downloaded}} heruntergeladen","settings.about.updateDialog.installError.title":"Update fehlgeschlagen","settings.about.updateDialog.installError.desc":"Das automatische Update konnte nicht abgeschlossen werden: {{error}}. Du kannst die neueste Version manuell herunterladen und installieren.","settings.about.updateDialog.manualDownload":"Manuell herunterladen","startup.loading":"OpenLess wird gestartet…","startup.loadingDesc":"Verbindung zum lokalen Dienst wird hergestellt und die Kompatibilität geprüft.","startup.failed":"OpenLess konnte nicht gestartet werden","startup.recovery":"Prüfe erneut. Falls das Problem bleibt, beende die App vollständig und öffne sie wieder. Trat es nach einem Update auf, stelle sicher, dass alle App-Komponenten dieselbe Version verwenden.","startup.retry":"Erneut prüfen","startup.details":"Fehlerdetails anzeigen","modal.serviceViews.label":"Diensteinstellungen","modal.serviceViews.llm":"Sprachmodelle","modal.serviceViews.asr":"Spracherkennung","modal.serviceViews.omni":"Multimodal","modal.serviceViews.models":"Lokale Modelle","modal.serviceViews.connections":"Verbindungen","modal.serviceViews.statusConfigured":"Eingerichtet","modal.serviceViews.statusMissing":"Nicht eingerichtet","modal.searchPlaceholder":"Einstellungskategorie suchen…","modal.clearSearch":"Suche leeren","modal.categoriesLabel":"Einstellungskategorien","modal.searchResults":"Suchergebnisse","modal.searchCount":"Gefundene Kategorien: {{count}}","modal.noResults":"Keine passenden Kategorien. Versuche „Mikrofon“, „Modelle“ oder „Design“.","modal.autoSaveHint":"Änderungen werden automatisch gespeichert","modal.backToAdvanced":"Zurück zu Experimente und Erweiterungen","modal.advancedPages.lessComputer":"Wähle einen Agenten und konfiguriere Modell, Berechtigungen und Arbeitsverzeichnis.","modal.advancedPages.claudeConsole":"Erkenne Claude Code und prüfe die Ausgabe von Testaufträgen.","modal.advancedPages.multimodal":"Verwalte die experimentelle multimodale Spracherkennung.","modal.advancedPages.debug":"Diagnoseaufnahmen speichern, Cursorkontext prüfen und Protokolle exportieren.","modal.descriptions.general":"Mikrofon wählen, Aufnahme und Texteingabe anpassen oder dein Smartphone verbinden.","modal.descriptions.shortcuts":"Kurzbefehle einrichten und Aktionen für ausgewählten Text festlegen.","modal.descriptions.services":"Spracherkennungs- und Textverarbeitungsdienste wählen. Kanäle, lokale Modelle und Verbindungen verwalten.","modal.descriptions.appearance":"Design, Seitenlayout und Sprache der Oberfläche für angenehmes Lesen anpassen.","modal.descriptions.privacy":"Systemberechtigungen und Verbindungen prüfen. Verlauf, Aufnahmen und lokale Daten verwalten.","modal.descriptions.advanced":"Less Computer, multimodale Verarbeitung und Diagnose nach Bedarf einrichten.","modal.descriptions.about":"Version, Updatekanal und automatische Updateeinstellungen anzeigen.","modal.searchKeywords.general":"Mikrofon Aufnahme Eingabe Smartphone Ferneingabe LAN PIN Kapsel Stumm Start Autostart","modal.searchKeywords.shortcuts":"Kurzbefehl Hotkey Taste Tastenkombination Auswahl Überarbeitung Sprachbearbeitung","modal.searchKeywords.services":"ASR LLM API Kanal Modell Cloud Lokal Offline Netzwerk Proxy Marktplatz","modal.searchKeywords.appearance":"Design Dunkel Hell Sprache Schrift Textgröße Layout Aktivitätsübersicht","modal.searchKeywords.privacy":"Berechtigung Mikrofon Bedienungshilfen Verlauf Aufnahme Speicher Datenschutz Export","modal.searchKeywords.advanced":"Less Computer Claude Agent Multimodal Omni Diagnose Protokolle Experiment","modal.searchKeywords.about":"Version Beta Stabil Update Aktualisierung","modal.sections.appearance":"Darstellung und Sprache","modal.sections.shortcuts":"Kurzbefehle und Auswahl","modal.sections.general":"Aufnahme und Eingabe","modal.sections.services":"KI-Dienste und Modelle","modal.sections.privacy":"Berechtigungen und Daten","modal.sections.advanced":"Experimente und Erweiterungen","modal.sections.personalize":"Anpassen","modal.sections.about":"Über OpenLess und Updates","modal.sections.helpCenter":"Hilfezentrum","modal.sections.releaseNotes":"Versionshinweise","modal.personalize.font":"Schriftgröße","modal.personalize.fontDesc":"Skaliert die Schriftgröße der gesamten Oberfläche sofort.","modal.personalize.fontSmall":"Klein","modal.personalize.fontMedium":"Mittel","modal.personalize.fontLarge":"Groß","modal.personalize.blur":"Stärke des Glaseffekts","modal.personalize.blurDesc":"Passt die Stärke des internen Hintergrundfilters an. Die systemeigene macOS-Milchglasschicht lässt sich zur Laufzeit nicht ändern.","modal.about.tagline":"Natürlich sprechen, klar schreiben","modal.about.checkUpdate":"Nach Updates suchen","modal.about.checkUpdateBtn":"Prüfen","modal.about.docs":"Dokumentation","modal.about.docsBtn":"openless.app/docs ↗","modal.about.feedback":"Rückmeldung senden","modal.about.feedbackBtn":"GitHub Issues ↗","modal.about.source":"Quellcode","modal.about.qq":"QQ-Community-Gruppe","modal.about.qqDesc":"Suche in QQ nach der Gruppennummer oder scanne den QR-Code, um beizutreten.","modal.about.copyQq":"Gruppennummer kopieren","modal.about.exportErrorLog":"Fehlerprotokoll exportieren","modal.about.exportErrorLogDesc":"Speichert das Protokoll der aktuellen Sitzung für die Fehleranalyse oder zum Senden einer Rückmeldung.","modal.about.exportErrorLogBtn":"Exportieren","modal.about.exporting":"Wird exportiert…","modal.about.exportSuccess":"Gespeichert","modal.about.exportFailed":"Export fehlgeschlagen","modal.about.privacy":"Datenschutz","modal.about.privacyDesc":"Transkripte bleiben auf diesem Gerät. Eingerichtete Cloud-Anbieter können Audioaufnahmen zur Transkription erhalten.","modal.about.localFirst":"Lokal orientiert","windowChrome.restore":"Wiederherstellen","windowChrome.minimize":"Minimieren","windowChrome.maximize":"Maximieren","windowChrome.close":"Schließen","hotkey.triggers.rightOption":"Rechte Option-Taste","hotkey.triggers.leftOption":"Linke Option-Taste","hotkey.triggers.rightControl":"Rechte Control-Taste","hotkey.triggers.leftControl":"Linke Control-Taste","hotkey.triggers.rightCommand":"Rechte Command-Taste","hotkey.triggers.leftCommand":"Linke Command-Taste","hotkey.triggers.leftShift":"Linke Shift-Taste","hotkey.triggers.rightShift":"Rechte Shift-Taste","hotkey.triggers.fn":"Fn (Globustaste)","hotkey.triggers.rightAlt":"Rechte Alt-Taste","hotkey.triggers.mediaPlayPause":"⏯ Medienwiedergabe / Pause","hotkey.triggers.custom":"Eigene Tastenkombination…","hotkey.fallback":"Globaler Kurzbefehl","hotkey.modeHoldSuffix":" (zum Sprechen gedrückt halten)","hotkey.modeToggleSuffix":" (starten / beenden)","hotkey.modeAutoSuffix":" (automatisch erkennen)","hotkey.usageHold":"Halte {{trigger}} zum Sprechen gedrückt und lasse die Taste zum Beenden los.","hotkey.usageToggle":"Drücke {{trigger}} zum Starten und erneut zum Beenden.","hotkey.usageAuto":"Tippe {{trigger}} zum Starten / Beenden an oder halte die Taste zum Sprechen gedrückt und lasse sie zum Beenden los.","hotkey.adapter.macEventTap":"macOS Event Tap","hotkey.adapter.windowsLowLevel":"Windows-Tastaturüberwachung (Low-Level-Hook)","hotkey.adapter.fcitx5":"fcitx5-Eingabemethoden-Plugin","hotkey.adapter.unavailable":"Nicht verfügbar","localAsr.kicker":"LOKALE ASR","localAsr.title":"Modelle","localAsr.desc":"Spracherkennungsmodelle auf diesem Gerät verwalten.","localAsr.storageTitle":"Speicherort für Modelle","localAsr.storageBaseDir":"Gewählter übergeordneter Ordner","localAsr.storageModelsRoot":"Tatsächlicher Modellordner","localAsr.storageDefault":"Standardordner des Systems","localAsr.storageChoose":"Ordner ändern","localAsr.storageReset":"Auf Standard zurücksetzen","localAsr.storageReveal":"Modellordner öffnen","localAsr.storageDesc":"Bei einem eigenen Speicherort wird OpenLess/models im gewählten Ordner erstellt und vorhandene Modelle werden dorthin verschoben. Zuvor bricht OpenLess Downloads ab und entlädt geladene Modelle.","localAsr.storageChooseTitle":"Übergeordneten Speicherordner für lokale Modelle wählen","localAsr.storageChangeConfirm":"Vorhandene lokale Modelle werden nach {{path}}/OpenLess/models verschoben. Zuvor werden Downloads abgebrochen und geladene Modelle entladen. Fortfahren?","localAsr.storageResetConfirm":"Vorhandene lokale Modelle werden in den Systemstandardordner zurückverschoben. Aktueller Ordner: {{path}}. Fortfahren?","localAsr.modelDir":"Modellverzeichnis","localAsr.revealDir":"Verzeichnis öffnen","localAsr.deleteConfirm":"Lokale Modelldateien von {{name}} löschen? Dies kann nicht rückgängig gemacht werden.","localAsr.appleSpeechTitle":"Spracherkennung mit Apple Speech (macOS)","localAsr.appleSpeechDesc":"Transkribiert Sprache lokal mit der integrierten macOS-Spracherkennung: ohne Modelldownload, API-Schlüssel oder Netzwerk. Eine lokale Alternative ohne Zugangsdaten, wenn die Cloud-ASR unzuverlässig ist. macOS fragt bei der ersten Verwendung nach der Berechtigung zur Spracherkennung.","localAsr.appleSpeechUse":"Apple Speech verwenden","localAsr.qwenTitle":"Qwen3-ASR-Modellverwaltung","localAsr.qwenExperimentalBadge":"Experimentell","localAsr.engineUnavailable":"Die Qwen3-ASR-Inferenz-Engine ist auf dieser Plattform nicht enthalten. Modelle können heruntergeladen, Qwen3-ASR kann hier aber noch nicht aktiviert werden.","localAsr.qwenUnavailableOnWindows":"Qwen3-ASR wird unter Windows noch nicht unterstützt. Verwende stattdessen oben Foundry Local Whisper.","localAsr.foundryTitle":"Windows Foundry Local Whisper","localAsr.foundryDesc":"Spracherkennung auf dem Gerät, ohne ASR-API-Schlüssel. Bei der ersten Verwendung müssen Laufzeit und Modell heruntergeladen werden.","localAsr.foundryAvailable":"Unter Windows verfügbar","localAsr.foundryUnavailable":"Nur Windows","localAsr.foundryRuntimeReady":"Laufzeitkomponenten heruntergeladen","localAsr.foundryRuntimeMissing":"Laufzeitkomponenten nicht heruntergeladen","localAsr.foundryRuntimeSourceLabel":"Quelle der Laufzeitkomponenten","localAsr.foundryRuntimeSourceAuto":"Automatisch (NuGet zuerst)","localAsr.foundryRuntimeSourceNuget":"Offizielle NuGet-Quelle","localAsr.foundryRuntimeSourceOrtNightly":"Microsoft ORT-Nightly-Quelle","localAsr.foundryRuntimeSourceDesc":"Laufzeitkomponenten werden vor der ersten Verwendung heruntergeladen.","localAsr.foundrySelectedModel":"Gewähltes Modell","localAsr.foundryActiveModel":"Aktueller Standardalias","localAsr.foundryLoadedModel":"Geladenes Modell","localAsr.foundryNotLoaded":"Nicht geladen","localAsr.foundryError":"Foundry-Status","localAsr.foundrySetDefault":"Als Standard festlegen / lokale Windows-ASR aktivieren","localAsr.foundryEnabling":"Wird aktiviert…","localAsr.foundryPrepare":"Vorbereiten / Herunterladen / Laden","localAsr.foundryPreparing":"Wird vorbereitet…","localAsr.foundryReleasing":"Wird entladen…","localAsr.foundryRetryPrepare":"Fortsetzen / Vorbereitung wiederholen","localAsr.foundryCancelPrepare":"Vorbereitung abbrechen","localAsr.foundryCancelRequested":"Abbruch angefordert","localAsr.foundryCancelling":"Wird abgebrochen…","localAsr.foundryCancelBestEffort":"Abbruch angefordert. Der Vorgang stoppt nach dem aktuellen Schritt. Versuche es später erneut.","localAsr.foundryPrepareRuntime":"Laufzeitkomponenten vorbereiten","localAsr.foundryPrepareModel":"Modell herunterladen","localAsr.foundryPrepareLoad":"Modell laden","localAsr.foundryPrepareModelSkipped":"Modell bereits heruntergeladen; Download übersprungen","localAsr.foundryPrepareDone":"Fertig","localAsr.foundryPrepareWaiting":"Wartet","localAsr.foundryApproxSizeMb":"ca. {{mb}} MB","localAsr.foundryLanguageLabel":"Erkennungssprache","localAsr.foundryLanguageAuto":"Automatisch","localAsr.foundryLanguageZh":"Chinesisch zh","localAsr.foundryLanguageEn":"Englisch en","localAsr.foundryLanguageDesc":"Wähle „Chinesisch“ für chinesische Diktate oder „Automatisch“ für gemischte Sprachen.","localAsr.foundryModelSmall":"Whisper Small (Standard / ausgewogen)","localAsr.foundryModelSmallDesc":"Ausgewogene Standardoption für Qualität und Ressourcenverbrauch.","localAsr.foundryModelMedium":"Whisper Medium (höhere Qualität)","localAsr.foundryModelMediumDesc":"Höhere Genauigkeit für leistungsfähigere Geräte, die größere Downloads und langsamere Inferenz bewältigen können.","localAsr.foundryModelLarge":"Whisper Large V3 Turbo (beste Qualität)","localAsr.foundryModelLargeDesc":"Großes Modell für leistungsstarke Geräte und höchste Qualitätsansprüche.","localAsr.foundryModelBase":"Whisper Base (schneller / sparsamer)","localAsr.foundryModelBaseDesc":"Schneller und ressourcenschonender für einfache alltägliche Diktate.","localAsr.foundryModelTiny":"Whisper Tiny (am schnellsten / Funktionstest)","localAsr.foundryModelTinyDesc":"Schnellste Testoption, um die Funktionsfähigkeit von Foundry zu prüfen.","localAsr.sherpaTitle":"Lokales sherpa-onnx unter Windows (experimentell)","localAsr.sherpaDesc":"Windows verwendet sherpa-onnx für lokale Offline-Stapelerkennung ohne ASR-API-Schlüssel.","localAsr.sherpaRuntimeReady":"Modell geladen","localAsr.sherpaRuntimeMissing":"Modell nicht geladen","localAsr.sherpaSetDefault":"Als Standard festlegen / sherpa-onnx aktivieren","localAsr.sherpaPrepare":"Lokale Dateien prüfen / Laden","localAsr.sherpaPreparing":"Wird geladen…","localAsr.sherpaPrepareLocalFiles":"Lokale Modelldateien prüfen","localAsr.sherpaModelDir":"Modellverzeichnis","localAsr.sherpaRevealDir":"Modellverzeichnis öffnen","localAsr.sherpaError":"sherpa-onnx-Status","localAsr.sherpaLanguageJa":"Japanisch ja","localAsr.sherpaLanguageKo":"Koreanisch ko","localAsr.sherpaLanguageYue":"Kantonesisch yue","localAsr.sherpaModelSenseVoice":"SenseVoice Small (Standard / Schwerpunkt Chinesisch)","localAsr.sherpaModelSenseVoiceDesc":"Experimentelles Standardmodell für chinesische und gemischte chinesisch-englische Diktate.","localAsr.sherpaModelParaformer":"Paraformer Chinese","localAsr.sherpaModelParaformerDesc":"Experimentelles Modell mit Schwerpunkt Chinesisch.","localAsr.sherpaModelWhisper":"Whisper Small mehrsprachig","localAsr.sherpaModelWhisperDesc":"Experimentelle mehrsprachige Alternative mit dem Verhalten der Whisper-Modellfamilie.","localAsr.sherpaModelWhisperLargeV3":"Whisper Large V3 (mehrsprachig)","localAsr.sherpaModelWhisperLargeV3Desc":"Leistungsstärkste mehrsprachige Open-Source-Whisper-Variante: hohe Qualität, großer Download.","localAsr.sherpaModelZipformer":"Zipformer-Streaming (zh/en)","localAsr.sherpaModelZipformerDesc":"Streaming-Modell für Chinesisch und Englisch mit der geringsten Latenz – Text erscheint beim Sprechen.","localAsr.sherpaModelQwen3":"Qwen3-ASR 0.6B INT8","localAsr.sherpaModelQwen3Desc":"Konvertiertes sherpa-onnx-Qwen3-ASR-Modell mit mehrsprachiger Erkennung und besserer Verarbeitung längerer Kontexte.","localAsr.modelSelectTitle":"Modelle auf diesem Gerät","localAsr.modelSelectDesc":"Downloads verfolgen, Dateien verwalten oder ein Modell zum Testen laden.","localAsr.modelSelectPlaceholder":"Heruntergeladenes Modell wählen…","localAsr.modelSelectEmpty":"Noch keine heruntergeladenen Modelle. Wähle eines unter „Herunterladen und verwalten“.","localAsr.groupDownload":"Herunterladen und verwalten","localAsr.groupOther":"Weitere","localAsr.mirrorLabel":"Download-Spiegelserver","localAsr.mirrorDesc":"huggingface.co ist die offizielle Quelle. hf-mirror.com ist ein Community-Spiegelserver, der aus Festlandchina oft besser erreichbar ist.","localAsr.mirrorHuggingface":"Offizielles HuggingFace (huggingface.co)","localAsr.mirrorHfMirror":"Spiegelserver für Festlandchina (hf-mirror.com)","localAsr.activeBadge":"In Verwendung","localAsr.downloadedBadge":"Heruntergeladen","localAsr.notDownloadedBadge":"Nicht heruntergeladen","localAsr.download":"Herunterladen","localAsr.resume":"Fortsetzen","localAsr.cancel":"Abbrechen","localAsr.delete":"Löschen","localAsr.setActive":"Als Standard festlegen","localAsr.failed":"Fehlgeschlagen","localAsr.cancelled":"Abgebrochen","localAsr.files":"Dateien","localAsr.sizeLoading":"Größe wird abgerufen…","localAsr.sizeUnknown":"Größe unbekannt","localAsr.performanceWarning":"Lokale ASR eignet sich besonders für Offline-Nutzung oder sensible Daten. Vor der ersten Verwendung muss das Modell heruntergeladen werden.","localAsr.test":"Laden und testen","localAsr.testRunning":"Wird getestet…","localAsr.testHeading":"Integrierter Audiotest","localAsr.testExpected":"Erwartet","localAsr.testActual":"Erkannt","localAsr.testStats":"Audio {{audio}}s · Laden {{load}}s · Transkription {{transcribe}}s · Backend {{backend}}","localAsr.testFailed":"Test fehlgeschlagen","localAsr.engineStatusLabel":"Engine im Arbeitsspeicher","localAsr.engineLoaded":"Geladen: {{model}}","localAsr.engineUnloaded":"Nicht geladen (vor der ersten Transkription muss das Modell geladen werden)","localAsr.loadNow":"Jetzt laden","localAsr.releaseNow":"Jetzt entladen","localAsr.keepLoadedLabel":"Geladen halten für","localAsr.keepLoadedDesc":"Wie lange Qwen3-ASR nach der letzten Verwendung im Arbeitsspeicher bleibt, bevor es entladen wird.","localAsr.keepImmediate":"Sofort entladen","localAsr.keep1min":"1 Minute nach letzter Verwendung","localAsr.keep5min":"5 Minuten nach letzter Verwendung (Standard)","localAsr.keep30min":"30 Minuten nach letzter Verwendung","localAsr.keepForever":"Nie entladen (immer geladen)","localAsr.sidebarTitle":"Heruntergeladen und laufende Downloads","localAsr.activePill":"Aktiv","localAsr.setDefault":"Als Standard festlegen","localAsr.downloading":"Wird heruntergeladen","localAsr.startDownload":"Download starten","localAsr.downloadNewModel":"Neues Modell herunterladen","localAsr.activeModelLabel":"Aktives Modell","localAsr.pickerNoModelDownloaded":"Noch keine Modelle heruntergeladen — lade sie zuerst auf der Seite „Lokale Modelle“.","localAsr.partialDownloadsLabel":"Unvollständige Downloads","localAsr.partialDownloadsDesc":"Abgebrochene Downloads haben temporäre Dateien hinterlassen; bereinige sie ohne die installierten Modelle zu beeinflussen.","localAsr.cleanupIncomplete":"Unvollständigen Download bereinigen","localAsr.languagesLabel":"Sprachen","localAsr.partialBytesLabel":"Restdateien","localAsr.downloadDialogTitle":"Modell herunterladen","localAsr.downloadDialogAlreadyHave":"Die Modelldateien sind heruntergeladen. Kehre zur Modellseite zurück, um es zu laden und zu testen, oder wähle seinen Anbieter unter „ASR-Transkription“.","localAsr.downloadDialogDesc":"Vergleiche Modellgrößen und Beschreibungen und lade das gewünschte Modell herunter. Wähle anschließend den passenden lokalen Dienst unter „Spracherkennung“.","localAsr.detailRepo":"Repository","localAsr.hfDownloads":"Downloads","localAsr.hfLikes":"Gefällt mir","localAsr.hfDescription":"Über das Modell","localAsr.hfNoDescription":"Noch keine Beschreibung","localAsr.hfCardFailed":"Modellinformationen konnten nicht geladen werden","localAsr.detailFiles":"Dateien","localAsr.detailDownloaded":"Heruntergeladen","localAsr.detailEmpty":"Wähle ein Modell aus, um seine Details anzuzeigen","localAsr.foundryLanguage":"Sprache","localAsr.foundryRuntimeSource":"Laufzeitquelle","localAsr.mirrorGithubRelease":"GitHub Releases","localAsr.keep":"Geladen halten","localAsr.downloadSettingsTitle":"Download und Speicher","localAsr.downloadSettingsDesc":"Spiegelserver · Modellspeicherort · Engine im Arbeitsspeicher","localAsr.libraryEmptyTitle":"Noch keine lokalen Modelle","localAsr.libraryEmptyDesc":"Lade ein Spracherkennungsmodell herunter, um Audio auf diesem Gerät zu verarbeiten. Fehlt ein bereits vorhandenes Modell, lade den Katalog neu.","localAsr.catalogTitle":"Modellkatalog","localAsr.catalogEmpty":"Keine Modelle zur Anzeige verfügbar. Lade den Katalog neu und versuche es erneut.","localAsr.reloadCatalog":"Katalog neu laden","localAsr.engineLabel":"Erkennungs-Engine","localAsr.sizeLabel":"Modellgröße","localAsr.allEngines":"Alle","localAsr.backToCatalog":"Zurück zum Katalog","localAsr.detailsTitle":"Modelldetails","localAsr.testActivateHint":"„Laden und testen“ aktiviert dieses Modell und führt anschließend den integrierten Audiotest aus.","localAsr.downloadProgressHint":"Nach dem Start kannst du den Fortschritt auf der Modellseite verfolgen oder den Download abbrechen.","localAsr.errorDetails":"Fehlerdetails"}} 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/packaging/AppRun b/openless-all/app/linux-egui/packaging/AppRun new file mode 100644 index 000000000..473d1cea0 --- /dev/null +++ b/openless-all/app/linux-egui/packaging/AppRun @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=${APPDIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"} +export APPDIR="$ROOT" +export OPENLESS_IME_FONT="$ROOT/usr/lib/openless/resources/fonts/NotoSansCJK-Regular.ttc" +exec "$ROOT/usr/bin/openless" "$@" diff --git a/openless-all/app/linux-egui/packaging/openless-desktop-integration b/openless-all/app/linux-egui/packaging/openless-desktop-integration new file mode 100644 index 000000000..b7b6f3eda --- /dev/null +++ b/openless-all/app/linux-egui/packaging/openless-desktop-integration @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=/usr +if [ -n "${APPDIR:-}" ]; then ROOT="$APPDIR/usr"; fi +exec bash "$ROOT/lib/openless/resources/linux-desktop/install.sh" "$@" diff --git a/openless-all/app/linux-egui/src/atspi.rs b/openless-all/app/linux-egui/src/atspi.rs new file mode 100644 index 000000000..cfd312be3 --- /dev/null +++ b/openless-all/app/linux-egui/src/atspi.rs @@ -0,0 +1,118 @@ +//! AT-SPI fallback uses the accessibility bus and a unique bus owner/object +//! pair. Only the focused text object is read; passwords are never queried. +use crate::context::{platform, TargetSnapshot, CONTEXT_PROTOCOL_VERSION}; +use dbus::blocking::{stdintf::org_freedesktop_dbus::Properties, Connection}; +use openless_core::BackendError; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +const ACCESSIBLE: &str = "org.a11y.atspi.Accessible"; +const TEXT: &str = "org.a11y.atspi.Text"; +const ROOT: &str = "/org/a11y/atspi/accessible/root"; +type Object = (String, dbus::Path<'static>); + +fn connect() -> Result { + let session = Connection::new_session().map_err(platform)?; + let (address,): (String,) = session + .with_proxy("org.a11y.Bus", "/org/a11y/bus", Duration::from_millis(500)) + .method_call("org.a11y.Bus", "GetAddress", ()) + .map_err(platform)?; + let mut channel = dbus::channel::Channel::open_private(&address).map_err(platform)?; + channel.register().map_err(platform)?; + Ok(Connection::from(channel)) +} + +fn focused(connection: &Connection, object: &Object) -> bool { + let states: Result<(Vec,), _> = connection + .with_proxy(&object.0, object.1.clone(), Duration::from_millis(100)) + .method_call(ACCESSIBLE, "GetState", ()); + states.is_ok_and(|(s,)| { + s.first() + .is_some_and(|bits| bits & (1 << 12) != 0 && bits & (1 << 6) == 0) + }) +} + +pub fn snapshot( + expected: Option<&str>, + include_text: bool, +) -> Result { + let connection = connect()?; + let object = if let Some(expected) = expected { + let (owner, path) = expected + .strip_prefix("atspi:") + .and_then(|s| s.split_once('|')) + .ok_or_else(|| platform("invalid AT-SPI identity"))?; + ( + owner.to_string(), + dbus::Path::new(path.to_string()).map_err(platform)?, + ) + } else { + let root = + connection.with_proxy("org.a11y.atspi.Registry", ROOT, Duration::from_millis(200)); + let (apps,): (Vec,) = root + .method_call(ACCESSIBLE, "GetChildren", ()) + .map_err(platform)?; + let mut queue: VecDeque = apps.into(); + let started = Instant::now(); + let mut visited = 0; + let mut found = None; + while let Some(candidate) = queue.pop_front() { + if visited >= 512 || started.elapsed() > Duration::from_secs(2) { + break; + } + visited += 1; + if focused(&connection, &candidate) { + found = Some(candidate); + break; + } + let children: Result<(Vec,), _> = connection + .with_proxy( + &candidate.0, + candidate.1.clone(), + Duration::from_millis(100), + ) + .method_call(ACCESSIBLE, "GetChildren", ()); + if let Ok((children,)) = children { + queue.extend(children.into_iter().take(128)); + } + } + found.ok_or_else(|| platform("no focused AT-SPI object"))? + }; + if !focused(&connection, &object) { + return Err(platform("AT-SPI target lost focus")); + } + let proxy = connection.with_proxy(&object.0, object.1.clone(), Duration::from_millis(300)); + let (role,): (String,) = proxy + .method_call(ACCESSIBLE, "GetRoleName", ()) + .map_err(platform)?; + let sensitive = role.to_ascii_lowercase().contains("password"); + let application = connection + .with_proxy(&object.0, ROOT, Duration::from_millis(100)) + .get::(ACCESSIBLE, "Name") + .unwrap_or_default(); + let mut snapshot = TargetSnapshot { + version: CONTEXT_PROTOCOL_VERSION, + target: format!("atspi:{}|{}", object.0, object.1), + application, + sensitive, + text: None, + cursor: 0, + }; + if include_text && !sensitive { + let count: i32 = proxy.get(TEXT, "CharacterCount").map_err(platform)?; + if !(0..=16384).contains(&count) { + return Err(platform("AT-SPI document exceeds observation limit")); + } + let cursor: i32 = proxy.get(TEXT, "CaretOffset").map_err(platform)?; + let (text,): (String,) = proxy + .method_call(TEXT, "GetText", (0_i32, count)) + .map_err(platform)?; + snapshot.text = Some(text); + snapshot.cursor = cursor.max(0) as usize; + } + if !focused(&connection, &object) { + return Err(platform("AT-SPI target changed during capture")); + } + crate::context::validate_snapshot(&snapshot, expected, include_text)?; + Ok(snapshot) +} 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..c19d505b4 --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_mute.rs @@ -0,0 +1,192 @@ +//! Restore the output that was muted at activation, even if the default changes. +use std::sync::Arc; + +trait OutputControl: Send + Sync { + fn current(&self) -> Result<(String, bool), String>; + fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String>; +} + +pub struct AudioMuteGuard { + inner: Option<(Arc, String, bool)>, +} +impl std::fmt::Debug for AudioMuteGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AudioMuteGuard") + .field("active", &self.inner.is_some()) + .finish() + } +} +impl AudioMuteGuard { + pub fn activate() -> Result { + Self::with_control(Arc::new(NativeOutput)) + } + fn with_control(control: Arc) -> Result { + let (sink, was_muted) = control.current()?; + let guard = Self { + inner: Some((control.clone(), sink.clone(), was_muted)), + }; + if !was_muted { + control.set_muted(&sink, true)?; + } + Ok(guard) + } + pub fn none() -> Self { + Self { inner: None } + } +} +impl Drop for AudioMuteGuard { + fn drop(&mut self) { + if let Some((control, sink, was_muted)) = self.inner.take() { + if let Err(error) = control.set_muted(&sink, was_muted) { + log::warn!("restore recording output mute: {error}"); + } + } + } +} +pub fn parse_wpctl_muted(output: &str) -> bool { + output.contains("[MUTED]") +} +pub fn parse_pactl_muted(output: &str) -> bool { + output.to_ascii_lowercase().contains("yes") || output.contains('是') +} +struct NativeOutput; +#[cfg(target_os = "linux")] +fn command(program: &str, args: &[&str]) -> Result { + let output = std::process::Command::new("timeout") + .args(["--signal=KILL", "2s", program]) + .args(args) + .env("LC_ALL", "C") + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "{program}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).map_err(|e| e.to_string()) +} +#[cfg(target_os = "linux")] +impl OutputControl for NativeOutput { + fn current(&self) -> Result<(String, bool), String> { + // PipeWire's PulseAudio compatibility service uses the same stable sink + // names, which also work on the GNOME 42 PulseAudio baseline. + if let Ok(sink) = command("pactl", &["get-default-sink"]) { + let sink = sink.trim(); + if !sink.is_empty() { + let state = command("pactl", &["get-sink-mute", sink])?; + return Ok((format!("pulse:{sink}"), parse_pactl_muted(&state))); + } + } + let object = command("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"])?; + let id = object + .trim() + .strip_prefix("id ") + .and_then(|s| s.split(',').next()) + .filter(|s| s.bytes().all(|b| b.is_ascii_digit())) + .ok_or("cannot identify the original output sink")?; + let state = command("wpctl", &["get-volume", id])?; + Ok((format!("pipewire:{id}"), parse_wpctl_muted(&state))) + } + fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + if let Some(name) = sink.strip_prefix("pulse:") { + command("pactl", &["set-sink-mute", name, value])?; + } else if let Some(id) = sink.strip_prefix("pipewire:") { + command("wpctl", &["set-mute", id, value])?; + } else { + return Err("invalid output identity".into()); + } + Ok(()) + } +} +#[cfg(not(target_os = "linux"))] +impl OutputControl for NativeOutput { + fn current(&self) -> Result<(String, bool), String> { + Err("Linux audio unavailable".into()) + } + fn set_muted(&self, _: &str, _: bool) -> Result<(), String> { + Err("Linux audio unavailable".into()) + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + struct Output { + default: Mutex, + calls: Mutex>, + muted: bool, + } + impl OutputControl for Output { + fn current(&self) -> Result<(String, bool), String> { + Ok((self.default.lock().unwrap().clone(), self.muted)) + } + fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String> { + self.calls.lock().unwrap().push((sink.into(), muted)); + Ok(()) + } + } + #[test] + fn restore_original_sink_on_stop_cancel_and_unwind() { + for failure in [false, true] { + let output = Arc::new(Output { + default: Mutex::new("speakers".into()), + calls: Mutex::default(), + muted: false, + }); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _recording = AudioMuteGuard::with_control(output.clone()).unwrap(); + *output.default.lock().unwrap() = "headphones".into(); + if failure { + panic!("recording failed"); + } + })); + assert_eq!(result.is_err(), failure); + assert_eq!( + *output.calls.lock().unwrap(), + vec![("speakers".into(), true), ("speakers".into(), false)] + ); + } + } + #[test] + fn already_muted_output_stays_muted() { + let output = Arc::new(Output { + default: Mutex::new("speakers".into()), + calls: Mutex::default(), + muted: true, + }); + drop(AudioMuteGuard::with_control(output.clone()).unwrap()); + assert_eq!( + *output.calls.lock().unwrap(), + vec![("speakers".into(), true)] + ); + } + #[test] + fn partially_applied_mute_is_restored_when_activation_reports_failure() { + struct FailingOutput(Mutex>); + impl OutputControl for FailingOutput { + fn current(&self) -> Result<(String, bool), String> { + Ok(("speakers".into(), false)) + } + fn set_muted(&self, _: &str, muted: bool) -> Result<(), String> { + self.0.lock().unwrap().push(muted); + if muted { + Err("server disconnected after applying mute".into()) + } else { + Ok(()) + } + } + } + let output = Arc::new(FailingOutput(Mutex::default())); + assert!(AudioMuteGuard::with_control(output.clone()).is_err()); + assert_eq!(*output.0.lock().unwrap(), vec![true, false]); + } + #[test] + fn native_state_parsers() { + assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]")); + assert!(!parse_wpctl_muted("Volume: 0.82")); + assert!(parse_pactl_muted("Mute: yes")); + assert!(!parse_pactl_muted("Mute: no")); + } +} diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 7c1ff6a7f..bf52fbcc5 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -756,6 +756,9 @@ impl LinuxBackendBuilder { )); let mut services = BackendServices::unsupported(); + let context = Arc::new(crate::context::LinuxContextAdapter::default()); + services.host_context = context.clone(); + services.edit_observation = context; if let Ok(model_config) = ModelStoreConfig::new( linux_local_runtime .root @@ -876,9 +879,12 @@ 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 = self.recorder.unwrap_or_else(|| { + Arc::new(LinuxCpalRecorder::with_recordings_dir( + None, + self.config.data_dir.join("recordings"), + )) as Arc + }); let recorder: Arc = Arc::new(openless_core::AudioRecorderRouter::new( recorder, openless_core::ExternalAudioRecorder::with_recordings_directory( @@ -902,7 +908,13 @@ impl LinuxBackendBuilder { } }; let settings_runtime = self.settings_runtime.unwrap_or(default_settings_runtime); - let mut services = self.services.unwrap_or_else(BackendServices::unsupported); + let mut services = self.services.unwrap_or_else(|| { + let mut services = BackendServices::unsupported(); + let context = Arc::new(crate::context::LinuxContextAdapter::default()); + services.host_context = context.clone(); + services.edit_observation = context; + services + }); services.platform = Arc::new(LinuxPlatformApi::new(self.config.platform.clone())); let host_actions = self .host_actions @@ -1449,7 +1461,7 @@ mod tests { ], None, Arc::new(std::sync::atomic::AtomicBool::new(false)), - std::time::Duration::from_millis(100), + std::time::Duration::from_secs(2), ) .await .unwrap_err(); diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index ca80cd467..15fdf6ffb 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -192,12 +192,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 +205,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()), diff --git a/openless-all/app/linux-egui/src/coding_agent.rs b/openless-all/app/linux-egui/src/coding_agent.rs index e2c71250d..9de3f13f3 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, @@ -130,6 +124,13 @@ fn kill_process_group_with_id( child.start_kill().map_err(platform_error) } +pub(crate) fn kill_process_group( + child: &mut tokio::process::Child, +) -> Result<(), openless_core::BackendError> { + let id = child.id(); + kill_process_group_with_id(child, id) +} + impl CodingAgentProcessAdapter for LinuxCodingAgentProcessAdapter { fn execute( &self, @@ -336,14 +337,22 @@ mod tests { 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) - { + let alive = || { + 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) + }; + // SIGKILL delivery to grandchildren can finish after wait() reaps + // the group leader. Wait for that kernel transition, boundedly. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while alive() && tokio::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + if alive() { running.push(pid.to_owned()); // Only fixture PIDs read from our private ready file are killed. unsafe { diff --git a/openless-all/app/linux-egui/src/context.rs b/openless-all/app/linux-egui/src/context.rs new file mode 100644 index 000000000..acb3060fc --- /dev/null +++ b/openless-all/app/linux-egui/src/context.rs @@ -0,0 +1,298 @@ +//! Privacy-gated context and edit observations, bound to the original input +//! identity. Neither a late worker nor a newly focused field can replace it. +use futures_util::future::BoxFuture; +use openless_core::{ + BackendError, BackendErrorCode, EditObservationAdapter, EditObservationSink, + HostContextAdapter, HostContextCapture, +}; +use serde::{Deserialize, Serialize}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, +}; + +pub const CONTEXT_PROTOCOL_VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetSnapshot { + pub version: u32, + pub target: String, + #[serde(default)] + pub application: String, + #[serde(default)] + pub sensitive: bool, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub cursor: usize, +} + +pub trait ContextReader: Send + Sync { + fn read( + &self, + expected: Option<&str>, + include_text: bool, + ) -> Result; +} + +pub struct NativeContextReader; +impl ContextReader for NativeContextReader { + fn read( + &self, + expected: Option<&str>, + include_text: bool, + ) -> Result { + #[cfg(target_os = "linux")] + { + if expected.is_some_and(|s| s.starts_with("atspi:")) { + return crate::atspi::snapshot(expected, include_text); + } + let result = (|| { + let connection = dbus::blocking::Connection::new_session().map_err(platform)?; + let proxy = connection.with_proxy( + crate::fcitx5::DESTINATION, + crate::fcitx5::OBJECT_PATH, + std::time::Duration::from_millis(500), + ); + let (json,): (String,) = proxy + .method_call( + crate::fcitx5::INTERFACE, + "ContextSnapshot", + (expected.unwrap_or_default(), include_text), + ) + .map_err(platform)?; + let snapshot: TargetSnapshot = serde_json::from_str(&json).map_err(platform)?; + validate_snapshot(&snapshot, expected, include_text)?; + Ok(snapshot) + })(); + // Once a native target was bound, never switch bridge or application. + if expected.is_none() + && result.as_ref().map_or(true, |s: &TargetSnapshot| { + include_text && s.text.is_none() && !s.sensitive + }) + { + if let Ok(snapshot) = crate::atspi::snapshot(None, include_text) { + return Ok(snapshot); + } + } + result + } + #[cfg(not(target_os = "linux"))] + { + let _ = (expected, include_text); + Err(platform("Linux context capture unavailable")) + } + } +} + +pub fn validate_snapshot( + snapshot: &TargetSnapshot, + expected: Option<&str>, + include_text: bool, +) -> Result<(), BackendError> { + if snapshot.version != CONTEXT_PROTOCOL_VERSION + || snapshot.target.is_empty() + || expected.is_some_and(|e| e != snapshot.target) + { + return Err(platform( + "context target expired or bridge version mismatch", + )); + } + if (snapshot.sensitive || !include_text) && snapshot.text.is_some() { + return Err(platform("context bridge returned text without permission")); + } + if snapshot.text.as_ref().is_some_and(|s| s.len() > 65536) { + return Err(platform("context exceeds capture limit")); + } + Ok(()) +} + +pub(crate) fn platform(error: impl std::fmt::Display) -> BackendError { + BackendError::new(BackendErrorCode::Platform, error.to_string()) +} + +#[derive(Clone)] +pub struct LinuxContextAdapter { + reader: Arc, + target: Arc>>, + generation: Arc, +} +impl Default for LinuxContextAdapter { + fn default() -> Self { + Self::new(Arc::new(NativeContextReader)) + } +} +impl LinuxContextAdapter { + pub fn new(reader: Arc) -> Self { + Self { + reader, + target: Arc::default(), + generation: Arc::default(), + } + } +} +impl HostContextAdapter for LinuxContextAdapter { + fn capture( + &self, + include_cursor: bool, + ) -> BoxFuture<'static, Result> { + let this = self.clone(); + let generation = { + let mut target = this.target.lock().unwrap_or_else(|p| p.into_inner()); + let generation = this.generation.fetch_add(1, Ordering::SeqCst) + 1; + *target = None; + generation + }; + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let snapshot = match this.reader.read(None, include_cursor) { + Ok(snapshot) => snapshot, + // Applications may have neither IME surrounding text nor + // accessibility support. Dictation itself remains usable. + Err(_) => return Ok(HostContextCapture::default()), + }; + validate_snapshot(&snapshot, None, include_cursor)?; + let mut target = this.target.lock().unwrap_or_else(|p| p.into_inner()); + if this.generation.load(Ordering::SeqCst) != generation { + return Ok(HostContextCapture::default()); + } + *target = Some(snapshot.target); + Ok(HostContextCapture { + front_app: (!snapshot.application.is_empty()).then_some(snapshot.application), + cursor_context: snapshot.text, + }) + }) + .await + .map_err(platform)? + }) + } +} +impl EditObservationAdapter for LinuxContextAdapter { + fn arm( + &self, + typed_text: String, + sink: Arc, + ) -> Result<(), BackendError> { + let (generation, target) = { + let target = self.target.lock().unwrap_or_else(|p| p.into_inner()); + ( + self.generation.fetch_add(1, Ordering::SeqCst) + 1, + target.clone(), + ) + }; + let Some(target) = target else { + return Ok(()); + }; + let this = self.clone(); + std::thread::Builder::new() + .name("openless-edit-observer".into()) + .spawn(move || { + let started = std::time::Instant::now(); + let mut baseline: Option = None; + while started.elapsed() < std::time::Duration::from_secs(90) + && this.generation.load(Ordering::SeqCst) == generation + { + std::thread::sleep(std::time::Duration::from_millis(300)); + let Ok(snapshot) = this.reader.read(Some(&target), true) else { + break; + }; + if validate_snapshot(&snapshot, Some(&target), true).is_err() + || snapshot.sensitive + { + break; + } + let Some(text) = snapshot.text else { + break; + }; + if this.generation.load(Ordering::SeqCst) != generation { + break; + } + match baseline.as_ref() { + None if text.contains(&typed_text) && !typed_text.is_empty() => { + baseline = Some(text) + } + Some(before) if before != &text => { + if let Some(edit) = + openless_core::host_document::minimal_edit(before, &text) + { + if sink.publish(edit) { + baseline = Some(text); + } + } + } + _ => (), + } + } + }) + .map_err(platform)?; + Ok(()) + } + fn disarm(&self) { + let _guard = self.target.lock().unwrap_or_else(|p| p.into_inner()); + self.generation.fetch_add(1, Ordering::SeqCst); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn late_capture_cannot_replace_a_newer_target() { + struct Reader { + count: AtomicU64, + started: std::sync::mpsc::Sender<()>, + release: Mutex>, + } + impl ContextReader for Reader { + fn read(&self, _: Option<&str>, _: bool) -> Result { + let old = self.count.fetch_add(1, Ordering::SeqCst) == 0; + if old { + self.started.send(()).unwrap(); + self.release.lock().unwrap().recv().unwrap(); + } + let name = if old { "old" } else { "new" }; + Ok(TargetSnapshot { + version: 1, + target: name.into(), + application: name.into(), + ..Default::default() + }) + } + } + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let adapter = LinuxContextAdapter::new(Arc::new(Reader { + count: AtomicU64::new(0), + started: started_tx, + release: Mutex::new(release_rx), + })); + let first = tokio::spawn(adapter.capture(false)); + tokio::task::spawn_blocking(move || started_rx.recv().unwrap()) + .await + .unwrap(); + assert_eq!( + adapter.capture(false).await.unwrap().front_app.as_deref(), + Some("new") + ); + release_tx.send(()).unwrap(); + assert!(first.await.unwrap().unwrap().front_app.is_none()); + assert_eq!(adapter.target.lock().unwrap().as_deref(), Some("new")); + } + #[test] + fn mismatched_target_version_and_privacy_are_rejected() { + let mut s = TargetSnapshot { + version: 1, + target: "original".into(), + ..Default::default() + }; + assert!(validate_snapshot(&s, Some("new-target"), true).is_err()); + s.version = 2; + assert!(validate_snapshot(&s, None, true).is_err()); + s.version = 1; + s.text = Some("private".into()); + assert!(validate_snapshot(&s, None, false).is_err()); + s.sensitive = true; + assert!(validate_snapshot(&s, None, true).is_err()); + } +} diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 0f506728e..fd39def51 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -447,9 +447,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/design_tokens.rs b/openless-all/app/linux-egui/src/design_tokens.rs new file mode 100644 index 000000000..cc2431073 --- /dev/null +++ b/openless-all/app/linux-egui/src/design_tokens.rs @@ -0,0 +1,230 @@ +//! 2.0 设计令牌(tokens.css 的 Rust 对照表)。 +//! +//! 本模块是纯数据:不依赖 egui,任何目标都能编译与单测。egui 侧的 +//! 换算(Color32 / Style / Visuals)在 `ui` 模块,仅 Linux 编译。 +//! +//! 数值必须与 `src/styles/tokens.css` 一一对应;修改任何一侧都要同步 +//! 另一侧并通过本模块的对照测试。CSS 十六进制按 `0xRRGGBB` 转录, +//! 省略 alpha 的透明色按实际叠加效果取实色近似并在字段注释里标明。 + +/// 圆角阶梯(tokens.css `--ol-r-*`)。egui 0.31 的 `CornerRadius` 以 u8 计, +/// 这里直接存 u8;需要 f32 的场合由使用方转换。 +pub mod radius { + /// `--ol-r-sm: 6px`(控件内小组件) + pub const SM: u8 = 6; + /// `--ol-control-radius: 8px`(按钮、导航项) + pub const CONTROL: u8 = 8; + /// `--ol-r-md: 10px` + pub const MD: u8 = 10; + /// `--ol-r-lg` / `--ol-card-radius: 14px`(卡片、设置弹窗) + pub const CARD: u8 = 14; + /// `--ol-panel-radius` / `--ol-r-xl: 18px`(浮层面板) + pub const PANEL: u8 = 18; + /// `--ol-shell-radius: 32px`(窗口外壳;Linux 由 WM 裁剪,这里供自定义绘制参考) + pub const SHELL: u8 = 32; +} + +/// 侧栏宽度:FloatingShell `SIDEBAR_WIDTH = 226`。 +pub const SIDEBAR_WIDTH: f32 = 226.0; +/// 设置弹窗最大宽度:SettingsModal `maxWidth: 960`。 +pub const SETTINGS_MAX_WIDTH: f32 = 960.0; +/// 设置弹窗最大高度:SettingsModal `maxHeight: 680`。 +pub const SETTINGS_MAX_HEIGHT: f32 = 680.0; + +/// 一个主题的全部颜色令牌。字段名与 tokens.css 的 `--ol-*` 变量对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ThemeTokens { + /// `--ol-canvas` + pub canvas: u32, + /// `--ol-surface`(内容底;2.0 为纯白) + pub surface: u32, + /// `--ol-surface-2`(次级底 / 导航激活底) + pub surface_2: u32, + /// `--ol-line` + pub line: u32, + /// `--ol-line-strong` + pub line_strong: u32, + /// `--ol-line-soft` + pub line_soft: u32, + /// `--ol-ink`(主文字) + pub ink: u32, + /// `--ol-ink-2` + pub ink_2: u32, + /// `--ol-ink-3`(次级文字 / 导航未选中) + pub ink_3: u32, + /// `--ol-ink-4`(占位 / 弱化文字) + pub ink_4: u32, + /// `--ol-ink-5` + pub ink_5: u32, + /// `--ol-blue`(强调色) + pub blue: u32, + /// `--ol-blue-hover` + pub blue_hover: u32, + /// `--ol-blue-soft`(蓝色软底;暗色主题是 alpha 色,这里取实色近似) + pub blue_soft: u32, + /// `--ol-on-accent` + pub on_accent: u32, + /// `--ol-ok` + pub ok: u32, + /// `--ol-ok-soft`(暗色主题为 alpha 色,取实色近似) + pub ok_soft: u32, + /// `--ol-warn` + pub warn: u32, + /// `--ol-warn-soft`(暗色主题为 alpha 色,取实色近似) + pub warn_soft: u32, + /// `--ol-err` + pub err: u32, + /// `--ol-sidebar-bg` + pub sidebar_bg: u32, + /// `--ol-settings-rail-bg` + pub settings_rail_bg: u32, + /// `--ol-settings-content-bg` + pub settings_content_bg: u32, + /// `--ol-pill-selected-bg`(导航 / 分段选中深色胶囊;暗色主题为蓝色渐变,取上端色) + pub pill_selected_bg: u32, + /// `--ol-pill-selected-ink` + pub pill_selected_ink: u32, + /// `--ol-segmented-bg`(alpha 色,取实色近似) + pub segmented_bg: u32, + /// `--ol-segmented-active-bg` + pub segmented_active_bg: u32, + /// `--ol-overlay-bg`(设置弹窗遮罩;alpha 在 ui 层叠加,这里存 RGB) + pub overlay_rgb: u32, +} + +/// 浅色主题(tokens.css `:root`,2.0 默认)。 +pub const LIGHT: ThemeTokens = ThemeTokens { + canvas: 0xFFFFFF, + surface: 0xFFFFFF, + surface_2: 0xF4F4F5, + line: 0xE4E4E7, + line_strong: 0xD4D4D8, + line_soft: 0xF4F4F5, + ink: 0x09090B, + ink_2: 0x3F3F46, + ink_3: 0x71717A, + ink_4: 0xA1A1AA, + ink_5: 0xD4D4D8, + blue: 0x2563EB, + blue_hover: 0x1D4ED8, + blue_soft: 0xEFF4FF, + on_accent: 0xFFFFFF, + ok: 0x16A34A, + ok_soft: 0xECFDF5, + warn: 0xD97706, + warn_soft: 0xFFF7ED, + err: 0xDC2626, + sidebar_bg: 0xF0F0F1, + settings_rail_bg: 0xF0F0F1, + settings_content_bg: 0xF7F7F8, + pill_selected_bg: 0x18181B, + pill_selected_ink: 0xFFFFFF, + segmented_bg: 0xF4F4F5, + segmented_active_bg: 0xFFFFFF, + overlay_rgb: 0x0F1116, +}; + +/// 深色主题(tokens.css `[data-ol-theme='dark']`)。 +pub const DARK: ThemeTokens = ThemeTokens { + canvas: 0x0C0C0E, + surface: 0x1C1C1F, + surface_2: 0x2A2A2E, + line: 0x27272A, + line_strong: 0x3F3F46, + line_soft: 0x1F1F23, + ink: 0xFAFAFA, + ink_2: 0xD4D4D8, + ink_3: 0xA1A1AA, + ink_4: 0x71717A, + ink_5: 0x3F3F46, + blue: 0x74B7FF, + blue_hover: 0x93C5FD, + // rgba(116,183,255,0.16) 叠加 surface 的实色近似。 + blue_soft: 0x2A3A4E, + on_accent: 0xF8FBFF, + ok: 0x4ADE80, + // rgba(74,222,128,0.14) 叠加 surface 的实色近似。 + ok_soft: 0x223529, + warn: 0xF59E0B, + // rgba(245,158,11,0.14) 叠加 surface 的实色近似。 + warn_soft: 0x3A2E1C, + err: 0xF87171, + sidebar_bg: 0x141417, + settings_rail_bg: 0x141417, + settings_content_bg: 0x18181B, + // 暗色选中是蓝色渐变 3b82f6→2563eb,取上端。 + pill_selected_bg: 0x3B82F6, + pill_selected_ink: 0xF4F7FB, + // rgba(226,232,240,0.07) 叠加 surface 的实色近似。 + segmented_bg: 0x26262A, + segmented_active_bg: 0x27272A, + overlay_rgb: 0x05080D, +}; + +/// 按主题取令牌。 +pub const fn tokens(dark: bool) -> &'static ThemeTokens { + if dark { + &DARK + } else { + &LIGHT + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 关键令牌必须与 tokens.css 字面值一致:这组断言是两个 UI 之间的对照合同。 + #[test] + fn light_tokens_match_tokens_css() { + assert_eq!(LIGHT.surface, 0xFFFFFF); + assert_eq!(LIGHT.surface_2, 0xF4F4F5); + assert_eq!(LIGHT.line, 0xE4E4E7); + assert_eq!(LIGHT.ink, 0x09090B); + assert_eq!(LIGHT.ink_3, 0x71717A); + assert_eq!(LIGHT.blue, 0x2563EB); + assert_eq!(LIGHT.blue_hover, 0x1D4ED8); + assert_eq!(LIGHT.blue_soft, 0xEFF4FF); + assert_eq!(LIGHT.ok, 0x16A34A); + assert_eq!(LIGHT.warn, 0xD97706); + assert_eq!(LIGHT.err, 0xDC2626); + assert_eq!(LIGHT.sidebar_bg, 0xF0F0F1); + assert_eq!(LIGHT.settings_content_bg, 0xF7F7F8); + assert_eq!(LIGHT.pill_selected_bg, 0x18181B); + } + + #[test] + fn dark_tokens_match_tokens_css() { + assert_eq!(DARK.canvas, 0x0C0C0E); + assert_eq!(DARK.surface, 0x1C1C1F); + assert_eq!(DARK.line, 0x27272A); + assert_eq!(DARK.ink, 0xFAFAFA); + assert_eq!(DARK.blue, 0x74B7FF); + assert_eq!(DARK.ok, 0x4ADE80); + assert_eq!(DARK.err, 0xF87171); + assert_eq!(DARK.sidebar_bg, 0x141417); + // 暗色选中胶囊是蓝色渐变(3b82f6→2563eb),ui 层取上端色。 + assert_eq!(DARK.pill_selected_bg, 0x3B82F6); + } + + /// 明暗两套令牌都自洽:文字色和底色有足够对比(不与底色撞色)。 + #[test] + fn themes_keep_ink_distinct_from_surfaces() { + for theme in [&LIGHT, &DARK] { + assert_ne!(theme.ink, theme.surface); + assert_ne!(theme.ink, theme.surface_2); + assert_ne!(theme.line, theme.surface); + assert_ne!(theme.blue, theme.surface); + } + } + + #[test] + fn radii_match_tokens_css_scale() { + assert_eq!(radius::SM, 6); + assert_eq!(radius::CONTROL, 8); + assert_eq!(radius::CARD, 14); + assert_eq!(radius::PANEL, 18); + assert_eq!(radius::SHELL, 32); + assert_eq!(SIDEBAR_WIDTH, 226.0); + } +} 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/desktop_bridge.rs b/openless-all/app/linux-egui/src/desktop_bridge.rs new file mode 100644 index 000000000..fb4c12977 --- /dev/null +++ b/openless-all/app/linux-egui/src/desktop_bridge.rs @@ -0,0 +1,491 @@ +//! Desktop bridge v1: screen coordinates are logical pixels, targets are +//! opaque, and hotkeys are installed as a transaction before preferences commit. +use crate::context::platform; +use openless_core::{BackendError, HotkeyRuntimeTarget}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex, OnceLock}; + +pub const DESKTOP_PROTOCOL_VERSION: u32 = 1; +pub const BUS_NAME: &str = "org.openless.Desktop1"; +pub const BUS_PATH: &str = "/org/openless/Desktop1"; + +pub fn manage_component(mode: &str) -> Result { + if !["install", "enable", "uninstall"].contains(&mode) { + return Err(platform("invalid desktop component action")); + } + let layout = crate::LinuxResourceLayout::detect( + std::env::var_os("OPENLESS_LINUX_RESOURCES").map(std::path::PathBuf::from), + )?; + let packaged = layout.resource_root.join("linux-desktop/install.sh"); + let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../scripts/linux-desktop/install.sh"); + let script = if packaged.is_file() { + packaged + } else { + development + }; + if !script.is_file() { + return Err(platform("桌面组件缺失,请重新安装 Linux 安装包")); + } + let output = std::process::Command::new("timeout") + .args(["30s", "bash"]) + .arg(script) + .arg(mode) + .output() + .map_err(platform)?; + let message = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if output.status.success() { + Ok(message.trim().into()) + } else { + Err(platform(message.trim())) + } +} + +static TARGETS: OnceLock>> = + OnceLock::new(); +static LAST_SCREEN: OnceLock>> = OnceLock::new(); +fn targets() -> &'static Mutex> { + TARGETS.get_or_init(Mutex::default) +} +pub(crate) fn remember_focus(ticket: &str) { + if let Some(snapshot) = adapter().and_then(|a| a.snapshot().ok()) { + *LAST_SCREEN.get_or_init(Mutex::default).lock().unwrap() = Some(snapshot.clone()); + let mut targets = targets().lock().unwrap(); + if targets.len() >= 128 { + targets.clear(); + } + targets.insert(ticket.into(), snapshot); + } +} +pub(crate) fn rekey_focus(from: &str, to: &str) { + let mut targets = targets().lock().unwrap(); + if let Some(snapshot) = targets.remove(from) { + targets.insert(to.into(), snapshot); + } +} +pub(crate) fn forget_focus(ticket: &str) { + targets().lock().unwrap().remove(ticket); +} +pub(crate) fn restore_bound_focus(ticket: &str) -> Result<(), BackendError> { + let target = targets().lock().unwrap().get(ticket).cloned(); + if let Some(target) = target { + let adapter = adapter().ok_or_else(|| platform("桌面组件暂时不可用"))?; + adapter.restore_focus(&target.target)?; + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_millis(700) { + if adapter.snapshot().is_ok_and(|s| s.target == target.target) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + return Err(platform("无法恢复原目标窗口")); + } + Ok(()) // fcitx5 still verifies the original focused input context. +} +pub fn place_popup(title: &'static str, width: i32, height: i32, capsule: bool) { + let screen = LAST_SCREEN + .get_or_init(Mutex::default) + .lock() + .unwrap() + .clone(); + let Some(screen) = screen else { + return; + }; + let _ = std::thread::Builder::new() + .name("openless-popup-placement".into()) + .spawn(move || { + let x = screen.x + ((screen.width as i32 - width) / 2).max(0); + let y = screen.y + + if capsule { + (screen.height as i32 - height - 28).max(0) + } else { + ((screen.height as i32 - height) / 2).max(0) + }; + for _ in 0..12 { + std::thread::sleep(std::time::Duration::from_millis(80)); + if adapter().is_some_and(|a| a.place(title, x, y).is_ok()) { + break; + } + } + }); +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSnapshot { + pub version: u32, + pub target: String, + pub application: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + #[serde(default = "one")] + pub scale: f64, +} +fn one() -> f64 { + 1.0 +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopBinding { + pub action: String, + pub symbol: u32, + pub states: u32, + pub accelerator: String, +} + +pub trait DesktopAdapter: Send + Sync { + fn snapshot(&self) -> Result; + fn bind(&self, bindings: &[DesktopBinding]) -> Result<(), BackendError>; + fn restore_focus(&self, target: &str) -> Result<(), BackendError>; + fn place(&self, title: &str, x: i32, y: i32) -> Result<(), BackendError>; + fn drain(&self) -> Vec; +} + +#[derive(Default)] +struct BridgeState { + adapter: Option>, + bindings: Option>, + owner: Option, +} +static ADAPTER: OnceLock>> = OnceLock::new(); +static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static DISCONNECTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub fn take_disconnected() -> bool { + DISCONNECTED.swap(false, std::sync::atomic::Ordering::AcqRel) +} +pub fn active() -> bool { + ACTIVE.load(std::sync::atomic::Ordering::Acquire) +} +pub fn bind_target(target: &HotkeyRuntimeTarget) -> Result<(), BackendError> { + let bindings = bindings(target)?; + let mut state = registry().lock().unwrap_or_else(|p| p.into_inner()); + if let Some(adapter) = &state.adapter { + adapter.bind(&bindings)?; + ACTIVE.store(true, std::sync::atomic::Ordering::Release); + } + state.bindings = Some(bindings); + Ok(()) +} +fn registry() -> &'static Arc> { + ADAPTER.get_or_init(|| { + let state = Arc::new(Mutex::new(BridgeState::default())); + refresh(&state); + let shared = state.clone(); + std::thread::Builder::new() + .name("openless-desktop-recovery".into()) + .spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + refresh(&shared); + }) + .expect("create desktop recovery worker"); + state + }) +} +pub fn adapter() -> Option> { + registry() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .adapter + .clone() +} +fn refresh(shared: &Mutex) { + #[cfg(target_os = "linux")] + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); + #[cfg(target_os = "linux")] + let owner = if wayland { + desktop_owner() + } else { + Some("x11".into()) + }; + let mut state = shared.lock().unwrap_or_else(|p| p.into_inner()); + #[cfg(target_os = "linux")] + { + if state.owner != owner { + if ACTIVE.swap(false, std::sync::atomic::Ordering::AcqRel) { + DISCONNECTED.store(true, std::sync::atomic::Ordering::Release); + } + state.adapter = None; + state.owner = owner.clone(); + } + if state.adapter.is_some() || owner.is_none() { + return; + } + let detected = { + if wayland { + WaylandDesktop::start() + .map(|a| Arc::new(a) as Arc) + .ok() + } else { + crate::x11_desktop::X11Desktop::start() + .map(|a| Arc::new(a) as Arc) + .ok() + } + }; + if let Some(adapter) = detected { + if let Some(bindings) = &state.bindings { + if adapter.bind(bindings).is_err() { + return; + } + ACTIVE.store(true, std::sync::atomic::Ordering::Release); + } + state.adapter = Some(adapter); + } + } + #[cfg(not(target_os = "linux"))] + let _ = &mut state; +} +#[cfg(target_os = "linux")] +fn desktop_owner() -> Option { + let connection = dbus::blocking::Connection::new_session().ok()?; + let result: Result<(String,), _> = connection + .with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + std::time::Duration::from_millis(500), + ) + .method_call("org.freedesktop.DBus", "GetNameOwner", (BUS_NAME,)); + result.ok().map(|value| value.0) +} + +fn accelerator(symbol: u32, states: u32) -> String { + let mut result = String::new(); + for (bit, name) in [ + (4, ""), + (1, ""), + (8, ""), + (64, ""), + ] { + if states & bit != 0 { + result.push_str(name); + } + } + // GDK accepts X11 keysym hexadecimal notation as well as named keys. + let name = match symbol { + 0xffe1 => "Shift_L", + 0xffe2 => "Shift_R", + 0xffe3 => "Control_L", + 0xffe4 => "Control_R", + 0xffe9 => "Alt_L", + 0xffea => "Alt_R", + 0xffeb => "Super_L", + 0xffec => "Super_R", + 0xff0d => "Return", + 0xff09 => "Tab", + 0xff1b => "Escape", + 0x20 => "space", + 0xff08 => "BackSpace", + 0xffff => "Delete", + 0xff50 => "Home", + 0xff57 => "End", + 0xff55 => "Page_Up", + 0xff56 => "Page_Down", + 0xff52 => "Up", + 0xff54 => "Down", + 0xff51 => "Left", + 0xff53 => "Right", + _ => "", + }; + if !name.is_empty() { + result.push_str(name); + } else if (0xffbe..=0xffd5).contains(&symbol) { + result.push_str(&format!("F{}", symbol - 0xffbe + 1)); + } else if (0x21..=0x7e).contains(&symbol) { + result.push(char::from_u32(symbol).unwrap()); + } else { + result.push_str(&format!("0x{symbol:x}")); + } + result +} + +pub fn bindings(target: &HotkeyRuntimeTarget) -> Result, BackendError> { + let mut result = Vec::new(); + let items = [ + ("DictationKeyEvent", Some(&target.dictation)), + ("QaShortcutEvent", target.qa.as_ref()), + ("SelectionPolishEvent", target.selection_polish.as_ref()), + ("TranslationModifierEvent", Some(&target.translation)), + ("OpenAppEvent", target.open_app.as_ref()), + ( + "LessComputerPanelEvent", + target + .coding_agent_panel + .as_ref() + .filter(|_| target.coding_agent_enabled), + ), + ( + "LessComputerQuickEvent", + target + .coding_agent_quick + .as_ref() + .filter(|_| target.coding_agent_enabled), + ), + ("SwitchStyleEvent", target.switch_style.as_ref()), + ( + "LessComputerKeyEvent", + target + .coding_agent_voice + .as_ref() + .filter(|_| target.coding_agent_enabled), + ), + ]; + for (action, binding) in items { + if let Some(binding) = binding { + let (symbol, states) = crate::settings::shortcut_to_raw(binding)?; + result.push(DesktopBinding { + action: action.into(), + symbol, + states, + accelerator: accelerator(symbol, states), + }); + } + } + for hotkey in &target.style_packs { + let (symbol, states) = crate::settings::shortcut_to_raw(&hotkey.binding)?; + result.push(DesktopBinding { + action: "StylePackHotkeyEvent".into(), + symbol, + states, + accelerator: accelerator(symbol, states), + }); + } + for (index, binding) in result.iter().enumerate() { + if result[..index] + .iter() + .any(|b| b.symbol == binding.symbol && b.states == binding.states) + { + return Err(platform(format!("快捷键冲突:{}", binding.accelerator))); + } + } + Ok(result) +} + +#[cfg(target_os = "linux")] +struct WaylandDesktop { + events: Arc>>, + stop: Arc, +} +#[cfg(target_os = "linux")] +impl WaylandDesktop { + fn call( + &self, + method: &str, + args: impl dbus::arg::AppendAll, + ) -> Result { + let connection = dbus::blocking::Connection::new_session().map_err(platform)?; + connection + .with_proxy(BUS_NAME, BUS_PATH, std::time::Duration::from_secs(2)) + .method_call(BUS_NAME, method, args) + .map_err(platform) + } + fn start() -> Result { + let events = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let this = Self { + events: events.clone(), + stop: stop.clone(), + }; + let (version,): (u32,) = this.call("Version", ())?; + if version != DESKTOP_PROTOCOL_VERSION { + return Err(platform("desktop bridge version mismatch")); + } + std::thread::Builder::new() + .name("openless-desktop-events".into()) + .spawn(move || { + let Ok(connection) = dbus::blocking::Connection::new_session() else { + return; + }; + let mut rule = dbus::message::MatchRule::new_signal(BUS_NAME, "Hotkey"); + rule.sender = Some(BUS_NAME.into()); + rule.path = Some(BUS_PATH.into()); + let press_ids = crate::hotkeys::HotkeyPressIds::default(); + let _subscription = connection.add_match( + rule, + move |(action, symbol, states, pressed): (String, u32, u32, bool), _, _| { + if let Some(event) = crate::hotkeys::event_from_signal( + &action, + symbol, + states, + pressed, + std::time::Instant::now(), + &press_ids, + ) { + events.lock().unwrap().push(event); + } + true + }, + ); + while !stop.load(std::sync::atomic::Ordering::Acquire) + && connection + .process(std::time::Duration::from_secs(1)) + .is_ok() + {} + }) + .map_err(platform)?; + Ok(this) + } +} +#[cfg(target_os = "linux")] +impl Drop for WaylandDesktop { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Release); + } +} +#[cfg(target_os = "linux")] +impl DesktopAdapter for WaylandDesktop { + fn snapshot(&self) -> Result { + let (json,): (String,) = self.call("Snapshot", ())?; + let snapshot: DesktopSnapshot = serde_json::from_str(&json).map_err(platform)?; + if snapshot.version != 1 { + return Err(platform("desktop version mismatch")); + } + Ok(snapshot) + } + fn bind(&self, bindings: &[DesktopBinding]) -> Result<(), BackendError> { + let (error,): (String,) = self.call( + "Bind", + (serde_json::to_string(bindings).map_err(platform)?,), + )?; + if error.is_empty() { + Ok(()) + } else { + Err(platform(error)) + } + } + fn restore_focus(&self, target: &str) -> Result<(), BackendError> { + let (ok,): (bool,) = self.call("Restore", (target,))?; + if ok { + Ok(()) + } else { + Err(platform("original window no longer exists")) + } + } + fn place(&self, title: &str, x: i32, y: i32) -> Result<(), BackendError> { + let (ok,): (bool,) = self.call("Place", (title, x, y))?; + if ok { + Ok(()) + } else { + Err(platform("popup not found")) + } + } + fn drain(&self) -> Vec { + std::mem::take(&mut *self.events.lock().unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn duplicate_shortcuts_fail_before_installing_anything() { + let mut preferences = openless_core::UserPreferences::default(); + preferences.translation_hotkey = preferences.dictation_hotkey.clone(); + assert!(bindings(&HotkeyRuntimeTarget::from(&preferences)).is_err()); + } +} diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index e33aeb61f..b7f740826 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -4,11 +4,12 @@ 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 openless_core::ResourceResolver; #[cfg(target_os = "linux")] pub(crate) const DESTINATION: &str = "org.fcitx.Fcitx5"; @@ -195,14 +196,31 @@ fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { } 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() + 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 let Some(parent) = config.parent() { + library_dirs.push(parent.to_path_buf()); + } + library_dirs + .iter() + .any(|dir| dir.join("libopenless.so").is_file()) } #[derive(Debug, Clone)] @@ -231,6 +249,7 @@ impl TextInserter for Fcitx5TextInserter { // A missing native target is a supported clipboard fallback, // not permission to choose a new window after transcription. let _ = tokio::task::spawn_blocking(move || { + crate::desktop_bridge::remember_focus("_dictation_screen"); send_bool_message("CaptureDictationTarget", |message| { message.append1(capture_ticket) }) @@ -505,6 +524,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, @@ -551,6 +587,7 @@ pub fn commit_text(_: &str) -> Result<(), BackendError> { #[cfg(target_os = "linux")] pub(crate) fn capture_selection_target(session_id: &str) -> Result { + crate::desktop_bridge::remember_focus(session_id); send_string_message("CaptureSelectionTarget", |message| { message.append1(session_id) }) @@ -570,6 +607,7 @@ pub(crate) fn apply_selection_target( source: &str, replacement: &str, ) -> Result<(), BackendError> { + crate::desktop_bridge::restore_bound_focus(session_id)?; if send_bool_message("ApplySelectionTarget", |message| { message.append3(session_id, source, replacement) })? { @@ -592,6 +630,7 @@ pub(crate) fn apply_selection_target(_: &str, _: &str, _: &str) -> Result<(), Ba #[cfg(target_os = "linux")] pub(crate) fn revert_selection_target(session_id: &str) -> Result<(), BackendError> { + crate::desktop_bridge::restore_bound_focus(session_id)?; if send_bool_message("RevertSelectionTarget", |message| { message.append1(session_id) })? { @@ -614,6 +653,7 @@ pub(crate) fn revert_selection_target(_: &str) -> Result<(), BackendError> { #[cfg(target_os = "linux")] pub(crate) fn cancel_selection_target(session_id: &str) -> Result<(), BackendError> { + crate::desktop_bridge::forget_focus(session_id); let _ = send_bool_message("CancelSelectionTarget", |message| { message.append1(session_id) })?; @@ -631,6 +671,7 @@ pub(crate) fn cancel_selection_target(_: &str) -> Result<(), BackendError> { #[cfg(target_os = "linux")] pub(crate) fn rekey_selection_target(from: &str, to: &str) -> Result<(), BackendError> { if send_bool_message("RekeySelectionTarget", |message| message.append2(from, to))? { + crate::desktop_bridge::rekey_focus(from, to); Ok(()) } else { Err(BackendError::new( @@ -725,13 +766,89 @@ 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")] +pub fn reload_running_fcitx5() -> bool { + if !fcitx5_name_has_owner() { + return false; + } + match std::process::Command::new("fcitx5").arg("-r").status() { + Ok(status) if status.success() => { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + true + } + Ok(status) => { + log::warn!("[fcitx] fcitx5 -r failed with status {status}"); + false + } + Err(error) => { + log::warn!("[fcitx] could not run 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")] -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 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 +869,19 @@ mod tests { use super::*; #[test] - fn appimage_plan_copies_only_from_the_versioned_resource_contract() { + fn plugin_plan_is_probe_only_for_system_packages() { let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::AppImage, - resource_root: PathBuf::from("/app/usr/lib/openless/resources"), + 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!(plan.copy_required); assert_eq!( - plan.source_library.unwrap(), - PathBuf::from("/app/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so") + plan.target_library, + PathBuf::from("/home/test/.local/lib/fcitx5/libopenless.so") ); assert_eq!( plan.target_config, PathBuf::from("/home/test/.local/share/fcitx5/addon/openless.conf") ); } - - #[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()); - } - - #[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(); - - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated - ); - assert_eq!(std::fs::read(&plan.target_library).unwrap(), b"plugin"); - assert_eq!(std::fs::read(&plan.target_config).unwrap(), b"config"); - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Ready - ); - - std::fs::write(plan.source_library.as_ref().unwrap(), b"updated plugin").unwrap(); - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated - ); - assert_eq!( - std::fs::read(&plan.target_library).unwrap(), - b"updated plugin" - ); - 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/host_history.rs b/openless-all/app/linux-egui/src/host_history.rs new file mode 100644 index 000000000..ee3a7a6cf --- /dev/null +++ b/openless-all/app/linux-egui/src/host_history.rs @@ -0,0 +1,61 @@ +impl OpenLessEguiApp { + fn history_transform(&mut self, retranscribe: bool) { + if self.history_task.is_some() {return;} + let (Some(backend),Some(entry))=(self.backend(),self.frontend_vm.history_entries.get(self.frontend_vm.history_selected).cloned()) else {return;}; + let tx=self.tx.clone();self.history_generation+=1;let generation=self.history_generation; + let style=self.frontend_vm.history_repolish_style.clone(); + self.frontend_vm.history_busy=true; + self.history_task=Some(self.tokio.spawn(async move { + let id=entry.id.clone(); + let result:Result=async { + if retranscribe { + let directory=backend.config().data_dir.clone();let recording=id.clone(); + let pcm=tokio::task::spawn_blocking(move || { + let wav=openless_linux_egui::read_recording_wav(&directory,&recording).map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string()))?; + openless_linux_egui::recording_pcm(&wav).map(|p|p.to_vec()).map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string())) + }).await.map_err(|e|BackendError::new(openless_core::BackendErrorCode::Platform,e.to_string()))??; + let started=std::time::Instant::now(); + let result=backend.services().auxiliary.retranscribe_pcm(pcm).await.map_err(|e|e.error)?; + let updated=backend.apply_history_retranscription(&id,result.text,&result.asr,started.elapsed().as_millis() as u64)?; + Ok(updated.final_text) + } else { + backend.services().auxiliary.repolish(openless_core::RepolishRequest { + raw_text:entry.raw,style_pack_id:(!style.is_empty()).then_some(style),front_app:None, + }).await + } + }.await; + let _=tx.send(UiResult::HistoryTransform {generation,id,repolish:!retranscribe,result:result.map_err(|e|e.to_string())}); + })); + } + fn history_confirmation_ui(&mut self, ctx: &egui::Context) { + let Some(target)=self.history_confirmation.clone() else {return;}; + let response=egui::Modal::new(egui::Id::new("history-confirmation")) + .frame(egui::Frame::new().fill(theme::surface()).corner_radius(14).inner_margin(24)) + .show(ctx,|ui| { + ui.set_width(340.0); + ui.heading(theme::text(if target.is_some() {"删除这条记录?"}else{"清空全部历史记录?"})); + ui.label(theme::text("关联的录音也将删除。")); + ui.add_space(20.0); + ui.horizontal(|ui| { + if ui.button(theme::text("取消")).clicked() {self.history_confirmation=None;} + if ui.button(theme::text("确认删除")).clicked() { + self.history_confirmation=None; + if let Some(backend)=self.backend() { + let target=target.clone();let playback=self.playback.clone(); + self.spawn(async move { + playback.stop(); + let ids=if let Some(id)=&target {vec![id.clone()]}else{backend.list_history()?.into_iter().map(|e|e.id).collect()}; + for id in ids { + openless_linux_egui::remove_recording(&backend.config().data_dir,&id) + .map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string()))?; + backend.delete_history(&id)?; + } + Ok("历史记录已删除".into()) + }); + } + } + }); + }); + if response.should_close() {self.history_confirmation=None;} + } +} diff --git a/openless-all/app/linux-egui/src/host_marketplace.rs b/openless-all/app/linux-egui/src/host_marketplace.rs new file mode 100644 index 000000000..db8fe54c2 --- /dev/null +++ b/openless-all/app/linux-egui/src/host_marketplace.rs @@ -0,0 +1,94 @@ +#[derive(Default)] +struct MarketplaceUi { + generation:u64, + loading:bool, + search_due:Option, + mine_open:bool, + busy:bool, + upload_pack:String, + confirmation:Option<(MarketplaceMutation,String)>, +} +#[derive(Clone)] +enum MarketplaceMutation {Like(String),Upload(String,Option),Delete(String)} +impl OpenLessEguiApp { + fn load_marketplace(&mut self) { + let Some(backend)=self.backend() else {return;}; + self.marketplace_ui.generation+=1; + self.marketplace_ui.loading=true;self.marketplace_ui.search_due=None; + self.frontend_vm.marketplace_selected=None; + let generation=self.marketplace_ui.generation; + let query=self.marketplace_query.trim().to_string();let sort=self.frontend_vm.marketplace_sort; + let tx=self.tx.clone(); + self.tokio.spawn(async move { + let result:Result<_,BackendError>=async { + use frontend::view_model::MarketplaceSort; + let mut items=backend.services().marketplace.list(openless_core::MarketplaceQuery { + query:(!query.is_empty()).then_some(query),limit:Some(100), + sort:Some(if sort==MarketplaceSort::New {"new"}else{"popular"}.into()), + }).await?; + let signed_in=backend.services().marketplace.auth_status().await?.signed_in; + let likes=if signed_in {backend.services().marketplace.my_likes().await?}else{vec![]}; + if sort==MarketplaceSort::Liked {items.retain(|item|likes.contains(&item.id));} + Ok((items,likes)) + }.await; + let _=tx.send(UiResult::Marketplace{generation,result:result.map_err(|e|e.to_string())}); + }); + } + fn mutate_marketplace(&mut self, operation:MarketplaceMutation) { + if self.marketplace_ui.busy {return;} + let Some(backend)=self.backend() else {return;}; + self.marketplace_ui.busy=true;let tx=self.tx.clone(); + self.tokio.spawn(async move { + let result:Result=async { + match operation { + MarketplaceMutation::Like(id)=>{let result=backend.services().marketplace.toggle_like(id).await?;Ok(format!("{} 个赞",result.like_count))} + MarketplaceMutation::Upload(id,origin)=>{let result=backend.services().marketplace.upload(id,origin).await?;Ok(result.message)} + MarketplaceMutation::Delete(id)=>{backend.services().marketplace.delete(id).await?;Ok("已删除发布".into())} + } + }.await; + let _=tx.send(UiResult::MarketplaceMutation(result.map_err(|e|e.to_string()))); + }); + } + fn marketplace_windows_v2(&mut self,ctx:&egui::Context) { + if self.marketplace_ui.search_due.is_some_and(|due|std::time::Instant::now()>=due){self.load_marketplace();} + if self.marketplace_ui.mine_open { + let mut open=true; + egui::Window::new(theme::text("我的发布")).open(&mut open).default_width(600.0).show(ctx,|ui| { + self.marketplace_account_v2(ui); + ui.separator(); + egui::ComboBox::from_id_salt("upload-style").selected_text(self.style_packs.iter() + .find(|p|p.id==self.marketplace_ui.upload_pack).map(|p|p.name.as_str()).unwrap_or("选择本地风格包")) + .show_ui(ui,|ui| {for pack in &self.style_packs {ui.selectable_value(&mut self.marketplace_ui.upload_pack,pack.id.clone(),&pack.name);}}); + if ui.add_enabled(!self.marketplace_ui.busy && !self.marketplace_ui.upload_pack.is_empty(),egui::Button::new(theme::text("发布风格包"))).clicked() { + if let Some(pack)=self.style_packs.iter().find(|p|p.id==self.marketplace_ui.upload_pack) { + self.marketplace_ui.confirmation=Some((MarketplaceMutation::Upload(pack.id.clone(),pack.origin_pack_id.clone()),format!("发布「{}」到风格市场?提示词及风格信息将公开。",pack.name))); + } + } + ui.add_space(12.0); + egui::ScrollArea::vertical().max_height(320.0).show(ui,|ui| { + for pack in &self.marketplace_my_packs { + ui.horizontal(|ui| { + ui.strong(&pack.summary.name);ui.label(&pack.state); + if ui.add_enabled(!self.marketplace_ui.busy,egui::Button::new(theme::text("删除"))).clicked(){ + self.marketplace_ui.confirmation=Some((MarketplaceMutation::Delete(pack.summary.id.clone()),format!("从市场删除「{}」?",pack.summary.name))); + } + });ui.separator(); + } + }); + if self.marketplace_ui.busy {ui.spinner();} + ui.label(&self.status); + }); + if !open {self.marketplace_ui.mine_open=false;} + } + if let Some((operation,message))=self.marketplace_ui.confirmation.clone() { + let response=egui::Modal::new(egui::Id::new("marketplace-confirmation")).show(ctx,|ui| { + ui.set_width(360.0);ui.label(message);ui.add_space(16.0); + ui.horizontal(|ui| { + if ui.button(theme::text("取消")).clicked(){self.marketplace_ui.confirmation=None;} + if ui.button(theme::text("确认")).clicked(){self.marketplace_ui.confirmation=None;self.mutate_marketplace(operation);} + }); + }); + if response.should_close(){self.marketplace_ui.confirmation=None;} + } + } +} diff --git a/openless-all/app/linux-egui/src/host_models.rs b/openless-all/app/linux-egui/src/host_models.rs new file mode 100644 index 000000000..4a975a131 --- /dev/null +++ b/openless-all/app/linux-egui/src/host_models.rs @@ -0,0 +1,282 @@ +impl OpenLessEguiApp { + fn load_local_models(&mut self) { + if self.local_models_loading { + return; + } + let Some(backend) = self.backend() else { + return; + }; + self.local_models_loading = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .local_asr + .list_models(openless_core::LocalAsrRuntime::Generic) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(UiResult::LocalModels(result)); + }); + } + + fn local_models_v2( + &mut self, + ui: &mut egui::Ui, + state: &mut crate::ui::settings::SettingsState, + ) { + use crate::ui::settings::card; + if self.local_models.is_none() { + self.load_local_models(); + } + card(ui, "本地语音识别", |ui| { + ui.label(theme::text("在本机处理录音。下载完成后可启用、测试或释放模型。")); + if ui + .add_enabled( + !self.local_models_loading, + egui::Button::new("刷新模型列表"), + ) + .clicked() + { + self.load_local_models(); + } + if self.local_models_loading { + ui.spinner(); + } + }); + for model in self.local_models.clone().unwrap_or_default() { + let target = model.target.clone(); + let id = target.model_id().to_owned(); + card(ui, &model.display_name, |ui| { + ui.label(format!( + "{} · {}", + model.family, + if model.installed { + "已下载" + } else { + "尚未下载" + } + )); + if let Some(size) = model.size_bytes { + ui.label(format!("{:.1} MB", size as f64 / 1_048_576.0)); + } + if let Some(progress)=self.model_downloads.get(&id) { + ui.label(format!("{} · {}/{}",progress.file,progress.file_index,progress.file_count)); + if progress.bytes_total>0 {ui.add(egui::ProgressBar::new(progress.bytes_downloaded as f32 / progress.bytes_total as f32).show_percentage());} + if let Some(error)=&progress.error {ui.colored_label(egui::Color32::from_rgb(220, 38, 38),error);} + } + if let Some(progress)=self.model_prepare.as_ref().filter(|p|p.model_alias==id) { + ui.label(&progress.label); if let Some(percent)=progress.percent {ui.add(egui::ProgressBar::new((percent/100.0) as f32).show_percentage());} + if let Some(error)=&progress.error {ui.colored_label(egui::Color32::from_rgb(220, 38, 38),error);} + } + let mut action = None; + ui.horizontal_wrapped(|ui| { + if model.installed { + for (key, label) in [ + ("activate", "启用"), + ("test", "测试模型"), + ("release", "释放内存"), + ("folder", "打开目录"), + ("delete", "删除模型"), + ] { + if ui.button(label).clicked() { + action = Some(key); + } + } + } else { + for (key, label) in [ + ("download", "下载"), + ("cancel", "取消下载"), + ("cleanup", "清理未完成下载"), + ] { + if ui.button(label).clicked() { + action = Some(key); + } + } + } + if ui.button(theme::text("取消加载")).clicked() { + action = Some("cancel_prepare"); + } + }); + if action == Some("delete") { + state.confirmation = Some(format!("model:{id}")); + action = None; + } + if state.confirmation.as_deref() == Some(format!("model:{id}").as_str()) { + ui.label(theme::text("确认删除此模型文件?")); + ui.horizontal(|ui| { + if ui.button(theme::text("确认删除")).clicked() { + action = Some("delete"); + state.confirmation = None; + } + if ui.button(theme::text("取消")).clicked() { + state.confirmation = None; + } + }); + } + if let Some(action) = action { + self.local_model_action(target, action); + } + }); + } + card(ui, "存储与下载", |ui| { + let document = self + .preferences + .as_ref() + .and_then(|p| serde_json::to_value(p).ok()) + .unwrap_or_default(); + let mut edits = std::collections::BTreeMap::new(); + preference_field( + ui, + &document, + &mut edits, + "/localAsrMirror", + "下载源", + FieldKind::Choice(&[ + ("huggingface", "Hugging Face"), + ("hf-mirror", "HF Mirror"), + ]), + ); + preference_field( + ui, + &document, + &mut edits, + "/localAsrKeepLoadedSecs", + "模型保留时间(秒)", + FieldKind::Number(0.0, 86400.0), + ); + self.save_field_edits(edits); + if ui.button(theme::text("更改模型目录…")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + let folder = rfd::AsyncFileDialog::new().pick_folder().await; + if let Some(folder) = folder { + backend + .services() + .local_asr + .set_models_base_dir(Some(folder.path().to_owned())) + .await?; + Ok("模型目录已保存".into()) + } else { + Ok("已取消".into()) + } + }); + } + } + }); + } + + fn local_model_action(&self, target: openless_core::LocalAsrTarget, action: &str) { + let Some(backend) = self.backend() else { + return; + }; + let action = action.to_owned(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let api = backend.services().local_asr.clone(); + let result: Result = async { + match action.as_str() { + "download" => api.start_download(target, None).await?, + "cancel" => api.cancel_download(target).await?, + "cleanup" => api.cleanup_incomplete(target).await?, + "cancel_prepare" => api.cancel_prepare(target.runtime).await?, + "activate" => { + api.activate(openless_core::LocalAsrActivationRequest { + provider_id: if target.model_id().starts_with("whisper") { + "local-whisper".into() + } else { + target.runtime.provider_id().into() + }, + target, + }) + .await?; + } + "release" => api.release(target.runtime).await?, + "delete" => api.delete_model(target).await?, + "test" => { + let result = api.test_model(target).await?; + return Ok(format!( + "{} · {} ms", + result.transcribed_text, result.transcribe_ms + )); + } + "folder" => { + let path = api.model_dir(target).await?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::open_local_file(&path) + }) + .await + .map_err(|e| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + e.to_string(), + ) + })? + .map_err(|e| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + e.to_string(), + ) + })?; + } + _ => unreachable!(), + } + Ok("模型操作完成".into()) + } + .await; + let _ = tx.send(UiResult::LocalModelAction( + result.map_err(|e| e.to_string()), + )); + }); + } + + fn marketplace_account_v2(&mut self, ui: &mut egui::Ui) { + if ui.button(theme::text("使用 GitHub 登录")).clicked() { + if let Some(backend) = self.backend() { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .start_device_flow() + .await + .map_err(|e| e.to_string()); + let _ = tx.send(UiResult::MarketplaceFlow(result)); + }); + } + } + if let Some(flow) = self.marketplace_flow.clone() { + ui.monospace(&flow.user_code); + ui.hyperlink_to("打开 GitHub 授权页", &flow.verification_uri); + if ui.button(theme::text("检查授权状态")).clicked() { + if let Some(backend) = self.backend() { + let tx = self.tx.clone(); + let flow_id=flow.flow_id.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .poll_device_flow(flow_id) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(UiResult::MarketplaceAuthPoll(result)); + }); + } + } + if ui.button(theme::text("取消登录")).clicked() { + if let Some(backend)=self.backend() { + let id=flow.flow_id.clone(); + self.spawn(async move {backend.services().marketplace.cancel_device_flow(Some(id)).await?;Ok("已取消登录".into())}); + } + self.marketplace_flow = None; + } + } + if ui.button(theme::text("退出登录")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().marketplace.logout().await?; + Ok("已退出登录".into()) + }); + } + } + } +} diff --git a/openless-all/app/linux-egui/src/host_omni.rs b/openless-all/app/linux-egui/src/host_omni.rs new file mode 100644 index 000000000..8fa0c3b3a --- /dev/null +++ b/openless-all/app/linux-egui/src/host_omni.rs @@ -0,0 +1,276 @@ +#[derive(Clone, Default)] +struct OmniEditor { + provider: String, + endpoint: String, + model: String, + secret: String, + headers: String, + temperature: String, + models: Vec, +} +impl OpenLessEguiApp { + fn load_omni(&mut self, requested: Option) { + if self.omni_loading { + return; + } + let Some(backend) = self.backend() else { + return; + }; + self.omni_loading = true; + self.omni_error=None; + self.omni = None; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result: Result = async { + let provider = + requested.unwrap_or_else(|| backend.get_preferences().active_omni_provider); + let descriptor = openless_core::provider_rules::provider_descriptor( + openless_core::ProviderKind::Omni, + &provider, + ) + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "未知的 Omni 服务", + ) + })?; + let mut editor = OmniEditor { + provider: provider.clone(), + endpoint: descriptor.default_endpoint.unwrap_or_default(), + model: descriptor.default_model.unwrap_or_default(), + models: descriptor.static_models, + ..Default::default() + }; + for (account, field) in [ + ( + openless_core::credentials::OMNI_ENDPOINT_ACCOUNT, + &mut editor.endpoint, + ), + ( + openless_core::credentials::OMNI_MODEL_ACCOUNT, + &mut editor.model, + ), + ( + openless_core::credentials::OMNI_EXTRA_HEADERS_ACCOUNT, + &mut editor.headers, + ), + ( + openless_core::credentials::OMNI_TEMPERATURE_ACCOUNT, + &mut editor.temperature, + ), + ] { + let key = openless_core::CredentialKey::new( + openless_core::CredentialNamespace::Omni, + Some(provider.clone()), + account, + )?; + if let Some(value) = backend.read_credential(key).await? { + *field = value.into_exposed(); + } + } + Ok(editor) + } + .await; + let _ = tx.send(UiResult::Omni(result.map_err(|e| e.to_string()))); + }); + } + fn omni_v2(&mut self, ui: &mut egui::Ui) { + if let Some(error)=self.omni_error.clone() { + ui.label(error);if ui.button(theme::text("重试")).clicked(){self.load_omni(None);}return; + } + if self.omni.is_none() && !self.omni_loading { + self.load_omni(None); + } + if self.omni_loading { + ui.spinner(); + return; + } + let Some(mut editor) = self.omni.clone() else { + return; + }; + crate::ui::settings::card(ui, "Omni 多模态", |ui| { + let mut provider = editor.provider.clone(); + egui::ComboBox::from_id_salt("omni-provider") + .selected_text(&provider) + .show_ui(ui, |ui| { + for descriptor in openless_core::provider_rules::provider_descriptors( + openless_core::ProviderKind::Omni, + ) { + ui.selectable_value( + &mut provider, + descriptor.provider_type.to_string(), + theme::text_key(&descriptor.label_key), + ); + } + }); + if provider != editor.provider { + self.load_omni(Some(provider)); + return; + } + for (label, value) in [ + ("API 地址", &mut editor.endpoint), + ("模型", &mut editor.model), + ("Temperature", &mut editor.temperature), + ] { + ui.horizontal(|ui| { + ui.label(theme::text(label)); + ui.text_edit_singleline(value); + }); + } + ui.horizontal(|ui| { + ui.label("API Key"); + ui.add( + egui::TextEdit::singleline(&mut editor.secret) + .password(true) + .hint_text(theme::text("留空保留已保存的密钥")), + ); + }); + ui.label(theme::text("附加请求头(JSON)")); + ui.add( + egui::TextEdit::multiline(&mut editor.headers) + .desired_rows(2) + .desired_width(f32::INFINITY), + ); + egui::ComboBox::from_id_salt("omni-models") + .selected_text(theme::text("选择模型")) + .show_ui(ui, |ui| { + for model in &editor.models { + ui.selectable_value(&mut editor.model, model.clone(), model); + } + }); + let mut operation = None; + ui.horizontal_wrapped(|ui| { + for (key, label) in [ + ("save", "保存并启用"), + ("test", "测试连接"), + ("models", "获取模型"), + ("clear", "清除密钥"), + ] { + if ui.button(theme::text(label)).clicked() { + operation = Some(key); + } + } + }); + if let Some(operation) = operation { + if let Some(backend) = self.backend() { + let saved = editor.clone(); + editor.secret.clear(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result: Result = async { + use openless_core::credentials::*; + let key = |account| { + openless_core::CredentialKey::new( + openless_core::CredentialNamespace::Omni, + Some(saved.provider.clone()), + account, + ) + }; + if operation == "clear" { + backend + .remove_credential(key(OMNI_API_KEY_ACCOUNT)?) + .await?; + return Ok("密钥已清除".into()); + } + if !saved.headers.trim().is_empty() { + let parsed: serde_json::Value = + serde_json::from_str(&saved.headers).map_err(|e| { + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + e.to_string(), + ) + })?; + if !parsed + .as_object() + .is_some_and(|o| o.values().all(|v| v.is_string())) + { + return Err(BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "请求头必须是 JSON 字符串映射", + )); + } + } + if !saved.temperature.trim().is_empty() + && !saved + .temperature + .parse::() + .ok() + .is_some_and(|n| n.is_finite() && (0.0..=2.0).contains(&n)) + { + return Err(BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "Temperature 应在 0 到 2 之间", + )); + } + for (account, value) in [ + (OMNI_ENDPOINT_ACCOUNT, saved.endpoint), + (OMNI_MODEL_ACCOUNT, saved.model), + (OMNI_EXTRA_HEADERS_ACCOUNT, saved.headers), + (OMNI_TEMPERATURE_ACCOUNT, saved.temperature), + ] { + if value.trim().is_empty() { + backend.remove_credential(key(account)?).await?; + } else { + backend + .set_credential( + key(account)?, + openless_core::SecretValue::new(value), + ) + .await?; + } + } + if !saved.secret.is_empty() { + backend + .set_credential( + key(OMNI_API_KEY_ACCOUNT)?, + openless_core::SecretValue::new(saved.secret), + ) + .await?; + } + backend + .set_active_provider( + openless_core::ProviderSlot::Omni, + saved.provider.clone(), + ) + .await?; + let request = openless_core::ProviderRequest { + kind: openless_core::ProviderKind::Omni, + channel_id: Some(saved.provider.clone()), + thinking_enabled: false, + }; + match operation { + "test" => { + let status = + backend.services().provider.validate(request).await?; + Ok(if status.ok { + "连接成功" + } else { + "连接失败" + } + .into()) + } + "models" => { + let models = backend + .services() + .provider + .list_models(request) + .await? + .models; + let _ = tx.send(UiResult::OmniModels(saved.provider,Ok(models))); + Ok("模型列表已更新".into()) + } + _ => Ok("Omni 配置已保存".into()), + } + } + .await; + let _ = + tx.send(UiResult::Message(result.unwrap_or_else(|e| e.to_string()))); + }); + } + } + }); + if !self.omni_loading { + self.omni = Some(editor); + } + } +} diff --git a/openless-all/app/linux-egui/src/host_onboarding.rs b/openless-all/app/linux-egui/src/host_onboarding.rs new file mode 100644 index 000000000..ac135589d --- /dev/null +++ b/openless-all/app/linux-egui/src/host_onboarding.rs @@ -0,0 +1,106 @@ +impl OpenLessEguiApp { + fn desktop_component(&self, mode: &'static str) { + self.spawn(async move { + tokio::task::spawn_blocking(move || { + openless_linux_egui::desktop_bridge::manage_component(mode) + }) + .await + .map_err(|e| { + BackendError::new(openless_core::BackendErrorCode::Platform, e.to_string()) + })? + }); + } + fn onboarding_v2(&mut self, ctx: &egui::Context) { + let id = egui::Id::new("onboarding-completed"); + let completed = ctx.data(|d| d.get_temp::(id)).unwrap_or_else(|| { + let completed = openless_linux_egui::load_ui_value("onboardingComplete") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + ctx.data_mut(|d| d.insert_temp(id, completed)); + completed + }); + if completed { + return; + } + egui::Modal::new(egui::Id::new("onboarding-2.0")) + .frame( + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.5, theme::line())) + .corner_radius(14) + .inner_margin(32), + ) + .show(ctx, |ui| { + ui.set_width((ctx.content_rect().width() - 104.0).clamp(240.0, 456.0)); + ui.label( + egui::RichText::new("OpenLess") + .size(24.0) + .strong() + .color(theme::blue()), + ); + ui.add_space(16.0); + ui.heading(theme::text_key("onboarding.welcome")); + ui.label(theme::text_key("onboarding.intro")); + ui.add_space(24.0); + crate::ui::settings::card(ui, &theme::text_key("onboarding.hotkeyTitle"), |ui| { + ui.label(theme::text( + "使用 fcitx5 输入,并启用当前桌面的 OpenLess 组件。", + )); + ui.label(if openless_linux_egui::desktop_bridge::active() { + theme::text("桌面热键已连接") + } else { + theme::text("桌面组件尚未连接") + }); + if ui.button(theme::text("安装并启用桌面组件")).clicked() { + self.desktop_component("install"); + } + if ui.button(theme::text("重新启用")).clicked() { + self.desktop_component("enable"); + } + }); + crate::ui::settings::card(ui, &theme::text_key("onboarding.micTitle"), |ui| { + ui.label(theme::text_key("onboarding.micDesc")); + ui.label(format!( + "{} {}", + self.microphones.len(), + theme::text("个输入设备") + )); + if ui + .button(theme::text_key("onboarding.actionRequestMic")) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .platform + .request_microphone_permission() + .await?; + Ok("麦克风权限已检查".into()) + }); + } + self.load_microphones(); + } + }); + ui.label(&self.status); + if ui + .add_sized( + [ui.available_width(), 34.0], + egui::Button::new(theme::text_key("onboarding.continueToSettings")), + ) + .clicked() + { + match openless_linux_egui::save_ui_value( + "onboardingComplete", + serde_json::json!(true), + ) { + Ok(()) => { + ctx.data_mut(|d| d.insert_temp(id, true)); + self.frontend_vm.settings_open = true; + } + Err(error) => self.status = error.to_string(), + } + } + }); + } +} diff --git a/openless-all/app/linux-egui/src/host_settings.rs b/openless-all/app/linux-egui/src/host_settings.rs new file mode 100644 index 000000000..febbfa4aa --- /dev/null +++ b/openless-all/app/linux-egui/src/host_settings.rs @@ -0,0 +1,763 @@ +// Included inside linux_app so native editors can use the same session owners +// and result queue as the main window. No second backend or credential cache. +impl OpenLessEguiApp { + fn save_field_edits(&mut self, edits: std::collections::BTreeMap) { + if edits.is_empty() { + return; + } + let Some(native) = &self.native else { + return; + }; + if let Some(preferences) = &self.preferences { + match openless_linux_egui::patch_preferences(preferences, &edits) { + Ok(draft) => self.preferences = Some(draft), + Err(error) => { + self.status = error.to_string(); + return; + } + } + } + let host = native.host_arc(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = tokio::task::spawn_blocking(move || host.update_preference_fields(&edits)) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r.map_err(|e| e.to_string())); + let _ = tx.send(UiResult::SettingsSaved(Box::new(result))); + }); + } + + fn settings_v2(&mut self, ctx: &egui::Context) { + use crate::ui::settings::{self, SettingsState}; + let id = egui::Id::new("openless-settings-state"); + let mut state = ctx + .data(|d| d.get_temp::(id)) + .unwrap_or_default(); + if settings::modal(ctx, &mut state, |ui, state| { + self.settings_section_v2(ui, state) + }) { + self.frontend_vm.settings_open = false; + } + ctx.data_mut(|d| d.insert_temp(id, state)); + } + + fn settings_section_v2( + &mut self, + ui: &mut egui::Ui, + state: &mut crate::ui::settings::SettingsState, + ) { + use crate::ui::settings::{card, Section}; + use serde_json::{json, Value}; + let mut edits = std::collections::BTreeMap::new(); + let document = self + .preferences + .as_ref() + .and_then(|p| serde_json::to_value(p).ok()) + .unwrap_or(Value::Null); + macro_rules! fields { + ($ui:expr,$title:expr,$rows:expr) => { + card($ui, $title, |ui| { + for (pointer, label, kind) in $rows { + preference_field(ui, &document, &mut edits, pointer, label, *kind); + } + }) + }; + } + match state.section { + Section::General => { + fields!( + ui, + "录音", + &[ + ( + "/hotkey/mode", + "录音方式", + FieldKind::Choice(&[ + ("hold", "按住说话"), + ("toggle", "切换录音"), + ("auto", "智能模式") + ]) + ), + ("/silenceAutoStopEnabled", "静音时自动停止", FieldKind::Bool), + ( + "/silenceAutoStopSeconds", + "静音等待时间(秒)", + FieldKind::Number(0.5, 10.0) + ), + ( + "/muteDuringRecording", + "录音期间静音系统声音", + FieldKind::Bool + ), + ( + "/audioCueOnRecord", + "开始和停止时播放提示音", + FieldKind::Bool + ), + ("/showCapsule", "显示录音胶囊", FieldKind::Bool), + ] + ); + card(ui, "麦克风", |ui| { + let selected = document["microphoneDeviceName"] + .as_str() + .unwrap_or_default(); + let mut value = selected.to_string(); + egui::ComboBox::from_id_salt("microphone-v2") + .selected_text(if selected.is_empty() { + "跟随系统" + } else { + selected + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut value, String::new(), "跟随系统"); + for device in &self.microphones { + ui.selectable_value(&mut value, device.name.clone(), &device.name); + } + }); + if value != selected { + edits.insert("/microphoneDeviceName".into(), json!(value)); + } + }); + card(ui, "文本输入", |ui| { + for (pointer, label) in [ + ("/streamingInsert", "流式插入"), + ("/restoreClipboardAfterPaste", "粘贴后恢复剪贴板"), + ("/streamingInsertSaveClipboard", "保留流式文本到剪贴板"), + ] { + preference_field( + ui, + &document, + &mut edits, + pointer, + label, + FieldKind::Bool, + ); + } + }); + card(ui, "手机输入", |ui| { + preference_field( + ui, + &document, + &mut edits, + "/remoteInputEnabled", + "启用手机输入", + FieldKind::Bool, + ); + preference_field( + ui, + &document, + &mut edits, + "/remoteInputPort", + "服务端口", + FieldKind::Number(1024.0, 65535.0), + ); + if let Some((remote, pin)) = &self.remote_access { + ui.label(if remote.running { + "服务运行中" + } else if remote.starting { + "正在启动…" + } else { + "服务已停止" + }); + if remote.running { + ui.label(format!("已连接 {} 台设备", remote.connection_count)); + ui.monospace(format!("PIN {pin}")); + for url in &remote.urls { + ui.hyperlink(url); + } + } + } + if ui.button(theme::text("重置配对码")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .remote_input + .regenerate_pairing_pin() + .await?; + Ok("配对码已重置".into()) + }); + } + } + }); + } + Section::Shortcuts => { + card(ui, "全局快捷键", |ui| { + if let Some(prefs) = self.preferences.as_mut() { + let mut changed = false; + changed |= shortcut_editor(ui, "听写", &mut prefs.dictation_hotkey); + changed |= shortcut_editor(ui, "翻译", &mut prefs.translation_hotkey); + changed |= optional_shortcut_editor( + ui, + self.lang, + "划词追问", + &mut prefs.qa_hotkey, + ";", + ); + changed |= optional_shortcut_editor( + ui, + self.lang, + "划词润色", + &mut prefs.selection_polish_hotkey, + "P", + ); + changed |= optional_shortcut_editor( + ui, + self.lang, + "切换风格", + &mut prefs.switch_style_hotkey, + "S", + ); + changed |= optional_shortcut_editor( + ui, + self.lang, + "显示主窗口", + &mut prefs.open_app_hotkey, + "O", + ); + changed |= optional_shortcut_editor( + ui, + self.lang, + "Less Computer 语音", + &mut prefs.coding_agent_voice_hotkey, + "L", + ); + changed |= optional_shortcut_editor( + ui, + self.lang, + "Less Computer 面板", + &mut prefs.coding_agent_panel_hotkey, + "K", + ); + changed |= optional_shortcut_editor(ui,self.lang,"Less Computer 快速输入",&mut prefs.coding_agent_quick_hotkey,"J"); + for pack in &self.style_packs { + let mut binding=prefs.style_pack_hotkeys.iter().find(|h|h.pack_id==pack.id).map(|h|h.binding.clone()); + if optional_shortcut_editor(ui,self.lang,&pack.name,&mut binding,"1") { + set_style_pack_hotkey(prefs,&pack.id,binding);changed=true; + } + } + if changed { + self.settings_dirty.hotkeys = true; + } + } + }); + fields!( + ui, + "划词语音", + &[ + ("/selectionVoiceEnabled", "启用划词语音", FieldKind::Bool), + ( + "/selectionVoiceIntentMode", + "意图识别", + FieldKind::Choice(&[("auto", "自动识别"), ("manual", "手动选择")]) + ), + ( + "/selectionVoiceManualIntent", + "默认意图", + FieldKind::Choice(&[("question", "追问"), ("edit", "修改")]) + ), + ("/qaSaveHistory", "保存追问历史", FieldKind::Bool) + ] + ); + self.save_settings_if_dirty(); + } + Section::Services => { + let omni_enabled = self.preferences.as_ref().is_some_and(|p| p.multimodal_pipeline_enabled); + let omni = self.preferences.as_ref().is_some_and(|p| { + p.multimodal_pipeline_enabled + && p.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal + }); + ui.horizontal_wrapped(|ui| { + let mut tabs = if omni { + vec![(2, "多模态"), (3, "本地模型"), (4, "网络与连接")] + } else { + vec![ + (0, "语言模型"), + (1, "语音识别"), + (3, "本地模型"), + (4, "网络与连接"), + ] + }; + if omni_enabled && !omni {tabs.insert(2,(2,"多模态"));} + if !tabs.iter().any(|(index, _)| *index == state.service) { + state.service = tabs[0].0; + } + for (index, label) in tabs { + if ui.selectable_label(state.service == index, label).clicked() { + state.service = index; + } + } + }); + ui.add_space(12.0); + match state.service { + 2 => self.omni_v2(ui), + 0..=1 => { + let kind = match state.service { + 1 => openless_core::ChannelKind::Asr, + _ => openless_core::ChannelKind::Llm, + }; + if self.provider_kind != kind { + self.load_providers(kind); + } + self.provider_management_ui(ui); + } + 3 => self.local_models_v2(ui, state), + _ => { + fields!( + ui, + "网络", + &[("/useSystemProxy", "使用系统代理", FieldKind::Bool)] + ); + card(ui, "GitHub 账户与风格市场", |ui| { + self.marketplace_account_v2(ui) + }); + } + } + } + Section::Appearance => { + fields!( + ui, + "外观", + &[ + ( + "/themeMode", + "主题", + FieldKind::Choice(&[ + ("system", "跟随系统"), + ("light", "浅色"), + ("dark", "深色") + ]) + ), + ("/stackedRowLayout", "纵向排列设置项", FieldKind::Bool), + ("/conservativeLayout", "简洁布局", FieldKind::Bool), + ( + "/showOverviewActivityHeatmap", + "显示活动热力图", + FieldKind::Bool + ) + ] + ); + card(ui, "语言与字体", |ui| { + self.language_selector_ui(ui); + let id = egui::Id::new("openless-font-scale"); + let mut size = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_else(|| openless_linux_egui::load_ui_value("fontScale").and_then(|v|v.as_f64()).unwrap_or(1.0) as f32); + if ui + .add(egui::Slider::new(&mut size, 0.85..=1.35).text("字体大小")) + .changed() + { + ui.ctx().data_mut(|d| d.insert_temp(id, size)); + ui.ctx().set_zoom_factor(size); + if let Err(error) = openless_linux_egui::save_ui_value("fontScale", serde_json::json!(size)) {self.status=error.to_string();} + } + }); + } + Section::Privacy => { + fields!( + ui, + "隐私", + &[ + ("/cursorContextEnabled", "读取光标上下文", FieldKind::Bool), + ("/qaSaveHistory", "保存划词追问历史", FieldKind::Bool) + ] + ); + fields!( + ui, + "历史与录音", + &[ + ( + "/historyRetentionDays", + "历史保留天数(0 为永久)", + FieldKind::Number(0.0, 3650.0) + ), + ( + "/historyMaxEntries", + "历史条目上限", + FieldKind::OptionalNumber + ), + ("/recordAudioForDebug", "保存录音", FieldKind::Bool), + ( + "/audioRecordingMaxEntries", + "录音条目上限", + FieldKind::OptionalNumber + ) + ] + ); + card(ui, "云同步", |ui| { + if let Some(status) = &self.cloud_sync_status { + ui.label(if status.has_snapshot { + "已有云端备份" + } else { + "尚无云端备份" + }); + if let Some(updated) = &status.updated_at { + ui.label(updated); + } + } + ui.horizontal_wrapped(|ui| { + for (action, label) in [ + ("status", "刷新状态"), + ("upload", "备份到云端"), + ("restore", "从云端恢复"), + ("delete", "删除云端备份"), + ] { + if ui.button(label).clicked() { + if matches!(action, "restore" | "delete") { + state.confirmation = Some(action.into()); + } else { + self.cloud_sync_action(action); + } + } + } + }); + if let Some(action) = state + .confirmation + .clone() + .filter(|s| s == "restore" || s == "delete") + { + ui.colored_label( + egui::Color32::from_rgb(217, 119, 6), + if action == "restore" { + "将用云端快照替换本地词典、纠错与风格,确认继续?" + } else { + "确认删除云端备份?" + }, + ); + ui.horizontal(|ui| { + if ui.button(theme::text("确认")).clicked() { + state.confirmation = None; + self.cloud_sync_action(&action); + } + if ui.button(theme::text("取消")).clicked() { + state.confirmation = None; + } + }); + } + }); + card(ui, "权限与数据目录", |ui| { + if let Some(backend) = self.backend() { + ui.label(backend.config().data_dir.display().to_string()); + if ui.button(theme::text("打开数据目录")).clicked() { + let path = backend.config().data_dir.clone(); + std::thread::spawn(move || { + let _ = openless_linux_egui::open_local_file(&path); + }); + } + } + ui.label(format!( + "桌面会话:{:?}", + std::env::var("XDG_SESSION_TYPE").unwrap_or_default() + )); + }); + } + Section::Advanced => { + ui.horizontal_wrapped(|ui| { + for (index, label) in ["Less Computer", "多模态管线", "调试工具"] + .iter() + .enumerate() + { + ui.selectable_value(&mut state.advanced, index, *label); + } + }); + match state.advanced { + 0 => { + fields!( + ui, + "Less Computer", + &[ + ("/codingAgentEnabled", "启用 Less Computer", FieldKind::Bool), + ("/codingAgentProvider", "后端", FieldKind::Text), + ("/codingAgentExe", "可执行文件", FieldKind::OptionalText), + ("/codingAgentWorkdir", "工作目录", FieldKind::OptionalText), + ("/codingAgentModel", "模型", FieldKind::OptionalText), + ( + "/codingAgentPermissionMode", + "权限模式", + FieldKind::Choice(&[ + ("default", "默认"), + ("plan", "规划"), + ("acceptEdits", "允许编辑") + ]) + ) + ] + ); + self.less_computer_ui(ui); + } + 1 => { + fields!( + ui, + "多模态管线", + &[ + ( + "/multimodalPipelineEnabled", + "启用多模态管线", + FieldKind::Bool + ), + ( + "/pipelineMode", + "管线模式", + FieldKind::Choice(&[ + ("traditional", "语音识别 + 语言模型"), + ("multimodal", "多模态") + ]) + ), + ("/llmThinkingEnabled", "启用模型思考", FieldKind::Bool) + ] + ); + } + _ => { + card(ui, "诊断", |ui| { + ui.label(&self.status); + if ui.button(theme::text("导出日志")).clicked() { + self.apply_settings_action( + frontend::view_model::SettingsActionField::ExportDiagnostics, + ); + } + if ui.button(theme::text("重新加载 fcitx5 插件")).clicked() { + self.status = if reload_running_fcitx5() { + "已请求重新加载" + } else { + "未能重新加载 fcitx5" + } + .into(); + } + }); + } + } + } + Section::About => { + card(ui,"Linux 桌面集成",|ui| { + for (mode,label) in [("install","安装桌面组件"),("enable","启用桌面组件"),("uninstall","卸载桌面组件")] { + if ui.button(theme::text(label)).clicked() {self.desktop_component(mode);} + } + }); + card(ui, "OpenLess", |ui| { + ui.heading(format!("OpenLess {}", self.app_version())); + ui.label(theme::text("开源语音输入 · Linux")); + ui.hyperlink_to("GitHub", "https://github.com/Open-Less/openless"); + }); + fields!( + ui, + "启动与更新", + &[ + ("/launchAtLogin", "登录时启动", FieldKind::Bool), + ("/startMinimized", "启动时最小化", FieldKind::Bool), + ("/autoUpdateCheck", "自动检查更新", FieldKind::Bool), + ( + "/updateChannel", + "更新频道", + FieldKind::Choice(&[("stable", "稳定版"), ("beta", "Beta")]) + ) + ] + ); + self.update_controls_v2(ui); + } + } + self.save_field_edits(edits); + if !self.status.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(&self.status) + .size(11.0) + .color(theme::ink_3()), + ); + } + } + + fn app_version(&self) -> String { + serde_json::from_str::(include_str!("../../package.json")) + .ok() + .and_then(|v| v["version"].as_str().map(str::to_owned)) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()) + } + + fn update_controls_v2(&mut self, ui: &mut egui::Ui) { + let channel = self + .preferences + .as_ref() + .map(|p| p.update_channel) + .unwrap_or_default(); + ui.horizontal(|ui| { + if ui + .add_enabled(!self.update_busy, egui::Button::new("检查更新")) + .clicked() + { + self.request_update_check(channel); + } + if self.update_manifest.is_some() + && ui + .add_enabled(!self.update_busy, egui::Button::new("下载并安装")) + .clicked() + { + self.install_update(); + } + }); + if self.update_busy && self.update_cancellation.is_some() && ui.button(theme::text("取消下载")).clicked() { + if self.update_cancellation.as_ref().is_some_and(|c|c.cancel()) {self.status="正在取消更新…".into();} + } + if let Some(progress) = self.update_progress { + if let Some(total) = progress.content_length.filter(|n| *n > 0) { + ui.add(egui::ProgressBar::new( + progress.downloaded as f32 / total as f32, + )); + } + } + } + + fn cloud_sync_action(&self, action: &str) { + let Some(backend) = self.backend() else { + return; + }; + let action = action.to_string(); + let scale = openless_linux_egui::load_ui_value("fontScale").and_then(|v|v.as_f64()).unwrap_or(1.0); + let ui_preferences = openless_core::CloudSyncUiPreferences { + locale: serde_json::from_value(serde_json::json!(self.locale_pref.to_tag())).ok(), + font_scale: Some(if scale < 0.95 {openless_core::SyncFontScale::Small} else if scale > 1.05 {openless_core::SyncFontScale::Large} else {openless_core::SyncFontScale::Medium}), + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let latest = backend.cloud_sync_status().await?; + match action.as_str() { + "upload" => { + backend + .cloud_sync_upload( + latest.revision, + ui_preferences, + ) + .await + } + "restore" => {let restored=backend.cloud_sync_restore().await?; let _=tx.send(UiResult::CloudUi(restored.ui_preferences));Ok(restored.status)}, + "delete" => backend.cloud_sync_delete(latest.revision).await, + _ => Ok(latest), + } + } + .await + .map_err(|e: BackendError| e.to_string()); + let _ = tx.send(UiResult::CloudSync(result)); + }); + } +} + +#[derive(Clone, Copy)] +enum FieldKind { + Bool, + Text, + OptionalText, + OptionalNumber, + Number(f64, f64), + Choice(&'static [(&'static str, &'static str)]), +} + +fn preference_field( + ui: &mut egui::Ui, + document: &serde_json::Value, + edits: &mut std::collections::BTreeMap, + pointer: &str, + label: &str, + kind: FieldKind, +) { + use serde_json::{json, Value}; + let Some(value) = document.pointer(pointer) else { + return; + }; + ui.push_id(pointer, |ui| { + ui.horizontal_wrapped(|ui| { + ui.set_min_height(32.0); + ui.label(theme::text(label)); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| match kind { + FieldKind::Bool => { + let mut v = value.as_bool().unwrap_or(false); + if ui.checkbox(&mut v, "").changed() { + edits.insert(pointer.into(), json!(v)); + } + } + FieldKind::Choice(options) => { + let mut v = value.as_str().unwrap_or_default().to_string(); + let title = options + .iter() + .find(|(id, _)| *id == v) + .map(|(_, label)| *label) + .unwrap_or(&v); + egui::ComboBox::from_id_salt(pointer) + .selected_text(title) + .show_ui(ui, |ui| { + for (id, label) in options { + ui.selectable_value(&mut v, id.to_string(), theme::text(label)); + } + }); + if value.as_str() != Some(v.as_str()) { + edits.insert(pointer.into(), json!(v)); + } + } + FieldKind::Number(min, max) => { + let mut v = value.as_f64().unwrap_or(min); + if ui + .add(egui::DragValue::new(&mut v).range(min..=max)) + .changed() + { + edits.insert( + pointer.into(), + if value.is_u64() { + json!(v.round() as u64) + } else { + json!(v) + }, + ); + } + } + FieldKind::OptionalNumber => { + let mut enabled = !value.is_null(); + if ui.checkbox(&mut enabled, "限制数量").changed() { + edits.insert( + pointer.into(), + if enabled { json!(500) } else { Value::Null }, + ); + } + if enabled { + let mut count = value.as_u64().unwrap_or(500); + if ui + .add(egui::DragValue::new(&mut count).range(1..=100_000)) + .changed() + { + edits.insert(pointer.into(), json!(count)); + } + } + } + FieldKind::Text | FieldKind::OptionalText => { + let id = ui.make_persistent_id(("draft", pointer)); + let mut text = ui + .ctx() + .data(|d| d.get_temp::(id)) + .unwrap_or_else(|| value.as_str().unwrap_or_default().into()); + let response = + ui.add(egui::TextEdit::singleline(&mut text).desired_width(230.0)); + if response.lost_focus() + || (response.has_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + if text != value.as_str().unwrap_or_default() { + edits.insert( + pointer.into(), + if matches!(kind, FieldKind::OptionalText) + && text.trim().is_empty() + { + Value::Null + } else { + json!(text) + }, + ); + } + ui.ctx().data_mut(|d| d.remove::(id)); + } else if response.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(id, text)); + } + } + }, + ); + }); + ui.separator(); + }); +} diff --git a/openless-all/app/linux-egui/src/host_styles.rs b/openless-all/app/linux-egui/src/host_styles.rs new file mode 100644 index 000000000..15745317f --- /dev/null +++ b/openless-all/app/linux-egui/src/host_styles.rs @@ -0,0 +1,68 @@ +impl OpenLessEguiApp { + fn open_style_v2(&mut self, pack: openless_core::StylePack) { + let vm=&mut self.frontend_vm; + vm.style_editor_open=true; vm.style_saving=false; + vm.style_name=pack.name.clone(); vm.style_description=pack.description.clone(); + vm.style_prompt=pack.prompt.clone(); vm.style_selection_prompt=pack.selection_prompt.clone(); + vm.style_builtin=pack.kind==openless_core::StylePackKind::Builtin; + self.style_editor=Some(pack); + } + fn activate_style_v2(&mut self, index: usize) { + let id=if index==usize::MAX {"builtin.raw".into()}else{ + let Some(pack)=self.style_packs.get(index) else {return;}; pack.id.clone() + }; + if self.frontend_vm.style_selection_workflow { + self.save_field_edits(std::collections::BTreeMap::from([("/selectionPolishStylePackId".into(),serde_json::json!(id))])); + } else if let Some(backend)=self.backend() { + self.spawn(async move {backend.activate_style_pack(&id)?; Ok("风格已切换".into())}); + } + } + fn save_style_v2(&mut self, prompt: String) { + let Some(mut pack)=self.style_editor.clone() else {self.frontend_vm.style_saving=false;return;}; + let Some(backend)=self.backend() else {self.frontend_vm.style_saving=false;return;}; + pack.name=self.frontend_vm.style_name.trim().to_string(); + pack.description=self.frontend_vm.style_description.clone(); + pack.prompt=prompt; pack.selection_prompt=self.frontend_vm.style_selection_prompt.clone(); + let exists=self.style_packs.iter().any(|p|p.id==pack.id); + let tx=self.tx.clone(); + self.tokio.spawn(async move { + let result=tokio::task::spawn_blocking(move || { + if exists {backend.update_style_pack(pack)}else{backend.create_style_pack(pack)} + }).await.map_err(|e|e.to_string()).and_then(|r|r.map_err(|e|e.to_string())); + let _=tx.send(UiResult::StyleSaved(result)); + }); + } + fn reset_style_v2(&mut self) { + let (Some(pack),Some(backend))=(self.style_editor.as_ref(),self.backend()) else {return;}; + let id=pack.id.clone(); let tx=self.tx.clone(); self.frontend_vm.style_saving=true; + self.tokio.spawn(async move { + let result=tokio::task::spawn_blocking(move ||backend.reset_builtin_style_pack(&id)).await + .map_err(|e|e.to_string()).and_then(|r|r.map_err(|e|e.to_string())); + let _=tx.send(UiResult::StyleSaved(result)); + }); + } + fn style_delete_confirmation_ui(&mut self, ctx:&egui::Context) { + let Some(id)=self.style_delete_pending.clone() else {return;}; + let response=egui::Modal::new(egui::Id::new("delete-style")) + .frame(egui::Frame::new().fill(theme::surface()).corner_radius(14).inner_margin(24)) + .show(ctx,|ui| { + ui.set_width(340.0);ui.heading(theme::text("删除风格包?")); + ui.label(theme::text("此操作会同时解除关联的快捷键。"));ui.add_space(16.0); + ui.horizontal(|ui| { + if ui.button(theme::text("取消")).clicked(){self.style_delete_pending=None;} + if ui.button(theme::text("删除")).clicked(){ + self.style_delete_pending=None; + let exists=self.style_packs.iter().any(|p|p.id==id); + if exists { + if let Some(native)=&self.native { + let host=native.host_arc(); + self.spawn(async move {host.remove_style_pack(&id)?;Ok("风格包已删除".into())}); + } + } + self.frontend_vm.style_editor_open=false;self.style_editor=None; + } + }); + }); + if response.should_close(){self.style_delete_pending=None;} + } +} diff --git a/openless-all/app/linux-egui/src/host_windows.rs b/openless-all/app/linux-egui/src/host_windows.rs new file mode 100644 index 000000000..5aec40c46 --- /dev/null +++ b/openless-all/app/linux-egui/src/host_windows.rs @@ -0,0 +1,179 @@ +impl OpenLessEguiApp { + fn native_windows(&mut self, ctx: &egui::Context) { + use openless_core::SelectionVoicePhase as Phase; + if let Some(snapshot) = self + .selection_voice_state + .clone() + .filter(|s| matches!(s.phase, Phase::AwaitingIntent | Phase::Preview)) + { + let intent = snapshot.phase == Phase::AwaitingIntent; + let title = if intent { + "OpenLess Voice Intent" + } else { + "OpenLess Voice Preview" + }; + let placement=egui::Id::new(("voice-placement",snapshot.session_id,intent)); + if !ctx.data(|d|d.get_temp::(placement).unwrap_or(false)) { + openless_linux_egui::desktop_bridge::place_popup(title,480,320,false); + ctx.data_mut(|d|d.insert_temp(placement,true)); + } + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("selection-voice"), + egui::ViewportBuilder::default() + .with_title(title) + .with_inner_size([480.0, 320.0]) + .with_always_on_top(), + |ctx, _class| { + theme::apply_visuals( + ctx, + self.preferences + .as_ref() + .map(|p| p.theme_mode) + .unwrap_or_default(), + ); + let close = ctx.input(|i| { + i.viewport().close_requested() || i.key_pressed(egui::Key::Escape) + }); + egui::CentralPanel::default().show(ctx, |ui| { + ui.add_space(12.0); + ui.heading(if intent { + theme::text("你想对选中文字做什么?") + } else { + theme::text("预览修改") + }); + ui.add_space(12.0); + ui.label(snapshot.source_text.as_deref().unwrap_or_default()); + let driver = self + .native + .as_ref() + .map(|native| native.host().selection_voice()); + let mut cancel = close; + if intent { + ui.label( + snapshot + .instruction_polished + .as_deref() + .or(snapshot.instruction_raw.as_deref()) + .unwrap_or_default(), + ); + ui.horizontal(|ui| { + for (intent, label) in [("question", "追问"), ("edit", "修改")] + { + if ui.button(theme::text(label)).clicked() { + if let (Some(driver), Some(id)) = + (driver.clone(), snapshot.session_id) + { + self.spawn(async move { + driver.confirm_intent(id, intent.into()).await?; + Ok("已选择语音意图".into()) + }); + } + } + } + }); + } else if let Some(preview) = &snapshot.preview { + let id = + egui::Id::new(("voice-preview", preview.session_id.to_string())); + let mut text = ctx + .data(|data| data.get_temp::(id)) + .unwrap_or_else(|| preview.text.clone()); + ui.add( + egui::TextEdit::multiline(&mut text) + .desired_rows(6) + .desired_width(f32::INFINITY), + ); + ctx.data_mut(|data| data.insert_temp(id, text.clone())); + if ui.button(theme::text("应用修改")).clicked() { + if let Some(driver) = driver.clone() { + let owner = preview.owner_session_id; + self.spawn(async move { + driver.apply(text, owner).await?; + Ok("已应用修改".into()) + }); + } + } + if preview.can_revert && ui.button(theme::text("撤回")).clicked() { + if let Some(backend) = self.backend() { + let owner = preview.owner_session_id; + self.spawn(async move { + backend.services().selection_voice.revert_preview(owner)?; + Ok("已撤回到上一版预览".into()) + }); + } + } + } + cancel |= ui.button(theme::text("取消")).clicked(); + if cancel { + if let (Some(driver), Some(id)) = (driver, snapshot.session_id) { + self.spawn(async move { + driver.cancel(id).await?; + Ok("已取消".into()) + }); + } + self.selection_voice_state = None; + } + }); + }, + ); + } + if let Some(driver)=self.native.as_ref().map(|n|n.host().selection_voice()) { + if let Some(id)=driver.applied_target() { + egui::Window::new(theme::text("修改已应用")).id(egui::Id::new("voice-applied")) + .anchor(egui::Align2::RIGHT_BOTTOM,[-20.0,-20.0]).resizable(false).collapsible(false).show(ctx,|ui| { + ui.label(theme::text("撤回前将核验原应用和已写入的文本。")); + ui.horizontal(|ui| { + if ui.button(theme::text("撤回修改")).clicked() { + let driver=driver.clone();self.spawn(async move {driver.revert_applied(id).await?;Ok("修改已撤回".into())}); + } + if ui.button(theme::text("完成")).clicked() {driver.dismiss_applied();} + }); + }); + } + } + if self.less_computer_visible { + let placement=egui::Id::new("less-computer-placement"); + if !ctx.data(|d|d.get_temp::(placement).unwrap_or(false)) { + openless_linux_egui::desktop_bridge::place_popup("OpenLess Less Computer",520,600,false); + ctx.data_mut(|d|d.insert_temp(placement,true)); + } + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("less-computer-panel"), + egui::ViewportBuilder::default() + .with_title("OpenLess Less Computer") + .with_inner_size([520.0, 600.0]) + .with_always_on_top(), + |ctx, _class| { + theme::apply_visuals( + ctx, + self.preferences + .as_ref() + .map(|p| p.theme_mode) + .unwrap_or_default(), + ); + if ctx.input(|i| i.viewport().close_requested()) { + self.less_computer_visible = false; + ctx.data_mut(|d|d.remove::(egui::Id::new("less-computer-placement"))); + } + egui::CentralPanel::default().show(ctx, |ui| { + let t = ui.input(|i| i.time) as f32; + let glow = if self.pending_approval.is_some() { + egui::Color32::from_rgb(217, 119, 6) + } else { + theme::blue() + }; + let rect = ui.max_rect().shrink(2.0); + ui.painter().rect_stroke( + rect, + 14, + egui::Stroke::new(1.5 + 0.5 * (t * 2.0).sin(), glow), + egui::StrokeKind::Inside, + ); + egui::ScrollArea::vertical().show(ui, |ui| { + self.less_computer_ui(ui); + }); + }); + }, + ); + } + } +} diff --git a/openless-all/app/linux-egui/src/hotkeys.rs b/openless-all/app/linux-egui/src/hotkeys.rs index aa1390ef5..56f60990f 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -12,7 +12,7 @@ static NEXT_PRESS_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU6 /// Core treats such a late release/combined edge as a harmless no-op. #[cfg(any(target_os = "linux", test))] #[derive(Default)] -struct HotkeyPressIds { +pub(crate) struct HotkeyPressIds { dictation: std::sync::atomic::AtomicU64, less_computer: std::sync::atomic::AtomicU64, } @@ -25,6 +25,9 @@ fn next_press_id() -> u64 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxHotkeyEvent { + DesktopDisconnected, + LessComputerPanelPressed, + LessComputerQuickPressed, DictationPressed { symbol: u32, states: u32, @@ -64,6 +67,12 @@ pub enum LinuxHotkeyEvent { QaPressed, SelectionPolishPressed, TranslationPressed, + SwitchStylePressed, + OpenAppPressed, + StylePackPressed { + symbol: u32, + states: u32, + }, } pub struct Fcitx5HotkeyListener { @@ -235,7 +244,7 @@ fn run_listener( } #[cfg(any(target_os = "linux", test))] -fn event_from_signal( +pub(crate) fn event_from_signal( member: &str, symbol: u32, states: u32, @@ -325,6 +334,13 @@ 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), + ("LessComputerPanelEvent", true) => Some(LinuxHotkeyEvent::LessComputerPanelPressed), + ("LessComputerQuickEvent", true) => Some(LinuxHotkeyEvent::LessComputerQuickPressed), + ("StylePackHotkeyEvent", true) => { + Some(LinuxHotkeyEvent::StylePackPressed { symbol, states }) + } _ => None, } } @@ -375,6 +391,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..f591038d4 --- /dev/null +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -0,0 +1,1395 @@ +//! 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, + Es, + Fr, + De, +} + +/// 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; 8] = [ + Lang::ZhCn, + Lang::ZhTw, + Lang::En, + Lang::Ja, + Lang::Ko, + Lang::Es, + Lang::Fr, + Lang::De, +]; + +/// 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", + Lang::Es => "es", + Lang::Fr => "fr", + Lang::De => "de", + } + } + + /// 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("es") { + return Some(Lang::Es); + } + if normalized.starts_with("fr") { + return Some(Lang::Fr); + } + if normalized.starts_with("de") { + return Some(Lang::De); + } + 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: "nav.overview", + text: row("概览", "概覽", "Overview", "概要", "개요"), + }, + Msg { + key: "nav.history", + text: row("历史", "歷史", "History", "履歴", "기록"), + }, + Msg { + key: "nav.vocab", + text: row( + "词汇与纠错", + "詞彙與糾錯", + "Vocabulary & Correction", + "語彙と修正", + "어휘 및 교정", + ), + }, + Msg { + key: "nav.styles", + text: row( + "风格包", + "風格包", + "Style Packs", + "スタイルパック", + "스타일 팩", + ), + }, + Msg { + key: "nav.marketplace", + text: row( + "Marketplace", + "Marketplace", + "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", "設定", "설정"), + }, + // ---- 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)", + "({} 日間・{} 日記録あり)", + "({}일 · {}일 기록)", + ), + }, + // ---- 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.empty", text: row("暂无历史记录", "暫無歷史紀錄", "No history yet", "履歴はありません", "기록이 없습니다") }, + Msg { key: "history.inserted", text: row("已插入", "已插入", "Inserted", "挿入済み", "삽입됨") }, + Msg { key: "history.copied_fallback", text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨") }, + Msg { key: "history.paste_sent", text: row("已发送粘贴", "已傳送貼上", "Paste sent", "貼り付け送信", "붙여넣기 전송됨") }, + 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", "終了", "종료") }, +]; + +fn lang_index(lang: Lang) -> usize { + match lang { + Lang::ZhCn => 0, + Lang::ZhTw => 1, + Lang::En => 2, + Lang::Ja => 3, + Lang::Ko => 4, + Lang::Es | Lang::Fr | Lang::De => 2, + } +} + +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"), Some(Lang::Fr)); + 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..db2ac81ec 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -4,46 +4,102 @@ //! credentials, input, settings and resource adapters, then forwards commands //! and semantic events between that backend and the UI. +#[cfg(target_os = "linux")] +mod atspi; mod audio; +mod audio_cue; +mod audio_mute; mod backend; mod capabilities; mod coding_agent; +pub mod context; mod credentials; +pub mod design_tokens; +mod desktop; +pub mod desktop_bridge; mod fcitx5; mod host_actions; mod hotkeys; +mod i18n; +mod logging; mod marketplace; +mod popup; +mod preference_patch; mod qa; +mod recordings; mod remote_input; mod resources; mod runtime; mod selection; +mod selection_voice; mod settings; mod single_instance; +mod tray; +pub mod ui_catalog; +mod ui_state; +mod updater; +#[cfg(target_os = "linux")] +mod x11_desktop; pub use audio::LinuxCpalRecorder; +pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; +pub use audio_mute::AudioMuteGuard; 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 fcitx5::{ available as fcitx5_available, commit_text as fcitx5_commit_text, - ensure_plugin_installed as ensure_fcitx5_plugin_installed, + copy_to_clipboard as fcitx5_copy_to_clipboard, + ensure_plugin_installed as ensure_fcitx5_plugin_installed, 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; 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::{ + read_jsonl, run_popup, write_jsonl, ApplyOutcome as PopupApplyOutcome, CapsulePopupState, + HostToPopup, 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 preference_patch::patch_preferences; +pub use recordings::{ + read_recording_wav, recording_path, recording_pcm, remove_recording, RecordingError, + RecordingPlayback, +}; pub use resources::{ LinuxPackageKind, LinuxResourceLayout, LinuxResourceResolver, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY, }; pub use runtime::{LinuxNativeRuntime, LinuxRuntimePumpResult}; pub use selection::LinuxSelectionRuntime; +pub use selection_voice::LinuxSelectionVoice; pub use settings::{LinuxSettingsEffects, LinuxSettingsRuntime}; pub use single_instance::{ LinuxLaunchIntent, SingleInstanceBroker, SingleInstanceGuard, SingleInstanceRole, }; +pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; +pub use ui_state::{ + load_locale_pref, load_ui_value, save_locale_pref, save_ui_value, 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, UpdateCancellation, + 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::*; @@ -52,6 +108,7 @@ pub use openless_core::contract::*; /// Capture state binds recorder callbacks to their owning Core session. Window /// objects and egui widgets stay in the frontend. pub struct LinuxHost { + selection_voice: LinuxSelectionVoice, backend: std::sync::Arc, settings_runtime: std::sync::Arc, translation_pending: std::sync::atomic::AtomicBool, @@ -220,6 +277,7 @@ impl LinuxHost { settings_runtime: std::sync::Arc, ) -> Self { Self { + selection_voice: LinuxSelectionVoice::new(backend.clone()), backend, settings_runtime, translation_pending: std::sync::atomic::AtomicBool::new(false), @@ -232,6 +290,9 @@ impl LinuxHost { pub fn backend(&self) -> &std::sync::Arc { &self.backend } + pub fn selection_voice(&self) -> LinuxSelectionVoice { + self.selection_voice.clone() + } /// Create an independent subscription for the egui view model. /// @@ -301,6 +362,28 @@ impl LinuxHost { event: LinuxHotkeyEvent, ) -> Result, BackendError> { match event { + LinuxHotkeyEvent::DesktopDisconnected => { + if let Some(id) = self + .backend + .services() + .selection_voice + .snapshot() + .await? + .session_id + { + let _ = self.selection_voice.cancel(id).await; + } + self.backend.cancel_active_voice_session(None).await?; + Ok(None) + } + LinuxHotkeyEvent::LessComputerPanelPressed + | LinuxHotkeyEvent::LessComputerQuickPressed => { + if self.backend.get_preferences().coding_agent_enabled { + self.backend + .request_host_action(HostAction::ShowLessComputer)?; + } + Ok(None) + } LinuxHotkeyEvent::LessComputerPressed { press_id, at, .. } => { self.dispatch_less_computer_edge(DictationHotkeyEdge::Pressed { press_id, at }) .await @@ -314,6 +397,9 @@ impl LinuxHost { .await } LinuxHotkeyEvent::DictationPressed { press_id, at, .. } => { + if self.selection_voice.edge(true, at).await? { + return Ok(Some(CliDispatchOutcome::Noop)); + } let translation_requested = self .translation_pending .swap(false, std::sync::atomic::Ordering::AcqRel); @@ -329,16 +415,31 @@ impl LinuxHost { .await .map(Some) } - LinuxHotkeyEvent::DictationReleased { press_id, at, .. } => self - .backend - .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Released { press_id, at }) - .await - .map(Some), - LinuxHotkeyEvent::DictationCombined { press_id, at, .. } => self - .backend - .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined { press_id, at }) - .await - .map(Some), + LinuxHotkeyEvent::DictationReleased { press_id, at, .. } => { + if self.selection_voice.edge(false, at).await? { + return Ok(Some(CliDispatchOutcome::Noop)); + } + self.backend + .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Released { press_id, at }) + .await + .map(Some) + } + LinuxHotkeyEvent::DictationCombined { press_id, at, .. } => { + if let Some(id) = self + .backend + .services() + .selection_voice + .snapshot() + .await? + .session_id + { + self.selection_voice.cancel(id).await?; + } + self.backend + .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined { press_id, at }) + .await + .map(Some) + } LinuxHotkeyEvent::QaPressed => self .backend .dispatch_cli_intent(CliIntent::ToggleQa) @@ -367,6 +468,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 2aff6bae9..c0c661694 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1,37 +1,55 @@ -#[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::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, fcitx5_copy_to_clipboard, notify, + open_external, reload_running_fcitx5, 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, }; use openless_linux_egui::{ - drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, - FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, - LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, - LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole, + fmt_l10n, load_locale_pref, save_locale_pref, tr_l10n, Lang, LocalePref, LANGS, }; enum UiResult { - Environment(LinuxCapabilitySnapshot), + HistoryTransform { + generation: u64, + id: String, + repolish: bool, + result: Result, + }, + Omni(Result), + OmniModels(String, Result, String>), + CloudUi(openless_core::CloudSyncUiPreferences), + CloudSync(Result), + LocalModels(Result, String>), + LocalModelAction(Result), Message(String), - Models(Result, String>), Remote(Result<(openless_core::RemoteInputStatus, String), String>), Providers(Result), ProviderEditor { @@ -45,13 +63,23 @@ mod linux_app { result: Result, String>, }, ProviderMutation(Result), - } - - #[derive(Clone)] - enum ModelsState { - Loading, - Loaded(Vec), - Failed(String), + Library(Result), + StyleSaved(Result), + SettingsSaved(Box>), + Marketplace { + generation: u64, + result: Result<(Vec, Vec), String>, + }, + MarketplaceMutation(Result), + MarketplaceFlow(Result), + MarketplaceAuthPoll(Result), + MarketplaceDetail(Result), + MarketplaceMine(Result<(Vec, Vec), String>), + Microphones(Result, String>), + Overview(u64, Result), + UpdateCheck(Result, String>), + UpdateProgress(openless_linux_egui::DownloadProgress), + UpdateInstalled(Result), } #[derive(Clone)] @@ -62,6 +90,167 @@ 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, + } + + #[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; + } + 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; + } + 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.coding_agent_panel_hotkey = draft.coding_agent_panel_hotkey.clone(); + merged.coding_agent_quick_hotkey = draft.coding_agent_quick_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, + duration_ms: Option, + } + + /// 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, + /// GitHub-style weekly columns (Sunday-first). Chronological oldest + /// first; a partial leading week keeps left-edge calendar alignment. + heatmap_weeks: Vec<[u32; 7]>, + heatmap_days: u32, + activity_days_total: usize, + } + + #[derive(Clone, Debug)] + enum OverviewState { + Loading, + Loaded(OverviewData), + Failed(String), + } + #[derive(Clone)] enum ProvidersState { Loading, @@ -69,6 +258,319 @@ mod linux_app { Failed(String), } + /// Trailing annual window rendered by the Overview heatmap. + const OVERVIEW_HEATMAP_DAYS: i64 = 364; + + 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 a Sunday-first weekly heatmap grid over the trailing `days` window + /// (inclusive) ending at `today`. Columns are chronological weeks; the first + /// column may be partial so weekday edges align like a GitHub contribution + /// graph. Dates absent from the store render as an inactive (0) cell. + fn build_heatmap_weeks( + by_date: &std::collections::BTreeMap, + today: chrono::NaiveDate, + days: i64, + ) -> Vec<[u32; 7]> { + let mut weeks: Vec<[u32; 7]> = Vec::new(); + let mut date = today - chrono::Duration::days(days - 1); + while date <= today { + let weekday = date.weekday().num_days_from_sunday() as usize; + if weekday == 0 || weeks.is_empty() { + // A fresh column. Sunday starts a new week; a partial leading + // column is created on the first non-Sunday date instead. + weeks.push([0u32; 7]); + } + weeks + .last_mut() + .expect("a heatmap week always exists before writing a cell")[weekday] = + by_date.get(&date).map(|day| day.count).unwrap_or(0); + date += chrono::Duration::days(1); + } + weeks + } + + /// 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(), + 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), + heatmap_weeks: build_heatmap_weeks(&by_date, today, OVERVIEW_HEATMAP_DAYS), + heatmap_days: OVERVIEW_HEATMAP_DAYS as u32, + activity_days_total: by_date.len(), + } + } + + fn format_duration(ms: u64, lang: Lang) -> String { + if ms < 1000 { + fmt_l10n(lang, "dur.ms", &[&ms]) + } else if ms < 60_000 { + fmt_l10n(lang, "dur.sec", &[&format!("{:.1}", ms as f64 / 1000.0)]) + } else { + let minutes = ms / 60_000; + let seconds = (ms % 60_000) / 1000; + fmt_l10n(lang, "dur.min_sec", &[&minutes, &seconds]) + } + } + + fn overview_provider_cards(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.columns(2, |columns| { + overview_provider_card( + &mut columns[0], + tr_l10n(lang, "overview.provider_cards"), + &summary.asr_provider, + summary.asr_configured, + lang, + ); + overview_provider_card( + &mut columns[1], + tr_l10n(lang, "overview.provider_cards_llm"), + &summary.llm_provider, + summary.llm_configured, + lang, + ); + }); + } + + fn overview_provider_card( + ui: &mut egui::Ui, + kind: &str, + provider: &str, + configured: bool, + lang: Lang, + ) { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_width(140.0); + ui.label(egui::RichText::new(kind).weak()); + let name = if provider.is_empty() { + tr_l10n(lang, "overview.not_set").to_string() + } else { + provider.to_string() + }; + ui.label(egui::RichText::new(name).strong()); + if configured { + ui.colored_label( + egui::Color32::from_rgb(60, 160, 90), + tr_l10n(lang, "overview.configured_dot"), + ); + } else { + ui.label(tr_l10n(lang, "overview.unconfigured")); + } + }); + } + + fn overview_metric(ui: &mut egui::Ui, label: &str, value: String, trend: &str) { + ui.vertical(|ui| { + ui.set_min_width(120.0); + ui.label(egui::RichText::new(label).weak()); + ui.label(egui::RichText::new(value).strong().size(18.0)); + if !trend.is_empty() { + ui.label(egui::RichText::new(trend).small().weak()); + } + }); + } + + fn overview_metric_row(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + let latency_trend = if summary.segments_today > 0 { + "".to_string() + } else { + tr_l10n(lang, "metric.no_data_today").to_string() + }; + egui::Grid::new("overview_metric_row") + .num_columns(4) + .spacing([16.0, 8.0]) + .show(ui, |ui| { + overview_metric( + ui, + tr_l10n(lang, "metric.chars_today"), + summary.chars_today.to_string(), + &fmt_l10n(lang, "metric.total_segments", &[&summary.segments_today]), + ); + overview_metric( + ui, + tr_l10n(lang, "metric.duration_today"), + format_duration(summary.duration_ms_today, lang), + "", + ); + overview_metric( + ui, + tr_l10n(lang, "metric.avg_latency"), + format_duration(summary.avg_latency_ms, lang), + &latency_trend, + ); + overview_metric( + ui, + tr_l10n(lang, "metric.total"), + summary.history_total.to_string(), + &fmt_l10n( + lang, + "metric.near7", + &[&summary.last_7.segments, &summary.last_30.segments], + ), + ); + ui.end_row(); + }); + } + + fn overview_recent(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.label(egui::RichText::new(tr_l10n(lang, "heading.recent")).strong()); + if summary.recent.is_empty() { + ui.label(tr_l10n(lang, "overview.recent_empty")); + return; + } + for entry in &summary.recent { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(format!( + "{} · {}", + entry.created_at, + format_duration(entry.duration_ms.unwrap_or(0), lang) + )); + let text = if entry.final_text.trim().is_empty() { + tr_l10n(lang, "overview.no_text").to_string() + } else { + entry.final_text.clone() + }; + ui.label(text); + }); + ui.add_space(4.0); + } + } + + fn heat_color(count: u32) -> egui::Color32 { + match count { + 0 => egui::Color32::from_gray(60), + 1..=2 => egui::Color32::from_rgb(80, 140, 220), + 3..=5 => egui::Color32::from_rgb(90, 120, 235), + 6..=10 => egui::Color32::from_rgb(110, 100, 235), + _ => egui::Color32::from_rgb(150, 90, 235), + } + } + + fn overview_heatmap(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.label(egui::RichText::new(tr_l10n(lang, "overview.heatmap_title")).strong()); + let weeks = &summary.heatmap_weeks; + if weeks.is_empty() { + ui.label(tr_l10n(lang, "overview.heatmap_empty")); + return; + } + let cell = 10.0f32; + let gap = 2.0f32; + let width = gap + weeks.len() as f32 * (cell + gap); + let height = gap + 7.0f32 * (cell + gap); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter(); + for (column, week) in weeks.iter().enumerate() { + for (row, count) in week.iter().enumerate() { + let min = egui::pos2( + rect.left() + gap + column as f32 * (cell + gap), + rect.top() + gap + row as f32 * (cell + gap), + ); + painter.rect_filled( + egui::Rect::from_min_size(min, egui::vec2(cell, cell)), + 2.0, + heat_color(*count), + ); + } + } + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "overview.heatmap_less")); + for count in [0u32, 1, 4, 8, 15] { + let (swatch, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, heat_color(count)); + } + ui.label(tr_l10n(lang, "overview.heatmap_more")); + ui.label(fmt_l10n( + lang, + "overview.heatmap_footnote", + &[&summary.heatmap_days, &summary.activity_days_total], + )); + }); + } + #[derive(Clone)] struct ProviderEditor { kind: openless_core::ChannelKind, @@ -79,7 +581,6 @@ mod linux_app { model: 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,21 +599,23 @@ mod linux_app { } 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, + overview: OverviewState, + overview_generation: std::sync::atomic::AtomicU64, + microphones: Vec, + playback: Arc, + history_confirmation: Option>, + history_task: Option>, + history_generation: u64, transcript: String, transcript_state: TranscriptAccumulator, transcript_session: Option, + recording_phase_active: bool, last_event_sequence: u64, less_computer_input: String, less_computer_output: String, @@ -134,8 +637,60 @@ mod linux_app { 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, + tray: Option, + exit_requested: bool, + update_support: LinuxUpdateSupport, + update_schedule: UpdateSchedule, + update_started: std::time::Instant, + update_manifest: Option, + update_busy: bool, + update_progress: Option, + update_cancellation: Option, + restored_font_scale: Option, + marketplace_items: Vec, + marketplace_query: String, + marketplace_ui: MarketplaceUi, + marketplace_flow: Option, + marketplace_detail: Option, + marketplace_my_packs: Vec, + marketplace_my_likes: Vec, + style_editor: Option, + style_delete_pending: 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, + selection_voice_state: Option, + less_computer_visible: bool, + cloud_sync_status: Option, + omni: Option, + omni_loading: bool, + omni_error: Option, + model_downloads: std::collections::HashMap, + model_prepare: Option, + local_models: Option>, + local_models_loading: bool, tx: mpsc::Sender, rx: mpsc::Receiver, } @@ -144,8 +699,18 @@ mod linux_app { fn new( tokio: Arc, native: Result, + tray: Option, + update_support: LinuxUpdateSupport, ) -> 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(); @@ -153,21 +718,23 @@ 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(), + overview: OverviewState::Loading, + overview_generation: std::sync::atomic::AtomicU64::new(0), + microphones: Vec::new(), + playback: Arc::default(), + history_confirmation: None, + history_task: None, + history_generation: 0, 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(), @@ -189,32 +756,88 @@ mod linux_app { 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(), + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + update_manifest: None, + update_busy: false, + update_progress: None, + update_cancellation: None, + restored_font_scale: None, + marketplace_items: Vec::new(), + marketplace_query: String::new(), + marketplace_ui: MarketplaceUi::default(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_delete_pending: 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(), + selection_voice_state: None, + less_computer_visible: false, + cloud_sync_status: None, + omni: None, + omni_loading: false, + omni_error: None, + model_downloads: Default::default(), + model_prepare: None, + local_models: None, + local_models_loading: false, 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, preferences: None, - models: ModelsState::Loading, + settings_dirty: SettingsDirty::default(), + overview: OverviewState::Loading, + overview_generation: std::sync::atomic::AtomicU64::new(0), + microphones: Vec::new(), + playback: Arc::default(), + history_confirmation: None, + history_task: None, + history_generation: 0, 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(), @@ -236,8 +859,60 @@ mod linux_app { 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(), + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + update_manifest: None, + update_busy: false, + update_progress: None, + update_cancellation: None, + restored_font_scale: None, + marketplace_items: Vec::new(), + marketplace_query: String::new(), + marketplace_ui: MarketplaceUi::default(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_delete_pending: 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(), + selection_voice_state: None, + less_computer_visible: false, + cloud_sync_status: None, + omni: None, + omni_loading: false, + omni_error: None, + model_downloads: Default::default(), + model_prepare: None, + local_models: None, + local_models_loading: false, tx, rx, }, @@ -250,1845 +925,3203 @@ 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, + } } - 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 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)) + fn send_popup(&mut self, kind: PopupKind, message: HostToPopup) { + let lang = self.lang; + 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:?}")], + ); + } + } } - .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 hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { + self.send_popup( + kind, + HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }, + ); } - fn load_provider_editor( - &self, - kind: openless_core::ChannelKind, - channel: openless_core::ChannelSummary, - descriptor: openless_core::ProviderDescriptor, - ) { - let Some(backend) = self.backend() else { + 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()), + } + } + + fn show_qa_popup(&mut self) { + openless_linux_egui::desktop_bridge::place_popup("OpenLess QA", 520, 520, false); + self.ensure_popup(PopupKind::Qa); + let Some(state) = self.qa_state.clone() 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), - }); - }); + 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, + }, + ); } - 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 show_selection_popup(&mut self) { + openless_linux_egui::desktop_bridge::place_popup("OpenLess Preview", 480, 300, false); + self.ensure_popup(PopupKind::Preview); + let Some(selection) = self.selection.clone() else { + return; + }; + 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 request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { - let Some(backend) = self.backend() else { + fn show_capsule_popup(&mut self) { + openless_linux_egui::desktop_bridge::place_popup("OpenLess Capsule", 340, 112, true); + self.ensure_popup(PopupKind::Capsule); + let Some(snapshot) = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.clone()) + 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, - }); - }); + let Some(session_id) = snapshot.session_id else { + return; + }; + 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: snapshot.message.unwrap_or_default(), + audio_level: Some(snapshot.level), + }, + ); } - 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; - } - 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(); + 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)); } } - 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()) - }); + } + 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::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; - } - // 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"); + 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()) + }); } - 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(); - } - LessComputerEventKind::Delta { text } => { - self.less_computer_output.push_str(&text); + 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()) + }); } - LessComputerEventKind::Tool { name } => { - self.status = format!("Less Computer 正在使用工具:{name}"); + } + 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()) + }); } - 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(), + }, + 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(); + PopupSupervisorEvent::Message( + PopupToHost::SubmitQa { .. } + | PopupToHost::ToggleQaRecording { .. } + | PopupToHost::DismissQa { .. }, + ) => { + self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); } - } - BackendEventKind::PreferencesChanged(_) => { - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); + PopupSupervisorEvent::ProtocolError(error) => { + self.status = fmt_l10n(lang, "popup.protocol_error", &[&error]); } - 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()); - } - } 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.navigation.notify(Page::Qa); - self.qa_state = Some(state); + PopupSupervisorEvent::SpawnFailed(error) => { + self.status = fmt_l10n(lang, "popup.spawn_failed", &[&error]); + *self.popup_slot(kind) = None; } - } - 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::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 { - let (launch_intents, hotkey_events, errors) = native.drain_native_events(); - let host = native.host_arc(); - for intent in launch_intents { - let host = Arc::clone(&host); - self.spawn(async move { - host.dispatch_launch_intent(intent).await?; - Ok("已处理启动请求".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()) - }); - } - if let Some(error) = errors.last() { - self.status = error.to_string(); + 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)); + }); + } - let mut actions = Vec::new(); - native.host_actions().drain(|action| actions.push(action)); - // HostAction controls only native visibility/focus/effects. - // QA and Selection contents and terminal ownership always come - // back through sequenced Core events handled above. - for action in actions { - match action { - HostAction::ShowMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + 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)); + }); + } + + 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 generation = self + .overview_generation + .fetch_add(1, std::sync::atomic::Ordering::AcqRel) + + 1; + 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(generation, 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 install_update(&mut self) { + let (LinuxUpdateSupport::AppImage(updater), Some(manifest)) = + (self.update_support.clone(), self.update_manifest.clone()) + else { + return; + }; + if self.update_busy { + return; + } + self.update_busy = true; + self.update_progress = Some(openless_linux_egui::DownloadProgress { + downloaded: 0, + content_length: None, + }); + let cancellation = openless_linux_egui::UpdateCancellation::default(); + self.update_cancellation = Some(cancellation.clone()); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let progress_tx = tx.clone(); + let result = updater + .download_and_install_cancellable( + manifest, + move |progress| { + let _ = progress_tx.send(UiResult::UpdateProgress(progress)); + }, + cancellation, + ) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::UpdateInstalled(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 => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + 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 => { + self.exit_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + } + + 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), + }); + }); + } + + 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 { + 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, + }); + }); + } + + /// 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::LocalAsrDownloadProgress(progress) => { + use openless_core::LocalAsrDownloadPhase as Phase; + if matches!( + progress.phase, + Phase::Finished | Phase::Cancelled | Phase::Failed + ) { + self.local_models = None; + } + self.model_downloads + .insert(progress.model_id.clone(), progress); + } + BackendEventKind::LocalAsrPrepareProgress(progress) => { + self.model_prepare = Some(progress) + } + BackendEventKind::SelectionVoiceStateChanged(snapshot) => { + self.selection_voice_state = Some(snapshot) + } + 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; + } + self.status = fmt_l10n( + lang, + "status.dictation_phase", + &[&format!("{:?}", state.phase)], + ); + if let Some(session_id) = state.session_id { + 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: state.message.unwrap_or_default(), + audio_level: Some(state.level), + }, + ); + } + } + 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 => { + backend.stop_dictation_session(request.session_id).await?; + } + openless_core::RecordingControlAction::Cancel => { + 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(); + } 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(); + } + LessComputerEventKind::Delta { text } => { + self.less_computer_output.push_str(&text); + } + LessComputerEventKind::Tool { name } => { + self.status = fmt_l10n(lang, "status.less_tool", &[&name]); + } + LessComputerEventKind::Compaction => { + self.status = tr_l10n(lang, "status.less_compacted").to_string(); + } + LessComputerEventKind::Completed { text, .. } => { + // 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 = 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.status = message; + } + LessComputerEventKind::Cancelled => { + self.pending_approval = None; + self.status = tr_l10n(lang, "less_computer.cancelled").to_string(); + } + } + } + BackendEventKind::PreferencesChanged(_) => { + 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::HistoryChanged(_) => self.load_overview(), + BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => { + self.load_library() + } + 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) + { + // 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, + }, + ); + } + } + 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(), + _ => {} + } + } + + fn poll(&mut self, ctx: &egui::Context) { + let lang = self.lang; + 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 { + let host = Arc::clone(&host); + self.spawn(async move { + host.dispatch_launch_intent(intent).await?; + 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(tr_l10n(lang, "status.hotkey_handled").to_string()) + }); + } + if let Some(error) = errors.last() { + self.status = error.to_string(); + } + + let mut actions = Vec::new(); + native.host_actions().drain(|action| actions.push(action)); + // HostAction controls only native visibility/focus/effects. + // QA and Selection contents and terminal ownership always come + // back through sequenced Core events handled above. + for action in actions { + match action { + HostAction::ShowLessComputer => self.less_computer_visible = true, + HostAction::ShowMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + HostAction::FocusMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + 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 => { + self.qa_visible = true; + self.show_qa_popup(); + } + HostAction::HideQa => { + 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), + ); + } + } + } + } + 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::HistoryTransform { + generation, + id, + repolish, + result, + } => { + if generation != self.history_generation { + continue; + } + self.history_task = None; + self.frontend_vm.history_busy = false; + match result { + Ok(text) => { + if repolish { + self.frontend_vm + .history_results + .entry(id) + .or_default() + .insert(0, text); + } + self.status = "历史操作完成".into(); + } + Err(error) => self.status = error, + } + } + UiResult::Omni(result) => { + self.omni_loading = false; + match result { + Ok(editor) => self.omni = Some(editor), + Err(error) => { + self.omni_error = Some(error.clone()); + self.status = error; + } + } + } + UiResult::OmniModels(provider, result) => match result { + Ok(models) => { + if let Some(editor) = + self.omni.as_mut().filter(|e| e.provider == provider) + { + editor.models = models; + } + } + Err(error) => self.status = error, + }, + UiResult::CloudUi(preferences) => { + if let Some(locale) = preferences.locale { + if let Ok(serde_json::Value::String(tag)) = serde_json::to_value(locale) + { + self.locale_pref = LocalePref::from_tag(&tag); + self.lang = self.locale_pref.resolve(); + if let Err(error) = + openless_linux_egui::save_locale_pref(self.locale_pref) + { + self.status = error.to_string(); + } + } + } + if let Some(scale) = preferences.font_scale { + let size = match scale { + openless_core::SyncFontScale::Small => 0.9, + openless_core::SyncFontScale::Medium => 1.0, + openless_core::SyncFontScale::Large => 1.15, + }; + self.restored_font_scale = Some(size); + if let Err(error) = openless_linux_egui::save_ui_value( + "fontScale", + serde_json::json!(size), + ) { + self.status = error.to_string(); + } + } + } + UiResult::CloudSync(Ok(status)) => { + self.cloud_sync_status = Some(status); + self.load_library(); + self.status = "云同步操作完成".into(); + } + UiResult::CloudSync(Err(error)) => self.status = error, + UiResult::LocalModels(result) => { + self.local_models_loading = false; + match result { + Ok(models) => self.local_models = Some(models), + Err(error) => { + self.local_models = Some(Vec::new()); + self.status = error; + } + } + } + UiResult::LocalModelAction(result) => { + self.status = result.unwrap_or_else(|e| e); + self.local_models = None; + self.local_models_loading = false; + } + UiResult::Message(message) => self.status = message, + 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 { + 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; + } + 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)); + } + Err(error) => { + self.provider_editor = ProviderEditorState::Failed(error.clone()); + self.status = error; + } + } + } + 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()], + ); + self.provider_models = models; + } + Err(error) => self.status = error, + } + } + } + UiResult::ProviderMutation(result) => { + match result { + Ok(message) => self.status = message, + Err(error) => self.status = error, + } + 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::StyleSaved(result) => { + self.frontend_vm.style_saving = false; + match result { + Ok(pack) => { + self.status = format!("{} 已保存", pack.name); + self.frontend_vm.style_editor_open = false; + self.style_editor = None; + self.load_library(); + } + Err(error) => { + self.frontend_vm.style_notice = Some(error.clone()); + self.status = error; + } + } + } + UiResult::SettingsSaved(result) => match *result { + Ok(outcome) => { + self.preferences = Some(outcome.preferences.clone()); + 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()) + }); + } + } + Err(error) => { + self.status = error; + if let Some(backend) = self.backend() { + self.preferences = Some(backend.get_preferences()); + } + self.settings_dirty = SettingsDirty::default(); + } + }, + UiResult::Marketplace { generation, result } => { + if generation != self.marketplace_ui.generation { + continue; + } + self.marketplace_ui.loading = false; + match result { + Ok((items, likes)) => { + self.marketplace_items = items; + self.marketplace_my_likes = likes; + self.frontend_vm.marketplace_notice = None; + } + Err(error) => { + self.frontend_vm.marketplace_notice = Some(error.clone()); + self.status = error; + } } - HostAction::ShowLessComputer => { - self.navigation.open(Page::Agent); - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + UiResult::MarketplaceMutation(result) => { + self.marketplace_ui.busy = false; + self.status = result.unwrap_or_else(|e| e); + self.load_marketplace(); + if self.marketplace_ui.mine_open { + self.load_marketplace_mine(); } - HostAction::FocusMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + 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::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(); - }); + openless_core::OAuthPollResult::Pending => { + self.status = tr_l10n(lang, "status.oauth_pending").to_string(); } - HostAction::RequestRestart => { - self.status = "请手动重启 OpenLess".to_string(); + openless_core::OAuthPollResult::SlowDown => { + self.status = tr_l10n(lang, "status.oauth_slowdown").to_string(); } - HostAction::ShowSelectionPreview => { - self.navigation.open(Page::Selection); - self.selection_preview_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + openless_core::OAuthPollResult::Error { message } => { + self.status = message; } - HostAction::HideSelectionPreview => { - self.selection_preview_visible = false; + }, + UiResult::MarketplaceAuthPoll(Err(error)) => self.status = error, + UiResult::MarketplaceDetail(Ok(detail)) => { + self.status = + fmt_l10n(lang, "status.detail_loaded", &[&detail.summary.name]); + if self + .frontend_vm + .marketplace_selected + .and_then(|i| self.marketplace_items.get(i)) + .is_some_and(|p| p.id == detail.summary.id) + { + self.frontend_vm.marketplace_prompt = Some(detail.prompt.clone()); + self.marketplace_detail = Some(detail); } - HostAction::ShowQa => { - self.navigation.open(Page::Qa); - self.qa_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + 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(); + } + } + } + UiResult::Microphones(Err(error)) => self.status = error, + UiResult::Overview(generation, Ok(data)) => { + if generation + == self + .overview_generation + .load(std::sync::atomic::Ordering::Acquire) + { + self.overview = OverviewState::Loaded(data); } - HostAction::HideQa => self.qa_visible = false, - HostAction::ShowDictationFeedback | HostAction::HideDictationFeedback => {} + } + UiResult::Overview(generation, Err(error)) => { + if generation + != self + .overview_generation + .load(std::sync::atomic::Ordering::Acquire) + { + continue; + } + 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_cancellation = None; + self.update_busy = false; + self.update_manifest = None; + self.status = + fmt_l10n(lang, "update.installed_restart", &[&installed.version]); + } + UiResult::UpdateInstalled(Err(error)) => { + self.update_cancellation = None; + 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(); + } + + fn less_computer_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.heading("Less Computer"); + ui.text_edit_multiline(&mut self.less_computer_input); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.run")).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.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.snapshot = Some(snapshot); - for event in replay.events { - self.apply_event(event); + self.spawn(async move { + backend.submit_less_computer(prompt).await?; + Ok(tr_l10n(lang, "less_computer.done").to_string()) + }); } - self.status = if replay.truncated { - format!("事件积压 {dropped} 条,已重置派生界面并重放可用事件") - } else { - format!("事件积压 {dropped} 条,已从 Core 重放补齐") - }; } - } - while let Ok(result) = self.rx.try_recv() { - match result { - UiResult::Environment(environment) => { - self.environment = Some(environment); - self.environment_refreshing = false; - } - 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; + if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_less_computer(None).await?; + Ok(tr_l10n(lang, "less_computer.cancelled").to_string()) + }); } - UiResult::Remote(Ok(remote)) => { - self.remote_error = None; - self.remote_access = Some(remote); + } + }); + if let Some((token, command)) = self.pending_approval.clone() { + ui.label(fmt_l10n(lang, "approval.request_run", &[&command])); + ui.horizontal(|ui| { + for (label, approved) in [ + (tr_l10n(lang, "btn.allow"), true), + (tr_l10n(lang, "btn.deny"), false), + ] { + if ui.button(label).clicked() { + if let Some(backend) = self.backend() { + let token = token.clone(); + self.pending_approval = None; + self.spawn(async move { + backend + .services() + .less_computer + .approve(token, approved) + .await?; + Ok(tr_l10n(lang, "approval.submitted").to_string()) + }); + } + } } - UiResult::Remote(Err(error)) => { - self.remote_access = None; - self.remote_error = Some(error.clone()); - self.status = error; + }); + } + ui.label(if self.less_computer_output.is_empty() { + tr_l10n(lang, "less_computer.no_output") + } else { + &self.less_computer_output + }); + } + + fn provider_management_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.horizontal(|ui| { + ui.strong(tr_l10n(lang, "providers.credentials")); + 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); } - 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 + } + if ui.button(tr_l10n(lang, "btn.refresh_channel")).clicked() { + self.providers = ProvidersState::Loading; + self.load_providers(self.provider_kind); + } + }); + + let panel = match self.providers.clone() { + ProvidersState::Loading => { + ui.label(tr_l10n(lang, "providers.loading_dir")); + return; + } + ProvidersState::Failed(error) => { + ui.colored_label(egui::Color32::RED, error); + return; + } + ProvidersState::Loaded(panel) => panel, + }; + + ui.group(|ui| { + ui.label(tr_l10n(lang, "btn.new_channel")); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("new-provider-type") + .selected_text( + 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; + .iter() + .find(|item| item.provider_type.as_str() == self.new_provider_type) + .map(provider_descriptor_label) + .unwrap_or_else(|| { + tr_l10n(lang, "lbl.choose_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), + ); + } + }); + ui.text_edit_singleline(&mut self.new_channel_name); + if ui + .add_enabled( + !self.new_provider_type.is_empty(), + egui::Button::new(tr_l10n(lang, "btn.create")), + ) + .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 { + backend.create_channel(kind, provider_type, name).await?; + Ok(tr_l10n(lang, "status.channel_created").to_string()) + }); } - self.providers = ProvidersState::Loaded(panel.clone()); + } + }); + ui.small(tr_l10n(lang, "providers.core_note")); + }); + + if panel.channels.is_empty() { + ui.label(tr_l10n(lang, "providers.empty")); + 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()); + let active_suffix = if active { + tr_l10n(lang, "btn.status_active") + } else { + "" + }; + let disabled_suffix = if channel.enabled { + "" + } else { + tr_l10n(lang, "btn.status_disabled") + }; + if ui + .selectable_label( + selected, + format!( + "{} · {}{active_suffix}{disabled_suffix}", + channel.name, channel.provider_type, + ), + ) + .clicked() + { + self.selected_channel_id = Some(channel.id.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; + 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); } } - UiResult::Providers(Err(error)) => { - self.providers = ProvidersState::Failed(error.clone()); - self.status = error; + if !active + && channel.enabled + && ui.button(tr_l10n(lang, "btn.set_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(tr_l10n(lang, "status.channel_active").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; + if ui + .button(if channel.enabled { + tr_l10n(lang, "btn.disable") + } else { + tr_l10n(lang, "btn.enable") + }) + .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(tr_l10n(lang, "status.channel_enabled").to_string()) + }); } - 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)); - } - Err(error) => { - self.provider_editor = ProviderEditorState::Failed(error.clone()); - self.status = error; - } + } + if index > 0 && ui.button(tr_l10n(lang, "btn.move_up")).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(tr_l10n(lang, "status.channel_reordered").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, - } + if index + 1 < panel.channels.len() + && ui.button(tr_l10n(lang, "btn.move_down")).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(tr_l10n(lang, "status.channel_reordered").to_string()) + }); } } - UiResult::ProviderMutation(result) => { - match result { - Ok(message) => self.status = message, - Err(error) => self.status = error, + if self.pending_channel_delete.as_deref() == Some(channel.id.as_str()) { + if ui.button(tr_l10n(lang, "btn.confirm_delete")).clicked() { + self.pending_channel_delete = None; + 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(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + } } - self.providers = ProvidersState::Loading; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(self.provider_kind); + if ui.button(tr_l10n(lang, "btn.cancel_delete")).clicked() { + self.pending_channel_delete = None; + } + } else if ui.button(tr_l10n(lang, "btn.delete")).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()); } - } - } - if let Some(backend) = self.backend() { - self.snapshot = Some(backend.snapshot()); + }); } - } - 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()) - }); - } - } - 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())) - }); - } - } - 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()) - }); - } + match self.provider_editor.clone() { + ProviderEditorState::Idle => {} + ProviderEditorState::Loading { kind, channel_id } => { + ui.label(fmt_l10n( + lang, + "providers.reading_channel", + &[&format!("{kind:?}"), &channel_id], + )); } - }); - 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()) - }); - } + ProviderEditorState::Failed(error) => { + ui.colored_label(egui::Color32::RED, error); } - 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()) + ProviderEditorState::Loaded(editor) => { + let mut editor = *editor; + ui.separator(); + ui.strong(fmt_l10n(lang, "providers.editing", &[&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 { + backend + .set_channel_provider_type(kind, channel_id, provider_type) + .await?; + Ok(tr_l10n(lang, "status.provider_type_updated").to_string()) + }); + } + return; } - } - }); - 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.label(fmt_l10n( + lang, + "providers.auth_probe", + &[ + &auth_requirement_label(lang, editor.descriptor.auth_requirement), + &format!("{:?}", editor.descriptor.validation_probe), + ], + )); + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "providers.name")); + ui.text_edit_singleline(&mut editor.name); }); - ui.horizontal(|ui| { - for (label, approved) in [("允许", true), ("拒绝", false)] { - if ui.button(label).clicked() { + provider_fields_ui(ui, lang, &mut editor); + + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.save_fields")).clicked() { if let Some(backend) = self.backend() { - let token = token.clone(); - self.pending_approval = None; - self.spawn(async move { - backend - .services() - .less_computer - .approve(token, approved) - .await?; - Ok("审批已提交".to_string()) + let saved = editor.clone(); + self.spawn_provider_mutation(async move { + save_provider_editor(backend, saved).await?; + Ok(tr_l10n(lang, "status.channel_saved").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.clear_secret")).clicked() { + if let Some(backend) = self.backend() { + let cleared = editor.clone(); + self.spawn_provider_mutation(async move { + clear_provider_secrets(backend, &cleared).await?; + Ok(tr_l10n(lang, "status.secret_cleared").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.validate")).clicked() { + 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(lang, backend, kind, channel_id).await }); } } + if ui.button(tr_l10n(lang, "btn.list_models")).clicked() { + self.provider_models.clear(); + self.request_provider_models(editor.kind, editor.channel.id.clone()); + } + }); + if !self.provider_models.is_empty() { + ui.label(tr_l10n(lang, "providers.model_list")); + for model in self.provider_models.clone() { + if ui.button(&model).clicked() { + editor.model = model; + } + } } - }); + self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + } } } - 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()) - }); - } - } + /// 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; } - if let Some(state) = &self.qa_state { - if let Some(messages) = &state.messages { - for message in messages { - ui.label(format!("{}:{}", message.role, message.content)); - } - } - if let Some(chunk) = &state.chunk { - ui.label(chunk); - } - if let Some(error) = &state.error { - ui.colored_label(egui::Color32::RED, error); - } + self.locale_pref = pref; + self.lang = pref.resolve(); + if let Some(tray) = &self.tray { + let _ = tray.set_lang(self.lang); } - ui.text_edit_multiline(&mut self.qa_input); + 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). + fn language_selector_ui(&mut self, ui: &mut egui::Ui) { + let mut chosen: Option = None; 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()) - }); - } - } - 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()) - }); - } - } - if ui.button("关闭").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.dismiss().await?; - Ok("问答已关闭".to_string()) - }); + ui.label(egui::RichText::new(tr_l10n(self.lang, "settings.language")).strong()); + let pref = self.locale_pref; + let lang = self.lang; + let selected = match pref { + LocalePref::System => { + tr_l10n(lang, "settings.language_follow_system").to_string() } - } - if 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()) - }); + LocalePref::Lang(explicit) => { + tr_l10n(explicit, locale_key(explicit)).to_string() } - } + }; + egui::ComboBox::from_id_salt("openless-ui-language") + .width(240.0) + .selected_text(selected) + .show_ui(ui, |ui| { + if ui + .selectable_label( + pref == LocalePref::System, + tr_l10n(lang, "settings.language_follow_system"), + ) + .clicked() + { + chosen = Some(LocalePref::System); + } + for option in LANGS { + let native_label = tr_l10n(option, locale_key(option)); + if ui + .selectable_label(pref == LocalePref::Lang(option), native_label) + .clicked() + { + chosen = Some(LocalePref::Lang(option)); + } + } + }); }); + if let Some(pref) = chosen { + self.apply_locale_pref(pref); + self.status = tr_l10n(self.lang, "settings.locale_saved").to_string(); + ui.ctx().request_repaint(); + } } - 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; + 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 vm = &mut self.frontend_vm; + vm.pending_corrections = backend + .as_ref() + .map(|b| b.pending_corrections()) + .unwrap_or_default(); + + // 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, }; - 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()) - }); - } - } - if ui.button("取消").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) - }); - } - } + + vm.status = self.status.clone(); + vm.history_audio_playing = self.playback.is_playing(); + vm.version = env!("CARGO_PKG_VERSION").to_string(); + + // 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, + duration_ms: entry.duration_ms, + }) + .collect(), + last_7_segments: summary.last_7.segments, + last_30_segments: summary.last_30.segments, + heatmap_weeks: summary.heatmap_weeks, + heatmap_days: summary.heatmap_days, + activity_days_total: summary.activity_days_total, }); - } else if selection.phase == SelectionPhase::Completed - && selection.revert_outcome.is_none() + } 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; + 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, + }; + s.recording_enabled = true; + s.realtime_mode = matches!( + prefs.hotkey.mode, + openless_core::shared_types::HotkeyMode::Hold + ); + s.restore_clipboard = true; + s.remember_history = true; + s.local_model = true; + s.selection_voice = prefs.selection_voice_enabled; + s.restore_clipboard = prefs.restore_clipboard_after_paste; + vm.qa_save_history = prefs.qa_save_history; + vm.translation_target_language = prefs.translation_target_language.clone(); + vm.translation_working_languages = prefs.working_languages.clone(); + vm.selection_unsupported = false; + vm.translation_unsupported = false; + } + + // History: wire from Core when backend is available. + if let OverviewState::Loaded(data) = &self.overview { + { + let history = data.history.clone(); + vm.history_entries = history + .into_iter() + .map(|item| frontend::view_model::HistoryEntry { + id: item.id.clone(), + time: item.created_at.clone(), + raw: item.raw_transcript.clone(), + mode: format!("{:?}", item.mode), + has_audio: item.has_audio_recording.unwrap_or(false), + asr: item.asr_provider.clone().unwrap_or_default(), + llm: item.llm_provider.clone().unwrap_or_default(), + asr_ms: item.asr_ms, + polish_ms: item.polish_ms, + text: item.final_text, + duration: item + .duration_ms + .map(|d| format_duration(d, lang)) + .unwrap_or_default(), + tag: match item.insert_status { + HistoryInsertStatus::Inserted => "已插入", + HistoryInsertStatus::CopiedFallback => "已复制", + HistoryInsertStatus::PasteSent => "已发送", + HistoryInsertStatus::Failed => "失败", + HistoryInsertStatus::NotRequested => "未请求", + } + .to_string(), + }) + .collect(); + } + } + + // Vocabulary: wire from existing data. { - ui.horizontal(|ui| { - ui.label("最近一次选区替换已完成"); - if ui.button("撤销").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - self.spawn(async move { - backend.services().selection.revert(session_id).await?; - Ok("选区替换已撤销".to_string()) - }); - } - } - }); + 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(); + } + + // Correction rules: wire from existing data. + { + vm.vocab_unsupported = false; + 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 { + id: preset.id.clone(), + name: preset.name.clone(), + phrases: preset.phrases.join("、"), + }) + .collect(); + + // Style packs: wire from existing data. + { + 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(), + tags: vec![pack.base_mode.display_name().to_string()], + accent: theme::blue(), + is_builtin: pack.kind == openless_core::StylePackKind::Builtin, + is_active: if vm.style_selection_workflow { + self.preferences + .as_ref() + .is_some_and(|p| p.selection_polish_style_pack_id == pack.id) + } else { + pack.active + }, + }) + .collect(); + } + + vm.style_selected = vm + .style_packs + .iter() + .position(|p| p.is_active) + .unwrap_or(usize::MAX); + // Marketplace: wire from Core data when available. + { + vm.marketplace_loading = self.marketplace_ui.loading; + vm.marketplace_query = self.marketplace_query.clone(); + vm.marketplace_liked = self + .marketplace_items + .iter() + .enumerate() + .filter_map(|(i, p)| self.marketplace_my_likes.contains(&p.id).then_some(i)) + .collect(); + vm.marketplace_unsupported = 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, + is_new: false, + }) + .collect(); + } + + // Startup error. + if let Some(error) = &self.startup_error { + vm.status = format!("启动失败: {error}"); } } - 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(); + /// 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. + 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; } - }); - let models = match self.models.clone() { - ModelsState::Loading => { - ui.label("正在加载模型目录…"); - return; + frontend::view_model::SettingsField::StartMinimized => { + preferences.start_minimized = !preferences.start_minimized; + self.settings_dirty.start_minimized = true; } - ModelsState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; + frontend::view_model::SettingsField::AutoUpdate => { + preferences.auto_update_check = !preferences.auto_update_check; + self.settings_dirty.auto_update_check = true; } - ModelsState::Loaded(models) if models.is_empty() => { - ui.label("模型目录未返回任何可用模型"); - return; + frontend::view_model::SettingsField::RemoteInput => { + preferences.remote_input_enabled = !preferences.remote_input_enabled; + self.settings_dirty.remote_input_enabled = true; } - ModelsState::Loaded(models) => models, - }; - for model in models { - ui.horizontal(|ui| { - ui.label(format!( - "{} · {} · {}", - model.display_name, - model.family, - if model.installed { - "已安装" - } else { - "未安装" - } - )); - if !model.installed && ui.button("下载").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - backend - .services() - .local_asr - .start_download(target, None) - .await?; - Ok("模型下载完成".to_string()) - }); - } - } - if model.installed && ui.button("激活").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - let descriptor = - openless_core::provider_rules::provider_descriptor( - openless_core::ProviderKind::Asr, - "local-qwen3-c", - ) - .ok_or_else(|| { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Unsupported, - "local Qwen provider is unavailable", - ) - })?; - 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, - }) - .await?; - Ok("本地模型已激活并预加载".to_string()) - }); - } - } - if ui.button("取消").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - backend.services().local_asr.cancel_download(target).await?; - Ok("模型下载已取消".to_string()) - }); + frontend::view_model::SettingsField::ActivityHeatmap => { + preferences.show_overview_activity_heatmap = + !preferences.show_overview_activity_heatmap; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::RealtimeMode => { + preferences.hotkey.mode = match preferences.hotkey.mode { + openless_core::shared_types::HotkeyMode::Hold => { + openless_core::shared_types::HotkeyMode::Toggle } - } - }); + _ => openless_core::shared_types::HotkeyMode::Hold, + }; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::RecordingEnabled => { + self.settings_dirty.recording = true; + // Toggle recording enabled state — no-op on preferences directly, + // but marks dirty so save will apply. + } + frontend::view_model::SettingsField::RestoreClipboard + | frontend::view_model::SettingsField::StackedLayout + | frontend::view_model::SettingsField::ConservativeLayout + | frontend::view_model::SettingsField::SystemProxy + | frontend::view_model::SettingsField::RememberHistory + | frontend::view_model::SettingsField::RecordAudio + | frontend::view_model::SettingsField::LessComputer + | frontend::view_model::SettingsField::Multimodal + | frontend::view_model::SettingsField::BetaChannel => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::SelectionAssistant => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::SelectionVoice => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::LocalModel => { + // Linux does not support local model inference. + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::MarketplaceEnabled => { + self.frontend_vm.marketplace_unsupported = + !self.frontend_vm.marketplace_unsupported; + } } + self.save_settings_if_dirty(); } - 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); - } - } - if ui.button("刷新渠道").clicked() { - self.providers = ProvidersState::Loading; - self.load_providers(self.provider_kind); + /// 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; } - }); - - let panel = match self.providers.clone() { - ProvidersState::Loading => { - ui.label("正在读取 Core 渠道目录…"); - return; + 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; } - ProvidersState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; + frontend::view_model::SettingsComboField::Provider + | frontend::view_model::SettingsComboField::Retention + | frontend::view_model::SettingsComboField::Microphone + | frontend::view_model::SettingsComboField::RecordingMode => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - ProvidersState::Loaded(panel) => panel, - }; - - ui.group(|ui| { - ui.label("新增渠道"); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("new-provider-type") - .selected_text( - panel - .descriptors - .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), - ); - } - }); - 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 { - backend.create_channel(kind, provider_type, name).await?; - Ok("渠道已创建".to_string()) - }); - } - } - }); - ui.small("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。"); - }); + } + self.save_settings_if_dirty(); + } - if panel.channels.is_empty() { - ui.label("尚无渠道;先从上方 Core Provider 列表创建一个。"); + /// 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; + } + } + frontend::view_model::SettingsTextField::ApiKey => { + self.frontend_vm.settings.api_key = text; + } + frontend::view_model::SettingsTextField::Endpoint => { + self.frontend_vm.settings.endpoint = text; + } + frontend::view_model::SettingsTextField::Model => { + self.frontend_vm.settings.model = text; + } + frontend::view_model::SettingsTextField::ClaudePrompt => { + self.frontend_vm.settings.claude_prompt = text; + } } + self.save_settings_if_dirty(); + } - 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); - } + /// 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::ConnectionTest => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsActionField::ClearHistory => { + 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()) + }); } - 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::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() + }) + .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()) + }); + } + } + 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/Open-Less/openless"); + } + frontend::view_model::SettingsActionField::OpenHelp => { + let _ = open_external("https://github.com/Open-Less/openless"); + } + frontend::view_model::SettingsActionField::OpenReleaseNotes => { + let _ = open_external("https://github.com/Open-Less/openless/releases"); + } + frontend::view_model::SettingsActionField::OpenFeedback => { + let _ = open_external("https://github.com/Open-Less/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()); + } + Err(error) => { + self.frontend_vm.settings_notice = Some(format!("复制失败: {error}")); } } - if ui - .button(if channel.enabled { "禁用" } else { "启用" }) - .clicked() - { + } + frontend::view_model::SettingsActionField::ModelManagement + | frontend::view_model::SettingsActionField::ExtensionManagement + | frontend::view_model::SettingsActionField::Permissions + | frontend::view_model::SettingsActionField::ClaudeDetect + | frontend::view_model::SettingsActionField::ClaudeConsole + | frontend::view_model::SettingsActionField::ClaudeRunTest => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + } + } + + /// 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))); + }); + } + } + + /// Dispatch frontend actions to existing Core / backend methods. + fn apply_frontend_actions( + &mut self, + actions: Vec, + ctx: &egui::Context, + ) { + for action in actions { + match action { + frontend::view_model::FrontendAction::AcceptCorrection(id) => { 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()) + self.spawn(async move { + backend.accept_pending_correction(&id)?; + Ok("已接受纠错建议".into()) }); } } - if index > 0 && ui.button("上移").clicked() { + frontend::view_model::FrontendAction::RejectCorrection(id) => { 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()) - }); + backend.reject_pending_correction(&id); } } - if index + 1 < panel.channels.len() && 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::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, + }; + } + frontend::view_model::FrontendAction::ToggleSettings => { + self.frontend_vm.settings_open = !self.frontend_vm.settings_open; + } + frontend::view_model::FrontendAction::CloseSettings => { + self.frontend_vm.settings_open = false; + } + frontend::view_model::FrontendAction::SidebarToggleStyle => { + self.frontend_vm.style_open = !self.frontend_vm.style_open; + } + frontend::view_model::FrontendAction::SidebarToggleTools => { + self.frontend_vm.tools_open = !self.frontend_vm.tools_open; + } + frontend::view_model::FrontendAction::WindowClose => { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + frontend::view_model::FrontendAction::WindowMaximize => { + let maximized = + ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + frontend::view_model::FrontendAction::WindowMinimize => { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + frontend::view_model::FrontendAction::MarketplaceRefresh => { + self.load_marketplace(); + } + frontend::view_model::FrontendAction::MarketplaceMyPacks => { + self.marketplace_ui.mine_open = true; + self.load_marketplace_mine(); + } + frontend::view_model::FrontendAction::MarketplaceSearch(query) => { + self.marketplace_query = query; + self.marketplace_ui.search_due = + Some(std::time::Instant::now() + std::time::Duration::from_millis(350)); + } + 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], + )) + }); + } } } - 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::MarketplaceDownload(index) => { + if let Some(item) = self.marketplace_items.get(index) { 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 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)); }); } } - if ui.button("取消删除").clicked() { - self.pending_channel_delete = None; + } + frontend::view_model::FrontendAction::MarketplaceToggleLike(index) => { + if let Some(item) = self.marketplace_items.get(index) { + self.mutate_marketplace(MarketplaceMutation::Like(item.id.clone())); } - } 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), - ); + frontend::view_model::FrontendAction::MarketplaceSort(sort) => { + self.frontend_vm.marketplace_sort = sort; + self.load_marketplace(); + } + frontend::view_model::FrontendAction::HistoryClear => { + self.history_confirmation = Some(None) + } + frontend::view_model::FrontendAction::HistoryRefresh => { + self.frontend_vm.history_cleared = false; + self.load_overview(); + } + frontend::view_model::FrontendAction::HistorySearch(query) => { + self.frontend_vm.history_query = query; + } + frontend::view_model::FrontendAction::HistoryFilter(index) => { + self.frontend_vm.history_filter = index; + } + frontend::view_model::FrontendAction::HistorySelect(index) => { + self.frontend_vm.history_selected = index; + } + frontend::view_model::FrontendAction::HistoryDelete(index) => { + self.history_confirmation = self + .frontend_vm + .history_entries + .get(index) + .map(|e| Some(e.id.clone())); + } + 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()], + )) + }); } - }); - if provider_type != editor.descriptor.provider_type.as_str() { + } + } + frontend::view_model::FrontendAction::HistoryRepolish => { + self.history_transform(false) + } + frontend::view_model::FrontendAction::HistoryRetranscribe => { + self.history_transform(true) + } + frontend::view_model::FrontendAction::HistoryCancel => { + self.history_generation += 1; + if let Some(task) = self.history_task.take() { + task.abort(); + } + self.frontend_vm.history_busy = false; + self.status = "已取消".into(); + } + frontend::view_model::FrontendAction::HistoryTogglePlay => { + let playback = self.playback.clone(); + if let (Some(backend), Some(entry)) = ( + self.backend(), + self.frontend_vm + .history_entries + .get(self.frontend_vm.history_selected), + ) { + let directory = backend.config().data_dir.clone(); + let id = entry.id.clone(); + self.spawn(async move { + tokio::task::spawn_blocking(move || { + if playback.is_playing() { + playback.stop(); + Ok("播放已停止".into()) + } else { + playback + .play(&directory, &id) + .map(|_| "正在播放录音".into()) + .map_err(|e| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + e, + ) + }) + } + }) + .await + .map_err(|e| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + e.to_string(), + ) + })? + }); + } + } + frontend::view_model::FrontendAction::VocabAddPhrase(phrase) => { if let Some(backend) = self.backend() { - let kind = editor.kind; - let channel_id = editor.channel.id.clone(); - self.spawn_provider_mutation(async move { - backend - .set_channel_provider_type(kind, channel_id, provider_type) - .await?; - Ok("Provider 类型已更新".to_string()) + let lang = self.lang; + self.spawn(async move { + backend.add_vocabulary(phrase, None)?; + Ok(tr_l10n(lang, "status.vocab_saved").to_string()) }); } - 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::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()) }); } } - if ui.button("清除 Secret").clicked() { - 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()) + } + 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()) }); } } - if ui.button("验证连接").clicked() { - 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 + } + frontend::view_model::FrontendAction::VocabAddRule { + pattern, + replacement, + } => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend.add_correction_rule(pattern, replacement)?; + Ok(tr_l10n(lang, "status.correction_saved").to_string()) + }); + } + } + 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()) + }); + } + } + } + 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()) }); } } - 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; + } + frontend::view_model::FrontendAction::VocabRefresh => self.load_library(), + frontend::view_model::FrontendAction::VocabApplyPreset(index) => { + if index != usize::MAX { + let selected = &mut self.frontend_vm.vocab_selected_presets; + if selected.contains(&index) { + selected.retain(|i| *i != index); + } else { + selected.push(index); } + } else if let Some(backend) = self.backend() { + let presets: Vec<_> = self + .frontend_vm + .vocab_selected_presets + .iter() + .filter_map(|i| self.vocab_presets.get(*i)) + .cloned() + .collect(); + self.spawn(async move { + for preset in presets { + for phrase in preset.phrases { + backend + .add_vocabulary(phrase, Some(preset.name.clone()))?; + } + } + Ok("词汇预设已应用".into()) + }); } } - 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::VocabCreatePreset { + id, + name, + phrases, + } => { + if let Some(backend) = self.backend() { + self.spawn(async move { + let mut phrases: Vec = phrases + .split([',', ',', '、', '\n']) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(ToOwned::to_owned) + .collect(); + phrases.sort(); + phrases.dedup(); + let mut store = backend.list_vocabulary_presets()?; + let id = id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let preset = openless_core::VocabPreset { + id: id.clone(), + name: name.trim().to_owned(), + phrases, + }; + let list = if openless_core::builtin_vocab_presets() + .iter() + .any(|p| p.id == id) + { + &mut store.overrides + } else { + &mut store.custom + }; + list.retain(|p| p.id != id); + list.push(preset); + backend.save_vocabulary_presets(&store)?; + Ok("预设已保存".into()) + }); + } } - )); - ui.label(format!( - "LLM:{}({})", - credentials.active_llm_provider, - if credentials.llm_configured { - "已配置" - } else { - "未配置" + frontend::view_model::FrontendAction::VocabDeletePreset(index) => { + if let (Some(backend), Some(preset)) = + (self.backend(), self.vocab_presets.get(index)) + { + let id = preset.id.clone(); + self.spawn(async move { + let mut store = backend.list_vocabulary_presets()?; + store.custom.retain(|p| p.id != id); + store.overrides.retain(|p| p.id != id); + if openless_core::builtin_vocab_presets() + .iter() + .any(|p| p.id == id) + && !store.disabled_builtin_preset_ids.contains(&id) + { + store.disabled_builtin_preset_ids.push(id); + } + backend.save_vocabulary_presets(&store)?; + Ok("预设已删除".into()) + }); + } } - )); - } - 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::StyleActivate(index) => { + self.activate_style_v2(index) } - } - 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::StyleRefresh => self.load_library(), + frontend::view_model::FrontendAction::StyleReset => self.reset_style_v2(), + frontend::view_model::FrontendAction::StyleDelete => { + if let Some(pack) = &self.style_editor { + self.style_delete_pending = Some(pack.id.clone()); + } } - } - }); - 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::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(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_export_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + }) + .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()) + }); } } - None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"), } - ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); - ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); - ui.monospace(format!("PIN:{pin}")); - for url in &remote.urls { - ui.monospace(url); + frontend::view_model::FrontendAction::StyleEdit(index) => { + if let Some(pack) = self.style_packs.get(index).cloned() { + self.open_style_v2(pack); + } } - if remote.urls.is_empty() { - ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); + frontend::view_model::FrontendAction::StyleSaveEditor(prompt) => { + self.save_style_v2(prompt) } - } - 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()) - }); + frontend::view_model::FrontendAction::StyleCloseEditor => { + self.style_editor = None; + self.frontend_vm.style_editor_open = false; } - } - } - } - - 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 => { - "麦克风:当前探测环境不支持;请进入图形桌面会话。" + frontend::view_model::FrontendAction::StyleNewPack => { + self.open_style_v2(openless_core::StylePack { + id: uuid::Uuid::new_v4().to_string(), + name: tr_l10n(self.lang, "lbl.new_style_default").to_string(), + ..Default::default() + }) } - _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", - }); - } 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/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。"); - }); - } - - 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。" - }); + frontend::view_model::FrontendAction::StyleImport => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + 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])) + }); + } } - 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 润色:已配置。" + frontend::view_model::FrontendAction::SelectionAskToggleHistory => { + self.save_field_edits(std::collections::BTreeMap::from([( + "/qaSaveHistory".into(), + serde_json::json!(!self.frontend_vm.qa_save_history), + )])); + } + frontend::view_model::FrontendAction::TranslationToggleLanguage(language) => { + let mut languages = self.frontend_vm.translation_working_languages.clone(); + if languages.contains(&language) { + languages.retain(|s| s != &language); } else { - "LLM 润色:尚未配置。" - }); + languages.push(language); + } + self.save_field_edits(std::collections::BTreeMap::from([( + "/workingLanguages".into(), + serde_json::json!(languages), + )])); } - } - 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); + frontend::view_model::FrontendAction::TranslationSetTarget(language) => { + self.save_field_edits(std::collections::BTreeMap::from([( + "/translationTargetLanguage".into(), + serde_json::json!(language), + )])); } - } - }); - 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); - } + frontend::view_model::FrontendAction::SettingsToggle(field) => { + self.apply_settings_toggle(field); } - }); - ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); - ui.small( - "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", - ); - } - } - - 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, - } - } - - 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 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); - } + frontend::view_model::FrontendAction::SettingsCombo(field, index) => { + self.apply_settings_combo(field, index); } - } - 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()) - }); + frontend::view_model::FrontendAction::SettingsText(field, text) => { + self.apply_settings_text(field, text); } - } - 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()) - }); - } + frontend::view_model::FrontendAction::SettingsAction(field) => { + self.apply_settings_action(field); } - } - 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()) - }); + frontend::view_model::FrontendAction::SettingsSection(section) => { + self.frontend_vm.settings_section = section; } - } - }); - } - - fn cancel_voice(&self) { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_active_voice_session(None).await?; - Ok("语音会话已取消".to_string()) - }); - } - } - - fn history_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("历史"); - ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); - let Some(backend) = self.backend() else { - return; - }; - 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 - )); + frontend::view_model::FrontendAction::SettingsNotice(msg) => { + self.frontend_vm.settings_notice = Some(msg); } - } - Err(error) => { - ui.label(error.to_string()); - } - } - } - } - - 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); - } - }); - 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); + frontend::view_model::FrontendAction::MarketplaceDetail(index) => { + self.frontend_vm.marketplace_selected = Some(index); + self.frontend_vm.marketplace_prompt = None; + 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)); + }); } - 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), } - }); - }); - ctx.request_repaint_after(Duration::from_millis(50)); + } + } + } } } - 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()); + impl eframe::App for OpenLessEguiApp { + 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) { + openless_linux_egui::ui_catalog::set_language(self.lang); + self.poll(ctx); + self.drain_tray(ctx); + theme::apply_visuals( + ctx, + self.preferences + .as_ref() + .map(|preferences| preferences.theme_mode) + .unwrap_or_default(), + ); + 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); } - }); + if ctx.input(|input| input.viewport().close_requested()) + && !self.exit_requested + && self.tray.is_some() + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + if !self.frontend_vm.settings_open + && ctx.input(|input| input.key_pressed(egui::Key::Escape)) + { + let lang = self.lang; + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_active_voice_session(None).await?; + Ok(tr_l10n(lang, "voice.cancelled").to_string()) + }); + } + } + + // Build the view model from current backend state, then render the + // production frontend. Actions are collected and dispatched to + // existing Core / backend methods. + self.sync_view_model(); + let mut actions = Vec::new(); + frontend::render(ctx, &mut self.frontend_vm, &mut actions); + self.apply_frontend_actions(actions, ctx); + self.native_windows(ctx); + if let Some(scale) = self.restored_font_scale.take() { + ctx.set_zoom_factor(scale); + ctx.data_mut(|d| d.remove::(egui::Id::new("openless-font-scale"))); + } + self.marketplace_windows_v2(ctx); + self.history_confirmation_ui(ctx); + self.style_delete_confirmation_ui(ctx); + self.onboarding_v2(ctx); + if self.frontend_vm.settings_open { + self.settings_v2(ctx); + } + + ctx.request_repaint_after(Duration::from_millis(50)); + } } impl Drop for OpenLessEguiApp { @@ -2099,6 +4132,30 @@ mod linux_app { } } + include!("host_settings.rs"); + include!("host_models.rs"); + include!("host_omni.rs"); + include!("host_windows.rs"); + include!("host_onboarding.rs"); + include!("host_history.rs"); + include!("host_styles.rs"); + include!("host_marketplace.rs"); + + /// 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 locale_key(lang: Lang) -> &'static str { + match lang { + Lang::ZhCn => "lang.zh-CN", + Lang::ZhTw => "lang.zh-TW", + Lang::En => "lang.en", + Lang::Ja => "lang.ja", + Lang::Ko => "lang.ko", + Lang::Es => "Español", + Lang::Fr => "Français", + Lang::De => "Deutsch", + } + } + fn provider_kind(kind: openless_core::ChannelKind) -> openless_core::ProviderKind { match kind { openless_core::ChannelKind::Asr => openless_core::ProviderKind::Asr, @@ -2161,21 +4218,25 @@ mod linux_app { ) } - fn auth_requirement_label(requirement: openless_core::AuthRequirement) -> &'static str { - match requirement { - openless_core::AuthRequirement::None => "无需 Secret", - openless_core::AuthRequirement::ApiKey => "API Key", + fn auth_requirement_label( + lang: Lang, + requirement: openless_core::AuthRequirement, + ) -> &'static str { + let key = match requirement { + openless_core::AuthRequirement::None => "auth.none", + openless_core::AuthRequirement::ApiKey => "auth.api_key", openless_core::AuthRequirement::EndpointModelOptionalApiKey => { - "Endpoint + Model,API Key 可选" + "auth.endpoint_model_optional" } openless_core::AuthRequirement::ApiKeyUnlessCustomEndpoint => { - "公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key" + "auth.api_key_unless_custom" } - openless_core::AuthRequirement::Volcengine => "火山引擎凭据", - openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key", - openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey", - openless_core::AuthRequirement::OAuth => "OAuth", - } + openless_core::AuthRequirement::Volcengine => "auth.volcengine", + openless_core::AuthRequirement::Xfyun => "auth.xfyun", + openless_core::AuthRequirement::OAuth => "auth.oauth", + openless_core::AuthRequirement::TencentCloud => "auth.api_key", + }; + tr_l10n(lang, key) } fn provider_channel_descriptor( @@ -2253,19 +4314,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(), @@ -2275,7 +4323,6 @@ mod linux_app { model, auth_mode, resource_id, - app_id, primary_secret: String::new(), secondary_secret: String::new(), }) @@ -2288,16 +4335,16 @@ mod linux_app { }); } - fn provider_fields_ui(ui: &mut egui::Ui, editor: &mut ProviderEditor) { + fn provider_fields_ui(ui: &mut egui::Ui, lang: Lang, 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 不使用云凭据;模型由本地模型面板管理。"); + ui.label(tr_l10n(lang, "providers.no_cloud_note")); } openless_core::AuthRequirement::OAuth => { - ui.label("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。"); + ui.label(tr_l10n(lang, "providers.oauth_note")); } openless_core::AuthRequirement::Volcengine => { egui::ComboBox::from_id_salt("volcengine-auth-mode") @@ -2333,20 +4380,12 @@ mod linux_app { secret_edit(ui, "AppID", &mut editor.primary_secret); secret_edit(ui, "API Key", &mut editor.secondary_secret); } - 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); - }); - } _ => { - secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); + secret_edit( + ui, + tr_l10n(lang, "providers.api_key_hint"), + &mut editor.primary_secret, + ); ui.horizontal(|ui| { ui.label("Endpoint"); ui.text_edit_singleline(&mut editor.endpoint); @@ -2481,40 +4520,6 @@ mod linux_app { ) .await?; } - 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?; - } _ => { write_or_remove_provider_value( &backend, @@ -2560,11 +4565,6 @@ mod linux_app { openless_core::credentials::XFYUN_APP_ID_ACCOUNT, openless_core::credentials::XFYUN_API_KEY_ACCOUNT, ], - 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, - ], _ => &[api_key_account(editor.kind)], }; for account in accounts { @@ -2580,6 +4580,7 @@ mod linux_app { } async fn validate_provider_channel( + lang: Lang, backend: Arc, kind: openless_core::ChannelKind, channel_id: String, @@ -2589,8 +4590,8 @@ mod linux_app { .services() .provider .validate(openless_core::ProviderRequest { - thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), + thinking_enabled: false, channel_id: Some(channel_id.clone()), }) .await; @@ -2600,7 +4601,7 @@ mod linux_app { backend .record_channel_test(kind, channel_id, true, Some(latency_ms), None) .await?; - Ok(format!("Provider 验证通过({latency_ms} ms)")) + Ok(fmt_l10n(lang, "status.provider_validated", &[&latency_ms])) } Err(error) => { let _ = backend @@ -2617,62 +4618,665 @@ mod linux_app { } } - fn package_kind() -> LinuxPackageKind { - if std::env::var_os("APPDIR").is_some() { - LinuxPackageKind::AppImage - } else if cfg!(debug_assertions) { - LinuxPackageKind::Development + 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 + } + + 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 package_kind() -> LinuxPackageKind { + if std::env::var_os("APPDIR").is_some() { + LinuxPackageKind::AppImage + } else if cfg!(debug_assertions) { + LinuxPackageKind::Development + } else { + LinuxPackageKind::SystemPackage + } + } + + 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 = 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 mut capabilities = LinuxCapabilitySnapshot::detect(tray_available, kind).capabilities; + capabilities.supports_auto_update &= updater_available; + capabilities.supports_overlay |= openless_linux_egui::desktop_bridge::adapter().is_some(); + 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())?; + reconcile_fcitx5_install(status) + } + + /// 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. + fn reconcile_fcitx5_install(status: FcitxPluginStatus) -> Result<(), String> { + match status { + FcitxPluginStatus::Ready => Ok(()), + FcitxPluginStatus::Updated => { + reload_running_fcitx5(); + Ok(()) + } + FcitxPluginStatus::Missing => { + Err("未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()) + } + } + } + + struct NativePopupApp { + kind: PopupKind, + state: PopupState, + incoming: mpsc::Receiver, + outgoing: mpsc::Sender, + qa_input: String, + outgoing_sequence: u64, + ready_sent: bool, + preview_focus_requested: bool, + lang: Lang, + } + + 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, + }, + }; + self.send(message); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + fn popup_heading(ui: &mut egui::Ui, title: &str) { + let response = ui + .horizontal(|ui| ui.heading(title)) + .response + .interact(egui::Sense::drag()); + if response.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + } + + /// Lightweight Markdown renderer ported from #997. It intentionally covers + /// the structures emitted by QA without introducing a WebView dependency. + fn render_popup_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 { + render_popup_code(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(4.0); + continue; + } + let (text, size, strong, italics, bullet) = + if let Some(value) = trimmed.strip_prefix("### ") { + (value, 14.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("## ") { + (value, 15.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("# ") { + (value, 16.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("> ") { + (value, 13.0, false, true, false) + } else if let Some(value) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + { + (value, 13.0, false, false, true) + } else { + (trimmed, 13.0, false, false, false) + }; + let display = if bullet { + format!("• {text}") + } else { + text.to_string() + }; + render_popup_inline(ui, &display, size, strong, italics); + } + if in_code && !code.is_empty() { + render_popup_code(ui, code.trim_end()); + } + } + + fn render_popup_code(ui: &mut egui::Ui, code: &str) { + egui::Frame::new() + .fill(theme::surface_2()) + .corner_radius(egui::CornerRadius::same(6)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(code).monospace().size(12.0)).wrap()); + }); + } + + fn render_popup_inline( + ui: &mut egui::Ui, + text: &str, + size: f32, + base_strong: bool, + base_italics: bool, + ) { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = ui.available_width(); + let mut rest = text; + while !rest.is_empty() { + let mut matched = false; + for (open, close, strong, italics, monospace) in [ + ("**", "**", true, false, false), + ("__", "__", true, false, false), + ("`", "`", false, false, true), + ("*", "*", false, true, false), + ("_", "_", false, true, false), + ] { + if let Some(after_open) = rest.strip_prefix(open) { + if let Some(end) = after_open.find(close) { + append_popup_text( + &mut job, + &after_open[..end], + size, + base_strong || strong, + base_italics || italics, + monospace, + ui, + ); + 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_popup_text( + &mut job, + &rest[..length], + size, + base_strong, + base_italics, + false, + ui, + ); + rest = &rest[length..]; + } + ui.add(egui::Label::new(job).wrap()); + } + + fn append_popup_text( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, + ui: &egui::Ui, + ) { + 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 { + ui.visuals().strong_text_color() + } else { + ui.visuals().text_color() + }, + background: if monospace { + theme::surface_2() + } else { + egui::Color32::TRANSPARENT + }, + italics, + ..Default::default() + }, + ); + } + + impl eframe::App for NativePopupApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let preference_tick = egui::Id::new("popup-preference-refresh"); + let now = std::time::Instant::now(); + if ctx + .data(|d| d.get_temp::(preference_tick)) + .is_none_or(|last| now.duration_since(last) >= std::time::Duration::from_secs(1)) + { + self.lang = openless_linux_egui::load_locale_pref().resolve(); + if let Some(scale) = + openless_linux_egui::load_ui_value("fontScale").and_then(|v| v.as_f64()) + { + ctx.set_zoom_factor(scale.clamp(0.85, 1.35) as f32); + } + ctx.data_mut(|d| d.insert_temp(preference_tick, now)); + } + openless_linux_egui::ui_catalog::set_language(self.lang); + while let Ok(message) = self.incoming.try_recv() { + if message + .content_kind() + .is_some_and(|message_kind| message_kind != self.kind) + { + continue; + } + if matches!(message, HostToPopup::Preview { .. }) { + self.preview_focus_requested = false; + } + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + let outcome = self.state.apply(message); + if outcome == openless_linux_egui::PopupApplyOutcome::Applied { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(self.state.visible)); + } + if shutdown || self.state.shutdown_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + } + if !self.ready_sent { + 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; + } + } + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + self.dismiss(ctx); + return; + } + let lang = self.lang; + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::surface()) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(18)), + ) + .show(ctx, |ui| match self.kind { + PopupKind::Preview => { + popup_heading(ui, tr_l10n(lang, "heading.insert_preview")); + ui.label(&self.state.preview.source); + let editor = ui.add( + egui::TextEdit::multiline(&mut self.state.preview.text) + .desired_rows(6) + .desired_width(f32::INFINITY), + ); + if !self.preview_focus_requested { + editor.request_focus(); + self.preview_focus_requested = true; + } + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { + self.dismiss(ctx); + } + if ui.button(tr_l10n(lang, "btn.insert")).clicked() { + 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: self.state.preview.text.clone(), + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + }); + } + PopupKind::Qa => { + popup_heading(ui, tr_l10n(lang, "heading.qa_preview")); + if let Some(selection) = &self.state.qa.selection_preview { + ui.label( + egui::RichText::new(selection) + .italics() + .color(theme::ink_3()), + ); + } + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for message in &self.state.qa.messages { + ui.label(egui::RichText::new(&message.role).strong()); + render_popup_markdown(ui, &message.content); + } + if !self.state.qa.streaming_answer.is_empty() { + render_popup_markdown(ui, &self.state.qa.streaming_answer); + } + if let Some(error) = &self.state.qa.error { + ui.colored_label(egui::Color32::RED, error); + } + }); + let input = ui.text_edit_singleline(&mut self.qa_input); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.close")).clicked() { + self.dismiss(ctx); + } + if ui + .button(if self.state.qa.phase == "Recording" { + tr_l10n(lang, "btn.stop_recording") + } else { + tr_l10n(lang, "btn.voice_ask") + }) + .clicked() + { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ToggleQaRecording { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + let submit = ui.button(tr_l10n(lang, "btn.send")).clicked() + || (input.lost_focus() + && ui.input(|state| state.key_pressed(egui::Key::Enter))); + if submit && !self.qa_input.trim().is_empty() { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + let text = std::mem::take(&mut self.qa_input); + self.send(PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + } + } + }); + } + PopupKind::Capsule => { + let response = ui + .horizontal(|ui| { + ui.spinner(); + ui.strong(&self.state.capsule.phase); + }) + .response + .interact(egui::Sense::drag()); + if response.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if !self.state.capsule.text.is_empty() { + ui.label(&self.state.capsule.text); + } + if let Some(level) = self.state.capsule.audio_level { + ui.add(egui::ProgressBar::new(level.clamp(0.0, 1.0))); + } + } + }); + ctx.request_repaint_after(Duration::from_millis(33)); + } + } + + 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 { - LinuxPackageKind::SystemPackage + None } } - 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()), - }) - } - - 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()) + fn run_popup_process(kind: PopupKind) -> Result<(), 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())?; + let size = match kind { + PopupKind::Qa => [520.0, 520.0], + PopupKind::Preview => [480.0, 300.0], + PopupKind::Capsule => [340.0, 112.0], + }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title(match kind { + PopupKind::Qa => "OpenLess QA", + PopupKind::Preview => "OpenLess Preview", + PopupKind::Capsule => "OpenLess Capsule", + }) + .with_inner_size(size) + .with_decorations(false) + .with_always_on_top() + .with_visible(false), + ..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(), + outgoing_sequence: 0, + ready_sent: false, + preview_focus_requested: false, + // The popup is a separate process, so it re-reads the + // persisted UI-locale preference rather than sharing state. + lang: load_locale_pref().resolve(), + })) + }), + ) + .map_err(|error| error.to_string()) } pub fn run() -> Result<(), String> { + let args = std::env::args().collect::>(); + if let Some(kind) = popup_kind(&args) { + return run_popup_process(kind); + } + let start_minimized = args.iter().any(|arg| arg == "--minimized"); let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?); - let config = backend_config()?; + let tray = openless_linux_egui::LinuxTray::start().ok(); + let tray_available = tray.is_some(); + let kind = package_kind(); + let update_support = LinuxUpdateSupport::initialize(kind); + let updater_available = update_support.supports_auto_update(); + 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 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"), @@ -2683,23 +5287,12 @@ mod linux_app { SingleInstanceRole::Primary(broker) => broker, SingleInstanceRole::Forwarded => return Ok(()), }; - let plugin_check = ensure_fcitx5_ready(&config); - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); 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()), - } + ensure_fcitx5_ready(&config)?; let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; let backend = { // Construction captures the existing executor for cpal/native @@ -2721,18 +5314,26 @@ mod linux_app { })(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([1040.0, 760.0]) - .with_min_inner_size([420.0, 400.0]), + .with_title("OpenLess") + .with_inner_size([1240.0, 800.0]) + .with_min_inner_size([960.0, 640.0]) + .with_decorations(false) + .with_transparent(true) + .with_resizable(true) + .with_visible(!start_minimized || !tray_available), ..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)) + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(OpenLessEguiApp::new( + tokio, + native, + tray, + update_support, + ))) }), ) .map_err(|error| error.to_string()) @@ -2742,284 +5343,15 @@ mod linux_app { mod tests { use super::*; - fn disconnected_app() -> OpenLessEguiApp { - OpenLessEguiApp::new( - Arc::new(tokio::runtime::Runtime::new().unwrap()), - Err("fixture: plugin unavailable".into()), - ) - } - - 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); - }); - }, - ); - 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") - } - - #[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}"); - } - } - } - - #[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() - }); - 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}"); - } - } - } - - #[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); - } - - #[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 { - 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}"); - } - - #[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:运行中")); - } - - #[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, - 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}"); - } - } - #[test] fn continuation_turn_keeps_receiving_output_and_approval() { 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, + }, ); let first = openless_core::SessionId::new(); let second = openless_core::SessionId::new(); @@ -3094,6 +5426,10 @@ 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, + }, ); let session = openless_core::SessionId::new(); let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); @@ -3122,6 +5458,279 @@ 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()); + } + + #[test] + fn missing_install_aborts_without_reloading() { + let error = reconcile_fcitx5_install(FcitxPluginStatus::Missing) + .expect_err("a missing plugin must abort startup"); + assert!( + error.contains("OpenLess fcitx5 插件"), + "unexpected Missing message: {error}" + ); + } + + // ---- 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); + assert_eq!(summary.activity_days_total, 4); + + // The trailing 364-day heatmap sums every in-window day. + let heat_total: u32 = summary + .heatmap_weeks + .iter() + .flat_map(|week| week.iter()) + .sum(); + assert_eq!(heat_total, 19); + assert_eq!(summary.heatmap_days, 364); + } + + #[test] + fn overview_heatmap_excludes_days_outside_trailing_window() { + 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_weeks + .iter() + .flat_map(|week| week.iter()) + .sum(); + assert_eq!( + heat_total, 3, + "days older than the trailing window must not appear in the heatmap" + ); + assert_eq!(summary.activity_days_total, 2); + } } } 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..8e9a1c508 --- /dev/null +++ b/openless-all/app/linux-egui/src/popup.rs @@ -0,0 +1,1018 @@ +//! 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 = 1; +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, +} + +impl PopupKind { + pub fn argument(self) -> &'static str { + match self { + Self::Qa => "--qa", + Self::Preview => "--preview", + Self::Capsule => "--capsule", + } + } +} + +#[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, + }, + 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, + }, + Hide { + version: u16, + session_id: String, + sequence: u64, + }, + 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::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::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::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::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, + }, +} + +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, .. } => *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, .. } => 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, .. } => *sequence, + } + } + + pub fn kind(&self) -> PopupKind { + match self { + Self::Ready { kind, .. } => *kind, + Self::ConfirmPreview { .. } | Self::CancelPreview { .. } => PopupKind::Preview, + Self::SubmitQa { .. } | Self::ToggleQaRecording { .. } | Self::DismissQa { .. } => { + PopupKind::Qa + } + Self::DismissCapsule { .. } => PopupKind::Capsule, + } + } +} + +#[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, +} + +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, + } + } + + 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, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CapsulePopupState { + pub phase: String, + pub text: String, + pub audio_level: 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, + 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 { .. } + ); + 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, + .. + } => { + self.qa = QaPopupState { + phase, + messages, + selection_preview, + streaming_answer, + error, + }; + self.visible = true; + } + HostToPopup::Capsule { + phase, + text, + audio_level, + .. + } => { + self.capsule = CapsulePopupState { + phase, + text, + audio_level, + }; + 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, +} + +/// 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 mut command = Command::new(executable.as_ref()); + command.arg("--openless-egui-popup").arg(kind.argument()); + Self::spawn_command(runtime, command) + } + + /// 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::*; + 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 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 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/preference_patch.rs b/openless-all/app/linux-egui/src/preference_patch.rs new file mode 100644 index 000000000..c0e0001ea --- /dev/null +++ b/openless-all/app/linux-egui/src/preference_patch.rs @@ -0,0 +1,97 @@ +//! Field edits are applied to the latest revision, so a settings modal cannot +//! overwrite a hotkey, active style or remote-input change made in the background. +use openless_core::{BackendError, BackendErrorCode, SettingsUpdateOutcome, UserPreferences}; +use serde_json::Value; +use std::collections::BTreeMap; + +pub fn patch_preferences( + current: &UserPreferences, + edits: &BTreeMap, +) -> Result { + let mut document = serde_json::to_value(current).map_err(invalid)?; + for (pointer, value) in edits { + let destination = document + .pointer_mut(pointer) + .ok_or_else(|| invalid(format!("unknown preference: {pointer}")))?; + *destination = value.clone(); + } + serde_json::from_value(document).map_err(invalid) +} + +fn invalid(error: impl std::fmt::Display) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, error.to_string()) +} + +impl crate::LinuxHost { + /// Release native shortcuts through the settings transaction before removing + /// their pack. A failed removal restores the previous preference document. + pub fn remove_style_pack(&self, id: &str) -> Result<(), BackendError> { + let previous = self.backend().get_preferences(); + let bindings: Vec<_> = previous + .style_pack_hotkeys + .iter() + .filter(|p| p.pack_id != id) + .cloned() + .collect(); + self.update_preference_fields(&BTreeMap::from([( + "/stylePackHotkeys".into(), + serde_json::to_value(bindings).map_err(invalid)?, + )]))?; + if let Err(error) = self.backend().remove_style_pack(id) { + self.update_preference_fields(&BTreeMap::from([( + "/stylePackHotkeys".into(), + serde_json::to_value(previous.style_pack_hotkeys).map_err(invalid)?, + )]))?; + return Err(error); + } + Ok(()) + } + pub fn update_preference_fields( + &self, + edits: &BTreeMap, + ) -> Result { + for _ in 0..3 { + let snapshot = self.snapshot(); + let draft = patch_preferences(&self.backend().get_preferences(), edits)?; + match self.update_settings_strict(draft, snapshot.preferences_revision) { + Err(error) if error.code == BackendErrorCode::Busy => continue, + outcome => return outcome, + } + } + Err(BackendError::new( + BackendErrorCode::Busy, + "settings changed while saving; please retry", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn field_patch_preserves_concurrent_unrelated_changes() { + let mut preferences = UserPreferences::default(); + preferences.active_style_pack_id = "newly-selected-style".into(); + let changed = patch_preferences( + &preferences, + &BTreeMap::from([("/showCapsule".into(), Value::Bool(false))]), + ) + .unwrap(); + assert_eq!(changed.active_style_pack_id, "newly-selected-style"); + assert!(!changed.show_capsule); + } + #[test] + fn unknown_fields_and_invalid_types_are_rejected() { + let preferences = UserPreferences::default(); + assert!(patch_preferences( + &preferences, + &BTreeMap::from([("/typo".into(), Value::Bool(false))]) + ) + .is_err()); + assert!(patch_preferences( + &preferences, + &BTreeMap::from([("/remoteInputPort".into(), Value::from(100_000))]) + ) + .is_err()); + } +} 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..da3e8fbc1 --- /dev/null +++ b/openless-all/app/linux-egui/src/recordings.rs @@ -0,0 +1,163 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +pub const MAX_RECORDING_BYTES: u64 = 1024 * 1024 * 1024; + +/// One host-owned player. Ending playback or shutting down kills only this child. +#[derive(Default)] +pub struct RecordingPlayback { + child: std::sync::Mutex>, +} +impl RecordingPlayback { + pub fn play(&self, data_dir: &Path, session_id: &str) -> Result<(), String> { + read_recording_wav(data_dir, session_id).map_err(|e| e.to_string())?; + let path = recording_path(data_dir, session_id).map_err(|e| e.to_string())?; + let mut child = self.child.lock().map_err(|e| e.to_string())?; + stop_child(&mut child); + *child = Some( + std::process::Command::new("paplay") + .args(["--client-name=OpenLess", "--stream-name=Recording"]) + .arg(path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("无法播放录音:{e}"))?, + ); + Ok(()) + } + pub fn stop(&self) { + if let Ok(mut child) = self.child.lock() { + stop_child(&mut child); + } + } + pub fn is_playing(&self) -> bool { + let Ok(mut slot) = self.child.try_lock() else { + return true; + }; + match slot.as_mut().map(|child| child.try_wait()) { + Some(Ok(None)) => true, + _ => { + *slot = None; + false + } + } + } +} +fn stop_child(slot: &mut Option) { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + let _ = child.wait(); + } +} +impl Drop for RecordingPlayback { + fn drop(&mut self) { + if let Ok(slot) = self.child.get_mut() { + stop_child(slot); + } + } +} + +#[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) +} + +/// Remove only the canonical recording for this history ID. Removing a symlink +/// unlinks the link itself and never follows it outside the recordings directory. +pub fn remove_recording(data_dir: &Path, session_id: &str) -> Result<(), RecordingError> { + let path = recording_path(data_dir, session_id)?; + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(RecordingError::Io(error)), + } +} + +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/runtime.rs b/openless-all/app/linux-egui/src/runtime.rs index 164d09a03..c501e00ad 100644 --- a/openless-all/app/linux-egui/src/runtime.rs +++ b/openless-all/app/linux-egui/src/runtime.rs @@ -108,6 +108,9 @@ impl LinuxNativeRuntime { let mut launch_intents = Vec::new(); let mut hotkey_events = Vec::new(); let mut errors = Vec::new(); + if crate::desktop_bridge::take_disconnected() { + hotkey_events.push(crate::LinuxHotkeyEvent::DesktopDisconnected); + } if let Some(broker) = &self.broker { broker.drain(|intent| launch_intents.push(intent)); if let Some(error) = broker.take_error() { @@ -115,11 +118,20 @@ impl LinuxNativeRuntime { } } if let Some(hotkeys) = &self.hotkeys { - hotkeys.drain(|event| hotkey_events.push(event)); + hotkeys.drain(|event| { + if !crate::desktop_bridge::active() { + hotkey_events.push(event); + } + }); if let Some(error) = hotkeys.take_error() { errors.push(error); } } + if crate::desktop_bridge::active() { + if let Some(adapter) = crate::desktop_bridge::adapter() { + hotkey_events.extend(adapter.drain()); + } + } (launch_intents, hotkey_events, errors) } diff --git a/openless-all/app/linux-egui/src/selection.rs b/openless-all/app/linux-egui/src/selection.rs index a564bd205..504df15ff 100644 --- a/openless-all/app/linux-egui/src/selection.rs +++ b/openless-all/app/linux-egui/src/selection.rs @@ -7,6 +7,9 @@ use openless_core::{ }; trait LinuxSelectionBridge: Send + Sync + 'static { + fn source_app(&self, _session_id: SessionId) -> Option { + None + } fn capture_target(&self, session_id: SessionId) -> Result; fn apply_target( &self, @@ -21,6 +24,14 @@ trait LinuxSelectionBridge: Send + Sync + 'static { struct Fcitx5SelectionBridge; impl LinuxSelectionBridge for Fcitx5SelectionBridge { + fn source_app(&self, session_id: SessionId) -> Option { + use crate::context::ContextReader; + crate::context::NativeContextReader + .read(Some(&session_id.to_string()), false) + .ok() + .map(|s| s.application) + .filter(|s| !s.is_empty()) + } fn capture_target(&self, session_id: SessionId) -> Result { crate::fcitx5::capture_selection_target(&session_id.to_string()) } @@ -119,7 +130,7 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { }); Ok(SelectionCapture { text, - source_app: None, + source_app: bridge.source_app(session_id), }) }) .await diff --git a/openless-all/app/linux-egui/src/selection_voice.rs b/openless-all/app/linux-egui/src/selection_voice.rs new file mode 100644 index 000000000..a939eff67 --- /dev/null +++ b/openless-all/app/linux-egui/src/selection_voice.rs @@ -0,0 +1,313 @@ +//! Native recording ownership for Core's selection-voice workflow. The Core +//! service owns intent routing, preview state and the cancellation generation. +use openless_core::{ + BackendError, OpenLessBackend, RecordingControlAction, RecordingControlSink, SessionId, + VoiceTranscriptionSession, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct Capture { + applied: Option, + expected: Option, + session: Option>, + finishing: bool, + pending: Option, +} +#[derive(Clone)] +pub struct LinuxSelectionVoice { + backend: Arc, + capture: Arc>, + runtime: Arc>>, +} +impl LinuxSelectionVoice { + pub fn new(backend: Arc) -> Self { + Self { + backend, + capture: Arc::default(), + runtime: Arc::default(), + } + } + + pub async fn edge(&self, pressed: bool, at: std::time::Instant) -> Result { + *self.runtime.lock().unwrap() = Some(tokio::runtime::Handle::current()); + use openless_core::{ + SelectionVoiceHotkeyAction as Action, SelectionVoiceHotkeyEdge as Edge, + SelectionVoicePhase as Phase, + }; + let api = &self.backend.services().selection_voice; + let snapshot = api.snapshot().await?; + let active = matches!( + snapshot.phase, + Phase::Recording + | Phase::Processing + | Phase::AwaitingIntent + | Phase::Preview + | Phase::Applying + ); + if !self.backend.get_preferences().selection_voice_enabled && !active { + return Ok(false); + } + if !pressed && !active { + return Ok(false); + } + let action = api.dispatch_hotkey_edge(if pressed { + Edge::Pressed { at } + } else { + Edge::Released { at } + })?; + match action { + Action::Start => { + self.dismiss_applied(); + let ticket = SessionId::new(); + let ticket_string = ticket.to_string(); + let source = tokio::task::spawn_blocking(move || { + crate::fcitx5::capture_selection_target(&ticket_string) + }) + .await + .map_err(crate::context::platform)?; + let source = match source { + Ok(text) if !text.is_empty() => text, + _ => { + let _ = crate::fcitx5::cancel_selection_target(&ticket.to_string()); + return Ok(false); + } + }; + let source_app = { + use crate::context::ContextReader; + let id = ticket.to_string(); + tokio::task::spawn_blocking(move || { + crate::context::NativeContextReader + .read(Some(&id), false) + .ok() + .map(|s| s.application) + .filter(|s| !s.is_empty()) + }) + .await + .ok() + .flatten() + }; + let session_id = match api + .begin(openless_core::SelectionCapture { + text: source, + source_app, + }) + .await + { + Ok(id) => id, + Err(error) => { + let _ = crate::fcitx5::cancel_selection_target(&ticket.to_string()); + return Err(error); + } + }; + if let Err(error) = crate::fcitx5::rekey_selection_target( + &ticket.to_string(), + &session_id.to_string(), + ) { + let _ = api.cancel(Some(session_id)).await; + let _ = crate::fcitx5::cancel_selection_target(&ticket.to_string()); + return Err(error); + } + { + let mut state = self.capture.lock().unwrap(); + state.expected = Some(session_id); + state.pending = None; + state.finishing = false; + } + let session = self + .backend + .start_selection_voice_capture(session_id, Arc::new(self.clone())) + .await; + let session = match session { + Ok(session) => session, + Err(error) => { + self.cancel(session_id).await?; + return Err(error); + } + }; + let mut late = Some(Arc::new(session)); + let pending = { + let mut state = self.capture.lock().unwrap(); + if state.expected == Some(session_id) { + state.session = late.take(); + state.pending.take() + } else { + None + } + }; + if let Some(session) = late { + session.cancel().await?; + return Ok(true); + } + if let Some(action) = pending { + self.request(session_id, action)?; + } + } + Action::Finish => { + if let Some(id) = snapshot.session_id { + self.request(id, RecordingControlAction::Stop)?; + } + } + Action::Noop => return Ok(active), + } + Ok(true) + } + + pub async fn cancel(&self, id: SessionId) -> Result<(), BackendError> { + let capture = { + let mut state = self.capture.lock().unwrap(); + if state.expected == Some(id) { + state.expected = None; + state.pending = None; + state.session.take() + } else { + None + } + }; + if let Some(capture) = capture { + let _ = capture.cancel().await; + } + self.backend + .services() + .selection_voice + .cancel(Some(id)) + .await?; + let ticket = id.to_string(); + tokio::task::spawn_blocking(move || crate::fcitx5::cancel_selection_target(&ticket)) + .await + .map_err(crate::context::platform)? + } + + pub async fn confirm_intent(&self, id: SessionId, intent: String) -> Result<(), BackendError> { + let api = &self.backend.services().selection_voice; + let disposition = api.confirm_intent(id, intent).await?; + self.route(disposition).await?; + Ok(()) + } + + async fn route( + &self, + disposition: openless_core::SelectionVoiceDisposition, + ) -> Result<(), BackendError> { + if let openless_core::SelectionVoiceRoute::ReadyToApply { preview } = self + .backend + .services() + .selection_voice + .route_disposition(disposition) + .await? + { + self.apply(preview.text, preview.owner_session_id).await?; + } + Ok(()) + } + + pub async fn apply(&self, text: String, owner: Option) -> Result<(), BackendError> { + let api = &self.backend.services().selection_voice; + let ticket = api.begin_preview_apply(owner, text.clone())?; + let source = ticket.source_text.clone(); + let id = ticket.session_id; + let result = tokio::task::spawn_blocking(move || { + crate::fcitx5::apply_selection_target(&id.to_string(), &source, &text) + }) + .await + .map_err(crate::context::platform)?; + api.finish_preview_apply( + ticket.ticket_id, + if result.is_ok() { + openless_core::SelectionVoiceApplyOutcome::Inserted + } else { + openless_core::SelectionVoiceApplyOutcome::Failed + }, + ) + .await?; + if result.is_ok() { + self.capture.lock().unwrap().applied = Some(id); + } + result + } + + pub fn applied_target(&self) -> Option { + self.capture.lock().unwrap().applied + } + pub fn dismiss_applied(&self) { + let id = self.capture.lock().unwrap().applied.take(); + if let (Some(id), Some(runtime)) = (id, self.runtime.lock().unwrap().clone()) { + runtime.spawn_blocking(move || crate::fcitx5::cancel_selection_target(&id.to_string())); + } + } + pub async fn revert_applied(&self, id: SessionId) -> Result<(), BackendError> { + if self.applied_target() != Some(id) { + return Err(crate::context::platform("applied selection expired")); + } + tokio::task::spawn_blocking(move || { + crate::fcitx5::revert_selection_target(&id.to_string()) + }) + .await + .map_err(crate::context::platform)??; + let mut capture = self.capture.lock().unwrap(); + if capture.applied == Some(id) { + capture.applied = None; + } + Ok(()) + } +} +impl RecordingControlSink for LinuxSelectionVoice { + fn request(&self, id: SessionId, action: RecordingControlAction) -> Result<(), BackendError> { + let capture = { + let mut state = self.capture.lock().unwrap(); + if state.expected != Some(id) { + return Ok(()); + } + if action == RecordingControlAction::Cancel { + state.expected = None; + state.pending = None; + state.session.take() + } else if state.finishing { + return Ok(()); + } else if let Some(session) = state.session.clone() { + state.finishing = true; + Some(session) + } else { + state.pending = Some(action); + return Ok(()); + } + }; + let this = self.clone(); + let runtime = self.runtime.lock().unwrap().clone().ok_or_else(|| { + crate::context::platform("selection voice runtime is not initialized") + })?; + runtime.spawn(async move { + let result: Result<(), BackendError> = async { + if action == RecordingControlAction::Cancel { + if let Some(capture) = capture { + capture.cancel().await?; + } + return this.cancel(id).await; + } + let Some(capture) = capture else { + return Ok(()); + }; + let api = &this.backend.services().selection_voice; + api.mark_processing(id).await?; + let transcript = capture.finish().await?; + let disposition = api.process_transcript(id, transcript).await?; + this.route(disposition).await?; + { + let mut state = this.capture.lock().unwrap(); + if state.expected == Some(id) { + state.expected = None; + state.session = None; + state.finishing = false; + } + } + Ok(()) + } + .await; + if let Err(error) = result { + log::warn!("selection voice failed: {error}"); + let _ = this.cancel(id).await; + } + }); + Ok(()) + } +} diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 8081afd00..7becc35db 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,10 +148,21 @@ 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> { + crate::desktop_bridge::bind_target(target)?; apply_dictation_hotkey(&target.dictation)?; apply_action_hotkey("SetQaHotkeyRaw", target.qa.as_ref())?; apply_action_hotkey( @@ -173,6 +170,35 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { 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 style_pack_hotkeys = target + .style_packs + .iter() + .map(|hotkey| { + shortcut_to_raw(&hotkey.binding) + .map(|(symbol, states)| (hotkey.pack_id.clone(), symbol, states)) + }) + .collect::, _>>()?; + tolerate_optional_fcitx_method(crate::fcitx5::set_style_pack_hotkeys(style_pack_hotkeys))?; + for (method, binding) in [ + ( + "SetLessComputerPanelHotkeyRaw", + target.coding_agent_panel.as_ref(), + ), + ( + "SetLessComputerQuickHotkeyRaw", + target.coding_agent_quick.as_ref(), + ), + ] { + apply_action_hotkey(method, binding.filter(|_| target.coding_agent_enabled))?; + } let (symbol, states) = target .coding_agent_voice .as_ref() @@ -182,7 +208,7 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { .map(shortcut_to_raw) .transpose()? .unwrap_or((0, 0)); - crate::fcitx5::set_less_computer_hotkey_raw(symbol, states) + tolerate_optional_fcitx_method(crate::fcitx5::set_less_computer_hotkey_raw(symbol, states)) } fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError> { @@ -194,6 +220,35 @@ 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> { @@ -239,7 +294,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)); } @@ -381,4 +436,27 @@ mod tests { }; assert_eq!(shortcut_to_raw(&shortcut).unwrap(), (b'/' as u32, 5)); } + + #[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/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs new file mode 100644 index 000000000..5e79caf35 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -0,0 +1,464 @@ +use eframe::egui; + +#[derive(Clone, Copy)] +pub enum IconName { + Overview, + History, + Vocab, + Style, + SelectionAsk, + Translation, + Settings, + Mic, + Sparkle, + Hash, + Clock, + Bolt, + Copy, + Search, + Trash, + Refresh, + Download, + Play, + ChevronDown, +} + +/// 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); + 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::ChevronDown => { + 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, 9.0), pt(12.0, 15.0), pt(18.0, 9.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::Translation => { + p.circle_stroke(center, 6.7, stroke); + p.line_segment( + [ + center + egui::vec2(-6.7, 0.0), + center + egui::vec2(6.7, 0.0), + ], + stroke, + ); + for sign in [-1.0, 1.0] { + p.add(egui::Shape::line( + [ + center + egui::vec2(0.0, -6.7), + center + egui::vec2(sign * 2.8, -4.0), + center + egui::vec2(sign * 3.3, 0.0), + center + egui::vec2(sign * 2.8, 4.0), + center + egui::vec2(0.0, 6.7), + ] + .to_vec(), + 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::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..7ac728e06 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -0,0 +1,693 @@ +use eframe::egui; + +use super::icons::{self, IconName}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, Page}; + +pub const SIDEBAR_WIDTH: f32 = 226.0; +pub const TITLEBAR_HEIGHT: f32 = 36.0; +const WINDOW_MARGIN: f32 = 0.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 drag = ui.interact( + titlebar, + ui.id().with("titlebar-drag"), + egui::Sense::click_and_drag(), + ); + if drag.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + 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 button_width = 40.0; + 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()); + ui.painter().line_segment( + [ + minimize.center() - egui::vec2(5.0, 0.0), + minimize.center() + egui::vec2(5.0, 0.0), + ], + stroke, + ); + ui.painter().rect_stroke( + maximize.shrink(14.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); + let edge = 10.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())); + ui.set_clip_rect(egui::Rect::from_min_size( + body.min, + egui::vec2(SIDEBAR_WIDTH, body.height()), + )); + ui.painter() + .rect_filled(ui.max_rect(), 0.0, theme::sidebar_bg()); + ui.painter().line_segment( + [ + egui::pos2(SIDEBAR_WIDTH, 0.0), + egui::pos2(SIDEBAR_WIDTH, body.height()), + ], + 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| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(20.0, 22.0), egui::Sense::hover()); + let texture = load_app_icon(ctx); + paint_app_icon(ui, rect, &texture); + ui.label(egui::RichText::new("OpenLess").strong().size(14.0)); + }); + ui.add_space(16.0); + nav(ui, vm, "概览", Page::Overview, IconName::Overview, actions); + nav(ui, vm, "历史", Page::History, IconName::History, actions); + nav(ui, vm, "词汇表", Page::Vocab, IconName::Vocab, actions); + ui.add_space(4.0); + group(ui, vm, "风格", IconName::Style, actions); + if vm.style_open { + subnav(ui, vm, "润色模式", Page::Style, actions); + subnav(ui, vm, "风格市场", Page::Marketplace, actions); + } + group(ui, vm, "工具", IconName::SelectionAsk, actions); + if vm.tools_open { + subnav(ui, vm, "翻译", Page::Translation, actions); + subnav(ui, vm, "划词追问", Page::SelectionAsk, actions); + subnav(ui, vm, "纠错", Page::Corrections, actions); + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + nav_with_icon(ui, vm, "设置", Page::Settings, IconName::Settings, actions); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.vertical(|ui| { + 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.add_space(3.0); + ui.label( + egui::RichText::new(format!("版本 {}", vm.version)) + .size(10.5) + .color(theme::ink_4()), + ); + }); + }); + }); + }); + }); +} + +fn nav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + icon: IconName, + actions: &mut Vec, +) { + nav_with_icon(ui, vm, label, page, icon, actions); +} + +fn nav_with_icon( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + icon: IconName, + actions: &mut Vec, +) { + let active = vm.active_page == page; + 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() { + actions.push(FrontendAction::Navigate(page)); + if page == Page::Settings { + actions.push(FrontendAction::ToggleSettings); + } + } +} + +fn subnav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + actions: &mut Vec, +) { + let active = vm.active_page == page; + 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() { + actions.push(FrontendAction::Navigate(page)); + } +} + +fn group( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + icon: IconName, + actions: &mut Vec, +) { + 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; + let is_open = match icon { + IconName::Style => vm.style_open, + _ => vm.tools_open, + }; + 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() { + match icon { + IconName::Style => actions.push(FrontendAction::SidebarToggleStyle), + _ => actions.push(FrontendAction::SidebarToggleTools), + } + } +} + +// ── 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 ────────────────────────────────────────────────────────── + +pub fn icon_text_button( + ui: &mut egui::Ui, + label: &str, + icon: IconName, + width: f32, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::click()); + let hovered = response.hovered(); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if hovered { + theme::surface_2() + } else { + theme::surface() + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::line()), + egui::StrokeKind::Inside, + ); + let icon_center = egui::pos2(rect.left() + 16.0, rect.center().y); + icons::draw_icon(ui, icon_center, icon, theme::ink_3()); + ui.painter().text( + egui::pos2(rect.left() + 28.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + theme::ink_2(), + ); + response +} + +pub fn text_chevron_button(ui: &mut egui::Ui, label: &str, width: f32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if response.hovered() { + theme::surface_2() + } else { + theme::surface() + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::line()), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + theme::ink_2(), + ); + icons::draw_icon( + ui, + egui::pos2(rect.right() - 14.0, rect.center().y), + IconName::ChevronDown, + theme::ink_3(), + ); + response +} + +pub fn small_pill( + ui: &mut egui::Ui, + text: &str, + fill: egui::Color32, + border: egui::Color32, + color: egui::Color32, +) -> egui::Response { + let width = (text.chars().count() as f32 * 10.0 + 16.0).max(42.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(9), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.5), + color, + ); + response +} + +pub fn card_at(ui: &mut egui::Ui, rect: egui::Rect, contents: impl FnOnce(&mut egui::Ui)) { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(14), theme::surface()); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::line()), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(17.0); + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(rect)); + contents(ui); + }, + ); +} + +pub fn tag(ui: &egui::Ui, pos: egui::Pos2, text: &str, blue: bool) { + let width = (text.chars().count() as f32 * 10.0 + 16.0).max(48.0); + let rect = egui::Rect::from_min_size(pos, egui::vec2(width, 20.0)); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(9), + if blue { + theme::blue_soft() + } else { + theme::surface_2() + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.0), + if blue { theme::blue() } else { theme::ink_3() }, + ); +} + +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)), + ); +} + +pub fn unsupported_page(ui: &mut egui::Ui, title: &str) { + ui.add_space(28.0); + 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(theme::text("此页面暂未接线")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(theme::text("数据桥接将在后续阶段完成")) + .size(11.0) + .color(theme::ink_4()), + ); + }); + }); +} 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..beb1eb862 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -0,0 +1,477 @@ +use eframe::egui; + +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, MarketplaceSort}; + +/// 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, +) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(theme::text("探索社区风格包")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let refresh = ui.add( + egui::Button::new(egui::RichText::new(theme::text("↻ 刷新")).size(11.5)) + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.8, theme::line())) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 29.0)), + ); + if refresh.clicked() { + actions.push(FrontendAction::MarketplaceRefresh); + } + ui.add_space(8.0); + let mine = ui.add( + egui::Button::new(egui::RichText::new(theme::text("我的发布")).size(11.5)) + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.8, theme::line())) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(78.0, 29.0)), + ); + if mine.clicked() { + actions.push(FrontendAction::MarketplaceMyPacks); + } + }); + }); + ui.add_space(18.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, + ); + let mut query = vm.marketplace_query.clone(); + let resp = ui.add( + egui::TextEdit::singleline(&mut query) + .hint_text(theme::text("搜索风格包")) + .frame(false) + .desired_width(search_width - 34.0), + ); + if resp.changed() { + actions.push(FrontendAction::MarketplaceSearch(query)); + } + }); + }); + ui.add_space(10.0); + for (mode, label) in [ + (MarketplaceSort::Popular, "热门"), + (MarketplaceSort::New, "最新"), + (MarketplaceSort::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(theme::text("正在加载风格市场…")); + }); + return; + } + + if vm.marketplace_unsupported { + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(1.0, theme::line())) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(theme::text("风格市场暂未接线")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(theme::text("市场后端桥接将在后续阶段完成")) + .size(11.0) + .color(theme::ink_4()), + ); + }); + }); + return; + } + + if vm.marketplace_packs.is_empty() { + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(1.0, theme::line())) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(theme::text("暂时没有找到风格包")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(theme::text("试试其他关键词或筛选条件")) + .size(11.0) + .color(theme::ink_4()), + ); + }); + }); + } else { + let columns = if ui.available_width() >= 900.0 { + 3 + } else if ui.available_width() >= 600.0 { + 2 + } else { + 1 + }; + let gap = 12.0; + let card_width = (ui.available_width() - gap * (columns - 1) as f32) / columns as f32; + ui.columns(columns, |uis| { + for (column, column_ui) in uis.iter_mut().enumerate() { + for (pack_index, pack) in vm.marketplace_packs.iter().enumerate() { + if pack_index % columns != column { + continue; + } + marketplace_card(column_ui, card_width, pack, pack_index, vm, actions); + column_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(), + pack, + index, + vm.marketplace_liked.contains(&index), + vm.marketplace_prompt.as_deref(), + body_rect, + actions, + ); + } + } +} + +fn marketplace_card( + ui: &mut egui::Ui, + width: f32, + pack: &super::view_model::MarketplacePack, + index: usize, + _vm: &FrontendViewModel, + actions: &mut Vec, +) { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 156.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::surface_2() + } else { + theme::surface() + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(1.0, theme::line()), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(14.0); + let mut card_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + let ui = &mut card_ui; + ui.style_mut().interaction.selectable_labels = false; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&pack.name) + .size(14.0) + .strong() + .color(theme::ink()), + ); + 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()) + .family(egui::FontFamily::Monospace), + ); + }); + }); + ui.add_space(6.0); + let description_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(description_width, 36.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(&pack.description) + .size(12.0) + .color(theme::ink_3()), + ) + .wrap(), + ); + }, + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + pill(ui, &pack.mode, true); + for tag in pack.tags.iter().take(2) { + pill(ui, tag, false); + } + }); + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("@{}", pack.author)) + .size(11.0) + .color(theme::ink_3()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let download = ui.add( + egui::Button::new(egui::RichText::new(theme::text("下载 ZIP")).size(10.0)) + .fill(theme::surface_2()) + .stroke(egui::Stroke::new(0.7, theme::line())) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(62.0, 23.0)), + ); + if download.clicked() { + actions.push(FrontendAction::MarketplaceDownload(index)); + } + ui.add_space(7.0); + ui.label( + egui::RichText::new(format!("☆ {} · ↓ {}", pack.likes, pack.downloads)) + .size(10.5) + .color(theme::ink_4()), + ); + }); + }); + }); + if response.clicked() { + actions.push(FrontendAction::MarketplaceDetail(index)); + } +} + +fn pill(ui: &mut egui::Ui, text: &str, outline: bool) { + egui::Frame::new() + .fill(if outline { + egui::Color32::TRANSPARENT + } else { + theme::surface_2() + }) + .stroke(egui::Stroke::new( + 0.5, + if outline { + theme::line() + } else { + egui::Color32::TRANSPARENT + }, + )) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).size(10.0).color(theme::ink_3())); + }); +} + +fn marketplace_detail( + ctx: &egui::Context, + pack: &super::view_model::MarketplacePack, + index: usize, + liked: bool, + prompt: Option<&str>, + 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, + }, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 56), + ); + // 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| { + if let Some(prompt)=prompt { + egui::ScrollArea::vertical().max_height(150.0).id_salt("marketplace-prompt").show(ui,|ui|{ui.label(prompt);}); + }else{ui.spinner();} + 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); + egui::Frame::new() + .fill(theme::surface_2()) + .stroke(egui::Stroke::new(0.5, theme::line())) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + "本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。", + ) + .size(12.0) + .color(theme::ink_2()) + .family(egui::FontFamily::Monospace), + ); + }); + 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("安装到本地") + .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("取消") + .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..ee4cc67ab --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -0,0 +1,223 @@ +pub mod icons; +pub mod layout; +pub mod marketplace; +pub mod pages; +pub mod view_model; + +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); + + // Style page owns its own scroll viewport. + if vm.active_page == Page::Style { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("润色模式")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + pages::style_page(ui, vm, actions); + ui.add_space(32.0); + return; + } + + // History owns two independent scroll regions (list and detail). + // Do not wrap in another ScrollArea. + if vm.active_page == Page::History { + pages::history_page(ui, vm, actions); + ui.add_space(32.0); + return; + } + + egui::ScrollArea::vertical() + .id_salt("openless-main-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + match vm.active_page { + Page::Overview => { + pages::overview_page(ui, vm, actions); + } + Page::Vocab => { + pages::vocab_page(ui, vm, actions); + } + Page::Marketplace => { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("风格市场")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + marketplace::marketplace_page(ui, vm, actions, body); + } + Page::SelectionAsk => { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("划词追问")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + pages::selection_ask_page(ui, vm, actions); + } + Page::Translation => { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("翻译")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + pages::translation_page(ui, vm, actions); + } + Page::Corrections => pages::corrections_page(ui, vm, actions), + Page::History | Page::Style | Page::Settings => { + // Handled above or via overlay. + } + } + ui.add_space(32.0); + }); + }); +} + +#[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 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, 142.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" + ); + } +} 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..bfd100e04 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/pages.rs @@ -0,0 +1,2701 @@ +use eframe::egui; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const SUPPORTED_LANGUAGES: [&str; 15] = [ + "简体中文", + "繁体中文", + "English", + "日本語", + "한국어", + "Français", + "Deutsch", + "Español", + "Italiano", + "Português", + "Русский", + "العربية", + "Tiếng Việt", + "ไทย", + "हिन्दी", +]; + +pub fn corrections_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + ui.add_space(28.0); + ui.label(egui::RichText::new(theme::text("纠错")).size(28.0).strong()); + ui.add_space(8.0); + ui.label( + egui::RichText::new(theme::text("修正常见识别错误,让每次输入更准确")) + .color(theme::ink_3()), + ); + ui.add_space(24.0); + if !vm.pending_corrections.is_empty() { + crate::ui::settings::card(ui, "从手动修改中发现", |ui| { + for suggestion in &vm.pending_corrections { + ui.horizontal_wrapped(|ui| { + ui.label(&suggestion.pattern); + ui.label("→"); + ui.strong(&suggestion.replacement); + if ui.button(theme::text("好")).clicked() { + actions.push(FrontendAction::AcceptCorrection(suggestion.id.clone())); + } + if ui.button(theme::text("忽略")).clicked() { + actions.push(FrontendAction::RejectCorrection(suggestion.id.clone())); + } + }); + } + }); + } + crate::ui::settings::card(ui, "添加替换规则", |ui| { + ui.horizontal_wrapped(|ui| { + ui.add( + egui::TextEdit::singleline(&mut vm.vocab_pattern) + .hint_text(theme::text("识别结果")) + .desired_width(200.0), + ); + ui.label("→"); + ui.add( + egui::TextEdit::singleline(&mut vm.vocab_replacement) + .hint_text(theme::text("替换为")) + .desired_width(200.0), + ); + if ui + .add_enabled( + !vm.vocab_pattern.trim().is_empty() && !vm.vocab_replacement.trim().is_empty(), + egui::Button::new("添加"), + ) + .clicked() + { + actions.push(FrontendAction::VocabAddRule { + pattern: std::mem::take(&mut vm.vocab_pattern), + replacement: std::mem::take(&mut vm.vocab_replacement), + }); + } + }); + }); + crate::ui::settings::card(ui, "替换规则", |ui| { + if vm.vocab_rules.is_empty() { + ui.label(theme::text("还没有替换规则")); + } + for (index, rule) in vm.vocab_rules.iter().enumerate() { + ui.push_id(index, |ui| { + ui.horizontal_wrapped(|ui| { + let mut enabled = rule.enabled; + if ui.checkbox(&mut enabled, "").changed() { + actions.push(FrontendAction::VocabToggleRule(index)); + } + ui.label(&rule.pattern); + ui.label("→"); + ui.strong(&rule.replacement); + if ui.button(theme::text("删除")).clicked() { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + }) + }); + ui.separator(); + } + }); +} + +fn truncate_text(text: &str, max_chars: usize) -> String { + let mut value: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + value.push('…'); + } + value +} + +// ── Overview page ─────────────────────────────────────────────────────────── + +pub fn overview_page( + ui: &mut egui::Ui, + vm: &FrontendViewModel, + _actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + + if vm.overview_loading { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("今日概览")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(egui::RichText::new(theme::text("正在加载概览数据…")).color(theme::ink_3())); + }); + return; + } + + if let Some(error) = &vm.overview_error { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("今日概览")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + ui.colored_label(egui::Color32::from_rgb(220, 80, 80), error); + return; + } + + let Some(summary) = &vm.overview else { + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("今日概览")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + ui.label(egui::RichText::new(theme::text("暂无数据")).color(theme::ink_3())); + return; + }; + + ui.set_min_width(width); + ui.set_max_width(width); + ui.add_space(28.0); + ui.label( + egui::RichText::new(theme::text("今日概览")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(22.0); + + let gap = 12.0; + let provider_width = (width - gap) / 2.0; + let provider_height = 98.0; + let provider_row = ui + .allocate_exact_size(egui::vec2(width, provider_height), egui::Sense::hover()) + .0; + provider_card( + ui, + egui::Rect::from_min_size( + provider_row.min, + egui::vec2(provider_width, provider_height), + ), + "ASR 语音", + &summary.asr_provider, + summary.asr_configured, + IconName::Mic, + ); + provider_card( + ui, + egui::Rect::from_min_size( + egui::pos2( + provider_row.left() + provider_width + gap, + provider_row.top(), + ), + egui::vec2(provider_width, provider_height), + ), + "LLM 模型", + &summary.llm_provider, + summary.llm_configured, + IconName::Sparkle, + ); + ui.add_space(32.0); + + // Metric row + let metric_width = (width - gap * 3.0) / 4.0; + let metric_row = ui + .allocate_exact_size(egui::vec2(width, 108.0), egui::Sense::hover()) + .0; + for (index, (icon, label, value, detail, accent)) in [ + ( + IconName::Hash, + "今日字数", + summary.chars_today.to_string(), + format!("{} 段", summary.segments_today), + false, + ), + ( + IconName::Mic, + "今日总时长", + if summary.duration_ms_today > 0 { + format_duration(summary.duration_ms_today) + } else { + "—".into() + }, + String::new(), + false, + ), + ( + IconName::Clock, + "平均段落", + if summary.avg_latency_ms > 0 { + format_duration(summary.avg_latency_ms) + } else { + "—".into() + }, + "暂无数据".to_string(), + false, + ), + ( + IconName::Bolt, + "累计记录", + summary.history_total.to_string(), + "本机存档".to_string(), + true, + ), + ] + .into_iter() + .enumerate() + { + metric_card( + ui, + egui::Rect::from_min_size( + egui::pos2( + metric_row.left() + index as f32 * (metric_width + gap), + metric_row.top(), + ), + egui::vec2(metric_width, 108.0), + ), + icon, + label, + &value, + &detail, + accent, + ); + } + ui.add_space(18.0); + + // Activity heatmap + activity_heatmap(ui, width, summary); + ui.add_space(18.0); + + // Bottom row: period + recent + let row_width = width - gap; + let left_width = row_width / 2.4; + let right_width = row_width - left_width; + let bottom_row = ui + .allocate_exact_size(egui::vec2(width, 304.0), egui::Sense::hover()) + .0; + period_card( + ui, + egui::Rect::from_min_size(bottom_row.min, egui::vec2(left_width, 304.0)), + summary, + ); + recent_card( + ui, + egui::Rect::from_min_size( + egui::pos2(bottom_row.left() + left_width + gap, bottom_row.top()), + egui::vec2(right_width, 304.0), + ), + summary, + ); +} + +fn format_duration(ms: u64) -> String { + if ms < 1000 { + format!("{}ms", ms) + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + let minutes = ms / 60_000; + let seconds = (ms % 60_000) / 1000; + format!("{}m{}s", minutes, seconds) + } +} + +fn provider_card( + ui: &mut egui::Ui, + rect: egui::Rect, + kind: &str, + name: &str, + configured: bool, + icon: IconName, +) { + layout::card_at(ui, rect, |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(38.0, 38.0), egui::Sense::hover()); + ui.painter() + .rect_filled(icon_rect, egui::CornerRadius::same(10), theme::blue_soft()); + icons::draw_icon(ui, icon_rect.center(), icon, theme::blue()); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(kind).size(10.5).color(theme::ink_4())); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).size(14.0).strong()); + if configured { + ui.label( + egui::RichText::new(theme::text("● 已配置")) + .size(10.5) + .color(theme::ok()), + ); + } else { + ui.label( + egui::RichText::new(theme::text("未配置")) + .size(10.5) + .color(theme::ink_4()), + ); + } + }); + }); + }); + }); +} + +fn metric_card( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: IconName, + label: &str, + value: &str, + detail: &str, + accent: bool, +) { + layout::card_at(ui, rect, |ui| { + ui.horizontal(|ui| { + icons::draw_icon( + ui, + ui.cursor().min + egui::vec2(7.0, 8.0), + icon, + theme::ink_3(), + ); + ui.add_space(16.0); + ui.label(egui::RichText::new(label).size(11.5).color(theme::ink_3())); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(value) + .size(26.0) + .strong() + .color(if accent { theme::blue() } else { theme::ink() }), + ); + if !detail.is_empty() { + ui.label(egui::RichText::new(detail).size(10.5).color(theme::ink_4())); + } + }); +} + +fn heat_color(count: u32) -> egui::Color32 { + match count { + 0 => theme::surface_2(), + 1..=2 => egui::Color32::from_rgb(80, 140, 220), + 3..=5 => egui::Color32::from_rgb(90, 120, 235), + 6..=10 => egui::Color32::from_rgb(110, 100, 235), + _ => egui::Color32::from_rgb(150, 90, 235), + } +} + +fn activity_heatmap(ui: &mut egui::Ui, width: f32, summary: &super::view_model::OverviewSummary) { + let rect = ui + .allocate_exact_size(egui::vec2(width, 184.0), egui::Sense::hover()) + .0; + layout::card_at(ui, rect, |ui| { + ui.label( + egui::RichText::new(theme::text("年度活动")) + .size(12.0) + .strong() + .color(theme::ink_2()), + ); + ui.add_space(10.0); + let grid_rect = ui + .allocate_exact_size( + egui::vec2(ui.available_width(), 126.0), + egui::Sense::hover(), + ) + .0; + let painter = ui.painter().with_clip_rect(grid_rect); + let label_width = 30.0; + let columns = 53; + let cell_gap = 3.0; + let column_step = ((grid_rect.width() - label_width) / columns as f32).max(6.0); + let cell_width = (column_step - cell_gap).max(5.0); + let cell_height = ((grid_rect.height() - 22.0 - 6.0 * cell_gap) / 7.0).max(5.0); + let months = [ + "9月", "10月", "11月", "12月", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", + ]; + let month_columns = [0, 4, 9, 13, 18, 22, 27, 31, 36, 40, 45, 49]; + for (index, month) in months.iter().enumerate() { + let x = grid_rect.left() + label_width + month_columns[index] as f32 * column_step; + painter.text( + egui::pos2(x, grid_rect.top()), + egui::Align2::LEFT_TOP, + *month, + egui::FontId::proportional(9.0), + theme::ink_4(), + ); + } + for row in 0..7 { + let y = grid_rect.top() + 22.0 + row as f32 * (cell_height + cell_gap); + painter.text( + egui::pos2(grid_rect.left(), y + cell_height / 2.0), + egui::Align2::LEFT_CENTER, + match row { + 0 => "周日", + 1 => "周一", + 2 => "周二", + 3 => "周三", + 4 => "周四", + 5 => "周五", + _ => "周六", + }, + egui::FontId::proportional(10.0), + theme::ink_4(), + ); + for col in 0..columns { + let intensity = if col < summary.heatmap_weeks.len() as i32 { + summary.heatmap_weeks[col as usize][row] + } else { + 0 + }; + let color = heat_color(intensity); + let x = grid_rect.left() + label_width + col as f32 * column_step; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(x, y), + egui::vec2(cell_width, cell_height), + ), + egui::CornerRadius::same(2), + color, + ); + } + } + ui.horizontal(|ui| { + ui.label(theme::text("少")); + for count in [0u32, 1, 4, 8, 15] { + let (swatch, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, heat_color(count)); + } + ui.label(theme::text("多")); + ui.label(format!( + "{} 天 · {} 天活跃", + summary.heatmap_days, summary.activity_days_total + )); + }); + }); +} + +fn period_card(ui: &mut egui::Ui, rect: egui::Rect, summary: &super::view_model::OverviewSummary) { + layout::card_at(ui, rect, |ui| { + let row_width = ui.available_width(); + ui.allocate_exact_size(egui::vec2(row_width, 28.0), egui::Sense::hover()); + ui.label( + egui::RichText::new(theme::text("近期活动")) + .size(13.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(12.0); + for (label, segments, _chars, _duration) in [ + ("近 7 天", summary.last_7_segments, "—", "—"), + ("近 30 天", summary.last_30_segments, "—", "—"), + ] { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(12.0).color(theme::ink_2())); + ui.label( + egui::RichText::new(format!("{} 段", segments)) + .size(12.0) + .color(theme::ink_3()), + ); + }); + ui.add_space(4.0); + } + }); +} + +fn recent_card(ui: &mut egui::Ui, rect: egui::Rect, summary: &super::view_model::OverviewSummary) { + layout::card_at(ui, rect, |ui| { + ui.label( + egui::RichText::new(theme::text("最近记录")) + .size(13.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(12.0); + if summary.recent.is_empty() { + ui.label( + egui::RichText::new(theme::text("暂无记录")) + .size(12.0) + .color(theme::ink_4()), + ); + return; + } + for entry in &summary.recent { + ui.label( + egui::RichText::new(format!( + "{} · {}", + entry.created_at, + entry + .duration_ms + .map(|d| format_duration(d)) + .unwrap_or_else(|| "—".into()) + )) + .size(11.0) + .color(theme::ink_3()), + ); + let text = if entry.final_text.trim().is_empty() { + "(无文字)" + } else { + &entry.final_text + }; + ui.label(egui::RichText::new(text).size(12.0).color(theme::ink_2())); + ui.add_space(8.0); + } + }); +} + +// ── History page ──────────────────────────────────────────────────────────── + +pub fn history_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); + ui.add_space(28.0); + + let header_rect = ui + .allocate_exact_size(egui::vec2(width, 84.0), egui::Sense::hover()) + .0; + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(header_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new("HISTORY") + .size(11.0) + .strong() + .color(theme::ink_4()), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new(theme::text("历史记录")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(5.0); + ui.label( + egui::RichText::new(theme::text("本机保存的识别记录。")) + .size(13.0) + .color(theme::ink_3()), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let clear = layout::icon_text_button(ui, "清空", IconName::Trash, 70.0); + if clear.clicked() { + actions.push(FrontendAction::HistoryClear); + } + ui.add_space(8.0); + let refresh = layout::icon_text_button(ui, "刷新", IconName::Refresh, 70.0); + if refresh.clicked() { + actions.push(FrontendAction::HistoryRefresh); + } + }); + }); + }, + ); + + if vm.history_entries.is_empty() { + 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(theme::text("暂无历史记录")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(theme::text("完成一次听写后,记录将显示在这里。")) + .size(11.0) + .color(theme::ink_4()), + ); + }); + }); + return; + } + + let gap = 14.0; + let list_width = 300.0; + let detail_width = (width - list_width - gap).max(300.0); + let body_height = ui.available_height().max(300.0); + let body = ui + .allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover()) + .0; + let list_rect = egui::Rect::from_min_size(body.min, egui::vec2(list_width, body.height())); + let detail_rect = egui::Rect::from_min_size( + egui::pos2(body.left() + list_width + gap, body.top()), + egui::vec2(detail_width, body.height()), + ); + + // List panel + layout::card_at(ui, list_rect, |ui| { + ui.add_space(1.0); + let search_width = ui.available_width(); + 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, 5)) + .show(ui, |ui| { + ui.set_width((search_width - 20.0).max(1.0)); + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(18.0, 24.0), egui::Sense::hover()); + icons::draw_icon(ui, icon_rect.center(), IconName::Search, theme::ink_3()); + ui.add_space(6.0); + let mut query = vm.history_query.clone(); + let resp = ui.add_sized( + [ui.available_width(), 24.0], + egui::TextEdit::singleline(&mut query) + .hint_text(theme::text("搜索转写内容…")) + .font(egui::FontId::proportional(12.5)) + .vertical_align(egui::Align::Center) + .frame(false), + ); + if resp.changed() { + actions.push(FrontendAction::HistorySearch(query)); + } + }); + }); + ui.label( + egui::RichText::new(format!("共 {} 条记录", vm.history_entries.len())) + .size(10.5) + .color(theme::ink_4()), + ); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let filters = ["全部", "原文", "轻度润色", "清晰结构", "正式表达"]; + for (index, label) in filters.iter().enumerate() { + let selected = vm.history_filter == index; + let filter_width = (label.chars().count() as f32 * 9.0 + 18.0).max(42.0); + let response = ui.add( + egui::Button::new(egui::RichText::new(*label).size(11.5).color(if selected { + theme::surface() + } else { + theme::ink_3() + })) + .fill(if selected { + theme::ink() + } else { + theme::surface() + }) + .stroke(egui::Stroke::new( + if selected { 0.0 } else { 0.8 }, + if selected { + egui::Color32::TRANSPARENT + } else { + theme::line() + }, + )) + .corner_radius(egui::CornerRadius::same(10)) + .min_size(egui::vec2(filter_width, 24.0)), + ); + if response.clicked() { + actions.push(FrontendAction::HistoryFilter(index)); + } + } + }); + ui.separator(); + egui::ScrollArea::vertical() + .id_salt("openless-history-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + for (index, entry) in vm.history_entries.iter().enumerate() { + if vm.history_filter > 0 + && entry.mode + != ["", "Raw", "Light", "Structured", "Formal"] + [vm.history_filter.min(4)] + { + continue; + } + if !vm.history_query.is_empty() + && !entry + .text + .to_lowercase() + .contains(&vm.history_query.to_lowercase()) + { + continue; + } + let selected = vm.history_selected == index; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 84.0), + egui::Sense::click(), + ); + if selected { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + theme::blue_soft(), + ); + let indicator_color = egui::Color32::from_rgb(29, 78, 216); + let left = rect.left() + 1.0; + let right = rect.left() + 4.0; + let top = rect.top() + 2.0; + let bottom = rect.bottom() - 2.0; + let radius = 3.0; + let mut indicator = Vec::with_capacity(18); + indicator.push(egui::pos2(right, top)); + indicator.push(egui::pos2(left + radius, top)); + for step in 0..=6 { + let angle = -std::f32::consts::FRAC_PI_2 + - std::f32::consts::FRAC_PI_2 * step as f32 / 6.0; + indicator.push(egui::pos2( + left + radius + angle.cos() * radius, + top + radius + angle.sin() * radius, + )); + } + indicator.push(egui::pos2(left, bottom - radius)); + for step in 0..=6 { + let angle = std::f32::consts::PI + - std::f32::consts::FRAC_PI_2 * step as f32 / 6.0; + indicator.push(egui::pos2( + left + radius + angle.cos() * radius, + bottom - radius + angle.sin() * radius, + )); + } + indicator.push(egui::pos2(right, bottom)); + ui.painter().add(egui::Shape::convex_polygon( + indicator, + indicator_color, + egui::Stroke::NONE, + )); + } + ui.painter().text( + rect.min + egui::vec2(12.0, 14.0), + egui::Align2::LEFT_CENTER, + &entry.time, + egui::FontId::monospace(10.5), + theme::ink_3(), + ); + ui.painter().text( + egui::pos2(rect.right() - 12.0, rect.top() + 14.0), + egui::Align2::RIGHT_CENTER, + &entry.duration, + egui::FontId::monospace(10.0), + theme::ink_4(), + ); + let chars_per_line = (((rect.width() - 24.0) / 11.5).floor() as usize).max(1); + let chars: Vec = entry.text.chars().collect(); + let first_line: String = chars.iter().take(chars_per_line).collect(); + let mut second_line: String = chars + .iter() + .skip(chars_per_line) + .take(chars_per_line) + .collect(); + if chars.len() > chars_per_line * 2 { + second_line.pop(); + second_line.push('…'); + } + ui.painter().text( + rect.min + egui::vec2(12.0, 29.0), + egui::Align2::LEFT_TOP, + first_line, + egui::FontId::proportional(11.5), + theme::ink_2(), + ); + if !second_line.is_empty() { + ui.painter().text( + rect.min + egui::vec2(12.0, 44.0), + egui::Align2::LEFT_TOP, + second_line, + egui::FontId::proportional(11.5), + theme::ink_2(), + ); + } + layout::tag( + ui, + egui::pos2(rect.min.x + 12.0, rect.bottom() - 23.0), + &entry.tag, + false, + ); + if response.clicked() { + actions.push(FrontendAction::HistorySelect(index)); + } + ui.add_space(1.0); + } + }); + }); + + // Detail panel + layout::card_at(ui, detail_rect, |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-history-detail-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + if let Some(entry) = vm.history_entries.get(vm.history_selected) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&entry.time) + .size(12.0) + .color(theme::ink_3()), + ); + ui.add_space(8.0); + let _ = layout::small_pill( + ui, + &entry.tag, + theme::surface_2(), + theme::line(), + theme::ink_3(), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("录音 {}", entry.duration)) + .size(11.0) + .color(theme::ink_4()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = layout::icon_text_button(ui, "删除", IconName::Trash, 70.0); + if del.clicked() { + actions.push(FrontendAction::HistoryDelete(vm.history_selected)); + } + ui.add_space(8.0); + let export = + layout::icon_text_button(ui, "导出录音", IconName::Download, 92.0); + if export.clicked() { + actions.push(FrontendAction::HistoryExport(vm.history_selected)); + } + }); + }); + ui.add_space(12.0); + layout::soft_separator(ui); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled( + !vm.history_busy, + egui::Button::new(theme::text("重新润色")), + ) + .clicked() + { + actions.push(FrontendAction::HistoryRepolish); + } + if ui + .add_enabled( + entry.has_audio && !vm.history_busy, + egui::Button::new(theme::text("重新转写")), + ) + .clicked() + { + actions.push(FrontendAction::HistoryRetranscribe); + } + if vm.history_busy && ui.button(theme::text("取消")).clicked() { + actions.push(FrontendAction::HistoryCancel); + } + if ui.button(theme::text("复制结果")).clicked() { + ui.ctx().copy_text(entry.text.clone()); + } + }); + let play = layout::icon_text_button( + ui, + if vm.history_audio_playing { + "停止播放" + } else { + "播放录音" + }, + IconName::Play, + 92.0, + ); + if play.clicked() { + actions.push(FrontendAction::HistoryTogglePlay); + } + if vm.history_audio_playing { + ui.label( + egui::RichText::new(theme::text("正在播放录音…")) + .size(11.0) + .color(theme::blue()), + ); + } + ui.add_space(10.0); + for (step, _provider, _status) in [ + ( + "识别", + entry.asr.as_str(), + entry.asr_ms.map(|n| format!("{n} ms")).unwrap_or_default(), + ), + ( + "润色", + entry.llm.as_str(), + entry + .polish_ms + .map(|n| format!("{n} ms")) + .unwrap_or_default(), + ), + ("插入", entry.tag.as_str(), String::new()), + ] { + ui.horizontal(|ui| { + let _ = layout::small_pill( + ui, + step, + theme::surface_2(), + theme::line(), + theme::ink_3(), + ); + ui.label( + egui::RichText::new(_provider) + .size(10.5) + .color(theme::ink_2()), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(_status) + .size(10.5) + .color(theme::ink_4()), + ); + }, + ); + }); + ui.add_space(4.0); + } + ui.add_space(8.0); + let inner_width = ui.available_width(); + let column_gap = 12.0; + let column_width = ((inner_width - column_gap) / 2.0).max(120.0); + let cards_row = ui + .allocate_exact_size(egui::vec2(inner_width, 165.0), egui::Sense::hover()) + .0; + let raw_rect = egui::Rect::from_min_size( + cards_row.min, + egui::vec2(column_width, cards_row.height()), + ); + let polished_rect = egui::Rect::from_min_size( + egui::pos2( + cards_row.left() + column_width + column_gap, + cards_row.top(), + ), + egui::vec2(column_width, cards_row.height()), + ); + detail_text_card(ui, raw_rect, "原文", &entry.raw, false); + detail_text_card(ui, polished_rect, "润色结果", &entry.text, true); + ui.add_space(16.0); + egui::ComboBox::from_id_salt("history-polish-style") + .selected_text( + vm.style_packs + .iter() + .find(|p| p.id == vm.history_repolish_style) + .map(|p| p.name.as_str()) + .unwrap_or("当前风格"), + ) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut vm.history_repolish_style, + String::new(), + theme::text("当前风格"), + ); + for pack in &vm.style_packs { + ui.selectable_value( + &mut vm.history_repolish_style, + pack.id.clone(), + &pack.name, + ); + } + }); + if let Some(results) = vm.history_results.get(&entry.id) { + for result in results { + crate::ui::settings::card(ui, "重新润色结果", |ui| { + ui.label(result); + if ui.button(theme::text("复制")).clicked() { + ui.ctx().copy_text(result.clone()); + } + }); + } + } + } else { + ui.label(theme::text("请选择一条记录")); + } + }); + }); +} + +fn detail_text_card(ui: &egui::Ui, rect: egui::Rect, title: &str, text: &str, blue: bool) { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(10), + if blue { + theme::blue_soft() + } else { + theme::surface_2() + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, if blue { theme::blue() } else { theme::line() }), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.min + egui::vec2(14.0, 18.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(10.5), + if blue { theme::blue() } else { theme::ink_3() }, + ); + let text_rect = egui::Rect::from_min_max( + rect.min + egui::vec2(14.0, 42.0), + rect.max - egui::vec2(14.0, 40.0), + ); + let text_painter = ui.painter().with_clip_rect(text_rect); + let galley = text_painter.layout( + text.to_owned(), + egui::FontId::proportional(12.5), + theme::ink_2(), + text_rect.width(), + ); + text_painter.galley(text_rect.left_top(), galley, theme::ink_2()); + let copy = egui::Rect::from_min_size( + egui::pos2(rect.right() - 58.0, rect.top() + 8.0), + egui::vec2(48.0, 22.0), + ); + ui.painter().rect_stroke( + copy, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::line()), + egui::StrokeKind::Inside, + ); + ui.painter().text( + copy.center(), + egui::Align2::CENTER_CENTER, + "复制", + egui::FontId::proportional(10.5), + theme::ink_2(), + ); + if ui + .interact( + copy, + ui.id().with(("history-copy", title)), + egui::Sense::click(), + ) + .clicked() + { + ui.ctx().copy_text(text.to_owned()); + } +} + +// ── Vocab page ────────────────────────────────────────────────────────────── + +pub fn vocab_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); + ui.label( + egui::RichText::new(theme::text("词汇表")) + .size(11.0) + .color(theme::ink_4()), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(theme::text("词汇表")) + .size(28.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(theme::text("自定义热词,提升专有名词识别率")) + .size(13.0) + .color(theme::ink_3()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new(theme::text("↻ 刷新")) + .size(11.5) + .color(theme::ink_2()), + ) + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.8, theme::line())) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 30.0)), + ) + .clicked() + { + vm.vocab_error = None; + actions.push(FrontendAction::VocabRefresh); + } + }); + }); + ui.add_space(24.0); + + if vm.vocab_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + // Presets card + vocab_card( + ui, + width, + "预设", + "选择一组常用词汇快速添加。", + &mut vm.vocab_presets_open, + |ui| { + ui.horizontal_wrapped(|ui| { + let preset_names: Vec<&str> = vm + .vocab_saved_presets + .iter() + .map(|p| p.name.as_str()) + .collect(); + for (index, name) in preset_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)) + .fill(if selected { + theme::blue_soft() + } else { + theme::surface_2() + }) + .stroke(egui::Stroke::new(0.5, theme::line())) + .corner_radius(egui::CornerRadius::same(12)) + .min_size(egui::vec2(88.0, 32.0)), + ); + if response.clicked() { + actions.push(FrontendAction::VocabApplyPreset(index)); + } + } + if ui + .add( + egui::Button::new(egui::RichText::new(theme::text("创建预设")).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, 34.0)), + ) + .clicked() + { + vm.vocab_editing_preset = Some(usize::MAX); + vm.vocab_preset_name = "新预设".into(); + vm.vocab_preset_phrases.clear(); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(theme::text("应用")) + .color(theme::surface()) + .size(12.0), + ) + .fill(theme::ink()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(64.0, 32.0)), + ) + .clicked() + { + actions.push(FrontendAction::VocabApplyPreset(usize::MAX)); + } + }); + if vm.vocab_editing_preset.is_some() { + ui.add_space(10.0); + let input_width = ui.available_width(); + let input_content_width = (input_width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::surface()) + .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(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_preset_name) + .hint_text(theme::text("预设名称")) + .desired_width(input_content_width) + .frame(false), + ); + }); + egui::Frame::new() + .fill(theme::surface()) + .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(input_content_width); + ui.add_sized( + [input_content_width, 64.0], + egui::TextEdit::multiline(&mut vm.vocab_preset_phrases) + .desired_rows(3) + .desired_width(input_content_width) + .hint_text(theme::text("词汇,用逗号或换行分隔")) + .frame(false), + ); + }); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new(theme::text("保存")) + .color(theme::surface()) + .size(12.0), + ) + .fill(theme::ink()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(72.0, 30.0)), + ) + .clicked() + { + let name = vm.vocab_preset_name.trim().to_owned(); + let phrases = vm.vocab_preset_phrases.clone(); + if !name.is_empty() { + let id = vm + .vocab_editing_preset + .and_then(|i| vm.vocab_saved_presets.get(i)) + .map(|p| p.id.clone()); + actions.push(FrontendAction::VocabCreatePreset { id, name, phrases }); + } + vm.vocab_editing_preset = None; + } + if ui + .add( + egui::Button::new(egui::RichText::new(theme::text("取消")).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, 30.0)), + ) + .clicked() + { + vm.vocab_editing_preset = None; + } + }); + } + if vm.vocab_editing_preset.is_none() && !vm.vocab_saved_presets.is_empty() { + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + let saved_presets = vm.vocab_saved_presets.clone(); + for (index, preset) in saved_presets.iter().enumerate() { + if ui + .add( + egui::Button::new( + egui::RichText::new(format!("编辑 {}", 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(92.0, 30.0)), + ) + .clicked() + { + vm.vocab_preset_name = preset.name.clone(); + vm.vocab_preset_phrases = preset.phrases.clone(); + vm.vocab_editing_preset = Some(index); + } + if ui.small_button(theme::text("删除")).clicked() { + actions.push(FrontendAction::VocabDeletePreset(index)); + } + } + }); + } + }, + ); + + // Correction rules card + vocab_card( + ui, + width, + "纠错规则", + "将识别结果中的常见错误自动替换为正确写法。", + &mut vm.vocab_corrections_open, + |ui| { + ui.horizontal(|ui| { + let spacing = ui.spacing().item_spacing.x; + let add_width = 72.0; + let arrow_width = 24.0; + let input_width = + ((ui.available_width() - add_width - arrow_width - spacing * 3.0) / 2.0) + .max(60.0); + let input_content_width = (input_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(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_pattern) + .desired_width(input_content_width) + .hint_text(theme::text("原文,例如:{num}粒")) + .frame(false), + ); + }); + ui.add_sized( + [arrow_width, 32.0], + egui::Label::new(egui::RichText::new("→").color(theme::ink_4())) + .wrap_mode(egui::TextWrapMode::Extend), + ); + 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(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_replacement) + .desired_width(input_content_width) + .hint_text(theme::text("替换为")) + .frame(false), + ); + }); + if ui + .add( + egui::Button::new( + egui::RichText::new(theme::text("添加")) + .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() + && !vm.vocab_pattern.trim().is_empty() + { + actions.push(FrontendAction::VocabAddRule { + pattern: vm.vocab_pattern.trim().into(), + replacement: vm.vocab_replacement.trim().into(), + }); + vm.vocab_pattern.clear(); + vm.vocab_replacement.clear(); + } + }); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + let mut remove_index = None; + for index in 0..vm.vocab_rules.len() { + let rule = &vm.vocab_rules[index]; + let label = format!( + "{} → {}{}", + rule.pattern, + rule.replacement, + if rule.learned { " 自动" } else { "" } + ); + 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(theme::text("暂无纠错规则")) + .size(12.0) + .color(theme::ink_4()), + ); + } + }); + }, + ); + + // Vocabulary entries card + vocab_card( + ui, + width, + "词汇", + "添加需要优先识别的自定义词汇。", + &mut vm.vocab_entries_open, + |ui| { + ui.horizontal(|ui| { + let input_width = (ui.available_width() - 90.0).max(80.0); + let input_content_width = (input_width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::surface()) + .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(input_content_width); + let resp = ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_input) + .desired_width(input_content_width) + .hint_text(theme::text("输入词汇,按回车添加")) + .frame(false), + ); + if resp.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !vm.vocab_input.trim().is_empty() + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + } + vm.vocab_input.clear(); + } + }); + if ui + .add( + egui::Button::new( + egui::RichText::new(theme::text("+ 添加")) + .color(theme::surface()) + .size(12.0), + ) + .fill(theme::ink()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(78.0, 32.0)), + ) + .clicked() + || (ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !vm.vocab_input.trim().is_empty()) + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + } + vm.vocab_input.clear(); + } + }); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + let mut remove_index = None; + for index in 0..vm.vocab_entries.len() { + let entry = &vm.vocab_entries[index]; + let (toggle, remove) = vocab_chip(ui, entry); + if remove { + remove_index = Some(index); + break; + } + if toggle { + actions.push(FrontendAction::VocabTogglePhrase(index)); + } + } + if let Some(index) = remove_index { + actions.push(FrontendAction::VocabRemovePhrase(index)); + } + }); + let learned = vm + .vocab_entries + .iter() + .filter(|entry| entry.learned) + .count(); + if learned > 0 { + ui.separator(); + ui.horizontal(|ui| { + ui.label(format!("自动收集 ({learned})")); + if ui.button(theme::text("全部删除")).clicked() { + // Remove all learned entries + let indices: Vec = vm + .vocab_entries + .iter() + .enumerate() + .filter(|(_, e)| e.learned) + .map(|(i, _)| i) + .rev() + .collect(); + for i in indices { + actions.push(FrontendAction::VocabRemovePhrase(i)); + } + } + }); + } + if let Some(error) = &vm.vocab_error { + ui.label( + egui::RichText::new(error) + .size(12.0) + .color(egui::Color32::from_rgb(185, 28, 28)), + ); + } + }, + ); +} + +// ── Style page ────────────────────────────────────────────────────────────── + +pub fn style_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); + + if vm.style_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(theme::text("选择润色风格,让每次输出都保持一致")) + .size(12.0) + .color(theme::ink_3()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let import = ui.add( + egui::Button::new(egui::RichText::new(theme::text("▣ 导入 ZIP")).size(11.5)) + .fill(theme::blue()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(92.0, 29.0)), + ); + if import.clicked() { + actions.push(FrontendAction::StyleImport); + } + ui.add_space(8.0); + let refresh = ui.add( + egui::Button::new( + egui::RichText::new(theme::text("↻ 刷新")) + .size(11.5) + .color(theme::ink_2()), + ) + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.7, theme::line())) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 29.0)), + ); + if refresh.clicked() { + actions.push(FrontendAction::StyleRefresh); + } + }); + }); + ui.add_space(14.0); + + let mut selected_action: Option = None; + + let style_card_height = ui.available_height().max(320.0); + layout::card_at( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(width, style_card_height)), + |ui| { + let raw_active = !vm.style_selection_workflow + && vm + .style_packs + .iter() + .any(|p| p.id == "builtin.raw" && p.is_active); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(theme::text("本地风格包")) + .size(15.0) + .strong(), + ); + let raw = ui.add( + egui::Button::new( + egui::RichText::new(theme::text("原文")).size(11.5).color( + if raw_active { + theme::surface() + } else { + theme::ink_3() + }, + ), + ) + .fill(if raw_active { + theme::blue() + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(52.0, 24.0)), + ); + if raw.clicked() { + selected_action = Some(usize::MAX); + vm.style_selection_workflow = false; + } + }); + ui.add_space(3.0); + ui.label( + egui::RichText::new(theme::text("浏览和切换风格包。")) + .size(11.5) + .color(theme::ink_3()), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.8, theme::line())) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(format!("{} 个风格包", vm.style_packs.len())) + .size(10.5) + .color(theme::ink_3()), + ); + }); + let selection = ui.add( + egui::Button::new( + egui::RichText::new(theme::text("选区润色")) + .size(11.5) + .color(if vm.style_selection_workflow { + theme::surface() + } else { + theme::ink_3() + }), + ) + .fill(if vm.style_selection_workflow { + theme::blue() + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(70.0, 24.0)), + ); + if selection.clicked() { + vm.style_selection_workflow = true; + } + let dictation = ui.add( + egui::Button::new( + egui::RichText::new(theme::text("语音润色")) + .size(11.5) + .color(if !vm.style_selection_workflow && !raw_active { + theme::surface() + } else { + theme::ink_3() + }), + ) + .fill(if !vm.style_selection_workflow && !raw_active { + theme::blue() + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(70.0, 24.0)), + ); + if dictation.clicked() { + vm.style_selection_workflow = false; + } + }); + }); + ui.add_space(16.0); + ui.separator(); + ui.add_space(16.0); + + egui::ScrollArea::vertical() + .id_salt("style-packs-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let grid_width = ui.available_width(); + let gap = 12.0; + 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 indices: Vec = vm + .style_packs + .iter() + .enumerate() + .filter_map(|(i, p)| (p.id != "builtin.raw").then_some(i)) + .collect(); + let total_tiles = indices.len() + 1; + for (row_index, start) in (0..total_tiles).step_by(columns).enumerate() { + if row_index > 0 { + ui.add_space(12.0); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for slot in start..(start + columns).min(total_tiles) { + if slot == indices.len() { + if new_style_pack_card(ui, egui::vec2(card_width, 232.0)) { + actions.push(FrontendAction::StyleNewPack); + } + continue; + } + let index = indices[slot]; + let pack = &vm.style_packs[index]; + match style_pack_card( + ui, + egui::vec2(card_width, 232.0), + index, + vm.style_selected, + &pack.name, + &pack.description, + &pack.tags, + pack.accent, + pack.is_active, + pack.is_builtin, + ) { + StyleCardAction::Activate => selected_action = Some(index), + StyleCardAction::Export => { + actions.push(FrontendAction::StyleExport(index)); + } + StyleCardAction::Edit => { + actions.push(FrontendAction::StyleEdit(index)); + } + StyleCardAction::None => {} + } + if slot + 1 < (start + columns).min(total_tiles) { + ui.add_space(gap); + } + } + }); + } + }); + }, + ); + + if let Some(index) = selected_action { + actions.push(FrontendAction::StyleActivate(index)); + } + + if let Some(notice) = vm.style_notice.clone() { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("✓").color(theme::ok()).strong()); + ui.label(egui::RichText::new(notice).size(11.5).color(theme::ink_2())); + if ui.small_button("×").clicked() { + vm.style_notice = None; + } + }); + } + style_editor_overlay(ui.ctx(), vm, actions); +} + +// ── Selection ask page ────────────────────────────────────────────────────── + +pub fn selection_ask_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); + + if vm.selection_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + // History toggle + let history_width = 142.0; + let history_rect = ui + .allocate_exact_size(egui::vec2(history_width, 36.0), egui::Sense::hover()) + .0; + ui.painter().rect_filled( + history_rect, + egui::CornerRadius::same(10), + egui::Color32::from_rgb(241, 241, 242), + ); + ui.painter().rect_stroke( + history_rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::line()), + egui::StrokeKind::Inside, + ); + ui.painter().text( + history_rect.min + egui::vec2(14.0, 18.0), + egui::Align2::LEFT_CENTER, + "保存历史", + egui::FontId::proportional(12.5), + theme::ink_2(), + ); + + let toggle_rect = egui::Rect::from_min_size( + egui::pos2(history_rect.right() - 50.0, history_rect.center().y - 10.0), + egui::vec2(36.0, 20.0), + ); + let toggle = ui.interact( + toggle_rect, + ui.id().with("selection-ask-history"), + egui::Sense::click(), + ); + let toggle_color = if vm.qa_save_history { + theme::blue() + } else { + egui::Color32::from_rgb(184, 184, 187) + }; + ui.painter() + .rect_filled(toggle_rect, egui::CornerRadius::same(10), toggle_color); + let knob_x = if vm.qa_save_history { + toggle_rect.right() - 10.0 + } else { + toggle_rect.left() + 10.0 + }; + ui.painter().circle_filled( + egui::pos2(knob_x, toggle_rect.center().y), + 8.0, + egui::Color32::WHITE, + ); + if toggle.clicked() { + actions.push(FrontendAction::SelectionAskToggleHistory); + } + ui.add_space(12.0); + + // Usage card + layout::card_at( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(width, 196.0)), + |ui| { + ui.label( + egui::RichText::new(theme::text("使用方法")) + .size(13.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(10.0); + let steps = [ + "按设置中的划词追问快捷键打开浮窗。", + "在任意 app 选中文字。", + "按听写快捷键录音,松开或再次按下提交。", + "可继续使用听写快捷键进行多轮追问。", + "按 Esc 关闭浮窗并清空历史。", + ]; + for (index, step) in steps.into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized( + [18.0, 20.0], + egui::Label::new( + egui::RichText::new(format!("{:}.", index + 1)) + .size(12.5) + .color(theme::ink_3()), + ), + ); + ui.label(egui::RichText::new(step).size(12.5).color(theme::ink_2())); + }); + if index < 4 { + ui.add_space(5.0); + } + } + }, + ); +} + +// ── Translation page ───────────────────────────────────────────────────────── + +pub fn translation_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); + + if vm.translation_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + let gap = 12.0; + let card_width = width.min(760.0); + + // Working languages card + translation_card(ui, card_width, |ui| { + ui.label( + egui::RichText::new(theme::text("工作语言")) + .size(13.0) + .strong(), + ); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for language in SUPPORTED_LANGUAGES { + let selected = vm + .translation_working_languages + .iter() + .any(|value| value == language); + let response = ui.add( + egui::Button::new(egui::RichText::new(language).size(12.5).color( + if selected { + egui::Color32::WHITE + } else { + theme::ink_2() + }, + )) + .fill(if selected { + theme::blue() + } else { + theme::surface_2() + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(255)) + .min_size(egui::vec2(0.0, 28.0)), + ); + if response.clicked() { + actions.push(FrontendAction::TranslationToggleLanguage( + language.to_string(), + )); + } + } + }); + }); + ui.add_space(gap); + + // Target language card + 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; + translation_card(ui, card_width, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(theme::text("翻译目标语言")) + .size(13.0) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(if enabled { "已启用" } else { "未启用" }) + .size(10.5) + .strong() + .color(if enabled { + theme::blue() + } else { + theme::ink_4() + }), + ); + }); + }); + ui.add_space(12.0); + ui.scope(|ui| { + let style = ui.style_mut(); + style.visuals.menu_corner_radius = egui::CornerRadius::same(10); + style.spacing.button_padding = egui::vec2(10.0, 0.0); + style.spacing.icon_spacing = 8.0; + style.spacing.icon_width = 11.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.weak_bg_fill = theme::surface(); + widget.bg_fill = theme::surface(); + widget.bg_stroke = egui::Stroke::new(0.8, theme::line()); + widget.fg_stroke = egui::Stroke::new(1.0, theme::ink_2()); + } + let mut selected_target = target.clone(); + egui::ComboBox::from_id_salt("translation-target-language") + .width(360.0) + .height(32.0) + .truncate() + .icon(|ui, rect, visuals, _| { + let center = rect.center(); + let stroke = egui::Stroke::new(1.1, visuals.fg_stroke.color); + ui.painter() + .line_segment([center + egui::vec2(-3.5, -1.5), center], stroke); + ui.painter() + .line_segment([center, center + egui::vec2(3.5, -1.5)], stroke); + }) + .selected_text(if target.is_empty() { + egui::RichText::new(theme::text("不启用(Shift 按下不触发翻译)")) + .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(), + "不启用(Shift 按下不触发翻译)", + ) + .clicked() + { + selected_target = String::new(); + ui.close(); + } + for language in SUPPORTED_LANGUAGES { + if ui + .selectable_label(selected_target == language, language) + .clicked() + { + selected_target = language.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(theme::text("翻译风格")) + .size(12.0) + .strong(), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(theme::text("自动继承“风格”页当前激活的风格包。")) + .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.as_str() + } else if vm.style_selected == usize::MAX { + "原样保留" + } else { + "轻度润色" + }; + egui::Frame::new() + .fill(theme::blue_soft()) + .stroke(egui::Stroke::NONE) + .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(egui::Color32::from_rgba_unmultiplied(217, 119, 6, 20)) + .stroke(egui::Stroke::new( + 0.5, + egui::Color32::from_rgba_unmultiplied(217, 119, 6, 62), + )) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + "目标语言与唯一工作语言相同,按翻译快捷键不会触发翻译。", + ) + .size(11.5) + .color(egui::Color32::from_rgb(180, 103, 10)), + ); + }); + } + }); + ui.add_space(gap); + + // Usage card + translation_card(ui, card_width, |ui| { + ui.label( + egui::RichText::new(theme::text("使用方法")) + .size(13.0) + .strong(), + ); + ui.add_space(10.0); + for (number, text) in [ + ("1", "按右 Option 开始录音。"), + ("2", "再次按右 Option 停止录音。"), + ("3", "录音过程中按翻译快捷键切换到翻译模式。"), + ("4", "松开按键后,译文会自动插入当前应用。"), + ("5", "翻译模式会在胶囊顶部显示状态。"), + ] { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("{number}.")) + .size(12.5) + .color(theme::ink_3()), + ); + ui.label(egui::RichText::new(text).size(12.5).color(theme::ink_2())); + }); + ui.add_space(4.0); + } + }); +} + +// ── Vocab helpers ─────────────────────────────────────────────────────────── + +fn vocab_card( + ui: &mut egui::Ui, + width: f32, + title: &str, + desc: &str, + open: &mut bool, + contents: impl FnOnce(&mut egui::Ui), +) { + let frame = 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(17)); + frame.show(ui, |ui| { + ui.set_width(width - 34.0); + let header = ui.horizontal(|ui| { + let (arrow_rect, response) = + ui.allocate_exact_size(egui::vec2(20.0, 24.0), egui::Sense::click()); + let arrow_stroke = egui::Stroke::new(1.4, theme::ink_4()); + let center = arrow_rect.center(); + if *open { + ui.painter().line_segment( + [ + center + egui::vec2(-4.0, -2.0), + center + egui::vec2(0.0, 2.0), + ], + arrow_stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(0.0, 2.0), + center + egui::vec2(4.0, -2.0), + ], + arrow_stroke, + ); + } else { + ui.painter().line_segment( + [ + center + egui::vec2(-2.0, -4.0), + center + egui::vec2(2.0, 0.0), + ], + arrow_stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(2.0, 0.0), + center + egui::vec2(-2.0, 4.0), + ], + arrow_stroke, + ); + } + if response.clicked() { + *open = !*open; + } + ui.vertical(|ui| { + ui.label(egui::RichText::new(title).size(13.0).strong()); + ui.label(egui::RichText::new(desc).size(11.5).color(theme::ink_4())); + }); + }); + let _ = header; + if *open { + ui.add_space(12.0); + contents(ui); + } + }); + ui.add_space(12.0); +} + +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) +} + +fn vocab_chip(ui: &mut egui::Ui, entry: &super::view_model::VocabEntry) -> (bool, bool) { + let fill = if entry.enabled && entry.hits > 0 { + theme::blue_soft() + } else if entry.enabled { + theme::surface() + } else { + theme::surface_2() + }; + let text_color = if entry.enabled { + theme::ink() + } else { + theme::ink_4() + }; + let phrase_galley = 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_galley = + ui.painter() + .layout_no_wrap(hits_text, egui::FontId::proportional(11.0), hits_color); + let hits_size = egui::vec2((hits_galley.size().x + 12.0).max(24.0), 22.0); + let close_size = 22.0; + let width = 12.0 + phrase_galley.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, + ); + painter.galley( + egui::pos2( + rect.left() + 12.0, + rect.center().y - phrase_galley.size().y / 2.0, + ), + phrase_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), + ); + 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 { + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 15) + }, + ); + painter.galley( + egui::pos2( + hits_rect.center().x - hits_galley.size().x / 2.0, + hits_rect.center().y - hits_galley.size().y / 2.0, + ), + hits_galley, + 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 response + .interact_pointer_pos() + .is_some_and(|pointer| close_rect.contains(pointer)) + { + return (false, true); + } + return (true, false); + } + (false, false) +} + +// ── Style helpers ─────────────────────────────────────────────────────────── + +enum StyleCardAction { + None, + Activate, + Export, + Edit, +} + +fn style_pack_card( + ui: &mut egui::Ui, + size: egui::Vec2, + index: usize, + selected: usize, + name: &str, + description: &str, + tags: &[String], + accent: egui::Color32, + is_active: bool, + is_builtin: bool, +) -> StyleCardAction { + let active = is_active || selected == index; + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(14), + if active || response.hovered() { + theme::blue_soft() + } else { + theme::surface() + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new( + if active { 1.5 } else { 1.0 }, + if active { theme::blue() } else { theme::line() }, + ), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(16.0); + let mut action = StyleCardAction::None; + let mut card_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + let ui = &mut card_ui; + ui.style_mut().interaction.selectable_labels = false; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(name) + .size(14.0) + .strong() + .color(theme::ink()), + ); + ui.add_space(8.0); + if is_builtin { + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(0.7, accent)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(theme::text("内置")) + .size(10.5) + .color(accent), + ); + }); + } + if active { + ui.add_space(4.0); + egui::Frame::new() + .fill(theme::ink()) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(theme::text("当前")) + .size(10.5) + .strong() + .color(theme::surface()), + ); + }); + } + }); + ui.add_space(9.0); + let description_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(description_width, 48.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(truncate_text(description, 96)) + .size(11.5) + .color(theme::ink_3()), + ) + .wrap(), + ); + }, + ); + ui.add_space(9.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for (tag_index, tag) in tags.iter().enumerate() { + egui::Frame::new() + .fill(if tag_index == 0 { + theme::blue_soft() + } else { + theme::surface_2() + }) + .stroke(egui::Stroke::new( + 0.5, + if tag_index == 0 { + accent + } else { + theme::line() + }, + )) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new(tag.as_str()).size(10.5).color( + if tag_index == 0 { + accent + } else { + theme::ink_3() + }, + )); + }); + } + }); + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let activate = ui.add_enabled( + !active, + egui::Button::new(egui::RichText::new(theme::text("激活")).size(10.5)) + .fill(if active { theme::ink() } else { theme::blue() }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if activate.clicked() { + action = StyleCardAction::Activate; + } + let export = ui.add( + egui::Button::new(egui::RichText::new(theme::text("导出")).size(10.5)) + .fill(theme::surface_2()) + .stroke(egui::Stroke::new(0.7, theme::line())) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if export.clicked() { + action = StyleCardAction::Export; + } + let edit = ui.add_enabled( + true, + egui::Button::new(egui::RichText::new(theme::text("编辑")).size(10.5)) + .fill(theme::surface_2()) + .stroke(egui::Stroke::new(0.7, theme::line())) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if edit.clicked() { + action = StyleCardAction::Edit; + } + }); + }); + if response.double_clicked() { + StyleCardAction::Edit + } else { + action + } +} + +fn new_style_pack_card(ui: &mut egui::Ui, size: egui::Vec2) -> bool { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(14), + if response.hovered() { + theme::surface_2() + } else { + theme::surface() + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::line()), + egui::StrokeKind::Inside, + ); + let center = rect.center() - egui::vec2(0.0, 20.0); + ui.painter().circle_filled(center, 22.0, theme::surface_2()); + let stroke = egui::Stroke::new(1.5, theme::blue()); + ui.painter().line_segment( + [center - egui::vec2(8.0, 0.0), center + egui::vec2(8.0, 0.0)], + stroke, + ); + ui.painter().line_segment( + [center - egui::vec2(0.0, 8.0), center + egui::vec2(0.0, 8.0)], + stroke, + ); + ui.painter().text( + rect.center() + egui::vec2(0.0, 22.0), + egui::Align2::CENTER_CENTER, + "新建风格包", + egui::FontId::proportional(14.0), + theme::ink_2(), + ); + ui.painter().text( + rect.center() + egui::vec2(0.0, 45.0), + egui::Align2::CENTER_CENTER, + "从模板开始创建自己的风格", + egui::FontId::proportional(11.0), + theme::ink_4(), + ); + response.clicked() +} + +pub fn style_editor_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + if !vm.style_editor_open { + return; + } + let mut open = true; + egui::Window::new(theme::text("编辑风格包")) + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(620.0) + .default_height(520.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(580.0) + .show(ui, |ui| { + ui.label(theme::text("风格名称")); + ui.text_edit_singleline(&mut vm.style_name); + ui.label(theme::text("风格描述")); + ui.text_edit_singleline(&mut vm.style_description); + ui.add_space(10.0); + ui.label(theme::text("润色提示词")); + ui.add( + egui::TextEdit::multiline(&mut vm.style_prompt) + .desired_rows(8) + .desired_width(f32::INFINITY), + ); + ui.add_space(10.0); + ui.label(theme::text("选区润色提示词")); + ui.add( + egui::TextEdit::multiline(&mut vm.style_selection_prompt) + .desired_rows(6) + .desired_width(f32::INFINITY), + ); + ui.add_space(12.0); + if let Some(error) = &vm.style_notice { + ui.label(error); + } + ui.add_enabled_ui(!vm.style_saving, |ui| { + ui.horizontal(|ui| { + if ui + .add(egui::Button::new(theme::text("保存")).fill(theme::blue())) + .clicked() + { + actions + .push(FrontendAction::StyleSaveEditor(vm.style_prompt.clone())); + vm.style_saving = true; + } + if vm.style_builtin && ui.button(theme::text("恢复默认")).clicked() + { + actions.push(FrontendAction::StyleReset); + } + if !vm.style_builtin && ui.button(theme::text("删除")).clicked() { + actions.push(FrontendAction::StyleDelete); + } + if ui.button(theme::text("取消")).clicked() { + actions.push(FrontendAction::StyleCloseEditor); + } + }); + }); + }); + }); + if !open { + actions.push(FrontendAction::StyleCloseEditor); + } +} + +// ── Translation helpers ───────────────────────────────────────────────────── + +fn translation_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(17)) + .show(ui, |ui| { + ui.set_width((width - 34.0).max(1.0)); + contents(ui); + }); +} 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..c63982625 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -0,0 +1,556 @@ +// ── Page / Tab ────────────────────────────────────────────────────────────── + +#[derive(Clone, 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, Debug)] +pub enum FrontendAction { + AcceptCorrection(String), + RejectCorrection(String), + /// 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), + /// History search query changed. + HistorySearch(String), + /// History filter changed. + HistoryFilter(usize), + /// Select a history entry. + HistorySelect(usize), + /// Clear all history. + HistoryClear, + /// Refresh history list. + HistoryRefresh, + /// Play/pause history audio. + HistoryTogglePlay, + /// Repolish a history entry. + HistoryRepolish, + HistoryRetranscribe, + HistoryCancel, + /// Delete a history entry. + HistoryDelete(usize), + /// Export history recording. + HistoryExport(usize), + /// Vocab entry added. + VocabAddPhrase(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 { + id: Option, + name: String, + phrases: String, + }, + VocabDeletePreset(usize), + VocabRefresh, + /// Style pack activated. + StyleActivate(usize), + StyleRefresh, + StyleReset, + StyleDelete, + /// 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), + /// Settings notice message. + SettingsNotice(String), + /// Window close requested. + WindowClose, + /// Window maximize/minimize toggle. + WindowMaximize, + /// Window minimize. + WindowMinimize, + /// Sidebar group toggle. + SidebarToggleStyle, + SidebarToggleTools, +} + +// ── Marketplace types ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarketplaceSort { + #[default] + Popular, + New, + Liked, +} + +#[derive(Clone, 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, + pub is_new: bool, +} + +// ── Settings types ────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsSection { + General, + Services, + Privacy, + Advanced, + About, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsField { + RecordingEnabled, + RealtimeMode, + StreamingInsert, + RestoreClipboard, + StartMinimized, + AutoUpdate, + RemoteInput, + SelectionAssistant, + SelectionVoice, + StackedLayout, + ConservativeLayout, + ActivityHeatmap, + SystemProxy, + LocalModel, + MarketplaceEnabled, + RememberHistory, + RecordAudio, + LessComputer, + Multimodal, + BetaChannel, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsComboField { + Provider, + Language, + Theme, + Retention, + Microphone, + RecordingMode, +} + +#[derive(Clone, Debug)] +pub enum SettingsTextField { + ApiKey, + Endpoint, + Model, + RemotePort, + ClaudePrompt, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsActionField { + ConnectionTest, + ModelManagement, + ExtensionManagement, + Permissions, + ClearHistory, + ClaudeDetect, + ClaudeConsole, + ClaudeRunTest, + ExportDiagnostics, + CheckUpdate, + OpenGitHub, + OpenHelp, + OpenReleaseNotes, + OpenFeedback, + CopyQQ, +} + +// ── Vocab types ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct VocabEntry { + pub phrase: String, + pub hits: usize, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, Debug)] +pub struct CorrectionRule { + pub pattern: String, + pub replacement: String, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, Debug)] +pub struct SavedVocabPreset { + pub id: String, + pub name: String, + pub phrases: String, +} + +// ── History types ─────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub id: String, + pub raw: String, + pub mode: String, + pub has_audio: bool, + pub asr: String, + pub llm: String, + pub asr_ms: Option, + pub polish_ms: Option, + pub time: String, + pub text: String, + pub duration: String, + pub tag: String, +} + +// ── Style types ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct StylePack { + pub id: String, + pub name: String, + pub description: String, + pub tags: Vec, + pub accent: egui::Color32, + pub is_builtin: bool, + pub is_active: bool, +} + +// ── Overview types ────────────────────────────────────────────────────────── + +#[derive(Clone, 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, + pub last_7_segments: u64, + pub last_30_segments: u64, + pub heatmap_weeks: Vec<[u32; 7]>, + pub heatmap_days: u32, + pub activity_days_total: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct OverviewRecentEntry { + pub created_at: String, + pub final_text: String, + 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, Debug)] +pub struct FrontendViewModel { + pub active_page: Page, + pub style_open: bool, + pub tools_open: bool, + pub settings_open: bool, + + // Overview + pub overview_loading: bool, + pub overview_error: Option, + pub overview: Option, + + // History + pub history_query: String, + pub history_filter: usize, + pub history_selected: usize, + pub history_entries: Vec, + pub history_cleared: bool, + pub history_repolished: bool, + pub history_audio_playing: bool, + pub history_busy: bool, + pub history_repolish_style: String, + pub history_results: std::collections::HashMap>, + pub history_style_picker_open: bool, + + // Vocab + pub vocab_entries: Vec, + pub vocab_rules: Vec, + pub pending_corrections: Vec, + 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_presets_open: bool, + pub vocab_corrections_open: bool, + pub vocab_entries_open: bool, + 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_name: String, + pub style_description: String, + pub style_selection_prompt: String, + pub style_builtin: bool, + pub style_saving: bool, + 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_liked: Vec, + pub marketplace_notice: Option, + pub marketplace_prompt: Option, + pub marketplace_loading: bool, + pub marketplace_unsupported: bool, + + // Settings + pub settings_section: SettingsSection, + pub settings_notice: Option, + pub settings: SettingsFields, + + // Selection ask + pub qa_save_history: bool, + pub selection_unsupported: bool, + + // Translation + pub translation_working_languages: Vec, + pub translation_target_language: String, + pub translation_unsupported: bool, + + // Status bar + pub version: String, + pub status: String, +} + +impl Default for FrontendViewModel { + fn default() -> Self { + Self { + active_page: Page::Overview, + style_open: true, + tools_open: false, + settings_open: false, + overview_loading: true, + overview_error: None, + overview: None, + history_query: String::new(), + history_filter: 0, + history_selected: 0, + history_entries: Vec::new(), + history_cleared: false, + history_repolished: false, + history_audio_playing: false, + history_busy: false, + history_repolish_style: String::new(), + history_results: Default::default(), + history_style_picker_open: false, + vocab_entries: Vec::new(), + vocab_rules: Vec::new(), + pending_corrections: Vec::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_presets_open: false, + vocab_corrections_open: false, + vocab_entries_open: true, + 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_name: String::new(), + style_description: String::new(), + style_selection_prompt: String::new(), + style_builtin: false, + style_saving: false, + style_notice: None, + style_unsupported: true, + marketplace_query: String::new(), + marketplace_sort: MarketplaceSort::Popular, + marketplace_packs: Vec::new(), + marketplace_selected: None, + marketplace_liked: Vec::new(), + marketplace_notice: None, + marketplace_prompt: None, + marketplace_loading: true, + marketplace_unsupported: true, + settings_section: SettingsSection::General, + settings_notice: None, + settings: SettingsFields::default(), + qa_save_history: false, + selection_unsupported: true, + translation_working_languages: Vec::new(), + translation_target_language: String::new(), + translation_unsupported: true, + version: env!("CARGO_PKG_VERSION").to_string(), + status: String::new(), + } + } +} + +/// Mirror of the egui-frontend `SettingsState` fields, but with no default +/// mock data. All values come from the host. +#[derive(Clone, Debug)] +pub struct SettingsFields { + pub recording_enabled: bool, + pub realtime_mode: bool, + pub streaming_insert: bool, + pub restore_clipboard: bool, + pub start_minimized: bool, + pub auto_update: bool, + pub remote_input: bool, + pub selection_assistant: bool, + pub selection_voice: bool, + pub stacked_layout: bool, + pub conservative_layout: bool, + pub activity_heatmap: bool, + pub system_proxy: bool, + pub local_model: bool, + pub marketplace_enabled: bool, + pub remember_history: bool, + pub record_audio: bool, + pub less_computer: bool, + pub multimodal: bool, + pub beta_channel: bool, + pub claude_expanded: bool, + pub provider: usize, + pub language: usize, + pub theme: usize, + pub retention: usize, + pub api_key: String, + pub endpoint: String, + pub model: String, + pub remote_port: String, + pub claude_prompt: String, +} + +impl Default for SettingsFields { + fn default() -> Self { + Self { + recording_enabled: false, + realtime_mode: false, + streaming_insert: false, + restore_clipboard: false, + start_minimized: false, + auto_update: false, + remote_input: false, + selection_assistant: false, + selection_voice: false, + stacked_layout: false, + conservative_layout: false, + activity_heatmap: false, + system_proxy: false, + local_model: false, + marketplace_enabled: false, + remember_history: false, + record_audio: false, + less_computer: false, + multimodal: false, + beta_channel: false, + claude_expanded: false, + provider: 0, + language: 0, + theme: 0, + retention: 0, + api_key: String::new(), + endpoint: String::new(), + model: String::new(), + remote_port: String::new(), + claude_prompt: String::new(), + } + } +} 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..c99acac83 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod frontend; +pub mod settings; +pub mod shell; +pub mod theme; diff --git a/openless-all/app/linux-egui/src/ui/settings.rs b/openless-all/app/linux-egui/src/ui/settings.rs new file mode 100644 index 000000000..17ad7f9ae --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/settings.rs @@ -0,0 +1,184 @@ +//! The seven-section 2.0 settings shell. Each pane is supplied by the host's +//! actual Core-backed editor, including provider credentials and model actions. +use super::theme; +use eframe::egui; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Section { + #[default] + General, + Shortcuts, + Services, + Appearance, + Privacy, + Advanced, + About, +} +impl Section { + pub const ALL: [Self; 7] = [ + Self::General, + Self::Shortcuts, + Self::Services, + Self::Appearance, + Self::Privacy, + Self::Advanced, + Self::About, + ]; + pub fn title(self) -> &'static str { + match self { + Self::General => "录音与输入", + Self::Shortcuts => "快捷键与选区", + Self::Services => "AI 服务与模型", + Self::Appearance => "外观与语言", + Self::Privacy => "权限与数据", + Self::Advanced => "实验与扩展", + Self::About => "关于与更新", + } + } + fn keywords(self) -> &'static str { + match self { + Self::General => "麦克风 静音 提示音 手机 远程 输入 microphone remote", + Self::Shortcuts => "按住 听写 翻译 划词 热键 shortcut hotkey", + Self::Services => "渠道 语音 识别 语言 模型 下载 网络 代理 ASR LLM Omni provider model", + Self::Appearance => "主题 深色 浅色 字体 语言 外观 theme language font", + Self::Privacy => "权限 历史 录音 上下文 云同步 存储 privacy sync history", + Self::Advanced => "Less Computer Agent 多模态 调试 日志 multimodal debug", + Self::About => "版本 更新 Beta 帮助 update version", + } + } +} +#[derive(Clone, Debug, Default)] +pub struct SettingsState { + pub section: Section, + pub query: String, + pub service: usize, + pub advanced: usize, + pub confirmation: Option, +} + +pub fn modal( + ctx: &egui::Context, + state: &mut SettingsState, + mut content: impl FnMut(&mut egui::Ui, &mut SettingsState), +) -> bool { + let mut close = false; + let size = egui::vec2( + (ctx.content_rect().width() - 40.0).clamp(280.0, 960.0), + (ctx.content_rect().height() - 40.0).clamp(240.0, 680.0), + ); + let response = egui::Modal::new(egui::Id::new("openless-settings-2.0")) + .frame( + egui::Frame::new() + .fill(theme::surface()) + .corner_radius(14) + .stroke(egui::Stroke::new(1.0, theme::line())) + .inner_margin(0), + ) + .show(ctx, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + ui.spacing_mut().item_spacing = egui::Vec2::ZERO; + ui.horizontal_top(|ui| { + let rail_width = 214.0_f32.min(size.x * 0.36); + egui::Frame::new() + .fill(theme::settings_rail_bg()) + .inner_margin(16) + .show(ui, |ui| { + ui.set_min_size(egui::vec2(rail_width - 32.0, size.y - 32.0)); + ui.set_max_width(rail_width - 32.0); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + ui.heading(theme::text("设置")); + ui.add_space(12.0); + ui.add( + egui::TextEdit::singleline(&mut state.query) + .hint_text(theme::text("搜索设置…")) + .desired_width(f32::INFINITY), + ); + ui.add_space(16.0); + for section in Section::ALL { + let haystack = format!("{} {}", section.title(), section.keywords()) + .to_lowercase(); + if !state + .query + .split_whitespace() + .all(|word| haystack.contains(&word.to_lowercase())) + { + continue; + } + let selected = state.section == section; + let button = egui::Button::new( + egui::RichText::new(theme::text(section.title())) + .size(13.0) + .color(if selected { + theme::ink() + } else { + theme::ink_3() + }), + ) + .fill(if selected { + theme::surface() + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(8); + if ui.add_sized([rail_width - 32.0, 36.0], button).clicked() { + state.section = section; + state.query.clear(); + } + } + }); + egui::Frame::new() + .fill(theme::settings_content_bg()) + .inner_margin(24) + .show(ui, |ui| { + ui.set_min_size(egui::vec2( + (size.x - rail_width - 48.0).max(1.0), + size.y - 48.0, + )); + ui.set_max_width((size.x - rail_width - 48.0).max(1.0)); + ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0); + ui.horizontal(|ui| { + ui.heading(theme::text(state.section.title())); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + close |= ui + .button("×") + .on_hover_text(theme::text("关闭设置")) + .clicked(); + }, + ); + }); + ui.add_space(12.0); + egui::ScrollArea::vertical() + .id_salt(( + "settings-section", + state.section as usize, + state.service, + state.advanced, + )) + .auto_shrink([false, false]) + .max_height(size.y - 112.0) + .show(ui, |ui| content(ui, state)); + }); + }); + }); + close || response.should_close() +} + +pub fn card(ui: &mut egui::Ui, title: &str, draw: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::surface()) + .stroke(egui::Stroke::new(1.0, theme::line())) + .corner_radius(14) + .inner_margin(18) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if !title.is_empty() { + ui.label(egui::RichText::new(theme::text(title)).size(14.0).strong()); + ui.add_space(8.0); + } + draw(ui); + }); + ui.add_space(12.0); +} 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..06095b6b3 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/shell.rs @@ -0,0 +1,460 @@ +use eframe::egui; + +use openless_linux_egui::{tr_l10n, Lang}; + +use super::theme; + +pub const SIDEBAR_WIDTH: f32 = 226.0; +pub const TITLEBAR_HEIGHT: f32 = 36.0; +const WINDOW_MARGIN: f32 = 0.0; +const WINDOW_RADIUS: u8 = 14; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Page { + #[default] + Overview, + History, + Vocabulary, + Styles, + Marketplace, + Providers, + Assistant, + Translation, + Corrections, +} + +impl Page { + pub fn title(self, lang: Lang) -> &'static str { + let key = match self { + Self::Overview => "nav.overview", + Self::History => "nav.history", + Self::Vocabulary => "nav.vocab", + Self::Styles => "nav.styles", + Self::Marketplace => "nav.marketplace", + Self::Providers => "nav.providers", + Self::Assistant => "nav.assistant", + Self::Translation => "nav.translation", + Self::Corrections => "nav.corrections", + }; + tr_l10n(lang, key) + } + + pub fn nav_title(self, lang: Lang) -> &'static str { + if self == Self::Providers { + tr_l10n(lang, "nav.settings") + } else { + self.title(lang) + } + } +} + +#[derive(Clone, Copy)] +enum IconName { + Overview, + History, + Vocabulary, + Style, + Tools, + Settings, +} + +fn window_rect(ctx: &egui::Context) -> egui::Rect { + ctx.content_rect().shrink(WINDOW_MARGIN) +} + +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) +} + +fn app_icon(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("openless-shell-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 titlebar(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, + ); + + egui::Area::new(egui::Id::new("openless-titlebar")) + .order(egui::Order::Middle) + .fixed_pos(window.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(window.width(), TITLEBAR_HEIGHT)); + let titlebar = egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(window.width(), TITLEBAR_HEIGHT), + ); + let drag = ui.interact( + titlebar, + ui.id().with("titlebar-drag"), + egui::Sense::click_and_drag(), + ); + if drag.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + let texture = app_icon(ctx); + ui.painter().image( + texture.id(), + egui::Rect::from_center_size( + egui::pos2(16.0, TITLEBAR_HEIGHT / 2.0), + egui::vec2(18.0, 18.0), + ), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + ui.painter().text( + egui::pos2(34.0, TITLEBAR_HEIGHT / 2.0 + 0.5), + egui::Align2::LEFT_CENTER, + "OpenLess", + egui::FontId::proportional(13.0), + theme::ink_2(), + ); + + let button_width = 40.0; + let close = egui::Rect::from_min_max( + egui::pos2(titlebar.right() - button_width, 0.0), + 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() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if maximize_response.clicked() { + let maximized = ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + if minimize_response.clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + 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()); + ui.painter().line_segment( + [ + minimize.center() - egui::vec2(5.0, 0.0), + minimize.center() + egui::vec2(5.0, 0.0), + ], + stroke, + ); + ui.painter().rect_stroke( + maximize.shrink(14.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, + ); + }); +} + +pub fn sidebar(ctx: &egui::Context, active: &mut Page, status: &str, lang: Lang) { + let body = body_rect(ctx); + egui::Area::new(egui::Id::new("openless-sidebar")) + .order(egui::Order::Middle) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.set_clip_rect(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(SIDEBAR_WIDTH, body.height()), + )); + ui.painter() + .rect_filled(ui.max_rect(), 0.0, theme::surface()); + ui.painter().line_segment( + [ + egui::pos2(SIDEBAR_WIDTH, 0.0), + egui::pos2(SIDEBAR_WIDTH, body.height()), + ], + 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); + nav(ui, active, Page::Overview, IconName::Overview, lang); + nav(ui, active, Page::History, IconName::History, lang); + nav(ui, active, Page::Vocabulary, IconName::Vocabulary, lang); + ui.add_space(5.0); + group_label(ui, tr_l10n(lang, "nav.styles"), IconName::Style); + subnav(ui, active, Page::Styles, lang); + subnav(ui, active, Page::Marketplace, lang); + ui.add_space(2.0); + group_label(ui, tr_l10n(lang, "nav.assistant"), IconName::Tools); + subnav(ui, active, Page::Assistant, lang); + + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + nav(ui, active, Page::Providers, IconName::Settings, lang); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.vertical(|ui| { + 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.add_space(3.0); + ui.label( + egui::RichText::new(format!( + "{} · {}", + env!("CARGO_PKG_VERSION"), + status + )) + .size(10.5) + .color(theme::ink_4()), + ); + }); + }); + }); + }); + }); +} + +fn nav(ui: &mut egui::Ui, active: &mut Page, page: Page, icon: IconName, lang: Lang) { + let selected = *active == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + if selected { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::surface_2()); + } + let color = if selected { + theme::ink() + } else { + theme::ink_3() + }; + 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, + page.nav_title(lang), + egui::FontId::proportional(13.0), + color, + ); + if response.clicked() { + *active = page; + } +} + +fn subnav(ui: &mut egui::Ui, active: &mut Page, page: Page, lang: Lang) { + let selected = *active == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 30.0), egui::Sense::click()); + if selected { + 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, + page.nav_title(lang), + egui::FontId::proportional(12.5), + if selected { + theme::ink() + } else { + theme::ink_3() + }, + ); + if response.clicked() { + *active = page; + } +} + +fn group_label(ui: &mut egui::Ui, label: &str, icon: IconName) { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::hover()); + draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, theme::ink_3()); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + theme::ink_3(), + ); +} + +fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.25, color); + match icon { + IconName::Overview => { + painter.line_segment( + [ + center + egui::vec2(-6.0, -6.0), + center + egui::vec2(-6.0, 6.0), + ], + stroke, + ); + painter.line_segment( + [ + center + egui::vec2(-6.0, 6.0), + center + egui::vec2(6.0, 6.0), + ], + stroke, + ); + for (x, top) in [(-2.5, 1.0), (1.5, -4.0), (5.5, -1.5)] { + painter.line_segment( + [center + egui::vec2(x, 5.0), center + egui::vec2(x, top)], + stroke, + ); + } + } + IconName::History => { + painter.circle_stroke(center, 6.0, stroke); + painter.line_segment([center, center + egui::vec2(0.0, -3.5)], stroke); + painter.line_segment([center, center + egui::vec2(3.0, 2.0)], stroke); + } + IconName::Vocabulary => { + for y in [-4.0, 0.0, 4.0] { + painter.circle_filled(center + egui::vec2(-5.0, y), 1.0, color); + painter.line_segment( + [center + egui::vec2(-2.0, y), center + egui::vec2(6.0, y)], + stroke, + ); + } + } + IconName::Style => { + painter.line_segment( + [ + center + egui::vec2(-5.0, 5.0), + center + egui::vec2(4.0, -4.0), + ], + stroke, + ); + painter.line_segment( + [ + center + egui::vec2(2.0, -5.0), + center + egui::vec2(5.0, -2.0), + ], + stroke, + ); + } + IconName::Tools => { + painter.circle_stroke(center, 5.5, stroke); + painter.line_segment( + [ + center + egui::vec2(-3.5, 3.5), + center + egui::vec2(3.5, -3.5), + ], + stroke, + ); + } + IconName::Settings => { + painter.circle_stroke(center, 5.5, stroke); + painter.circle_stroke(center, 2.0, stroke); + for angle in [ + 0.0_f32, + 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.5, center + direction * 7.0], stroke); + } + } + } +} + +pub fn content_panel( + ctx: &egui::Context, + _active: Page, + _lang: Lang, + 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) + .fixed_pos(content.min) + .show(ctx, |ui| { + ui.set_min_size(content.size()); + ui.set_clip_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, content.size())); + 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; + ui.add_space(22.0); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, add_contents); + }); +} 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..1577b5852 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; + +use eframe::egui; +pub use openless_linux_egui::ui_catalog::{key as text_key, source as text}; + +use openless_linux_egui::design_tokens::{self, ThemeTokens}; +use std::cell::Cell; + +thread_local! { static TOKENS: Cell<&'static ThemeTokens> = const { Cell::new(&design_tokens::LIGHT) }; } +fn color(value: u32) -> egui::Color32 { + egui::Color32::from_rgb((value >> 16) as u8, (value >> 8) as u8, value as u8) +} +macro_rules! token { + ($($name:ident),* $(,)?) => { $(pub fn $name() -> egui::Color32 { TOKENS.with(|t| color(t.get().$name)) })* }; +} +token!( + blue, + blue_soft, + canvas, + surface, + surface_2, + line, + ink, + ink_2, + ink_3, + ink_4, + ok, + sidebar_bg, + settings_rail_bg, + settings_content_bg +); + +/// Install the same Linux font fallback strategy as the redesigned prototype, +/// without shipping its large duplicate font bundle. +pub fn install(ctx: &egui::Context) { + let mut candidates: Vec<(&str, PathBuf)> = Vec::new(); + if let Some(path) = std::env::var_os("OPENLESS_IME_FONT").map(PathBuf::from) { + candidates.push(("openless-primary", path)); + } + candidates.extend([ + ( + "openless-cjk", + PathBuf::from("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + ), + ( + "openless-cjk-fallback", + PathBuf::from("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), + ), + ( + "openless-latin", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"), + ), + ( + "openless-arabic", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"), + ), + ( + "openless-thai", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf"), + ), + ( + "openless-devanagari", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf"), + ), + ]); + + let mut fonts = egui::FontDefinitions::default(); + let mut loaded = Vec::new(); + for (name, path) in candidates { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + fonts + .font_data + .insert(name.into(), egui::FontData::from_owned(bytes).into()); + loaded.push(name); + } + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + let family_fonts = fonts.families.entry(family).or_default(); + for name in loaded.iter().rev() { + family_fonts.insert(0, (*name).into()); + } + } + 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); + let scale = openless_linux_egui::load_ui_value("fontScale") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0) + .clamp(0.85, 1.35); + ctx.set_zoom_factor(scale as f32); +} + +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, + }; + TOKENS.with(|t| t.set(design_tokens::tokens(dark))); + let mut visuals = if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + visuals.panel_fill = canvas(); + visuals.window_fill = surface(); + visuals.faint_bg_color = surface_2(); + visuals.override_text_color = Some(ink()); + visuals.selection.bg_fill = blue_soft(); + visuals.selection.stroke = egui::Stroke::new(1.0, blue()); + visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(8); + visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(8); + visuals.widgets.active.corner_radius = egui::CornerRadius::same(8); + ctx.set_visuals(visuals); +} diff --git a/openless-all/app/linux-egui/src/ui_catalog.rs b/openless-all/app/linux-egui/src/ui_catalog.rs new file mode 100644 index 000000000..94e80486d --- /dev/null +++ b/openless-all/app/linux-egui/src/ui_catalog.rs @@ -0,0 +1,41 @@ +//! Generated React 2.0 catalogs are shared with the native renderer. Keep the +//! same keys and fallback rules, without runtime network or JS dependencies. +use crate::Lang; +use std::collections::BTreeMap; +use std::sync::OnceLock; +type Catalog = BTreeMap>; +fn catalog() -> &'static Catalog { + static DATA: OnceLock = OnceLock::new(); + DATA.get_or_init(|| { + serde_json::from_str(include_str!("../assets/ui-locales.json")) + .expect("generated UI catalog") + }) +} +thread_local! {static LANGUAGE:std::cell::Cell=const{std::cell::Cell::new(Lang::En)};} +pub fn set_language(lang: Lang) { + LANGUAGE.with(|value| value.set(lang)); +} +pub fn key<'a>(key: &'a str) -> &'a str { + LANGUAGE.with(|lang| { + catalog() + .get(lang.get().tag()) + .and_then(|rows| rows.get(key)) + .or_else(|| catalog().get("zh-CN").and_then(|rows| rows.get(key))) + .map(String::as_str) + .unwrap_or(key) + }) +} +pub fn source<'a>(source: &'a str) -> &'a str { + LANGUAGE.with(|lang| translate_source(lang.get(), source)) +} +pub fn translate_source<'a>(lang: Lang, source: &'a str) -> &'a str { + if lang == Lang::ZhCn { + return source; + } + let rows = catalog(); + rows.get("zh-CN") + .and_then(|zh| zh.iter().find(|(_, value)| value.as_str() == source)) + .and_then(|(key, _)| rows.get(lang.tag()).and_then(|values| values.get(key))) + .map(String::as_str) + .unwrap_or(source) +} diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs index c22ced65e..dbc610779 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -1,119 +1,208 @@ -//! 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)) +} + +/// 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 mut document = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .filter(|value| value.is_object()) + .unwrap_or_else(|| serde_json::json!({})); + document["locale"] = serde_json::json!(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()), + }) +} - pub fn has_update(&self, page: Page) -> bool { - self.unread[page as usize] +pub fn load_ui_value(key: &str) -> Option { + let path = ui_state_path()?; + let document: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?; + document.get(key).cloned() +} +pub fn save_ui_value(key: &str, value: serde_json::Value) -> Result<(), UiStateError> { + let path = ui_state_path() + .ok_or_else(|| UiStateError::Json("UI state directory unavailable".into()))?; + let mut document = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .filter(|v| v.is_object()) + .unwrap_or_else(|| serde_json::json!({})); + document[key] = value; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| io_error("create UI state directory", error))?; } + let bytes = serde_json::to_vec_pretty(&document) + .map_err(|error| UiStateError::Json(error.to_string()))?; + atomic_save(&path, &bytes) + .map(|_| ()) + .map_err(|error| UiStateError::Json(error.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..15c4ed162 --- /dev/null +++ b/openless-all/app/linux-egui/src/updater.rs @@ -0,0 +1,1252 @@ +//! 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.eq_ignore_ascii_case("Open-Less") + && repository == "openless" + && releases == "releases" + && download == "download" + && !tag.is_empty() + && !asset.is_empty() + && segments.next().is_none() +} + +#[derive(Debug)] +pub enum UpdateError { + Cancelled, + 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::Cancelled => f.write_str("更新已取消"), + 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 {} + +/// Cancellation is accepted until the verified atomic replacement begins. +#[derive(Clone, Default)] +pub struct UpdateCancellation { + state: std::sync::Arc, + wake: std::sync::Arc, +} +impl UpdateCancellation { + pub fn cancel(&self) -> bool { + if self + .state + .compare_exchange( + 0, + 1, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + { + self.wake.notify_one(); + true + } else { + false + } + } + fn check(&self) -> Result<(), UpdateError> { + if self.state.load(std::sync::atomic::Ordering::Acquire) == 1 { + Err(UpdateError::Cancelled) + } else { + Ok(()) + } + } + fn begin_commit(&self) -> Result<(), UpdateError> { + self.state + .compare_exchange( + 0, + 2, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .map(|_| ()) + .map_err(|_| UpdateError::Cancelled) + } +} +struct TemporaryDownload(PathBuf); +impl Drop for TemporaryDownload { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +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>; +} + +/// A fail-closed verifier for hosts which intentionally disable 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, + progress: impl FnMut(DownloadProgress), + ) -> Result { + self.download_and_install_cancellable(manifest, progress, UpdateCancellation::default()) + .await + } + + pub async fn download_and_install_cancellable( + &self, + manifest: UpdateManifest, + mut progress: impl FnMut(DownloadProgress), + cancellation: UpdateCancellation, + ) -> Result { + cancellation.check()?; + let operation = async { + 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 _cleanup = TemporaryDownload(download.clone()); + 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_controlled( + &manifest, + self.expected_arch, + input, + &self.target, + &self.verifier, + DEFAULT_MAX_APPIMAGE_BYTES, + &cancellation, + ) + } + .await; + let _ = fs::remove_file(download); + result + }; + tokio::select! { + biased; + _ = cancellation.wake.notified() => Err(UpdateError::Cancelled), + result = operation => 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, + source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, + max_bytes: u64, +) -> Result { + install_verified_appimage_controlled( + manifest, + expected_arch, + source, + target, + verifier, + max_bytes, + &UpdateCancellation::default(), + ) +} +fn install_verified_appimage_controlled( + manifest: &UpdateManifest, + expected_arch: &str, + mut source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, + max_bytes: u64, + cancellation: &UpdateCancellation, +) -> Result { + cancellation.check()?; + 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 { + cancellation.check()?; + 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))?; + } + cancellation.begin_commit()?; + 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> { + commit_with_rollback_and_sync(temp, target, |parent| File::open(parent)?.sync_all()) +} + +fn commit_with_rollback_and_sync( + temp: &Path, + target: &Path, + mut sync: impl FnMut(&Path) -> io::Result<()>, +) -> 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) = sync(parent) { + fs::rename(&backup, target) + .map_err(|error| io_error("restore AppImage after failed directory sync", error))?; + sync(parent).map_err(|error| io_error("sync restored AppImage directory", error))?; + 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(); + } + + #[test] + fn cancellation_before_and_during_verification_never_replaces_the_image() { + struct CancellingVerifier(UpdateCancellation); + impl SignatureVerifier for CancellingVerifier { + fn verify_base64_minisign(&self, _: &Path, _: &str) -> Result<(), UpdateError> { + self.0.cancel(); + Ok(()) + } + } + for before in [true, false] { + let root = temp_dir("cancel"); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let cancellation = UpdateCancellation::default(); + if before { + cancellation.cancel(); + } + let result = install_verified_appimage_controlled( + &manifest(b"new"), + "x86_64", + b"new".as_slice(), + &target, + &CancellingVerifier(cancellation.clone()), + 100, + &cancellation, + ); + assert!(matches!(result, Err(UpdateError::Cancelled))); + assert_eq!(fs::read(&path).unwrap(), b"old"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn cancellation_after_commit_starts_does_not_report_a_cancelled_install() { + let cancellation = UpdateCancellation::default(); + cancellation.begin_commit().unwrap(); + assert!(!cancellation.cancel()); + cancellation.check().unwrap(); + } + + #[test] + fn failed_directory_sync_restores_the_original_image() { + let root = temp_dir("rollback"); + let target = root.join("OpenLess.AppImage"); + let temp = root.join("download"); + fs::write(&target, b"old").unwrap(); + fs::write(&temp, b"new").unwrap(); + let mut calls = 0; + let result = commit_with_rollback_and_sync(&temp, &target, |_| { + calls += 1; + if calls == 1 { + Err(io::Error::other("injected storage failure")) + } else { + Ok(()) + } + }); + assert!(result.is_err()); + assert_eq!(calls, 2); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/linux-egui/src/x11_desktop.rs b/openless-all/app/linux-egui/src/x11_desktop.rs new file mode 100644 index 000000000..fe7a4ff4b --- /dev/null +++ b/openless-all/app/linux-egui/src/x11_desktop.rs @@ -0,0 +1,392 @@ +use crate::context::platform; +use crate::desktop_bridge::{DesktopAdapter, DesktopBinding, DesktopSnapshot}; +use openless_core::BackendError; +use std::collections::HashMap; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; +use x11rb::connection::Connection; +use x11rb::protocol::randr::ConnectionExt as _; +use x11rb::protocol::xinput::{self, ConnectionExt as _}; +use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt, EventMask, GrabMode, ModMask}; +use x11rb::protocol::Event; +use x11rb::rust_connection::RustConnection; + +fn modifier_mask(symbol: u32) -> u16 { + match symbol { + 0xffe1 | 0xffe2 => 1, + 0xffe3 | 0xffe4 => 4, + 0xffe9 | 0xffea => 8, + 0xffeb | 0xffec => 64, + _ => 0, + } +} +fn passive_modifier(binding: &DesktopBinding) -> bool { + binding.states == 0 && modifier_mask(binding.symbol) != 0 +} + +type Reply = mpsc::SyncSender>; +pub struct X11Desktop { + commands: mpsc::Sender<(Vec, Reply)>, + events: Arc>>, +} +impl X11Desktop { + pub fn start() -> Result { + let (connection, screen) = x11rb::connect(None).map_err(platform)?; + let root = connection.setup().roots[screen].root; + connection + .xinput_xi_query_version(2, 0) + .map_err(platform)? + .reply() + .map_err(platform)?; + connection + .xinput_xi_select_events( + root, + &[xinput::EventMask { + deviceid: 1, // XIAllMasterDevices; no text is decoded or retained. + mask: vec![ + xinput::XIEventMask::RAW_KEY_PRESS | xinput::XIEventMask::RAW_KEY_RELEASE, + ], + }], + ) + .map_err(platform)? + .check() + .map_err(platform)?; + let (commands, receiver) = mpsc::channel::<(Vec, Reply)>(); + let events = Arc::new(Mutex::new(Vec::new())); + let output = events.clone(); + std::thread::Builder::new() + .name("openless-x11-hotkeys".into()) + .spawn(move || { + let mut installed: HashMap<(u8, u16), DesktopBinding> = HashMap::new(); + let press_ids = crate::hotkeys::HotkeyPressIds::default(); + let mut held: HashMap = HashMap::new(); + loop { + match receiver.recv_timeout(Duration::from_millis(10)) { + Ok((bindings, reply)) => { + let result = install(&connection, root, &installed, bindings); + let _ = reply.send(match result { + Ok(next) => { + for binding in held.values() { + if let Some(event) = crate::hotkeys::event_from_signal( + &binding.action, + binding.symbol, + binding.states, + false, + std::time::Instant::now(), + &press_ids, + ) { + output.lock().unwrap().push(event); + } + } + held.clear(); + installed = next; + Ok(()) + } + Err(error) => Err(error), + }); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => (), + } + loop { + let event = match connection.poll_for_event() { + Ok(Some(event)) => event, + Ok(None) => break, + Err(_) => return, + }; + let (key, state, pressed, raw) = match event { + Event::KeyPress(e) => (e.detail, u16::from(e.state), true, false), + Event::KeyRelease(e) => (e.detail, u16::from(e.state), false, false), + Event::XinputRawKeyPress(e) => (e.detail as u8, 0, true, true), + Event::XinputRawKeyRelease(e) => (e.detail as u8, 0, false, true), + _ => continue, + }; + if raw && pressed { + for (held_key, binding) in &held { + if *held_key != key && passive_modifier(binding) { + let action = match binding.action.as_str() { + "DictationKeyEvent" => "DictationKeyCombined", + "LessComputerKeyEvent" => "LessComputerKeyCombined", + _ => continue, + }; + if let Some(event) = crate::hotkeys::event_from_signal( + action, + binding.symbol, + binding.states, + true, + std::time::Instant::now(), + &press_ids, + ) { + output.lock().unwrap().push(event); + } + } + } + } + let binding = if pressed { + installed.get(&(key, state & !18)).cloned() + } else { + held.get(&key) + .filter(|b| passive_modifier(b) == raw) + .cloned() + }; + let binding = binding.filter(|b| passive_modifier(b) == raw); + if let Some(binding) = binding { + if !pressed { + held.remove(&key); + } + if raw && pressed && !held.contains_key(&key) { + if let Ok(cookie) = connection.query_pointer(root) { + if let Ok(pointer) = cookie.reply() { + if u16::from(pointer.mask) + & !(18 | modifier_mask(binding.symbol)) + != 0 + { + continue; + } + } + } + } + if pressed { + held.insert(key, binding.clone()); + } + if let Some(event) = crate::hotkeys::event_from_signal( + &binding.action, + binding.symbol, + binding.states, + pressed, + std::time::Instant::now(), + &press_ids, + ) { + output.lock().unwrap().push(event); + } + } + } + } + }) + .map_err(platform)?; + Ok(Self { commands, events }) + } +} + +fn install( + connection: &RustConnection, + root: u32, + previous: &HashMap<(u8, u16), DesktopBinding>, + bindings: Vec, +) -> Result, BackendError> { + let setup = connection.setup(); + let mapping = connection + .get_keyboard_mapping(setup.min_keycode, setup.max_keycode - setup.min_keycode + 1) + .map_err(platform)? + .reply() + .map_err(platform)?; + let mut next = HashMap::new(); + let mut grabbed = Vec::new(); + for binding in bindings { + let index = mapping + .keysyms + .chunks(mapping.keysyms_per_keycode as usize) + .position(|symbols| symbols.contains(&binding.symbol)) + .ok_or_else(|| platform(format!("键盘布局不包含 {}", binding.accelerator)))?; + let key = setup.min_keycode + index as u8; + let states = binding.states as u16; + next.insert((key, states), binding); + } + // Resolve every keysym before grabbing anything. A missing symbol must not + // leave a partially installed binding set after a failed transaction. + for (&(key, states), binding) in &next { + if !passive_modifier(binding) && !previous.contains_key(&(key, states)) { + for locks in [0, 2, 16, 18] { + let result = connection + .grab_key( + true, + root, + ModMask::from(states | locks), + key, + GrabMode::ASYNC, + GrabMode::ASYNC, + ) + .map_err(platform) + .and_then(|cookie| cookie.check().map_err(platform)); + if let Err(error) = result { + for (code, mask) in grabbed { + let _ = connection.ungrab_key(code, root, ModMask::from(mask)); + } + let _ = connection.flush(); + return Err(platform(format!( + "快捷键被占用:{} ({error})", + binding.accelerator + ))); + } + grabbed.push((key, states | locks)); + } + } + } + for (key, states) in previous.keys().filter(|key| !next.contains_key(key)) { + if passive_modifier(&previous[&(*key, *states)]) { + continue; + } + for locks in [0, 2, 16, 18] { + connection + .ungrab_key(*key, root, ModMask::from(states | locks)) + .map_err(platform)? + .check() + .map_err(platform)?; + } + } + connection.flush().map_err(platform)?; + Ok(next) +} + +fn atom(connection: &RustConnection, name: &str) -> Result { + Ok(connection + .intern_atom(false, name.as_bytes()) + .map_err(platform)? + .reply() + .map_err(platform)? + .atom) +} +fn property( + connection: &RustConnection, + window: u32, + name: &str, +) -> Result { + connection + .get_property( + false, + window, + atom(connection, name)?, + AtomEnum::ANY, + 0, + 4096, + ) + .map_err(platform)? + .reply() + .map_err(platform) +} + +impl DesktopAdapter for X11Desktop { + fn snapshot(&self) -> Result { + let (connection, screen) = x11rb::connect(None).map_err(platform)?; + let screen = &connection.setup().roots[screen]; + let window = property(&connection, screen.root, "_NET_ACTIVE_WINDOW")? + .value32() + .and_then(|mut v| v.next()) + .ok_or_else(|| platform("no foreground window"))?; + let application = + String::from_utf8_lossy(&property(&connection, window, "WM_CLASS")?.value) + .replace('\0', " ") + .trim() + .to_string(); + let geometry = connection + .get_geometry(window) + .map_err(platform)? + .reply() + .map_err(platform)?; + let coords = connection + .translate_coordinates(window, screen.root, 0, 0) + .map_err(platform)? + .reply() + .map_err(platform)?; + // _NET_WORKAREA is already in X11 pixels; RandR logical scaling is 1. + let work = property(&connection, screen.root, "_NET_WORKAREA") + .ok() + .and_then(|p| p.value32().map(|v| v.collect::>())); + let (mut x, mut y, mut width, mut height) = work + .filter(|v| v.len() >= 4) + .map(|v| (v[0] as i32, v[1] as i32, v[2], v[3])) + .unwrap_or(( + coords.dst_x as i32, + coords.dst_y as i32, + geometry.width as u32, + geometry.height as u32, + )); + // Use the monitor containing the target window, constrained by panels + // and docks in the EWMH work area. This avoids centering between screens. + if let Ok(cookie) = connection.randr_get_monitors(screen.root, true) { + if let Ok(monitors) = cookie.reply() { + let cx = i32::from(coords.dst_x) + i32::from(geometry.width) / 2; + let cy = i32::from(coords.dst_y) + i32::from(geometry.height) / 2; + if let Some(m) = monitors.monitors.iter().find(|m| { + cx >= i32::from(m.x) + && cy >= i32::from(m.y) + && cx < i32::from(m.x) + i32::from(m.width) + && cy < i32::from(m.y) + i32::from(m.height) + }) { + let right = (x + width as i32).min(i32::from(m.x) + i32::from(m.width)); + let bottom = (y + height as i32).min(i32::from(m.y) + i32::from(m.height)); + x = x.max(i32::from(m.x)); + y = y.max(i32::from(m.y)); + width = (right - x).max(1) as u32; + height = (bottom - y).max(1) as u32; + } + } + } + Ok(DesktopSnapshot { + version: 1, + target: format!("x11:{window}"), + application, + x, + y, + width, + height, + scale: 1.0, + }) + } + fn bind(&self, bindings: &[DesktopBinding]) -> Result<(), BackendError> { + let (tx, rx) = mpsc::sync_channel(1); + self.commands + .send((bindings.to_vec(), tx)) + .map_err(platform)?; + rx.recv_timeout(Duration::from_secs(4)).map_err(platform)? + } + fn restore_focus(&self, target: &str) -> Result<(), BackendError> { + let window = target + .strip_prefix("x11:") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| platform("invalid X11 target"))?; + let (connection, screen) = x11rb::connect(None).map_err(platform)?; + connection + .get_window_attributes(window) + .map_err(platform)? + .reply() + .map_err(platform)?; + let event = xproto::ClientMessageEvent::new( + 32, + window, + atom(&connection, "_NET_ACTIVE_WINDOW")?, + [2, 0, 0, 0, 0], + ); + connection + .send_event( + false, + connection.setup().roots[screen].root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + ) + .map_err(platform)? + .check() + .map_err(platform)?; + connection.flush().map_err(platform) + } + fn place(&self, title: &str, x: i32, y: i32) -> Result<(), BackendError> { + let (connection, screen) = x11rb::connect(None).map_err(platform)?; + let root = connection.setup().roots[screen].root; + let list = property(&connection, root, "_NET_CLIENT_LIST")?; + for window in list.value32().into_iter().flatten() { + let name = property(&connection, window, "_NET_WM_NAME")?; + if name.value == title.as_bytes() { + connection + .configure_window(window, &xproto::ConfigureWindowAux::new().x(x).y(y)) + .map_err(platform)? + .check() + .map_err(platform)?; + return connection.flush().map_err(platform); + } + } + Err(platform("popup window not found")) + } + fn drain(&self) -> Vec { + std::mem::take(&mut *self.events.lock().unwrap()) + } +} 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/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/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index adea7b099..1cc519caf 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -3,91 +3,120 @@ set -euo pipefail APP_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) VERSION=${OPENLESS_LINUX_VERSION:?OPENLESS_LINUX_VERSION is required} +PACKAGE_VERSION=${VERSION/-/\~} ARCH=${OPENLESS_LINUX_ARCH:-x86_64} -TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} +test "$ARCH" = x86_64 || { echo 'Only x86_64 packages are supported' >&2; exit 1; } +TARGET_DIR=$(realpath -m "${CARGO_TARGET_DIR:-"$APP_ROOT/target"}") +case "$TARGET_DIR/" in "$APP_ROOT/target/"*) ;; *) echo 'Package staging must be under app/target' >&2; exit 1;; esac BINARY="$TARGET_DIR/release/openless-linux-egui" -PLUGIN_ROOT="$APP_ROOT/../scripts/linux-fcitx5-plugin/build" +PLUGIN_ROOT=${OPENLESS_FCITX_BUILD:-"$APP_ROOT/../scripts/linux-fcitx5-plugin/build"} +DESKTOP_ROOT="$APP_ROOT/../scripts/linux-desktop" +DESKTOP_BUILD=${OPENLESS_DESKTOP_BUILD:-"$DESKTOP_ROOT/kde/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" - -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 - +FONT=${OPENLESS_FONT:-/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc} +for file in "$BINARY" "$QWEN_RUNTIME" "$DESKTOP_BUILD/openless-desktop-bridge"; do test -x "$file"; done +for file in "$PLUGIN_ROOT/libopenless.so" "$PLUGIN_ROOT/openless.conf" "$FONT" "$ICON"; do test -s "$file"; done +for tool in fpm appimagetool patchelf desktop-file-validate; do command -v "$tool" >/dev/null; done +desktop-file-validate "$PACKAGING/openless.desktop" mkdir -p "$OUTPUT" +STAGE=$(mktemp -d "$TARGET_DIR/linux-package.XXXXXX") +cleanup() { case "$STAGE" in "$TARGET_DIR"/linux-package.*) rm -rf -- "$STAGE";; esac; } +trap cleanup EXIT + +# ldd reports transitive dependencies. Keep glibc and graphics drivers on the +# target desktop; all other runtime libraries go beside the private binary. +bundle_libraries() { + local binary=$1 destination=$2 notices=$3 library owner package copyright + mkdir -p "$destination" + mkdir -p "$notices" + if ldd "$binary" | grep -q 'not found'; then ldd "$binary"; return 1; fi + while read -r library; do + case "$(basename "$library")" in + libc.so.*|libm.so.*|libpthread.so.*|libdl.so.*|librt.so.*|ld-linux-*.so.*|libEGL.so.*|libGL.so.*|libGLX.so.*|libGLdispatch.so.*|libdrm.so.*) continue;; + esac + install -m755 "$library" "$destination/$(basename "$library")" + # Keep distribution notices beside every private runtime, including the + # standalone desktop-component archive. The build baseline is Ubuntu. + owner=$(dpkg-query -S "$library" 2>/dev/null | head -1 || true) + if [ -z "$owner" ]; then owner=$(dpkg-query -S "$(realpath "$library")" 2>/dev/null | head -1 || true); fi + package=${owner%%: /*}; package=${package%%:*} + copyright="/usr/share/doc/$package/copyright" + if [ -n "$package" ] && [ -f "$copyright" ]; then + install -m644 "$copyright" "$notices/$package-copyright" + fi + done < <(ldd "$binary" | awk '$2 == "=>" && $3 ~ /^\// {print $3}') + for library in "$destination"/*; do patchelf --set-rpath '$ORIGIN' "$library"; done +} stage_common() { - local root=$1 + local root=$1 resources="$1/usr/lib/openless/resources" install -Dm755 "$BINARY" "$root/usr/bin/openless" - install -Dm644 "$PACKAGING/openless.desktop" \ - "$root/usr/share/applications/openless.desktop" - install -Dm644 "$PACKAGING/top.openless.OpenLess.metainfo.xml" \ - "$root/usr/share/metainfo/top.openless.OpenLess.metainfo.xml" + install -Dm644 "$PACKAGING/openless.desktop" "$root/usr/share/applications/openless.desktop" + install -Dm644 "$PACKAGING/top.openless.OpenLess.metainfo.xml" "$root/usr/share/metainfo/top.openless.OpenLess.metainfo.xml" install -Dm644 "$ICON" "$root/usr/share/icons/hicolor/256x256/apps/openless.png" - install -Dm755 "$QWEN_RUNTIME" \ - "$root/usr/lib/openless/resources/qwen-asr/qwen_asr" + install -Dm755 "$QWEN_RUNTIME" "$resources/qwen-asr/qwen_asr" + if [ -f "$(dirname "$QWEN_RUNTIME")/LICENSE" ]; then + install -Dm644 "$(dirname "$QWEN_RUNTIME")/LICENSE" "$resources/qwen-asr/LICENSE" + fi + for relative in install.sh README.md gnome/legacy/metadata.json gnome/legacy/extension.js gnome/modern/metadata.json gnome/modern/extension.js kwin/metadata.json kwin/metadata.desktop kwin/contents/code/main.js; do + install -Dm644 "$DESKTOP_ROOT/$relative" "$resources/linux-desktop/$relative" + done + chmod +x "$resources/linux-desktop/install.sh" + install -Dm755 "$DESKTOP_BUILD/openless-desktop-bridge" "$resources/linux-desktop/openless-desktop-bridge" + bundle_libraries "$DESKTOP_BUILD/openless-desktop-bridge" "$resources/linux-desktop/lib" "$resources/linux-desktop/licenses" + local plugin_root + plugin_root=$(qmake -query QT_INSTALL_PLUGINS) + install -Dm755 "$plugin_root/platforms/libqoffscreen.so" "$resources/linux-desktop/plugins/platforms/libqoffscreen.so" + bundle_libraries "$plugin_root/platforms/libqoffscreen.so" "$resources/linux-desktop/lib" "$resources/linux-desktop/licenses" + patchelf --set-rpath '$ORIGIN/../../lib' "$resources/linux-desktop/plugins/platforms/libqoffscreen.so" + patchelf --set-rpath '$ORIGIN/lib' "$resources/linux-desktop/openless-desktop-bridge" + install -Dm755 "$PACKAGING/openless-desktop-integration" "$root/usr/bin/openless-desktop-integration" + install -Dm644 "$APP_ROOT/../../LICENSE" "$root/usr/share/doc/openless/copyright" } -DEB_ROOT="$TARGET_DIR/linux-egui-deb-root" -rm -rf "$DEB_ROOT" +DEB_ROOT="$STAGE/deb" stage_common "$DEB_ROOT" -install -Dm755 "$PLUGIN_ROOT/libopenless.so" \ - "$DEB_ROOT/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" -install -Dm644 "$PLUGIN_ROOT/openless.conf" \ - "$DEB_ROOT/usr/share/fcitx5/addon/openless.conf" -fpm -s dir -t deb -C "$DEB_ROOT" \ - -n openless -v "$VERSION" -a amd64 \ - --description "OpenLess Linux egui host" \ - --license AGPL-3.0-only \ - --url https://github.com/Open-Less/openless \ +install -Dm755 "$PLUGIN_ROOT/libopenless.so" "$DEB_ROOT/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" +install -Dm644 "$PLUGIN_ROOT/openless.conf" "$DEB_ROOT/usr/share/fcitx5/addon/openless.conf" +fpm --force -s dir -t deb -C "$DEB_ROOT" -n openless -v "$PACKAGE_VERSION" -a amd64 \ + --description 'OpenLess 2.0 Linux egui desktop' --license AGPL-3.0-only --url https://github.com/Open-Less/openless \ -d fcitx5 -d fcitx5-module-dbus -d libdbus-1-3 -d libasound2 -d libopenblas0-pthread \ + -d pulseaudio-utils -d fonts-noto-cjk -d libxkbcommon0 -d libxcb1 -d libssl3 \ -p "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.deb" . -RPM_ROOT="$TARGET_DIR/linux-egui-rpm-root" -rm -rf "$RPM_ROOT" +RPM_ROOT="$STAGE/rpm" stage_common "$RPM_ROOT" -install -Dm755 "$PLUGIN_ROOT/libopenless.so" \ - "$RPM_ROOT/usr/lib64/fcitx5/libopenless.so" -install -Dm644 "$PLUGIN_ROOT/openless.conf" \ - "$RPM_ROOT/usr/share/fcitx5/addon/openless.conf" -fpm -s dir -t rpm -C "$RPM_ROOT" \ - -n openless -v "$VERSION" -a x86_64 \ - --description "OpenLess Linux egui host" \ - --license AGPL-3.0-only \ - --url https://github.com/Open-Less/openless \ - -d fcitx5 -d dbus-libs -d alsa-lib -d openblas \ +install -Dm755 "$PLUGIN_ROOT/libopenless.so" "$RPM_ROOT/usr/lib64/fcitx5/libopenless.so" +install -Dm644 "$PLUGIN_ROOT/openless.conf" "$RPM_ROOT/usr/share/fcitx5/addon/openless.conf" +fpm --force -s dir -t rpm -C "$RPM_ROOT" -n openless -v "$PACKAGE_VERSION" -a x86_64 \ + --description 'OpenLess 2.0 Linux egui desktop' --license AGPL-3.0-only --url https://github.com/Open-Less/openless \ + -d fcitx5 -d dbus-libs -d alsa-lib -d openblas -d pulseaudio-utils -d google-noto-sans-cjk-fonts \ + -d libxkbcommon -d libxcb -d openssl-libs \ -p "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.rpm" . -APPDIR="$TARGET_DIR/OpenLess.AppDir" -rm -rf "$APPDIR" +APPDIR="$STAGE/OpenLess.AppDir" stage_common "$APPDIR" -install -Dm755 "$PLUGIN_ROOT/libopenless.so" \ - "$APPDIR/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so" -install -Dm644 "$PLUGIN_ROOT/openless.conf" \ - "$APPDIR/usr/lib/openless/resources/linux-fcitx5-plugin/openless.conf" -QWEN_APPDIR="$APPDIR/usr/lib/openless/resources/qwen-asr" -while read -r library; do - case "$(basename "$library")" in - libc.so.* | libm.so.* | libpthread.so.* | libdl.so.* | librt.so.* | ld-linux-*.so.*) continue ;; - esac - install -Dm755 "$library" "$QWEN_APPDIR/$(basename "$library")" -done < <(ldd "$QWEN_RUNTIME" | awk '$2 == "=>" && $3 ~ /^\// { print $3 }') -for binary in "$QWEN_APPDIR"/*; do - patchelf --set-rpath '$ORIGIN' "$binary" +RESOURCES="$APPDIR/usr/lib/openless/resources" +install -Dm755 "$PLUGIN_ROOT/libopenless.so" "$RESOURCES/linux-fcitx5-plugin/libopenless.so" +install -Dm644 "$PLUGIN_ROOT/openless.conf" "$RESOURCES/linux-fcitx5-plugin/openless.conf" +install -Dm644 "$FONT" "$RESOURCES/fonts/NotoSansCJK-Regular.ttc" +for license in /usr/share/doc/fonts-noto-cjk/copyright /usr/share/doc/libqt5core5a/copyright /usr/share/doc/libkf5globalaccel5/copyright; do + test ! -f "$license" || install -Dm644 "$license" "$APPDIR/usr/share/doc/openless/$(basename "$(dirname "$license")")-copyright" done -ln -s usr/bin/openless "$APPDIR/AppRun" +bundle_libraries "$BINARY" "$APPDIR/usr/lib/openless/bundle" "$APPDIR/usr/share/doc/openless/bundled-libraries" +patchelf --set-rpath '$ORIGIN/../lib/openless/bundle' "$APPDIR/usr/bin/openless" +bundle_libraries "$QWEN_RUNTIME" "$RESOURCES/qwen-asr/lib" "$RESOURCES/qwen-asr/licenses" +patchelf --set-rpath '$ORIGIN/lib' "$RESOURCES/qwen-asr/qwen_asr" +install -m755 "$PACKAGING/AppRun" "$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" +ARCH="$ARCH" appimagetool "$APPDIR" "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.AppImage" +install -m755 "$BINARY" "$OUTPUT/openless-linux-egui" +tar -C "$RESOURCES" -czf "$OUTPUT/OpenLess-desktop-integration-${VERSION}-${ARCH}.tar.gz" linux-desktop +(cd "$OUTPUT"; sha256sum openless-linux-egui *.deb *.rpm *.AppImage *.tar.gz > SHA256SUMS) find "$OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/openless-all/app/scripts/verify-linux-egui-packages.sh b/openless-all/app/scripts/verify-linux-egui-packages.sh new file mode 100644 index 000000000..b8bd7124a --- /dev/null +++ b/openless-all/app/scripts/verify-linux-egui-packages.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Package/backend verification only: never starts an egui or desktop UI. +set -euo pipefail +APP_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TARGET_DIR=$(realpath -m "${CARGO_TARGET_DIR:-"$APP_ROOT/target"}") +case "$TARGET_DIR/" in "$APP_ROOT/target/"*) ;; *) echo 'Verification must use app/target' >&2; exit 1;; esac +OUTPUT="$TARGET_DIR/linux-egui-packages" +shopt -s nullglob +debs=("$OUTPUT"/*.deb); rpms=("$OUTPUT"/*.rpm); appimages=("$OUTPUT"/*.AppImage); components=("$OUTPUT"/*.tar.gz) +test "${#debs[@]}" -eq 1 +test "${#rpms[@]}" -eq 1 +test "${#appimages[@]}" -eq 1 +test "${#components[@]}" -eq 1 +(cd "$OUTPUT"; sha256sum --check --strict SHA256SUMS) + +check_elf() { + local binary=$1 dependencies + file "$binary" | grep 'ELF 64-bit.*x86-64' >/dev/null + dependencies=$(ldd "$binary") + if grep -q 'not found' <<<"$dependencies"; then printf '%s\n' "$dependencies"; return 1; fi + if grep -Eqi 'webkit|wry|tauri' <<<"$dependencies"; then printf '%s\n' "$dependencies"; return 1; fi +} +check_elf "$OUTPUT/openless-linux-egui" +WORK=$(mktemp -d "$TARGET_DIR/linux-package-check.XXXXXX") +cleanup() { case "$WORK" in "$TARGET_DIR"/linux-package-check.*) rm -rf -- "$WORK";; esac; } +trap cleanup EXIT +dpkg-deb --contents "${debs[0]}" > "$WORK/deb-contents.txt" +rpm -qlp "${rpms[0]}" > "$WORK/rpm-contents.txt" +for item in usr/bin/openless usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so usr/lib/openless/resources/qwen-asr/qwen_asr usr/lib/openless/resources/linux-desktop/openless-desktop-bridge; do + grep -F "$item" "$WORK/deb-contents.txt" >/dev/null +done +for item in /usr/bin/openless /usr/lib64/fcitx5/libopenless.so /usr/lib/openless/resources/qwen-asr/qwen_asr /usr/lib/openless/resources/linux-desktop/openless-desktop-bridge; do + grep -Fx "$item" "$WORK/rpm-contents.txt" >/dev/null +done +dpkg-deb --extract "${debs[0]}" "$WORK/deb" +desktop-file-validate "$WORK/deb/usr/share/applications/openless.desktop" +appstreamcli validate --no-net "$WORK/deb/usr/share/metainfo/top.openless.OpenLess.metainfo.xml" +check_elf "$WORK/deb/usr/bin/openless" +check_elf "$WORK/deb/usr/lib/openless/resources/linux-desktop/openless-desktop-bridge" +check_elf "$WORK/deb/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" +( + cd "$WORK" + "${appimages[0]}" --appimage-extract > extract.log +) +ROOT="$WORK/squashfs-root" +RESOURCES="$ROOT/usr/lib/openless/resources" +check_elf "$ROOT/usr/bin/openless" +check_elf "$RESOURCES/qwen-asr/qwen_asr" +check_elf "$RESOURCES/linux-desktop/openless-desktop-bridge" +check_elf "$RESOURCES/linux-desktop/plugins/platforms/libqoffscreen.so" +test -s "$RESOURCES/linux-fcitx5-plugin/libopenless.so" +test -s "$RESOURCES/linux-fcitx5-plugin/openless.conf" +test -s "$RESOURCES/fonts/NotoSansCJK-Regular.ttc" +test -s "$RESOURCES/linux-desktop/gnome/modern/extension.js" +test -s "$RESOURCES/linux-desktop/gnome/legacy/extension.js" +test -s "$RESOURCES/linux-desktop/kwin/contents/code/main.js" +test -s "$RESOURCES/linux-desktop/licenses/libqt5core5a-copyright" +"$RESOURCES/qwen-asr/qwen_asr" --help >/dev/null 2>&1 +tar -tzf "${components[0]}" > "$WORK/components.txt" +grep -Fx linux-desktop/install.sh "$WORK/components.txt" >/dev/null +grep -Fx linux-desktop/licenses/libqt5core5a-copyright "$WORK/components.txt" >/dev/null +printf 'PASS: ELF, metadata, SHA-256, deb/rpm/AppImage contents and desktop components\n' 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/scripts/linux-desktop/README.md b/openless-all/scripts/linux-desktop/README.md new file mode 100644 index 000000000..eb48f2ed4 --- /dev/null +++ b/openless-all/scripts/linux-desktop/README.md @@ -0,0 +1,27 @@ +# Linux 桌面集成 + +桥协议为 `org.openless.Desktop1` v1,与 Core 业务数据版本独立。X11 直接使用 X11 接口;Wayland 使用桌面组件提供前台窗口、屏幕工作区、焦点恢复、浮窗定位和全局快捷键。 + +安装包附带 `openless-desktop-install`。在用户会话中运行: + +```sh +openless-desktop-install install +openless-desktop-install enable +openless-desktop-install uninstall +``` + +GNOME 42–44 和 45+ 使用不同模块入口。首次安装 GNOME 扩展后可能需要注销并重新登录,再执行 `enable`。KDE 使用 KWin 脚本与 KGlobalAccel 辅助程序;安装时写入用户的脚本、D-Bus 服务和登录启动项。安装后重启 OpenLess,使当前设置重新注册到桌面桥。 + +组件只运行在当前用户的会话中,不要求 root,不读取密码输入框。fcitx5 输入目标使用 UUID 与会话票据;桌面窗口身份用于定位与恢复焦点,不代替写入前的文本目标校验。 + +开发构建: + +```sh +node gnome/build.mjs +cmake -S kde -B kde/build -DCMAKE_BUILD_TYPE=Release +cmake --build kde/build --parallel +``` + +KDE 构建支持 Qt 5 / KF5 与 Qt 6 / KF6。桌面组件的实际交互验收见 Linux 交接清单;编译通过不代表已经在相应桌面版本上完成验收。 + +接口参考:[GNOME 扩展](https://gjs.guide/extensions/)、[KWin](https://develop.kde.org/docs/plasma/kwin/api/)、[KGlobalAccel](https://api.kde.org/kglobalaccel.html)、[AT-SPI Text](https://gnome.pages.gitlab.gnome.org/at-spi2-core/libatspi/iface.Text.html)。 diff --git a/openless-all/scripts/linux-desktop/gnome/bridge.js b/openless-all/scripts/linux-desktop/gnome/bridge.js new file mode 100644 index 000000000..9bd46b58e --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/bridge.js @@ -0,0 +1,99 @@ +/* SPDX-License-Identifier: AGPL-3.0-only + * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. + */ +const IFACE = ` + + + + + + +`; + +class DesktopBridge { + constructor() { + this.bindings = new Map(); this.held = new Map(); + this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); + this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); + this.activation = global.display.connect('accelerator-activated', (_display, id) => { + const binding = this.bindings.get(id); + if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } + }); + this.release = global.stage.connect('captured-event', (_actor, event) => { + if (event.type() === Clutter.EventType.KEY_RELEASE) { + for (const [id, binding] of this.held) { + if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } + } + } + return Clutter.EVENT_PROPAGATE; + }); + // Older Mutter versions expose modifier state outside shell focus but + // no accelerator-deactivated signal. It also releases a held modifier + // after focus changes or a compositor modal operation. + this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { + const state = global.get_pointer()[2]; + for (const [id, binding] of this.held) { + let mask = binding.states; + if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; + if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; + if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; + if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; + if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } + } + return GLib.SOURCE_CONTINUE; + }); + try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { + const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } + }); } catch (_) { this.deactivation = 0; } + } + Version() { return 1; } + windows() { return global.get_window_actors().map(actor => actor.meta_window); } + Snapshot() { + const window = global.display.focus_window; + if (!window) return '{}'; + const rect = window.get_work_area_for_monitor(window.get_monitor()); + return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', + x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); + } + emit(binding, pressed) { + this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); + } + Bind(json) { + let requested; + try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } + catch (error) { return String(error); } + const next = new Map(); const created = []; + for (const binding of requested) { + const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); + const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); + if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } + if (!existing) created.push(id); + next.set(id, binding); + } + for (const [id, binding] of this.held) this.emit(binding, false); + this.held.clear(); + for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); + for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); + this.bindings = next; + return ''; + } + Restore(target) { + const window = this.windows().find(window => String(window.get_stable_sequence()) === target); + if (!window) return false; + window.activate(global.get_current_time()); return true; + } + Place(title, x, y) { + if (!title.startsWith('OpenLess ')) return false; + const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); + if (!window) return false; + window.move_frame(true, x, y); return true; + } + destroy() { + for (const binding of this.held.values()) this.emit(binding, false); + for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); + global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); + global.stage.disconnect(this.release); GLib.Source.remove(this.watch); + this.object.unexport(); Gio.bus_unown_name(this.owner); + } +} diff --git a/openless-all/scripts/linux-desktop/gnome/build.mjs b/openless-all/scripts/linux-desktop/gnome/build.mjs new file mode 100644 index 000000000..03f5105f5 --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/build.mjs @@ -0,0 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +const here=path.dirname(fileURLToPath(import.meta.url)); +const body=fs.readFileSync(path.join(here,'bridge.js'),'utf8'); +for(const [name,versions,imports,entry] of [ + ['legacy',['42','43','44'], 'const {Gio, GLib, Meta, Shell, Clutter} = imports.gi;\nconst Main = imports.ui.main;\n', 'let bridge;\nfunction init() {}\nfunction enable() { bridge = new DesktopBridge(); }\nfunction disable() { bridge?.destroy(); bridge = null; }\n'], + ['modern',['45','46','47','48','49','50'], `import Gio from 'gi://Gio';\nimport GLib from 'gi://GLib';\nimport Meta from 'gi://Meta';\nimport Shell from 'gi://Shell';\nimport Clutter from 'gi://Clutter';\nimport * as Main from 'resource:///org/gnome/shell/ui/main.js';\n`, 'export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } }\n'], +]) { + const out=path.join(here,name);fs.mkdirSync(out,{recursive:true}); + fs.writeFileSync(path.join(out,'metadata.json'),JSON.stringify({uuid:'openless@openless.app',name:'OpenLess Desktop Bridge',description:'Native OpenLess hotkeys and window placement',version:1,'shell-version':versions},null,2)+'\n','utf8'); + fs.writeFileSync(path.join(out,'extension.js'),imports+body+'\n'+entry,'utf8'); +} diff --git a/openless-all/scripts/linux-desktop/gnome/legacy/extension.js b/openless-all/scripts/linux-desktop/gnome/legacy/extension.js new file mode 100644 index 000000000..a7a9b1ad5 --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/legacy/extension.js @@ -0,0 +1,106 @@ +const {Gio, GLib, Meta, Shell, Clutter} = imports.gi; +const Main = imports.ui.main; +/* SPDX-License-Identifier: AGPL-3.0-only + * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. + */ +const IFACE = ` + + + + + + +`; + +class DesktopBridge { + constructor() { + this.bindings = new Map(); this.held = new Map(); + this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); + this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); + this.activation = global.display.connect('accelerator-activated', (_display, id) => { + const binding = this.bindings.get(id); + if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } + }); + this.release = global.stage.connect('captured-event', (_actor, event) => { + if (event.type() === Clutter.EventType.KEY_RELEASE) { + for (const [id, binding] of this.held) { + if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } + } + } + return Clutter.EVENT_PROPAGATE; + }); + // Older Mutter versions expose modifier state outside shell focus but + // no accelerator-deactivated signal. It also releases a held modifier + // after focus changes or a compositor modal operation. + this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { + const state = global.get_pointer()[2]; + for (const [id, binding] of this.held) { + let mask = binding.states; + if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; + if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; + if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; + if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; + if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } + } + return GLib.SOURCE_CONTINUE; + }); + try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { + const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } + }); } catch (_) { this.deactivation = 0; } + } + Version() { return 1; } + windows() { return global.get_window_actors().map(actor => actor.meta_window); } + Snapshot() { + const window = global.display.focus_window; + if (!window) return '{}'; + const rect = window.get_work_area_for_monitor(window.get_monitor()); + return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', + x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); + } + emit(binding, pressed) { + this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); + } + Bind(json) { + let requested; + try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } + catch (error) { return String(error); } + const next = new Map(); const created = []; + for (const binding of requested) { + const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); + const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); + if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } + if (!existing) created.push(id); + next.set(id, binding); + } + for (const [id, binding] of this.held) this.emit(binding, false); + this.held.clear(); + for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); + for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); + this.bindings = next; + return ''; + } + Restore(target) { + const window = this.windows().find(window => String(window.get_stable_sequence()) === target); + if (!window) return false; + window.activate(global.get_current_time()); return true; + } + Place(title, x, y) { + if (!title.startsWith('OpenLess ')) return false; + const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); + if (!window) return false; + window.move_frame(true, x, y); return true; + } + destroy() { + for (const binding of this.held.values()) this.emit(binding, false); + for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); + global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); + global.stage.disconnect(this.release); GLib.Source.remove(this.watch); + this.object.unexport(); Gio.bus_unown_name(this.owner); + } +} + +let bridge; +function init() {} +function enable() { bridge = new DesktopBridge(); } +function disable() { bridge?.destroy(); bridge = null; } diff --git a/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json b/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json new file mode 100644 index 000000000..9f5993bf2 --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "openless@openless.app", + "name": "OpenLess Desktop Bridge", + "description": "Native OpenLess hotkeys and window placement", + "version": 1, + "shell-version": [ + "42", + "43", + "44" + ] +} diff --git a/openless-all/scripts/linux-desktop/gnome/modern/extension.js b/openless-all/scripts/linux-desktop/gnome/modern/extension.js new file mode 100644 index 000000000..b835b1269 --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/modern/extension.js @@ -0,0 +1,107 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +/* SPDX-License-Identifier: AGPL-3.0-only + * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. + */ +const IFACE = ` + + + + + + +`; + +class DesktopBridge { + constructor() { + this.bindings = new Map(); this.held = new Map(); + this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); + this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); + this.activation = global.display.connect('accelerator-activated', (_display, id) => { + const binding = this.bindings.get(id); + if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } + }); + this.release = global.stage.connect('captured-event', (_actor, event) => { + if (event.type() === Clutter.EventType.KEY_RELEASE) { + for (const [id, binding] of this.held) { + if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } + } + } + return Clutter.EVENT_PROPAGATE; + }); + // Older Mutter versions expose modifier state outside shell focus but + // no accelerator-deactivated signal. It also releases a held modifier + // after focus changes or a compositor modal operation. + this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { + const state = global.get_pointer()[2]; + for (const [id, binding] of this.held) { + let mask = binding.states; + if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; + if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; + if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; + if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; + if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } + } + return GLib.SOURCE_CONTINUE; + }); + try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { + const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } + }); } catch (_) { this.deactivation = 0; } + } + Version() { return 1; } + windows() { return global.get_window_actors().map(actor => actor.meta_window); } + Snapshot() { + const window = global.display.focus_window; + if (!window) return '{}'; + const rect = window.get_work_area_for_monitor(window.get_monitor()); + return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', + x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); + } + emit(binding, pressed) { + this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); + } + Bind(json) { + let requested; + try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } + catch (error) { return String(error); } + const next = new Map(); const created = []; + for (const binding of requested) { + const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); + const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); + if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } + if (!existing) created.push(id); + next.set(id, binding); + } + for (const [id, binding] of this.held) this.emit(binding, false); + this.held.clear(); + for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); + for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); + this.bindings = next; + return ''; + } + Restore(target) { + const window = this.windows().find(window => String(window.get_stable_sequence()) === target); + if (!window) return false; + window.activate(global.get_current_time()); return true; + } + Place(title, x, y) { + if (!title.startsWith('OpenLess ')) return false; + const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); + if (!window) return false; + window.move_frame(true, x, y); return true; + } + destroy() { + for (const binding of this.held.values()) this.emit(binding, false); + for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); + global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); + global.stage.disconnect(this.release); GLib.Source.remove(this.watch); + this.object.unexport(); Gio.bus_unown_name(this.owner); + } +} + +export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } } diff --git a/openless-all/scripts/linux-desktop/gnome/modern/metadata.json b/openless-all/scripts/linux-desktop/gnome/modern/metadata.json new file mode 100644 index 000000000..22f3efb09 --- /dev/null +++ b/openless-all/scripts/linux-desktop/gnome/modern/metadata.json @@ -0,0 +1,14 @@ +{ + "uuid": "openless@openless.app", + "name": "OpenLess Desktop Bridge", + "description": "Native OpenLess hotkeys and window placement", + "version": 1, + "shell-version": [ + "45", + "46", + "47", + "48", + "49", + "50" + ] +} diff --git a/openless-all/scripts/linux-desktop/install.sh b/openless-all/scripts/linux-desktop/install.sh new file mode 100644 index 000000000..3bbbdeecc --- /dev/null +++ b/openless-all/scripts/linux-desktop/install.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail +MODE=${1:-install} +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +DATA=${XDG_DATA_HOME:-"$HOME/.local/share"} +CONFIG=${XDG_CONFIG_HOME:-"$HOME/.config"} +BIN="$HOME/.local/bin" +UUID=openless@openless.app +GNOME_DIR="$DATA/gnome-shell/extensions/$UUID" +KWIN_DIR="$DATA/kwin/scripts/openless-desktop" +HELPER="$BIN/openless-desktop-bridge" +RUNTIME="$HOME/.local/lib/openless-desktop-bridge" +case "$MODE" in install|enable|uninstall) ;; *) echo 'Usage: install.sh [install|enable|uninstall]' >&2; exit 2;; esac + +is_gnome=false +desktop=${XDG_CURRENT_DESKTOP:-} +case "${desktop,,}" in *gnome*|*ubuntu*) is_gnome=true;; esac + +if [ "$MODE" = uninstall ]; then + if command -v gnome-extensions >/dev/null; then gnome-extensions disable "$UUID" || true; fi + for tool in kwriteconfig6 kwriteconfig5; do + if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled false; break; fi + done + # Only remove these package-owned directories and files under the user's + # configured roots. Never enumerate arbitrary home files or use shell globs. + test "$GNOME_DIR" = "$DATA/gnome-shell/extensions/openless@openless.app" + test "$KWIN_DIR" = "$DATA/kwin/scripts/openless-desktop" + rm -rf -- "$GNOME_DIR" "$KWIN_DIR" "$RUNTIME" + rm -f -- "$HELPER" "$DATA/dbus-1/services/org.openless.Desktop1.service" "$CONFIG/autostart/openless-desktop-bridge.desktop" + echo 'Desktop integration removed. Log out and back in to unload the running component.' + exit 0 +fi + +if [ "$MODE" = install ]; then + if $is_gnome; then + version=$(gnome-shell --version | sed -n 's/.* \([0-9][0-9]*\).*/\1/p') + test -n "$version" + variant=modern; if [ "$version" -lt 45 ]; then variant=legacy; fi + install -Dm644 "$ROOT/gnome/$variant/metadata.json" "$GNOME_DIR/metadata.json" + install -Dm644 "$ROOT/gnome/$variant/extension.js" "$GNOME_DIR/extension.js" + else + install -Dm644 "$ROOT/kwin/metadata.json" "$KWIN_DIR/metadata.json" + install -Dm644 "$ROOT/kwin/metadata.desktop" "$KWIN_DIR/metadata.desktop" + install -Dm644 "$ROOT/kwin/contents/code/main.js" "$KWIN_DIR/contents/code/main.js" + install -Dm755 "$ROOT/openless-desktop-bridge" "$RUNTIME/openless-desktop-bridge" + if [ -d "$ROOT/lib" ]; then mkdir -p "$RUNTIME/lib"; cp -a "$ROOT/lib/." "$RUNTIME/lib/"; fi + if [ -d "$ROOT/plugins" ]; then mkdir -p "$RUNTIME/plugins"; cp -a "$ROOT/plugins/." "$RUNTIME/plugins/"; fi + if [ -d "$ROOT/licenses" ]; then mkdir -p "$RUNTIME/licenses"; cp -a "$ROOT/licenses/." "$RUNTIME/licenses/"; fi + mkdir -p "$BIN" + # Keep the helper and its Qt runtime after the AppImage mount disappears. + printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$RUNTIME/openless-desktop-bridge" > "$HELPER" + chmod +x "$HELPER" + mkdir -p "$DATA/dbus-1/services" "$CONFIG/autostart" + escaped=${HELPER//\\/\\\\}; escaped=${escaped//\"/\\\"} + printf '[D-BUS Service]\nName=org.openless.Desktop1\nExec="%s"\n' "$escaped" > "$DATA/dbus-1/services/org.openless.Desktop1.service" + printf '[Desktop Entry]\nType=Application\nName=OpenLess Desktop Bridge\nExec="%s"\nOnlyShowIn=KDE;\nNoDisplay=true\n' "$escaped" > "$CONFIG/autostart/openless-desktop-bridge.desktop" + fi +fi + +if $is_gnome; then + if ! gnome-extensions enable "$UUID"; then + echo 'The extension is installed. Log out and back in, then run this command with enable.' >&2 + exit 1 + fi +else + enabled=false + for tool in kwriteconfig6 kwriteconfig5; do + if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled true; enabled=true; break; fi + done + $enabled || { echo 'KDE configuration tools are missing' >&2; exit 1; } + for tool in qdbus6 qdbus qdbus-qt5; do + if command -v "$tool" >/dev/null; then "$tool" org.kde.KWin /KWin reconfigure; break; fi + done + # The service is started by D-Bus on demand and on the next KDE login. +fi +echo 'OpenLess desktop integration enabled. Restart OpenLess to bind shortcuts.' diff --git a/openless-all/scripts/linux-desktop/kde/CMakeLists.txt b/openless-all/scripts/linux-desktop/kde/CMakeLists.txt new file mode 100644 index 000000000..2e145aa31 --- /dev/null +++ b/openless-all/scripts/linux-desktop/kde/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.16) +project(openless-desktop-bridge LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 QUIET COMPONENTS Core Gui DBus Widgets) +if(Qt6_FOUND) + find_package(KF6GlobalAccel REQUIRED) + set(OPENLESS_QT Qt6) + set(OPENLESS_KF KF6) +else() + find_package(Qt5 REQUIRED COMPONENTS Core Gui DBus Widgets) + find_package(KF5GlobalAccel REQUIRED) + set(OPENLESS_QT Qt5) + set(OPENLESS_KF KF5) +endif() +add_executable(openless-desktop-bridge bridge.cpp) +target_link_libraries(openless-desktop-bridge PRIVATE ${OPENLESS_QT}::Core ${OPENLESS_QT}::Gui ${OPENLESS_QT}::DBus ${OPENLESS_QT}::Widgets ${OPENLESS_KF}::GlobalAccel) +install(TARGETS openless-desktop-bridge RUNTIME DESTINATION bin) diff --git a/openless-all/scripts/linux-desktop/kde/bridge.cpp b/openless-all/scripts/linux-desktop/kde/bridge.cpp new file mode 100644 index 000000000..15e98bb02 --- /dev/null +++ b/openless-all/scripts/linux-desktop/kde/bridge.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class Bridge : public QObject, protected QDBusContext { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.openless.Desktop1") + QMap actions; + QMap bindings; + QMap acknowledgements; + QQueue commands; + QDBusMessage waiting; + quint64 waiterGeneration=0; + QString snapshot="{}"; + qint64 snapshotTime=0; + QString releasePath; + static QString encode(const QJsonObject &value) { return QString::fromUtf8(QJsonDocument(value).toJson(QJsonDocument::Compact)); } + void replyCommand() { + if (waiting.type()!=QDBusMessage::MethodCallMessage || commands.isEmpty()) return; + QDBusConnection::sessionBus().send(waiting.createReply(encode(commands.dequeue()))); + waiting=QDBusMessage(); ++waiterGeneration; + } + bool queue(QJsonObject command) { + if (!calledFromDBus() || QDateTime::currentMSecsSinceEpoch()-snapshotTime>5000) return false; + const QString id=QUuid::createUuid().toString(QUuid::WithoutBraces); + command["id"]=id; + setDelayedReply(true); acknowledgements[id]=message(); commands.enqueue(command); replyCommand(); + QTimer::singleShot(1500,this,[this,id](){ Ack(id,false); }); + return false; // delayed reply contains the actual KWin acknowledgement + } + static QKeySequence key(const QJsonObject &binding) { + QString accelerator=binding["accelerator"].toString(); + accelerator.replace("","Ctrl+").replace("","Shift+").replace("","Alt+").replace("","Meta+"); + accelerator.replace("Control_R","Ctrl").replace("Control_L","Ctrl").replace("Alt_R","Alt").replace("Alt_L","Alt"); + return QKeySequence(accelerator); + } +public: + Bridge() = default; +public slots: + void Released(const QString &name,qlonglong) {const auto binding=bindings.value(name);if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),false);} + uint Version() const { return 1; } + QString Snapshot() const { return QDateTime::currentMSecsSinceEpoch()-snapshotTime<5000?snapshot:"{}"; } + void Update(const QString &json) { + const auto document=QJsonDocument::fromJson(json.toUtf8()); + if (document.isObject() && document.object()["version"].toInt()==1) { snapshot=json; snapshotTime=QDateTime::currentMSecsSinceEpoch(); } + } + QString Bind(const QString &json) { + const auto document=QJsonDocument::fromJson(json.toUtf8()); + if (!document.isArray() || document.array().size()>64) return "Invalid desktop binding document"; + QMap next; + for (const auto value:document.array()) { + const auto binding=value.toObject(); const auto sequence=key(binding); + const QString id=binding["action"].toString()+":"+QString::number(binding["symbol"].toInt())+":"+QString::number(binding["states"].toInt()); + bool owned=false; for (auto action:actions) if (KGlobalAccel::self()->shortcut(action).contains(sequence)) owned=true; + if (sequence.isEmpty() || (!owned&&!KGlobalAccel::isGlobalShortcutAvailable(sequence,"openless-desktop-bridge"))) return "Shortcut conflict: "+binding["accelerator"].toString(); + next[id]=binding; + } + const auto previous=bindings; + for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action); + bool success=true; + for (auto it=next.begin();it!=next.end();++it) { + auto action=actions.value(it.key()); + if (!action) { + action=new QAction(it.key(),this);action->setObjectName(it.key());actions[it.key()]=action; + connect(action,&QAction::triggered,this,[this,action](){const auto binding=bindings.value(action->objectName());if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),true);}); + } + if (!KGlobalAccel::self()->setShortcut(action,{key(it.value())},KGlobalAccel::NoAutoloading)) { success=false;break; } + } + if (!success) { + for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action); + for (auto it=previous.begin();it!=previous.end();++it) KGlobalAccel::self()->setShortcut(actions[it.key()],{key(it.value())},KGlobalAccel::NoAutoloading); + return "Shortcut registration failed; previous bindings restored"; + } + bindings=next; + if(releasePath.isEmpty()) { + QDBusInterface service("org.kde.kglobalaccel","/kglobalaccel","org.kde.KGlobalAccel"); + QDBusReply component=service.call("getComponent","openless-desktop-bridge"); + if(component.isValid()) { + releasePath=component.value().path(); + QDBusConnection::sessionBus().connect("org.kde.kglobalaccel",releasePath,"org.kde.kglobalaccel.Component","globalShortcutReleased",this,SLOT(Released(QString,qlonglong))); + } + } + return {}; + } + bool Restore(const QString &target) { return queue({{"op","restore"},{"target",target}}); } + bool Place(const QString &title,int x,int y) { if(!title.startsWith("OpenLess "))return false;return queue({{"op","place"},{"title",title},{"x",x},{"y",y}}); } + QString Next() { + if (!commands.isEmpty()) return encode(commands.dequeue()); + if (waiting.type()==QDBusMessage::MethodCallMessage) return "{}"; + setDelayedReply(true);waiting=message();const auto generation=++waiterGeneration; + QTimer::singleShot(1000,this,[this,generation](){if(waiterGeneration==generation&&waiting.type()==QDBusMessage::MethodCallMessage){QDBusConnection::sessionBus().send(waiting.createReply(QString("{}")));waiting=QDBusMessage();}}); + return {}; + } + void Ack(const QString &id,bool success) { + if (!acknowledgements.contains(id)) return; + QDBusConnection::sessionBus().send(acknowledgements.take(id).createReply(success)); + } +signals: + void Hotkey(const QString &action,uint symbol,uint states,bool pressed); +}; + +int main(int argc,char **argv) { + // This helper only uses D-Bus; a private offscreen platform plugin avoids + // loading Qt plugins from an incompatible Plasma installation. + qputenv("QT_QPA_PLATFORM", "offscreen"); + const auto plugins=QFileInfo(QString::fromLocal8Bit(argv[0])).absoluteDir().filePath("plugins/platforms"); + if(QDir(plugins).exists())qputenv("QT_QPA_PLATFORM_PLUGIN_PATH",plugins.toUtf8()); + QApplication app(argc,argv);app.setApplicationName("openless-desktop-bridge");app.setQuitOnLastWindowClosed(false); + Bridge bridge; + auto bus=QDBusConnection::sessionBus(); + if(!bus.registerService("org.openless.Desktop1") || !bus.registerObject("/org/openless/Desktop1",&bridge,QDBusConnection::ExportAllSlots|QDBusConnection::ExportAllSignals))return 1; + return app.exec(); +} +#include "bridge.moc" diff --git a/openless-all/scripts/linux-desktop/kwin/contents/code/main.js b/openless-all/scripts/linux-desktop/kwin/contents/code/main.js new file mode 100644 index 000000000..3d17907f6 --- /dev/null +++ b/openless-all/scripts/linux-desktop/kwin/contents/code/main.js @@ -0,0 +1,40 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +var bus = 'org.openless.Desktop1', path = '/org/openless/Desktop1'; +var polling = false; +function windows() { return workspace.windowList ? workspace.windowList() : workspace.clientList(); } +function active() { return workspace.activeWindow !== undefined ? workspace.activeWindow : workspace.activeClient; } +function identity(window) { return String(window.internalId || window.windowId); } +function update() { + var window = active(); + if (!window) return; + var area = workspace.clientArea(KWin.MaximizeArea, window); + callDBus(bus, path, bus, 'Update', JSON.stringify({version: 1, target: identity(window), application: String(window.resourceClass), x: area.x, y: area.y, width: area.width, height: area.height, scale: 1})); +} +function next() { + if (polling) return; + polling = true; + callDBus(bus, path, bus, 'Next', function (json) { + polling = false; + // A missing helper must not turn a failed D-Bus call into a busy loop. + // A subsequent focus/screen event starts the connection again. + if (typeof json !== 'string') return; + var command, success = false; + try { + command = JSON.parse(json || '{}'); + if (command.op === 'restore') { + var target = windows().filter(function (window) { return identity(window) === command.target; })[0]; + if (target) { if (workspace.activeWindow !== undefined) workspace.activeWindow = target; else workspace.activeClient = target; success = true; } + } else if (command.op === 'place' && command.title.indexOf('OpenLess ') === 0) { + var popup = windows().filter(function (window) { return window.caption === command.title && String(window.resourceClass).toLowerCase().indexOf('openless') >= 0; })[0]; + if (popup) { var rect = popup.frameGeometry; rect.x = command.x; rect.y = command.y; popup.frameGeometry = rect; success = true; } + } + } catch (error) { print('OpenLess desktop bridge: ' + error); } + if (command && command.id) callDBus(bus, path, bus, 'Ack', command.id, success); + update(); next(); + }); +} +function changed() { update(); next(); } +if (workspace.windowActivated) workspace.windowActivated.connect(changed); +else workspace.clientActivated.connect(changed); +if (workspace.screensChanged) workspace.screensChanged.connect(changed); +update(); next(); diff --git a/openless-all/scripts/linux-desktop/kwin/metadata.desktop b/openless-all/scripts/linux-desktop/kwin/metadata.desktop new file mode 100644 index 000000000..3219508d9 --- /dev/null +++ b/openless-all/scripts/linux-desktop/kwin/metadata.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=OpenLess Desktop Bridge +Type=Service +X-KDE-ServiceTypes=KWin/Script +X-KDE-PluginInfo-Name=openless-desktop +X-KDE-PluginInfo-Version=1.0 +X-KDE-PluginInfo-License=AGPL-3.0-only +X-Plasma-API=javascript +X-Plasma-MainScript=code/main.js diff --git a/openless-all/scripts/linux-desktop/kwin/metadata.json b/openless-all/scripts/linux-desktop/kwin/metadata.json new file mode 100644 index 000000000..b3283e6e6 --- /dev/null +++ b/openless-all/scripts/linux-desktop/kwin/metadata.json @@ -0,0 +1,6 @@ +{ + "KPlugin": {"Id": "openless-desktop", "Name": "OpenLess Desktop Bridge", "Description": "OpenLess focus and floating window integration", "Version": "1.0", "License": "AGPL-3.0-only"}, + "X-Plasma-API": "javascript", + "X-Plasma-MainScript": "code/main.js", + "KPackageStructure": "KWin/Script" +} diff --git a/openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp b/openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp index 9d5e97479..3e423349e 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp @@ -40,9 +40,18 @@ int main() { instance.initialize(); fcitx::OpenLess plugin(&instance); RecordingInputContext first(instance.inputContextManager()); + first.focusIn(); fcitx::OpenLessInputTargetContract::select(plugin, first); first.surroundingText().setText("foo foo", 3, 0); assert(plugin.captureSelectionTarget("selection") == "foo"); + const auto metadata=plugin.contextSnapshot("selection",false); + assert(metadata.find("openless-contract")!=std::string::npos); + assert(metadata.find("\"text\"")==std::string::npos); + assert(plugin.contextSnapshot("selection",true).find("foo foo")!=std::string::npos); + first.setCapabilityFlags(fcitx::CapabilityFlag::Password); + assert(plugin.captureSelectionTarget("password").empty()); + assert(plugin.contextSnapshot("selection",true).find("\"text\"")==std::string::npos); + first.setCapabilityFlags({}); // Identical text at a different position is a different selection. // Comparing only the selected string would corrupt the wrong range. first.surroundingText().setCursor(7, 4); @@ -73,7 +82,12 @@ int main() { RecordingInputContext second(instance.inputContextManager()); fcitx::OpenLessInputTargetContract::type(plugin, first); assert(plugin.captureDictationTarget("dictation")); + first.focusOut(); + second.focusIn(); fcitx::OpenLessInputTargetContract::type(plugin, second); + assert(!plugin.commitDictationTarget("dictation", "focus changed")); + second.focusOut(); + first.focusIn(); assert(plugin.commitDictationTarget("dictation", "original target")); assert(first.committed.back() == "original target"); assert(second.committed.empty()); @@ -82,6 +96,8 @@ int main() { { RecordingInputContext destroyed(instance.inputContextManager()); + first.focusOut(); + destroyed.focusIn(); destroyed.surroundingText().setText("original", 8, 0); fcitx::OpenLessInputTargetContract::type(plugin, destroyed); fcitx::OpenLessInputTargetContract::select(plugin, destroyed); diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 4e143da35..cea968fb5 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,12 @@ * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 */ +#include #include +#include +#include #include +#include #include #include @@ -87,6 +92,14 @@ class OpenLess final : public AddonInstance, translationRawStates_(0), lessComputerRawSym_(0), lessComputerRawStates_(0), + switchStyleRawSym_(0), + switchStyleRawStates_(0), + lessComputerPanelRawSym_(0), + lessComputerPanelRawStates_(0), + lessComputerQuickRawSym_(0), + lessComputerQuickRawStates_(0), + openAppRawSym_(0), + openAppRawStates_(0), hasCustomDictationKey_(false), dictationTriggerHeld_(false), dictationTriggerCombined_(false), @@ -240,6 +253,31 @@ class OpenLess final : public AddonInstance, << "Translation modifier: sym=" << sym; translationModifierEvent(sym, states, isPress); } + if (switchStyleRawSym_ != 0 && sym == switchStyleRawSym_ && + states == switchStyleRawStates_) { + switchStyleEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + if (lessComputerPanelRawSym_ != 0 && sym == lessComputerPanelRawSym_ && states == lessComputerPanelRawStates_) { + lessComputerPanelEvent(sym,states,isPress); keyEvent.filterAndAccept(); return; + } + if (lessComputerQuickRawSym_ != 0 && sym == lessComputerQuickRawSym_ && states == lessComputerQuickRawStates_) { + lessComputerQuickEvent(sym,states,isPress); keyEvent.filterAndAccept(); return; + } + if (openAppRawSym_ != 0 && sym == openAppRawSym_ && + states == openAppRawStates_) { + openAppEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + for (const auto &[packId, packSym, packStates] : stylePackHotkeys_) { + if (packSym != 0 && sym == packSym && states == packStates) { + stylePackHotkeyEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + } })); // 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针 @@ -249,6 +287,10 @@ class OpenLess final : public AddonInstance, EventWatcherPhase::Default, [this](Event &event) { auto &icEvent = static_cast(event); + for (auto it = contextTargets_.begin(); it != contextTargets_.end();) { + if (it->second == icEvent.inputContext()) it = contextTargets_.erase(it); + else ++it; + } if (icEvent.inputContext() == savedIc_) { savedIc_ = nullptr; } @@ -341,8 +383,57 @@ class OpenLess final : public AddonInstance, return true; } + // Independently versioned read-only bridge. A target UUID never aliases a + // newly created input context, and includeText=false never reads its text. + std::string contextSnapshot(const std::string &expected, bool includeText) { + InputContext *ic = nullptr; + bool selectionTicket = false; + if (expected.empty()) { + instance_->inputContextManager().foreachFocused([&ic](InputContext *candidate) { + ic = candidate; return false; + }); + } else { + auto found = contextTargets_.find(expected); + if (found != contextTargets_.end()) ic = found->second; + auto selected = selectionTargets_.find(expected); + if (selected != selectionTargets_.end()) { ic=selected->second.inputContext; selectionTicket=true; } + } + if (!ic || !ic->hasFocus()) return "{}"; + std::ostringstream id; + for (auto byte : ic->uuid()) id << std::hex << std::setw(2) << std::setfill('0') << static_cast(byte); + const auto target = selectionTicket ? expected : id.str(); + if (!expected.empty() && expected != target) return "{}"; + if (!selectionTicket) contextTargets_[target] = ic; + const bool sensitive = ic->capabilityFlags().test(CapabilityFlag::Password); + auto quote = [](const std::string &value) { + std::ostringstream out; out << '"'; + for (unsigned char c : value) { + if (c == '"' || c == '\\') out << '\\' << c; + else if (c < 0x20) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(c); + else out << c; + } + out << '"'; return out.str(); + }; + std::ostringstream out; + out << "{\"version\":1,\"target\":" << quote(target) + << ",\"application\":" << quote(ic->program()) + << ",\"sensitive\":" << (sensitive ? "true" : "false"); + if (includeText && !sensitive && ic->surroundingText().isValid()) { + const auto &text = ic->surroundingText(); + // Reject oversized documents rather than returning a moving window + // that could be mistaken for a user edit by the observer. + if (text.text().size() <= 65536) + out << ",\"text\":" << quote(text.text()) << ",\"cursor\":" << text.cursor(); + } + out << '}'; return out.str(); + } + std::string captureSelectionTarget(const std::string &ticket) { - if (ticket.empty() || !selectionIc_) { + selectionIc_ = nullptr; + instance_->inputContextManager().foreachFocused([this](InputContext *ic) { + selectionIc_ = ic; return false; + }); + if (ticket.empty() || !selectionIc_ || selectionIc_->capabilityFlags().test(CapabilityFlag::Password)) { return std::string(); } std::string source; @@ -363,6 +454,10 @@ class OpenLess final : public AddonInstance, } bool captureDictationTarget(const std::string &ticket) { + savedIc_ = nullptr; + instance_->inputContextManager().foreachFocused([this](InputContext *ic) { + savedIc_ = ic; return false; + }); if (ticket.empty() || !savedIc_) return false; // A session keeps its own native target even when later key events // update savedIc_. Destruction invalidates the ticket instead of @@ -372,7 +467,7 @@ class OpenLess final : public AddonInstance, bool commitDictationTarget(const std::string &ticket, const std::string &text) { auto found = dictationTargets_.find(ticket); - if (found == dictationTargets_.end()) return false; + if (found == dictationTargets_.end() || !found->second->hasFocus()) return false; found->second->commitString(text); return true; } @@ -393,6 +488,7 @@ class OpenLess final : public AddonInstance, return false; } auto *ic = found->second.inputContext; + if (!ic->hasFocus()) return false; const auto &surrounding = ic->surroundingText(); const auto &captured = found->second; // PRIMARY can outlive the selection, and the same selected string may @@ -418,6 +514,7 @@ class OpenLess final : public AddonInstance, return false; } auto *ic = found->second.inputContext; + if (!ic->hasFocus()) return false; const auto &replacement = found->second.replacement; const auto &surrounding = ic->surroundingText(); if (!surrounding.isValid()) { @@ -658,6 +755,45 @@ 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 setLessComputerPanelHotkeyRaw(uint32_t sym,uint32_t states) { + lessComputerPanelRawSym_=sym; lessComputerPanelRawStates_=states; persistRawHotkey("LessComputerPanel",sym,states); + } + + void setLessComputerQuickHotkeyRaw(uint32_t sym,uint32_t states) { + lessComputerQuickRawSym_=sym; lessComputerQuickRawStates_=states; persistRawHotkey("LessComputerQuick",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,7 +810,19 @@ 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(contextSnapshot, "ContextSnapshot", "sb", "s"); FCITX_OBJECT_VTABLE_METHOD(captureDictationTarget, "CaptureDictationTarget", "s", "b"); FCITX_OBJECT_VTABLE_METHOD(commitDictationTarget, "CommitDictationTarget", "ss", "b"); FCITX_OBJECT_VTABLE_METHOD(cancelDictationTarget, "CancelDictationTarget", "s", "b"); @@ -692,7 +840,13 @@ 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(setLessComputerPanelHotkeyRaw,"SetLessComputerPanelHotkeyRaw","uu",""); + FCITX_OBJECT_VTABLE_METHOD(setLessComputerQuickHotkeyRaw,"SetLessComputerQuickHotkeyRaw","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 +854,11 @@ 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(lessComputerPanelEvent,"LessComputerPanelEvent","uub"); + FCITX_OBJECT_VTABLE_SIGNAL(lessComputerQuickEvent,"LessComputerQuickEvent","uub"); + FCITX_OBJECT_VTABLE_SIGNAL(openAppEvent, "OpenAppEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(stylePackHotkeyEvent, "StylePackHotkeyEvent", "uub"); Instance *instance() { return instance_; } @@ -749,6 +908,26 @@ 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,"LessComputerPanel",lessComputerPanelRawSym_,lessComputerPanelRawStates_); + loadRawHotkey(raw,"LessComputerQuick",lessComputerQuickRawSym_,lessComputerQuickRawStates_); + 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(); @@ -814,6 +993,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,6 +1023,15 @@ class OpenLess final : public AddonInstance, uint32_t translationRawStates_; uint32_t lessComputerRawSym_; uint32_t lessComputerRawStates_; + uint32_t switchStyleRawSym_; + uint32_t switchStyleRawStates_; + uint32_t lessComputerPanelRawSym_; + uint32_t lessComputerPanelRawStates_; + uint32_t lessComputerQuickRawSym_; + uint32_t lessComputerQuickRawStates_; + uint32_t openAppRawSym_; + uint32_t openAppRawStates_; + std::vector> stylePackHotkeys_; Key customDictationKey_; bool hasCustomDictationKey_; bool dictationTriggerHeld_; @@ -837,6 +1042,7 @@ class OpenLess final : public AddonInstance, /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 InputContext *savedIc_; + std::unordered_map contextTargets_; /// QA/Selection 快捷键按下时的原输入上下文。该指针只能由 fcitx5 主事件循环 /// 访问,并在 InputContextDestroyed 中与所有关联 ticket 一起失效。 InputContext *selectionIc_; diff --git a/openless-all/scripts/sync-egui-locales.mjs b/openless-all/scripts/sync-egui-locales.mjs new file mode 100644 index 000000000..c09e59502 --- /dev/null +++ b/openless-all/scripts/sync-egui-locales.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../app'); +const require=createRequire(path.join(root,'package.json')); +const ts=require('typescript'); +const result={}; +function flatten(value,prefix='',out={}){for(const [key,item] of Object.entries(value)){const name=prefix?prefix+'.'+key:key;if(typeof item==='string')out[name]=item;else if(item&&typeof item==='object')flatten(item,name,out);}return out;} +const modules=new Map(); +function load(locale) { + if(modules.has(locale))return modules.get(locale); + const source=fs.readFileSync(path.join(root,'src/i18n',locale+'.ts'),'utf8'); + const output=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; + const exports={};modules.set(locale,exports); + vm.runInNewContext(output,{exports,require:specifier=>{if(!/^\.\/[a-zA-Z-]+$/.test(specifier))throw new Error('Unexpected locale import');return load(specifier.slice(2));}},{timeout:5000}); + return exports; +} +for(const locale of ['zh-CN','zh-TW','en','ja','ko','es','fr','de'])result[locale]=flatten(Object.values(load(locale)).find(value=>value&&typeof value==='object')); +fs.mkdirSync(path.join(root,'linux-egui/assets'),{recursive:true}); +fs.writeFileSync(path.join(root,'linux-egui/assets/ui-locales.json'),JSON.stringify(result)+'\n','utf8'); From 6fe0b254ea0c0c54f0b85fb26d985eb180ad3f59 Mon Sep 17 00:00:00 2001 From: Nahida <1139500183@qq.com> Date: Fri, 11 Sep 2026 23:19:17 +0800 Subject: [PATCH 2/2] ci(linux-egui): stop immediately when any backend check fails --- .github/workflows/release-linux-egui.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index c7cb09b72..3be418a43 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -130,18 +130,19 @@ jobs: - name: Verify framework-independent Linux contract working-directory: openless-all/app - shell: pwsh + shell: bash run: | + set -euo pipefail cargo test --locked -p openless-core cargo clippy --locked -p openless-core --all-targets -- -D warnings cargo test --locked -p openless-linux-egui --lib --test host_contract cargo check --locked -p openless-linux-egui --all-targets - ./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 + pwsh -NoProfile -File ./scripts/check-core-deps.ps1 + pwsh -NoProfile -File ./scripts/check-core-deps.ps1 openless-linux-egui + pwsh -NoProfile -File ./scripts/check-core-secret-surface.ps1 + pwsh -NoProfile -File ./scripts/check-core-test-isolation.ps1 + pwsh -NoProfile -File ./scripts/check-core-runtime-seam.ps1 + pwsh -NoProfile -File ./scripts/check-linux-public-surface.ps1 - name: Validate release target and UI gate shell: bash